-
Notifications
You must be signed in to change notification settings - Fork 948
opentelemetry-configuration: substitute env vars after parsing (#5406) #5407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
dc928d1
340aa7d
5edcc93
7b40fa8
217c4fe
8f8b4b2
8de1d26
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| `opentelemetry-configuration`: perform environment variable substitution on scalar values after parsing the configuration file, so `${VAR}` references inside comments and mapping keys are no longer substituted and undefined references in comments no longer abort loading |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ | |
| import json | ||
| import logging | ||
| import os | ||
| import re | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
|
|
@@ -16,6 +17,7 @@ | |
| MissingDependencyError, | ||
| ) | ||
| from opentelemetry.configuration.file._env_substitution import ( | ||
| EnvSubstitutionError, | ||
| substitute_env_vars, | ||
| ) | ||
| from opentelemetry.configuration.models import OpenTelemetryConfiguration | ||
|
|
@@ -65,14 +67,148 @@ def _get_schema() -> dict: | |
|
|
||
| _logger = logging.getLogger(__name__) | ||
|
|
||
| _YAML_STR_TAG = "tag:yaml.org,2002:str" | ||
|
|
||
| # A configuration value whose entire content is a single ``${VAR}`` / | ||
| # ``${VAR:-default}`` reference. Only such standalone references (when | ||
| # unquoted) have their type re-interpreted after substitution; embedded or | ||
| # multiple references resolve to a string per the configuration spec. A | ||
| # leading ``$$`` escape does not match, so ``$${VAR}`` is treated as an | ||
| # embedded (string) value. | ||
| _STANDALONE_ENV_REF = re.compile( | ||
| r"\A\$\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\}\Z" | ||
| ) | ||
|
|
||
|
|
||
| def _substitute_env_in_yaml_node(node: yaml.Node, loader: yaml.SafeLoader): | ||
| """Apply env-var substitution to string configuration values in a node tree. | ||
|
|
||
| Substitution runs after parsing on configuration values only, so comments | ||
| and mapping keys are never candidates (per the configuration spec). For an | ||
| unquoted standalone ``${VAR}`` reference the node's type tag is re-resolved | ||
| from the substituted value so YAML type coercion still applies (e.g. | ||
| ``${LIMIT}`` -> int); quoted or embedded references stay strings. | ||
| """ | ||
| # Worked example: a node value that makes all three checks below true. | ||
| # | ||
| # YAML (with environment LIMIT=100): | ||
| # attribute_limits: | ||
| # attribute_count_limit: ${LIMIT} | ||
| # | ||
| # The parser hands this function the value node of attribute_count_limit, | ||
| # which has value="${LIMIT}", tag=str, style=None (plain/unquoted). Then: | ||
| # | ||
| # 1. isinstance(node, yaml.ScalarNode) -> True: it is a leaf value. | ||
| # 2. node.tag == _YAML_STR_TAG -> True: "${LIMIT}" is not a number or | ||
| # bool, so the parser tagged it str. We substitute in place, giving | ||
| # node.value = "100". | ||
| # 3. node.style is None and _STANDALONE_ENV_REF.match(raw) -> True: | ||
| # it was unquoted and the whole value is a single reference. We | ||
| # re-resolve the tag: resolve("100") -> int, and construct_document() | ||
| # later builds the integer 100 (not the string "100"). | ||
| # | ||
| # Counter-cases that reach check 3 but stop there: "${LIMIT}" quoted fails | ||
| # the style test; x${LIMIT} fails the regex. Both keep the str tag. | ||
| if isinstance(node, yaml.ScalarNode): | ||
| # A ``ScalarNode`` is a leaf -- one configuration value. Only a | ||
| # ``str``-tagged one can hold a ``${VAR}`` reference (nodes the parser | ||
| # already typed as int, float, bool or null cannot), so those are the | ||
| # only substitution candidates. | ||
| if node.tag == _YAML_STR_TAG: | ||
| # ``node.value`` is the raw text of this one value (never a key or | ||
| # comment). Replace any ${VAR} / ${VAR:-default} / $$ in it. | ||
| raw = node.value | ||
| node.value = substitute_env_vars(raw) | ||
| # The spec says node types are interpreted *after* substitution, | ||
| # but only for a value that is exactly one reference and written | ||
| # unquoted (``style is None``). Example: ``count: ${N}`` with | ||
| # ``N=42`` must yield the int 42, not the string "42". In that case | ||
| # re-run YAML's implicit type resolution on the substituted text | ||
| # and retag the node. A quoted (``"${N}"``) or embedded | ||
| # (``foo-${N}``) value keeps its ``str`` tag and stays a string. | ||
| if node.style is None and _STANDALONE_ENV_REF.match(raw): | ||
| # ``resolve`` returns the implicit tag (int/float/bool/null/ | ||
| # str) YAML would assign to ``node.value``; the ``(True, False)`` | ||
| # implicit-flag pair marks it as a plain scalar so that | ||
| # type-guessing applies, exactly as during normal parsing. | ||
| node.tag = loader.resolve( | ||
| yaml.ScalarNode, node.value, (True, False) | ||
| ) | ||
| elif isinstance(node, yaml.SequenceNode): | ||
| # A sequence (list): each item is itself a value node -- recurse. | ||
| for item in node.value: | ||
| _substitute_env_in_yaml_node(item, loader) | ||
| elif isinstance(node, yaml.MappingNode): | ||
| # A mapping (dict): recurse into values only; keys are not | ||
| # substitution candidates per the spec. | ||
| for _key_node, value_node in node.value: | ||
| _substitute_env_in_yaml_node(value_node, loader) | ||
|
|
||
|
|
||
| def _substitute_env_in_json_value(value: Any) -> Any: | ||
| """Recursively apply env-var substitution to string values in JSON data. | ||
|
|
||
| JSON has explicit types and no comments, so substitution applies only to | ||
| string values (the result stays a string) and mapping keys are left as-is. | ||
| """ | ||
| if isinstance(value, str): | ||
| return substitute_env_vars(value) | ||
| if isinstance(value, list): | ||
| return [_substitute_env_in_json_value(item) for item in value] | ||
| if isinstance(value, dict): | ||
| return { | ||
| key: _substitute_env_in_json_value(val) | ||
| for key, val in value.items() | ||
| } | ||
| return value | ||
|
|
||
|
|
||
| def _parse_config_content( | ||
| content: str, suffix: str, file_path: str | os.PathLike[str] | ||
| ) -> Any: | ||
| """Parse configuration text and substitute environment variables. | ||
|
|
||
| Parsing happens first, so ``${VAR}`` references in comments and mapping | ||
| keys are never substitution candidates; substitution then runs on | ||
| configuration values, with YAML node types re-resolved for standalone | ||
| references. | ||
|
|
||
| Raises: | ||
| ConfigurationError: If the content cannot be parsed or substitution of | ||
| a required environment variable fails. | ||
| """ | ||
| try: | ||
| if suffix == ".json": | ||
| return _substitute_env_in_json_value(json.loads(content)) | ||
| yaml_loader = yaml.SafeLoader(content) | ||
| try: | ||
| root_node = yaml_loader.get_single_node() | ||
| if root_node is None: | ||
| return None | ||
| _substitute_env_in_yaml_node(root_node, yaml_loader) | ||
| return yaml_loader.construct_document(root_node) | ||
| finally: | ||
| yaml_loader.dispose() | ||
| except EnvSubstitutionError as exc: | ||
| raise ConfigurationError( | ||
| f"Environment variable substitution failed: {exc}" | ||
| ) from exc | ||
| except yaml.YAMLError as exc: | ||
| _logger.exception("Failed to parse YAML from %s", file_path) | ||
| raise ConfigurationError(f"Failed to parse YAML: {exc}") from exc | ||
| except json.JSONDecodeError as exc: | ||
| _logger.exception("Failed to parse JSON from %s", file_path) | ||
| raise ConfigurationError(f"Failed to parse JSON: {exc}") from exc | ||
|
|
||
|
|
||
| def load_config_file( | ||
| file_path: str | os.PathLike[str], | ||
| ) -> OpenTelemetryConfiguration: | ||
| """Load and parse an OpenTelemetry configuration file. | ||
|
|
||
| Supports YAML and JSON formats. Performs environment variable substitution | ||
| before parsing. | ||
| Supports YAML and JSON formats. Environment variable substitution is | ||
| performed after parsing, on configuration values only, so ``${VAR}`` | ||
| references in comments or mapping keys are left untouched. | ||
|
|
||
| Args: | ||
| file_path: Path to the configuration file (.yaml, .yml, or .json). | ||
|
|
@@ -109,32 +245,17 @@ def load_config_file( | |
| f"Failed to read configuration file: {file_path}" | ||
| ) from exc | ||
|
|
||
| # Perform environment variable substitution | ||
| try: | ||
| content = substitute_env_vars(content) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is where the bug (#5406) was. Because it operated on the unparsed text, This PR removes this pre-parse step and instead substitutes per configuration value after parsing ( |
||
| except Exception as exc: | ||
| # Parse the file, then substitute env vars in configuration values only. | ||
| # Parsing first means comments and mapping keys are never substitution | ||
| # candidates, and node types are still resolved after substitution. | ||
| suffix = path.suffix.lower() | ||
| if suffix not in (".yaml", ".yml", ".json"): | ||
| _logger.error("Unsupported file format: %s", suffix) | ||
| raise ConfigurationError( | ||
| f"Environment variable substitution failed: {exc}" | ||
| ) from exc | ||
| f"Unsupported file format: {suffix}. Use .yaml, .yml, or .json" | ||
| ) | ||
|
|
||
| # Parse based on file extension | ||
| suffix = path.suffix.lower() | ||
| try: | ||
| if suffix in (".yaml", ".yml"): | ||
| data = yaml.safe_load(content) | ||
| elif suffix == ".json": | ||
| data = json.loads(content) | ||
| else: | ||
| _logger.error("Unsupported file format: %s", suffix) | ||
| raise ConfigurationError( | ||
| f"Unsupported file format: {suffix}. Use .yaml, .yml, or .json" | ||
| ) | ||
| except yaml.YAMLError as exc: | ||
| _logger.exception("Failed to parse YAML from %s", file_path) | ||
| raise ConfigurationError(f"Failed to parse YAML: {exc}") from exc | ||
| except json.JSONDecodeError as exc: | ||
| _logger.exception("Failed to parse JSON from %s", file_path) | ||
| raise ConfigurationError(f"Failed to parse JSON: {exc}") from exc | ||
| data = _parse_config_content(content, suffix, file_path) | ||
|
|
||
| if data is None: | ||
| _logger.error("Configuration file is empty: %s", file_path) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.