From 5e319bce6e7fbaaa1b7fe07c441f87d0511223de Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 2 Jun 2026 14:21:37 -0400 Subject: [PATCH] Scope live runtime sync to product expectations --- control_plane/cli.py | 2 + control_plane/cli_runtime_environments.py | 3 + control_plane/live_target_runtime.py | 210 +++++++++++++++++++--- control_plane/service.py | 1 + docs/secrets.md | 5 + docs/service-boundary.md | 7 +- tests/test_dokploy.py | 52 ++++++ tests/test_runtime_environments.py | 17 ++ tests/test_service.py | 84 ++++++++- 9 files changed, 348 insertions(+), 33 deletions(-) diff --git a/control_plane/cli.py b/control_plane/cli.py index 317bed3a..f156be73 100644 --- a/control_plane/cli.py +++ b/control_plane/cli.py @@ -3234,6 +3234,7 @@ def _sync_live_target_from_tracked_contract( def _apply_live_target_runtime_environment( *, + product_name: str, context_name: str, instance_name: str, apply_changes: bool, @@ -3244,6 +3245,7 @@ def _apply_live_target_runtime_environment( try: return control_plane_live_target_runtime.apply_live_target_runtime_environment( control_plane_root=_control_plane_root(), + product_name=product_name, context_name=context_name, instance_name=instance_name, apply_changes=apply_changes, diff --git a/control_plane/cli_runtime_environments.py b/control_plane/cli_runtime_environments.py index 26581f94..132375e0 100644 --- a/control_plane/cli_runtime_environments.py +++ b/control_plane/cli_runtime_environments.py @@ -453,6 +453,7 @@ def environments_sync_live_target( @environments.command("apply-live-target") +@click.option("--product", "product_name", required=True) @click.option("--context", "context_name", required=True) @click.option("--instance", "instance_name", required=True) @click.option("--dry-run", "dry_run", is_flag=True, default=False) @@ -461,6 +462,7 @@ def environments_sync_live_target( @click.option("--no-cache", is_flag=True, default=False) @click.option("--deploy-timeout-seconds", type=int, default=None, show_default=False) def environments_apply_live_target( + product_name: str, context_name: str, instance_name: str, dry_run: bool, @@ -475,6 +477,7 @@ def environments_apply_live_target( raise click.ClickException("Deploy options require --apply.") callbacks = _runtime_environment_callbacks() payload = callbacks.apply_live_target_runtime_environment( + product_name=product_name, context_name=context_name, instance_name=instance_name, apply_changes=apply_changes, diff --git a/control_plane/live_target_runtime.py b/control_plane/live_target_runtime.py index 1947d246..c638d828 100644 --- a/control_plane/live_target_runtime.py +++ b/control_plane/live_target_runtime.py @@ -8,14 +8,16 @@ from control_plane import dokploy as control_plane_dokploy from control_plane import runtime_environments as control_plane_runtime_environments from control_plane import secrets as control_plane_secrets +from control_plane.contracts.product_profile_record import LaunchplaneProductProfileRecord from control_plane.contracts.runtime_key_safety_policy import ( - RuntimeEnvironmentClass, RuntimeKeySafetyTarget, ) from control_plane.runtime_key_safety import ( RuntimeKeySafetyPolicyReadStore, + evaluate_runtime_key_safety, evaluate_runtime_key_safety_from_store, latest_active_runtime_key_safety_policy, + runtime_key_safety_environment_class, ) from control_plane.storage.factory import resolve_database_url from control_plane.storage.postgres import PostgresRecordStore @@ -40,6 +42,10 @@ def __call__( ) -> dict[str, str]: ... +class LiveTargetRuntimeProfileStore(RuntimeKeySafetyPolicyReadStore, Protocol): + def read_product_profile_record(self, product: str) -> LaunchplaneProductProfileRecord: ... + + def runtime_env_live_target_delta( *, desired_env_map: dict[str, str], @@ -67,47 +73,52 @@ def runtime_env_live_target_delta( } -def runtime_key_safety_environment_class(instance_name: str) -> RuntimeEnvironmentClass: - normalized_instance = instance_name.strip().lower() - if normalized_instance in {"prod", "production"}: - return "prod" - if normalized_instance in {"testing", "test", "staging", "stage"}: - return "testing" - if normalized_instance in {"preview", "pr"} or normalized_instance.startswith("pr-"): - return "preview" - if normalized_instance in {"dev", "local", "development"}: - return "dev" - return "unknown" - - def evaluate_runtime_key_safety_for_live_target_sync( *, record_store: RuntimeKeySafetyPolicyReadStore, context_name: str, instance_name: str, require_policy: bool = True, + required_binding_keys: tuple[str, ...] = (), ) -> dict[str, object]: - bindings = record_store.list_secret_bindings( + all_runtime_bindings = record_store.list_secret_bindings( integration=control_plane_secrets.RUNTIME_ENVIRONMENT_SECRET_INTEGRATION, - context_name=context_name, - instance_name=instance_name, limit=None, ) - binding_keys = tuple(binding.binding_key for binding in bindings) + bindings = tuple( + binding + for binding in all_runtime_bindings + if _runtime_secret_binding_matches_target( + binding_context=binding.context, + binding_instance=binding.instance, + context_name=context_name, + instance_name=instance_name, + ) + ) + binding_keys = required_binding_keys or tuple(binding.binding_key for binding in bindings) if not binding_keys: return {"required": False, "status": "skipped", "checked_binding_keys": []} try: policy_record = latest_active_runtime_key_safety_policy(record_store) - evaluation = evaluate_runtime_key_safety_from_store( - record_store=record_store, - policy_record=policy_record, - target=RuntimeKeySafetyTarget( - context=context_name, - instance=instance_name, - environment_class=runtime_key_safety_environment_class(instance_name), - ), - required_binding_keys=binding_keys, + target = RuntimeKeySafetyTarget( + context=context_name, + instance=instance_name, + environment_class=runtime_key_safety_environment_class(instance_name), ) + if required_binding_keys: + evaluation = evaluate_runtime_key_safety( + target=target, + required_binding_keys=binding_keys, + secret_bindings=bindings, + secret_rules=policy_record.rules, + ) + else: + evaluation = evaluate_runtime_key_safety_from_store( + record_store=record_store, + policy_record=policy_record, + target=target, + required_binding_keys=binding_keys, + ) except ValueError as error: if not require_policy: return { @@ -142,6 +153,110 @@ def skipped_runtime_key_safety_summary() -> dict[str, object]: return {"required": False, "status": "skipped", "checked_binding_keys": []} +def _runtime_secret_binding_matches_target( + *, binding_context: str, binding_instance: str, context_name: str, instance_name: str +) -> bool: + if not binding_context: + return True + if binding_context != context_name: + return False + if not binding_instance: + return True + return binding_instance == instance_name + + +def _require_product_profile_runtime_keys( + *, + record_store: LiveTargetRuntimeProfileStore, + product_name: str, + context_name: str, + instance_name: str, +) -> set[str]: + profile = record_store.read_product_profile_record(product_name) + lane = next( + ( + candidate + for candidate in profile.lanes + if candidate.context == context_name and candidate.instance == instance_name + ), + None, + ) + if lane is None: + raise LiveTargetRuntimeError( + f"Product {product_name!r} has no lane for {context_name}/{instance_name}.", + code="product_lane_not_found", + ) + allowed_keys: set[str] = set() + for runtime_requirement in profile.expected_config.runtime_environment_keys: + if _expected_config_route_matches( + requirement_context=runtime_requirement.context, + requirement_instance=runtime_requirement.instance, + context_name=context_name, + instance_name=instance_name, + ): + allowed_keys.add(runtime_requirement.key) + for secret_requirement in profile.expected_config.managed_secret_bindings: + if ( + secret_requirement.integration + != control_plane_secrets.RUNTIME_ENVIRONMENT_SECRET_INTEGRATION + ): + continue + if _expected_config_route_matches( + requirement_context=secret_requirement.context, + requirement_instance=secret_requirement.instance, + context_name=context_name, + instance_name=instance_name, + ): + allowed_keys.add(secret_requirement.binding_key) + if not allowed_keys: + raise LiveTargetRuntimeError( + f"Product {product_name!r} has no expected runtime keys for {context_name}/{instance_name}.", + code="runtime_environment_empty", + ) + return allowed_keys + + +def _require_product_profile_runtime_secret_keys( + *, + record_store: LiveTargetRuntimeProfileStore, + product_name: str, + context_name: str, + instance_name: str, +) -> set[str]: + profile = record_store.read_product_profile_record(product_name) + return { + requirement.binding_key + for requirement in profile.expected_config.managed_secret_bindings + if requirement.integration == control_plane_secrets.RUNTIME_ENVIRONMENT_SECRET_INTEGRATION + and _expected_config_route_matches( + requirement_context=requirement.context, + requirement_instance=requirement.instance, + context_name=context_name, + instance_name=instance_name, + ) + } + + +def _expected_config_route_matches( + *, + requirement_context: str, + requirement_instance: str, + context_name: str, + instance_name: str, +) -> bool: + if requirement_instance: + return requirement_context == context_name and requirement_instance == instance_name + if requirement_context: + return requirement_context == context_name + return True + + +def _filter_runtime_environment_to_product_keys( + *, desired_env_map: dict[str, str], allowed_keys: set[str] +) -> dict[str, str]: + return {key: value for key, value in desired_env_map.items() if key in allowed_keys} + + def require_dokploy_target_definition( *, source_of_truth: control_plane_dokploy.DokploySourceOfTruth, @@ -165,6 +280,7 @@ def require_dokploy_target_definition( def apply_live_target_runtime_environment( *, control_plane_root: Path, + product_name: str = "", context_name: str, instance_name: str, apply_changes: bool, @@ -196,6 +312,40 @@ def apply_live_target_runtime_environment( code="runtime_environment_empty", ) + database_url = resolve_database_url(None) + runtime_secret_binding_keys: set[str] = set() + if product_name.strip(): + if database_url is None: + raise LiveTargetRuntimeError( + "Live target runtime product scoping requires LAUNCHPLANE_DATABASE_URL.", + code="runtime_environment_unavailable", + ) + postgres_store = PostgresRecordStore(database_url=database_url) + try: + postgres_store.ensure_schema() + desired_env_map = _filter_runtime_environment_to_product_keys( + desired_env_map=desired_env_map, + allowed_keys=_require_product_profile_runtime_keys( + record_store=postgres_store, + product_name=product_name.strip(), + context_name=context_name, + instance_name=instance_name, + ), + ) + runtime_secret_binding_keys = _require_product_profile_runtime_secret_keys( + record_store=postgres_store, + product_name=product_name.strip(), + context_name=context_name, + instance_name=instance_name, + ) + finally: + postgres_store.close() + if not desired_env_map: + raise LiveTargetRuntimeError( + f"No expected Launchplane runtime environment values resolved for {product_name}/{context_name}/{instance_name}.", + code="runtime_environment_empty", + ) + try: host, token = control_plane_dokploy.read_dokploy_config( control_plane_root=control_plane_root @@ -224,7 +374,6 @@ def apply_live_target_runtime_environment( "reason": "dry_run" if not apply_changes else "no_runtime_env_changes", } - database_url = resolve_database_url(None) if apply_changes and database_url is None: raise LiveTargetRuntimeError( "Live target runtime apply requires LAUNCHPLANE_DATABASE_URL for DB-backed " @@ -233,13 +382,16 @@ def apply_live_target_runtime_environment( ) if database_url is not None and (not apply_changes or changed_key_count or deploy): postgres_store = PostgresRecordStore(database_url=database_url) - postgres_store.ensure_schema() try: + postgres_store.ensure_schema() runtime_key_safety = evaluate_runtime_key_safety_for_live_target_sync( record_store=postgres_store, 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) + ), ) finally: postgres_store.close() diff --git a/control_plane/service.py b/control_plane/service.py index 232c8c2c..3f27d35a 100644 --- a/control_plane/service.py +++ b/control_plane/service.py @@ -12511,6 +12511,7 @@ def product_action_allowed( try: driver_result = control_plane_live_target_runtime.apply_live_target_runtime_environment( control_plane_root=resolved_root, + product_name=live_target_runtime_request.product, context_name=live_target_runtime_request.context, instance_name=live_target_runtime_request.instance, apply_changes=live_target_runtime_request.apply_changes, diff --git a/docs/secrets.md b/docs/secrets.md index 7f1dffcf..7e96a25c 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -66,6 +66,11 @@ title: Secrets - Live target runtime sync uses `POST /v1/live-target-runtime/apply` or the `live-target-runtime.yml` workflow wrapper. Dry-run and apply both evaluate runtime key-safety policy before returning sanitized key/count evidence. +- Live target runtime sync through the service API filters the resolved runtime + payload to the product profile's expected runtime-environment keys and + 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. - 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/docs/service-boundary.md b/docs/service-boundary.md index a62c4299..fdbfd3a6 100644 --- a/docs/service-boundary.md +++ b/docs/service-boundary.md @@ -862,9 +862,10 @@ apply requires `live_target_runtime.apply`. The route resolves DB-backed runtime environment records, managed runtime secrets, and the tracked Dokploy target in the deployed Launchplane service, evaluates runtime key-safety policy, compares desired and live env by key, and returns sanitized key/count evidence without -runtime values or secret plaintext. Apply updates only the Launchplane-owned -keys on the live target, preserves unrelated live env, verifies persistence by -key metadata, and can explicitly trigger a deploy when requested. +runtime values or secret plaintext. Apply updates only the product profile's +expected runtime environment keys and runtime managed-secret binding keys for +the selected lane, preserves unrelated live env, verifies persistence by key +metadata, and can explicitly trigger a deploy when requested. Live target runtime applies are service-boundary work. Operators and agents must not run local CLI live-target mutation commands from arbitrary checkouts to make diff --git a/tests/test_dokploy.py b/tests/test_dokploy.py index c4ea15bf..6b91a587 100644 --- a/tests/test_dokploy.py +++ b/tests/test_dokploy.py @@ -102,6 +102,43 @@ def _write_odoo_product_profile_record(*, store: PostgresRecordStore) -> None: ) +def _write_live_target_product_profile_record( + *, + store: PostgresRecordStore, + product: str = "sellyouroutboard", + context: str = "sellyouroutboard-testing", + instance: str = "prod", + runtime_keys: tuple[str, ...] = ("CONTACT_EMAIL_MODE",), + secret_binding_keys: tuple[str, ...] = (), +) -> None: + store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate( + { + "product": product, + "display_name": "Sell Your Outboard", + "repository": "cbusillo/sellyouroutboard", + "driver_id": "generic-web", + "image": {"repository": "ghcr.io/cbusillo/sellyouroutboard"}, + "runtime_port": 3000, + "health_path": "/api/health", + "lanes": ({"instance": instance, "context": context},), + "expected_config": { + "runtime_environment_keys": tuple( + {"key": key, "context": context, "instance": instance} + for key in runtime_keys + ), + "managed_secret_bindings": tuple( + {"binding_key": binding_key, "context": context, "instance": instance} + for binding_key in secret_binding_keys + ), + }, + "updated_at": "2026-05-09T00:00:00Z", + "source": "test", + } + ) + ) + + class _FakeDokployTargetStore: def __init__( self, @@ -1005,6 +1042,11 @@ def test_apply_live_target_dry_run_reports_runtime_key_delta_without_values(self TRACKED_ONLY = "tracked-private-value" """, ) + _write_live_target_product_profile_record( + store=store, + runtime_keys=("CONTACT_EMAIL_MODE", "TRACKED_ONLY"), + secret_binding_keys=("SMTP_PASSWORD",), + ) with patch.dict( os.environ, { @@ -1055,6 +1097,8 @@ def test_apply_live_target_dry_run_reports_runtime_key_delta_without_values(self [ "environments", "apply-live-target", + "--product", + "sellyouroutboard", "--context", "sellyouroutboard-testing", "--instance", @@ -1111,6 +1155,7 @@ def test_apply_live_target_updates_runtime_env_and_verifies_without_values(self) deploy_timeout_seconds = 77 """, ) + _write_live_target_product_profile_record(store=store) finally: store.close() @@ -1146,6 +1191,8 @@ def fetch_target_payload(**_kwargs: object) -> dict[str, object]: [ "environments", "apply-live-target", + "--product", + "sellyouroutboard", "--context", "sellyouroutboard-testing", "--instance", @@ -1199,6 +1246,7 @@ def test_apply_live_target_deploy_is_explicit(self) -> None: deploy_timeout_seconds = 77 """, ) + _write_live_target_product_profile_record(store=store) finally: store.close() @@ -1231,6 +1279,8 @@ def test_apply_live_target_deploy_is_explicit(self) -> None: [ "environments", "apply-live-target", + "--product", + "sellyouroutboard", "--context", "sellyouroutboard-testing", "--instance", @@ -1259,6 +1309,8 @@ def test_apply_live_target_rejects_deploy_options_for_dry_run(self) -> None: [ "environments", "apply-live-target", + "--product", + "sellyouroutboard", "--context", "sellyouroutboard-testing", "--instance", diff --git a/tests/test_runtime_environments.py b/tests/test_runtime_environments.py index 8b27d5e8..ae540a81 100644 --- a/tests/test_runtime_environments.py +++ b/tests/test_runtime_environments.py @@ -264,6 +264,23 @@ def list_runtime_environment_records( class RuntimeEnvironmentTests(unittest.TestCase): + def test_environments_apply_live_target_requires_product(self) -> None: + result = CliRunner().invoke( + main, + [ + "environments", + "apply-live-target", + "--context", + "discord-blue", + "--instance", + "prod", + "--dry-run", + ], + ) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn("Missing option '--product'", result.output) + def test_load_optional_runtime_definition_uses_structural_store_boundary(self) -> None: definition = control_plane_runtime_environments.load_optional_runtime_environment_definition_from_store( record_store=_FakeRuntimeEnvironmentStore( diff --git a/tests/test_service.py b/tests/test_service.py index d59ce00d..c0a662d5 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1025,6 +1025,36 @@ def _product_profile_payload_with_prod(product: str = "sellyouroutboard") -> dic return payload +def _live_target_runtime_profile_payload( + *, + product: str = "sellyouroutboard", + context: str = "sellyouroutboard", + instance: str = "prod", + include_context_secret: bool = False, +) -> dict[str, object]: + payload = _product_profile_payload(product) + payload["lanes"] = ( + { + "instance": instance, + "context": context, + "base_url": "https://www.sellyouroutboard.com", + "health_url": "https://www.sellyouroutboard.com/api/health", + }, + ) + expected_config: dict[str, object] = { + "runtime_environment_keys": [ + {"key": "GOOGLE_ANALYTICS_MEASUREMENT_ID", "context": context, "instance": instance} + ], + "managed_secret_bindings": [], + } + if include_context_secret: + expected_config["managed_secret_bindings"] = [ + {"binding_key": "CONTEXT_API_TOKEN", "context": context} + ] + payload["expected_config"] = expected_config + return payload + + def _product_profile_lanes(payload: dict[str, object]) -> tuple[dict[str, object], ...]: return cast(tuple[dict[str, object], ...], payload["lanes"]) @@ -22303,6 +22333,19 @@ def test_live_target_runtime_api_dry_run_returns_redacted_delta(self) -> None: source_label="test", ) ) + store.write_runtime_environment_record( + RuntimeEnvironmentRecord( + scope="global", + env={"ODOO_DB_PASSWORD": "must-not-sync"}, + 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", @@ -22320,6 +22363,33 @@ def test_live_target_runtime_api_dry_run_returns_redacted_delta(self) -> None: clear=True, ): _write_dokploy_managed_secrets(store=store) + control_plane_secrets.write_secret_value( + record_store=store, + scope="context", + integration=control_plane_secrets.RUNTIME_ENVIRONMENT_SECRET_INTEGRATION, + name="context-api-token", + plaintext_value="context-secret-value", + binding_key="CONTEXT_API_TOKEN", + context_name="sellyouroutboard", + actor="test", + source_label="test", + ) + store.write_runtime_key_safety_policy_record( + RuntimeKeySafetyPolicyRecord( + record_id="runtime-key-safety-policy-live-target-test", + status="active", + source="test", + updated_at="2026-05-05T20:00:00Z", + rules=( + RuntimeSecretSafetyRule( + binding_key="CONTEXT_API_TOKEN", + secret_class="prod_only", + allowed_contexts=("sellyouroutboard",), + allowed_instances=("prod",), + ), + ), + ) + ) finally: store.close() policy = LaunchplaneAuthzPolicy.model_validate( @@ -22394,9 +22464,16 @@ def test_live_target_runtime_api_dry_run_returns_redacted_delta(self) -> None: self.assertEqual(result["mode"], "dry-run") self.assertEqual( result["runtime_environment"]["missing_keys"], - ["GOOGLE_ANALYTICS_MEASUREMENT_ID"], + ["CONTEXT_API_TOKEN", "GOOGLE_ANALYTICS_MEASUREMENT_ID"], + ) + self.assertEqual( + result["runtime_key_safety"]["checked_binding_keys"], ["CONTEXT_API_TOKEN"] ) + self.assertEqual(result["runtime_key_safety"]["status"], "pass") + self.assertNotIn("ODOO_DB_PASSWORD", result["runtime_environment"]["changed_keys"]) self.assertNotIn("G-9KRMER45KG", json.dumps(payload)) + self.assertNotIn("must-not-sync", json.dumps(payload)) + self.assertNotIn("context-secret-value", json.dumps(payload)) def test_live_target_runtime_api_apply_updates_env_and_verifies(self) -> None: with TemporaryDirectory() as temporary_directory_name: @@ -22415,6 +22492,11 @@ def test_live_target_runtime_api_apply_updates_env_and_verifies(self) -> None: source_label="test", ) ) + store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate( + _live_target_runtime_profile_payload() + ) + ) _seed_tracked_target_records( database_url=database_url, context="sellyouroutboard",