diff --git a/control_plane/live_target_runtime.py b/control_plane/live_target_runtime.py index c638d828..194797da 100644 --- a/control_plane/live_target_runtime.py +++ b/control_plane/live_target_runtime.py @@ -257,6 +257,18 @@ def _filter_runtime_environment_to_product_keys( return {key: value for key, value in desired_env_map.items() if key in allowed_keys} +def _require_expected_runtime_secret_values( + *, desired_env_map: dict[str, str], runtime_secret_binding_keys: set[str] +) -> None: + missing_keys = sorted(key for key in runtime_secret_binding_keys if key not in desired_env_map) + if missing_keys: + raise LiveTargetRuntimeError( + "Expected managed runtime secret values are missing from the resolved " + f"Launchplane runtime environment: {', '.join(missing_keys)}.", + code="runtime_secret_values_missing", + ) + + def require_dokploy_target_definition( *, source_of_truth: control_plane_dokploy.DokploySourceOfTruth, @@ -338,6 +350,10 @@ def apply_live_target_runtime_environment( context_name=context_name, instance_name=instance_name, ) + _require_expected_runtime_secret_values( + desired_env_map=desired_env_map, + runtime_secret_binding_keys=runtime_secret_binding_keys, + ) finally: postgres_store.close() if not desired_env_map: @@ -389,9 +405,7 @@ def apply_live_target_runtime_environment( context_name=context_name, instance_name=instance_name, require_policy=apply_changes, - required_binding_keys=tuple( - sorted(key for key in runtime_secret_binding_keys if key in desired_env_map) - ), + required_binding_keys=tuple(sorted(runtime_secret_binding_keys)), ) finally: postgres_store.close() diff --git a/docs/secrets.md b/docs/secrets.md index 7e96a25c..81982288 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -71,6 +71,12 @@ title: Secrets runtime managed-secret binding keys for the selected lane. Shared/global runtime records can provide values, but they are not synced to an unrelated product target unless that product profile declares the key. +- Odoo testing lanes declare only their stable compose runtime contract in + product onboarding seed material: `ODOO_DB_NAME`, `ODOO_DB_USER`, + `ODOO_DATA_VOLUME`, `ODOO_LOG_VOLUME`, `ODOO_DB_VOLUME`, and managed + testing-secret bindings for `ODOO_ADMIN_PASSWORD`, `ODOO_DB_PASSWORD`, and + `ODOO_MASTER_PASSWORD`. Odoo prod lanes stay undeclared until their production + runtime-secret ownership is validated explicitly. - Gates fail closed when a required binding is missing, disabled, ambiguous, unclassified, or scoped outside the target context/instance. A target with an unknown environment class also fails closed. diff --git a/import-material/launchplane/seed-imports/catalog.json b/import-material/launchplane/seed-imports/catalog.json index 4ab01da4..a5890977 100644 --- a/import-material/launchplane/seed-imports/catalog.json +++ b/import-material/launchplane/seed-imports/catalog.json @@ -268,6 +268,83 @@ "deploy_timeout_seconds": 900 } ], + "runtime_environments": [ + { + "scope": "instance", + "context": "cm", + "instance": "testing", + "env": { + "ODOO_DB_NAME": "cm_testing", + "ODOO_DB_USER": "odoo", + "ODOO_DATA_VOLUME": "cm_testing_odoo_data", + "ODOO_LOG_VOLUME": "cm_testing_odoo_logs", + "ODOO_DB_VOLUME": "cm_testing_odoo_db" + } + } + ], + "secret_bindings": [ + { + "binding_key": "ODOO_ADMIN_PASSWORD", + "context": "cm", + "instance": "testing" + }, + { + "binding_key": "ODOO_DB_PASSWORD", + "context": "cm", + "instance": "testing" + }, + { + "binding_key": "ODOO_MASTER_PASSWORD", + "context": "cm", + "instance": "testing" + } + ], + "expected_config": { + "runtime_environment_keys": [ + { + "key": "ODOO_DB_NAME", + "context": "cm", + "instance": "testing" + }, + { + "key": "ODOO_DB_USER", + "context": "cm", + "instance": "testing" + }, + { + "key": "ODOO_DATA_VOLUME", + "context": "cm", + "instance": "testing" + }, + { + "key": "ODOO_LOG_VOLUME", + "context": "cm", + "instance": "testing" + }, + { + "key": "ODOO_DB_VOLUME", + "context": "cm", + "instance": "testing" + } + ], + "managed_secret_bindings": [ + { + "binding_key": "ODOO_ADMIN_PASSWORD", + "context": "cm", + "instance": "testing" + }, + { + "binding_key": "ODOO_DB_PASSWORD", + "context": "cm", + "instance": "testing" + }, + { + "binding_key": "ODOO_MASTER_PASSWORD", + "context": "cm", + "instance": "testing" + } + ] + }, "source_label": "import-material:odoo-cm-product-onboarding" } }, @@ -377,6 +454,83 @@ "deploy_timeout_seconds": 900 } ], + "runtime_environments": [ + { + "scope": "instance", + "context": "opw", + "instance": "testing", + "env": { + "ODOO_DB_NAME": "opw_testing", + "ODOO_DB_USER": "odoo", + "ODOO_DATA_VOLUME": "opw_testing_odoo_data", + "ODOO_LOG_VOLUME": "opw_testing_odoo_logs", + "ODOO_DB_VOLUME": "opw_testing_odoo_db" + } + } + ], + "secret_bindings": [ + { + "binding_key": "ODOO_ADMIN_PASSWORD", + "context": "opw", + "instance": "testing" + }, + { + "binding_key": "ODOO_DB_PASSWORD", + "context": "opw", + "instance": "testing" + }, + { + "binding_key": "ODOO_MASTER_PASSWORD", + "context": "opw", + "instance": "testing" + } + ], + "expected_config": { + "runtime_environment_keys": [ + { + "key": "ODOO_DB_NAME", + "context": "opw", + "instance": "testing" + }, + { + "key": "ODOO_DB_USER", + "context": "opw", + "instance": "testing" + }, + { + "key": "ODOO_DATA_VOLUME", + "context": "opw", + "instance": "testing" + }, + { + "key": "ODOO_LOG_VOLUME", + "context": "opw", + "instance": "testing" + }, + { + "key": "ODOO_DB_VOLUME", + "context": "opw", + "instance": "testing" + } + ], + "managed_secret_bindings": [ + { + "binding_key": "ODOO_ADMIN_PASSWORD", + "context": "opw", + "instance": "testing" + }, + { + "binding_key": "ODOO_DB_PASSWORD", + "context": "opw", + "instance": "testing" + }, + { + "binding_key": "ODOO_MASTER_PASSWORD", + "context": "opw", + "instance": "testing" + } + ] + }, "source_label": "import-material:odoo-opw-product-onboarding" } }, diff --git a/tests/test_product_onboarding.py b/tests/test_product_onboarding.py index 2c3902dc..03c6e197 100644 --- a/tests/test_product_onboarding.py +++ b/tests/test_product_onboarding.py @@ -18,6 +18,18 @@ CLI_MAIN = cast(Command, main) +ODOO_TESTING_RUNTIME_KEYS = ( + "ODOO_DB_NAME", + "ODOO_DB_USER", + "ODOO_DATA_VOLUME", + "ODOO_LOG_VOLUME", + "ODOO_DB_VOLUME", +) +ODOO_TESTING_SECRET_KEYS = ( + "ODOO_ADMIN_PASSWORD", + "ODOO_DB_PASSWORD", + "ODOO_MASTER_PASSWORD", +) def _sqlite_database_url(database_path: Path) -> str: @@ -162,6 +174,64 @@ def _seed_import_manifest( return cast(dict[str, object], manifest_payload) +def _assert_odoo_testing_expected_runtime_contract( + test_case: unittest.TestCase, + *, + manifest: ProductOnboardingManifest, + context: str, + expected_database_name: str, +) -> None: + runtime_record = next( + record + for record in manifest.runtime_environments + if record.context == context and record.instance == "testing" + ) + test_case.assertEqual(runtime_record.scope, "instance") + test_case.assertEqual( + runtime_record.env, + { + "ODOO_DB_NAME": expected_database_name, + "ODOO_DB_USER": "odoo", + "ODOO_DATA_VOLUME": f"{context}_testing_odoo_data", + "ODOO_LOG_VOLUME": f"{context}_testing_odoo_logs", + "ODOO_DB_VOLUME": f"{context}_testing_odoo_db", + }, + ) + testing_secret_bindings = [ + (binding.context, binding.instance, binding.binding_key) + for binding in manifest.secret_bindings + if binding.context == context and binding.instance == "testing" + ] + test_case.assertEqual( + testing_secret_bindings, + [(context, "testing", binding_key) for binding_key in ODOO_TESTING_SECRET_KEYS], + ) + test_case.assertNotIn("prod", {record.instance for record in manifest.runtime_environments}) + test_case.assertNotIn("prod", {binding.instance for binding in manifest.secret_bindings}) + test_case.assertEqual( + [ + (requirement.context, requirement.instance, requirement.key) + for requirement in manifest.expected_config.runtime_environment_keys + ], + [(context, "testing", key) for key in ODOO_TESTING_RUNTIME_KEYS], + ) + test_case.assertEqual( + [ + (requirement.context, requirement.instance, requirement.binding_key) + for requirement in manifest.expected_config.managed_secret_bindings + ], + [(context, "testing", binding_key) for binding_key in ODOO_TESTING_SECRET_KEYS], + ) + test_case.assertNotIn( + "prod", + {requirement.instance for requirement in manifest.expected_config.runtime_environment_keys}, + ) + test_case.assertNotIn( + "prod", + {requirement.instance for requirement in manifest.expected_config.managed_secret_bindings}, + ) + + class ProductOnboardingTests(unittest.TestCase): def test_launchplane_seed_import_workflow_owns_seed_writes(self) -> None: deploy_script = Path("scripts/deploy/ensure-authz-grants.sh").read_text(encoding="utf-8") @@ -1440,6 +1510,12 @@ def test_seed_import_odoo_cm_onboarding_manifest_encodes_issue_backed_bootstrap_ ("cm", "prod", "compose", "compose-cm-prod"), ], ) + _assert_odoo_testing_expected_runtime_contract( + self, + manifest=manifest, + context="cm", + expected_database_name="cm_testing", + ) self.assertEqual(manifest.source_label, "import-material:odoo-cm-product-onboarding") def test_seed_import_odoo_cm_onboarding_manifest_requires_prod_target_id(self) -> None: @@ -1486,6 +1562,12 @@ def test_seed_import_odoo_opw_onboarding_manifest_encodes_upstream_restore_polic policies["prod"].expected_domains, ("opw-prod.shinycomputers.com",), ) + _assert_odoo_testing_expected_runtime_contract( + self, + manifest=manifest, + context="opw", + expected_database_name="opw_testing", + ) def test_apply_product_onboarding_manifest_writes_canonical_records(self) -> None: with TemporaryDirectory() as temporary_directory_name: diff --git a/tests/test_service.py b/tests/test_service.py index c0a662d5..9882c063 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -22475,6 +22475,102 @@ def test_live_target_runtime_api_dry_run_returns_redacted_delta(self) -> None: self.assertNotIn("must-not-sync", json.dumps(payload)) self.assertNotIn("context-secret-value", json.dumps(payload)) + def test_live_target_runtime_api_requires_expected_managed_secret_values(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + root = Path(temporary_directory_name) + database_url = _sqlite_database_url(root / "launchplane.sqlite3") + store = PostgresRecordStore(database_url=database_url) + store.ensure_schema() + try: + store.write_runtime_environment_record( + RuntimeEnvironmentRecord( + scope="instance", + context="sellyouroutboard", + instance="prod", + env={"GOOGLE_ANALYTICS_MEASUREMENT_ID": "G-9KRMER45KG"}, + updated_at="2026-05-06T17:00:00Z", + source_label="test", + ) + ) + store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate( + _live_target_runtime_profile_payload(include_context_secret=True) + ) + ) + _seed_tracked_target_records( + database_url=database_url, + context="sellyouroutboard", + instance="prod", + target_id="application-syo-prod", + target_type="application", + target_name="syo-prod-app", + ) + finally: + store.close() + policy = LaunchplaneAuthzPolicy.model_validate( + { + "github_actions": [ + { + "repository": "cbusillo/launchplane", + "workflow_refs": [ + "cbusillo/launchplane/.github/workflows/live-target-runtime.yml@refs/heads/main" + ], + "products": ["sellyouroutboard"], + "contexts": ["sellyouroutboard"], + "actions": ["live_target_runtime.plan"], + } + ] + } + ) + app = create_launchplane_service_app( + state_dir=root / "state", + verifier=_StubVerifier( + _identity( + repository="cbusillo/launchplane", + workflow_ref=( + "cbusillo/launchplane/.github/workflows/live-target-runtime.yml@refs/heads/main" + ), + event_name="workflow_dispatch", + ) + ), + authz_policy=policy, + control_plane_root_path=root, + database_url=database_url, + ) + + with ( + patch.dict(os.environ, {"LAUNCHPLANE_DATABASE_URL": database_url}, clear=True), + patch( + "control_plane.dokploy.fetch_dokploy_target_payload", + return_value={ + "applicationId": "application-syo-prod", + "name": "syo-prod-app", + "env": "CONTACT_EMAIL_MODE=resend\n", + }, + ), + patch("control_plane.dokploy.update_dokploy_target_env") as update_env, + ): + status_code, payload = _invoke_app( + app, + method="POST", + path="/v1/live-target-runtime/apply", + payload={ + "schema_version": 1, + "mode": "dry-run", + "product": "sellyouroutboard", + "context": "sellyouroutboard", + "instance": "prod", + }, + headers={"Idempotency-Key": "live-target-runtime:missing-secret"}, + ) + + self.assertEqual(status_code, 400, msg=json.dumps(payload, indent=2, sort_keys=True)) + update_env.assert_not_called() + self.assertEqual(payload["status"], "rejected") + self.assertEqual(payload["error"]["code"], "runtime_secret_values_missing") + self.assertIn("CONTEXT_API_TOKEN", payload["error"]["message"]) + self.assertNotIn("G-9KRMER45KG", json.dumps(payload)) + def test_live_target_runtime_api_apply_updates_env_and_verifies(self) -> None: with TemporaryDirectory() as temporary_directory_name: root = Path(temporary_directory_name)