From 036cd3f1097f7b2396660bdc6190a8f48f61b3ea Mon Sep 17 00:00:00 2001 From: JSimmsDev Date: Fri, 4 Sep 2026 12:56:40 +0100 Subject: [PATCH 1/2] TOPS-2701 - construct cloud lookup clients lazily during output resolution --- src/envars/main.py | 15 ++++++++++++--- tests/test_cli.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/envars/main.py b/src/envars/main.py index 04a8685..45f3304 100644 --- a/src/envars/main.py +++ b/src/envars/main.py @@ -414,8 +414,13 @@ def _get_resolved_variables( # Parameter Store and Secret Manager substitution if manager.cloud_provider == "aws": - ssm_store = SSMParameterStore() - cf_exports = CloudFormationExports() + needs_aws_lookup = any( + isinstance(v, str) and v.startswith(("parameter_store:", "cloudformation_export:")) + for v in resolved_vars.values() + ) + if needs_aws_lookup: + ssm_store = SSMParameterStore() + cf_exports = CloudFormationExports() for var_name, value in resolved_vars.items(): if isinstance(value, str): if value.startswith("parameter_store:"): @@ -431,7 +436,11 @@ def _get_resolved_variables( raise ValueError(f"Export '{export_name}' not found in CloudFormation exports.") resolved_vars[var_name] = export_value elif manager.cloud_provider == "gcp": - gcp_secret_manager = GCPSecretManager() + needs_gcp_lookup = any( + isinstance(v, str) and v.startswith("gcp_secret_manager:") for v in resolved_vars.values() + ) + if needs_gcp_lookup: + gcp_secret_manager = GCPSecretManager() for var_name, value in resolved_vars.items(): if isinstance(value, str): if value.startswith("gcp_secret_manager:"): diff --git a/tests/test_cli.py b/tests/test_cli.py index fccceed..240deb9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1477,3 +1477,51 @@ def test_output_multiline_secret_dotenv(tmp_path): expected_output = f'MY_MULTILINE_SECRET="{escaped_multiline_value}"' assert expected_output in result.stdout stubber.assert_no_pending_responses() + + +@patch("envars.main.GCPSecretManager") +def test_plaintext_gcp_file_output_without_gcp_credentials(mock_gcp_secret_manager, tmp_path): + mock_gcp_secret_manager.side_effect = Exception("no GCP credentials available") + + initial_content = """ +configuration: + kms_key: "projects/my-gcp-project/locations/us-central1/keyRings/my-key-ring/cryptoKeys/my-key" + environments: + - dev + locations: + - aws: "123456789012" + - gcp: "my-gcp-project" +environment_variables: + MY_VAR: + default: "plain-value" +""" + file_path = create_envars_file(tmp_path, initial_content) + result = runner.invoke(app, ["--file", file_path, "output", "--format", "yaml", "--env", "dev", "--loc", "aws"]) + assert result.exit_code == 0 + output_dict = yaml.safe_load(result.stdout) + assert output_dict["envars"]["MY_VAR"] == "plain-value" + mock_gcp_secret_manager.assert_not_called() + + +@patch("envars.main.CloudFormationExports") +@patch("envars.main.SSMParameterStore") +def test_plaintext_aws_file_output_without_aws_credentials(mock_ssm_store, mock_cf_exports, tmp_path): + mock_ssm_store.side_effect = Exception("no AWS credentials available") + mock_cf_exports.side_effect = Exception("no AWS credentials available") + + initial_content = """ +configuration: + kms_key: "arn:aws:kms:us-east-1:123456789012:key/mrk-12345" + environments: + - dev +environment_variables: + MY_VAR: + default: "plain-value" +""" + file_path = create_envars_file(tmp_path, initial_content) + result = runner.invoke(app, ["--file", file_path, "output", "--format", "yaml", "--env", "dev"]) + assert result.exit_code == 0 + output_dict = yaml.safe_load(result.stdout) + assert output_dict["envars"]["MY_VAR"] == "plain-value" + mock_ssm_store.assert_not_called() + mock_cf_exports.assert_not_called() From 7412062bd838fd8802365bdb8d16c6a6ddaeba6a Mon Sep 17 00:00:00 2001 From: JSimmsDev Date: Fri, 4 Sep 2026 13:21:14 +0100 Subject: [PATCH 2/2] TOPS-2701 - PR review fixes: construct each lookup client at first use of its own prefix - Replace the any() pre-scan with None-initialized clients built at first use, so a file using only parameter_store: no longer constructs the CloudFormationExports client (and vice versa) - Covers both review asks: per-prefix construction and None-initialized variables - New regression test: parameter_store lookup does not construct the CloudFormationExports client --- src/envars/main.py | 21 +++++++++------------ tests/test_cli.py | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/envars/main.py b/src/envars/main.py index 45f3304..952e23e 100644 --- a/src/envars/main.py +++ b/src/envars/main.py @@ -414,36 +414,33 @@ def _get_resolved_variables( # Parameter Store and Secret Manager substitution if manager.cloud_provider == "aws": - needs_aws_lookup = any( - isinstance(v, str) and v.startswith(("parameter_store:", "cloudformation_export:")) - for v in resolved_vars.values() - ) - if needs_aws_lookup: - ssm_store = SSMParameterStore() - cf_exports = CloudFormationExports() + ssm_store = None + cf_exports = None for var_name, value in resolved_vars.items(): if isinstance(value, str): if value.startswith("parameter_store:"): + if ssm_store is None: + ssm_store = SSMParameterStore() param_name = value.split(":", 1)[1] param_value = ssm_store.get_parameter(param_name) if param_value is None: raise ValueError(f"Parameter '{param_name}' not found in Parameter Store.") resolved_vars[var_name] = param_value elif value.startswith("cloudformation_export:"): + if cf_exports is None: + cf_exports = CloudFormationExports() export_name = value.split(":", 1)[1] export_value = cf_exports.get_export_value(export_name) if export_value is None: raise ValueError(f"Export '{export_name}' not found in CloudFormation exports.") resolved_vars[var_name] = export_value elif manager.cloud_provider == "gcp": - needs_gcp_lookup = any( - isinstance(v, str) and v.startswith("gcp_secret_manager:") for v in resolved_vars.values() - ) - if needs_gcp_lookup: - gcp_secret_manager = GCPSecretManager() + gcp_secret_manager = None for var_name, value in resolved_vars.items(): if isinstance(value, str): if value.startswith("gcp_secret_manager:"): + if gcp_secret_manager is None: + gcp_secret_manager = GCPSecretManager() secret_name = value.split(":", 1)[1] secret_value = gcp_secret_manager.access_secret_version(secret_name) if secret_value is None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 240deb9..7ffe9df 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1525,3 +1525,27 @@ def test_plaintext_aws_file_output_without_aws_credentials(mock_ssm_store, mock_ assert output_dict["envars"]["MY_VAR"] == "plain-value" mock_ssm_store.assert_not_called() mock_cf_exports.assert_not_called() + + +@patch("envars.main.CloudFormationExports") +@patch("envars.main.SSMParameterStore") +def test_parameter_store_lookup_does_not_construct_cf_exports_client(mock_ssm_store, mock_cf_exports, tmp_path): + mock_ssm_instance = mock_ssm_store.return_value + mock_ssm_instance.get_parameter.return_value = "ssm_value" + mock_cf_exports.side_effect = Exception("CloudFormationExports should not be constructed") + + initial_content = """ +configuration: + kms_key: "arn:aws:kms:us-east-1:123456789012:key/mrk-12345" + environments: + - dev +environment_variables: + MY_VAR: + default: "parameter_store:/my/parameter" +""" + file_path = create_envars_file(tmp_path, initial_content) + result = runner.invoke(app, ["--file", file_path, "output", "--format", "yaml", "--env", "dev"]) + assert result.exit_code == 0 + output_dict = yaml.safe_load(result.stdout) + assert output_dict["envars"]["MY_VAR"] == "ssm_value" + mock_cf_exports.assert_not_called()