From fdd0c19ad4503ef630fc8627f8a9e1a75ba141f1 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Thu, 7 May 2026 19:08:47 -0400 Subject: [PATCH] Add product environment config status read model --- .../product_environment_read_model.py | 255 ++++++++++++++++++ .../contracts/product_onboarding_manifest.py | 47 ++++ .../contracts/product_profile_record.py | 73 +++++ control_plane/product_read_service.py | 16 +- control_plane/service.py | 11 + control_plane/workflows/product_onboarding.py | 1 + docs/operator-experience.md | 9 + docs/records.md | 8 + docs/service-boundary.md | 8 + tests/test_product_environment_read_model.py | 64 +++++ tests/test_product_onboarding.py | 35 +++ tests/test_service.py | 141 ++++++++++ 12 files changed, 667 insertions(+), 1 deletion(-) diff --git a/control_plane/contracts/product_environment_read_model.py b/control_plane/contracts/product_environment_read_model.py index e2ebdb39..c8cc61e8 100644 --- a/control_plane/contracts/product_environment_read_model.py +++ b/control_plane/contracts/product_environment_read_model.py @@ -13,6 +13,8 @@ from control_plane.contracts.product_profile_record import ( LaunchplaneProductProfileRecord, ProductLaneProfile, + ProductRuntimeConfigRequirement, + ProductSecretConfigRequirement, ) from control_plane.contracts.runtime_environment_record import RuntimeEnvironmentRecord from control_plane.contracts.secret_record import SecretBinding @@ -25,6 +27,14 @@ ActionAllowed = Callable[[str, str, str], bool] ProductSecretBindingTrustState = FreshnessStatus | Literal["disabled"] +ProductConfigItemStatus = Literal[ + "configured", + "missing", + "disabled", + "unvalidated", + "stale", + "unsupported", +] class ProductReadModelStore(Protocol): @@ -190,6 +200,48 @@ class ProductEnvironmentDetail(BaseModel): provenance: DataProvenance +class ProductRuntimeConfigStatusItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + key: str + status: ProductConfigItemStatus + context: str = "" + instance: str = "" + source_label: str = "" + updated_at: str = "" + trust_state: FreshnessStatus = "missing" + + +class ProductManagedSecretConfigStatusItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + binding_key: str + status: ProductConfigItemStatus + integration: str + context: str = "" + instance: str = "" + updated_at: str = "" + trust_state: ProductSecretBindingTrustState | Literal["unsupported"] = "missing" + + +class ProductEnvironmentConfigStatus(BaseModel): + model_config = ConfigDict(extra="forbid") + + schema_version: int = Field(default=1, ge=1) + product: str + display_name: str + repository: str + driver_id: str + base_driver_id: str = "" + environment: str + context: str + runtime_settings: tuple[ProductRuntimeConfigStatusItem, ...] = () + managed_secrets: tuple[ProductManagedSecretConfigStatusItem, ...] = () + warnings: tuple[str, ...] = () + trust_state: FreshnessStatus + provenance: DataProvenance + + class ProductActivityRecordLink(BaseModel): model_config = ConfigDict(extra="forbid") @@ -331,6 +383,57 @@ def build_product_environment_detail( ) +def build_product_environment_config_status( + *, + record_store: ProductReadModelStore, + product: str, + environment: str, +) -> ProductEnvironmentConfigStatus: + profile = record_store.read_product_profile_record(product) + lane = _find_lane(profile=profile, environment=environment) + descriptor, descriptor_warning = _read_profile_descriptor(profile) + lane_summary = _read_product_lane_summary( + record_store=record_store, + profile=profile, + lane=lane, + ) + provenance = ( + lane_summary.provenance if lane_summary is not None else _missing_lane_provenance(lane) + ) + runtime_settings = _runtime_config_status_items( + requirements=profile.expected_config.runtime_environment_keys, + lane=lane, + lane_summary=lane_summary, + ) + managed_secrets = _managed_secret_config_status_items( + requirements=profile.expected_config.managed_secret_bindings, + lane=lane, + lane_summary=lane_summary, + ) + warnings = tuple(warning for warning in (descriptor_warning,) if warning) + trust_state = _combine_trust_states( + ( + *(_config_item_freshness(item.status) for item in runtime_settings), + *(_config_item_freshness(item.status) for item in managed_secrets), + ), + fallback=provenance.freshness_status, + ) + return ProductEnvironmentConfigStatus( + product=profile.product, + display_name=profile.display_name, + repository=profile.repository, + driver_id=profile.driver_id, + base_driver_id=descriptor.base_driver_id if descriptor is not None else "", + environment=lane.instance, + context=lane.context, + runtime_settings=runtime_settings, + managed_secrets=managed_secrets, + warnings=warnings, + trust_state=trust_state, + provenance=provenance, + ) + + def build_product_activity_read_model( *, record_store: ProductReadModelStore, product: str, limit: int = 50 ) -> ProductActivityReadModel: @@ -1173,6 +1276,158 @@ def _secret_binding_summary(binding: SecretBinding) -> ProductSecretBindingSumma ) +def _runtime_config_status_items( + *, + requirements: tuple[ProductRuntimeConfigRequirement, ...], + lane: ProductLaneProfile, + lane_summary: LaunchplaneLaneSummary | None, +) -> tuple[ProductRuntimeConfigStatusItem, ...]: + applicable_requirements = tuple( + requirement + for requirement in requirements + if _config_requirement_applies( + requirement_context=requirement.context, + requirement_instance=requirement.instance, + lane=lane, + ) + ) + if not applicable_requirements: + return () + records = lane_summary.runtime_environment_records if lane_summary is not None else () + return tuple( + _runtime_config_status_item(requirement=requirement, records=records, lane=lane) + for requirement in applicable_requirements + ) + + +def _runtime_config_status_item( + *, + requirement: ProductRuntimeConfigRequirement, + records: tuple[RuntimeEnvironmentRecord, ...], + lane: ProductLaneProfile, +) -> ProductRuntimeConfigStatusItem: + matching_record = next( + (record for record in records if _runtime_record_provides_key(record, requirement.key)), + None, + ) + context = requirement.context or lane.context + instance = requirement.instance or (lane.instance if requirement.context else "") + if matching_record is None: + return ProductRuntimeConfigStatusItem( + key=requirement.key, + status="missing", + context=context, + instance=instance, + trust_state="missing", + ) + return ProductRuntimeConfigStatusItem( + key=requirement.key, + status="configured", + context=matching_record.context, + instance=matching_record.instance, + source_label=matching_record.source_label, + updated_at=matching_record.updated_at, + trust_state="recorded", + ) + + +def _runtime_record_provides_key(record: RuntimeEnvironmentRecord, key: str) -> bool: + return key in record.env + + +def _managed_secret_config_status_items( + *, + requirements: tuple[ProductSecretConfigRequirement, ...], + lane: ProductLaneProfile, + lane_summary: LaunchplaneLaneSummary | None, +) -> tuple[ProductManagedSecretConfigStatusItem, ...]: + applicable_requirements = tuple( + requirement + for requirement in requirements + if _config_requirement_applies( + requirement_context=requirement.context, + requirement_instance=requirement.instance, + lane=lane, + ) + ) + if not applicable_requirements: + return () + bindings = lane_summary.secret_bindings if lane_summary is not None else () + return tuple( + _managed_secret_config_status_item( + requirement=requirement, + bindings=bindings, + lane=lane, + ) + for requirement in applicable_requirements + ) + + +def _managed_secret_config_status_item( + *, + requirement: ProductSecretConfigRequirement, + bindings: tuple[SecretBinding, ...], + lane: ProductLaneProfile, +) -> ProductManagedSecretConfigStatusItem: + matching_binding = next( + ( + binding + for binding in bindings + if binding.integration == requirement.integration + and binding.binding_key == requirement.binding_key + ), + None, + ) + context = requirement.context or lane.context + instance = requirement.instance or (lane.instance if requirement.context else "") + if matching_binding is None: + return ProductManagedSecretConfigStatusItem( + binding_key=requirement.binding_key, + status="missing", + integration=requirement.integration, + context=context, + instance=instance, + trust_state="missing", + ) + if matching_binding.status == "disabled": + status: ProductConfigItemStatus = "disabled" + trust_state: ProductSecretBindingTrustState = "disabled" + else: + status = "configured" + trust_state = "recorded" + return ProductManagedSecretConfigStatusItem( + binding_key=requirement.binding_key, + status=status, + integration=matching_binding.integration, + context=matching_binding.context, + instance=matching_binding.instance, + updated_at=matching_binding.updated_at, + trust_state=trust_state, + ) + + +def _config_item_freshness(status: ProductConfigItemStatus) -> FreshnessStatus: + if status == "configured": + return "recorded" + if status in {"disabled", "unvalidated"}: + return "missing" + if status == "stale": + return "stale" + if status == "unsupported": + return "unsupported" + return "missing" + + +def _config_requirement_applies( + *, requirement_context: str, requirement_instance: str, lane: ProductLaneProfile +) -> bool: + if not requirement_context: + return True + if requirement_context != lane.context: + return False + return not requirement_instance or requirement_instance == lane.instance + + def _target_summary(lane_summary: LaunchplaneLaneSummary | None) -> ProductTargetSummary: if lane_summary is None or lane_summary.dokploy_target is None: return ProductTargetSummary() diff --git a/control_plane/contracts/product_onboarding_manifest.py b/control_plane/contracts/product_onboarding_manifest.py index fb4bbfa1..1fa76b1c 100644 --- a/control_plane/contracts/product_onboarding_manifest.py +++ b/control_plane/contracts/product_onboarding_manifest.py @@ -4,6 +4,7 @@ from control_plane.contracts.dokploy_target_record import DokployTargetType from control_plane.contracts.product_profile_record import ( + ProductExpectedConfigProfile, ProductPreviewProfile, ProductPromotionWorkflowProfile, ) @@ -117,6 +118,10 @@ def _validate_binding(self) -> "ProductOnboardingSecretBindingManifest": return self +class ProductOnboardingExpectedConfigManifest(ProductExpectedConfigProfile): + pass + + class ProductOnboardingManifest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -138,6 +143,9 @@ class ProductOnboardingManifest(BaseModel): dokploy_targets: tuple[ProductOnboardingTargetManifest, ...] = () runtime_environments: tuple[ProductOnboardingRuntimeEnvironmentManifest, ...] = () secret_bindings: tuple[ProductOnboardingSecretBindingManifest, ...] = () + expected_config: ProductOnboardingExpectedConfigManifest = Field( + default_factory=ProductOnboardingExpectedConfigManifest + ) updated_at: str = "" source_label: str = "product-onboarding" @@ -213,4 +221,43 @@ def _validate_manifest(self) -> "ProductOnboardingManifest": "instance secret binding must match a stable lane: " f"{binding.context}/{binding.instance}" ) + for runtime_requirement in self.expected_config.runtime_environment_keys: + self._validate_expected_config_route( + allowed_contexts=allowed_contexts, + lane_routes=lane_routes, + context=runtime_requirement.context, + instance=runtime_requirement.instance, + label="runtime config requirement", + ) + for secret_requirement in self.expected_config.managed_secret_bindings: + self._validate_expected_config_route( + allowed_contexts=allowed_contexts, + lane_routes=lane_routes, + context=secret_requirement.context, + instance=secret_requirement.instance, + label="secret config requirement", + ) return self + + @staticmethod + def _validate_expected_config_route( + *, + allowed_contexts: set[str], + lane_routes: set[tuple[str, str]], + context: str, + instance: str, + label: str, + ) -> None: + if not context.strip(): + return + if context.strip() not in allowed_contexts: + raise ValueError(f"{label} context is not owned by the product profile: {context}") + if instance.strip() and (context.strip(), instance.strip()) not in lane_routes: + raise ValueError( + f"instance {label} must match a stable lane: {context}/{instance}" + ) + + def product_expected_config_profile(self) -> ProductExpectedConfigProfile: + return ProductExpectedConfigProfile.model_validate( + self.expected_config.model_dump(mode="json") + ) diff --git a/control_plane/contracts/product_profile_record.py b/control_plane/contracts/product_profile_record.py index 441b6dd6..3d93934c 100644 --- a/control_plane/contracts/product_profile_record.py +++ b/control_plane/contracts/product_profile_record.py @@ -126,6 +126,76 @@ def _validate_workflow(self) -> "ProductPromotionWorkflowProfile": return self +class ProductRuntimeConfigRequirement(BaseModel): + model_config = ConfigDict(extra="forbid") + + key: str + context: str = "" + instance: str = "" + + @model_validator(mode="after") + def _validate_requirement(self) -> "ProductRuntimeConfigRequirement": + if not self.key.strip(): + raise ValueError("product runtime config requirement requires key") + if self.instance.strip() and not self.context.strip(): + raise ValueError("instance runtime config requirement requires context") + self.key = self.key.strip() + self.context = self.context.strip() + self.instance = self.instance.strip() + return self + + +class ProductSecretConfigRequirement(BaseModel): + model_config = ConfigDict(extra="forbid") + + binding_key: str + integration: str = "runtime_environment" + context: str = "" + instance: str = "" + + @model_validator(mode="after") + def _validate_requirement(self) -> "ProductSecretConfigRequirement": + if not self.binding_key.strip(): + raise ValueError("product secret config requirement requires binding_key") + if not self.integration.strip(): + raise ValueError("product secret config requirement requires integration") + if self.instance.strip() and not self.context.strip(): + raise ValueError("instance secret config requirement requires context") + self.binding_key = self.binding_key.strip() + self.integration = self.integration.strip() + self.context = self.context.strip() + self.instance = self.instance.strip() + return self + + +class ProductExpectedConfigProfile(BaseModel): + model_config = ConfigDict(extra="forbid") + + runtime_environment_keys: tuple[ProductRuntimeConfigRequirement, ...] = () + managed_secret_bindings: tuple[ProductSecretConfigRequirement, ...] = () + + @model_validator(mode="after") + def _validate_expected_config(self) -> "ProductExpectedConfigProfile": + runtime_keys = [ + (requirement.context, requirement.instance, requirement.key) + for requirement in self.runtime_environment_keys + ] + if len(runtime_keys) != len(set(runtime_keys)): + raise ValueError("product expected runtime config keys must be unique") + secret_keys = [ + ( + requirement.integration, + requirement.context, + requirement.instance, + requirement.binding_key, + ) + for requirement in self.managed_secret_bindings + ] + if len(secret_keys) != len(set(secret_keys)): + raise ValueError("product expected secret config bindings must be unique") + return self + + class LaunchplaneProductProfileRecord(BaseModel): model_config = ConfigDict(extra="forbid") @@ -143,6 +213,9 @@ class LaunchplaneProductProfileRecord(BaseModel): promotion_workflow: ProductPromotionWorkflowProfile = Field( default_factory=ProductPromotionWorkflowProfile ) + expected_config: ProductExpectedConfigProfile = Field( + default_factory=ProductExpectedConfigProfile + ) updated_at: str source: str diff --git a/control_plane/product_read_service.py b/control_plane/product_read_service.py index b3da414f..a9b1248b 100644 --- a/control_plane/product_read_service.py +++ b/control_plane/product_read_service.py @@ -7,6 +7,7 @@ ActionAllowed, ProductReadModelStore, build_product_activity_read_model, + build_product_environment_config_status, build_product_environment_detail, build_product_site_overview, build_product_site_overviews, @@ -22,7 +23,7 @@ class ProductEnvironmentReadServiceResult: def is_product_environment_detail_request(params: Mapping[str, str]) -> bool: - return any(key in params for key in ("activity", "environment", "product")) + return any(key in params for key in ("activity", "config_status", "environment", "product")) def build_product_profile_list_service_payload( @@ -53,6 +54,19 @@ def build_product_environment_read_service_result( denial_message="Workflow cannot read the requested product activity.", ) + if params.get("config_status") == "true": + config_status = build_product_environment_config_status( + record_store=record_store, + product=params["product"], + environment=params["environment"], + ) + return ProductEnvironmentReadServiceResult( + payload={"config_status": config_status.model_dump(mode="json")}, + authorization_product=config_status.product, + authorization_context=config_status.context, + denial_message="Workflow cannot read the requested product environment config status.", + ) + if "environment" in params: detail = build_product_environment_detail( record_store=record_store, diff --git a/control_plane/service.py b/control_plane/service.py index 0aea045d..f75c4948 100644 --- a/control_plane/service.py +++ b/control_plane/service.py @@ -1714,6 +1714,17 @@ def _match_read_route(path: str) -> tuple[str, dict[str, str]] | None: return "product_environment.read", {"product": segments[2]} if len(segments) == 5 and segments[:2] == ["v1", "products"] and segments[3] == "environments": return "product_environment.read", {"product": segments[2], "environment": segments[4]} + if ( + len(segments) == 6 + and segments[:2] == ["v1", "products"] + and segments[3] == "environments" + and segments[5] == "config-status" + ): + return "product_environment.read", { + "product": segments[2], + "environment": segments[4], + "config_status": "true", + } return None diff --git a/control_plane/workflows/product_onboarding.py b/control_plane/workflows/product_onboarding.py index 17b8ae7f..826c72bf 100644 --- a/control_plane/workflows/product_onboarding.py +++ b/control_plane/workflows/product_onboarding.py @@ -66,6 +66,7 @@ def build_product_profile_record( ), preview=ProductPreviewProfile.model_validate(manifest.preview.model_dump(mode="json")), promotion_workflow=manifest.promotion_workflow, + expected_config=manifest.product_expected_config_profile(), updated_at=updated_at, source=manifest.source_label, ) diff --git a/docs/operator-experience.md b/docs/operator-experience.md index 9ae4feaa..44c3ce98 100644 --- a/docs/operator-experience.md +++ b/docs/operator-experience.md @@ -64,6 +64,7 @@ The first product/site read endpoints are: - `GET /v1/products/{product}` - `GET /v1/products/{product}/activity` - `GET /v1/products/{product}/environments/{environment}` +- `GET /v1/products/{product}/environments/{environment}/config-status` These endpoints are profile and driver driven. A standard `generic-web` site should appear in the read model from Launchplane records alone: product profile, @@ -78,6 +79,14 @@ status, timestamp, and record links so the UI can render deployments, promotions, rollbacks, backup gates, previews, cleanup, feedback, and relevant authz changes without loading raw record payloads. +Product environment config status compares product-profile expected config +requirements against recorded runtime-environment keys and managed secret +bindings for the stable lane. Expected keys are declarative product intent; +configured, missing, and disabled states are derived from Launchplane records. +The response includes key names, binding metadata, status, source, and freshness +only. It never includes runtime values, managed secret IDs, secret plaintext, or +ciphertext. + ## Promotion Safety Browser sessions may dry-run generic-web promotion directly. Live promotion from diff --git a/docs/records.md b/docs/records.md index 35528f3a..9a374884 100644 --- a/docs/records.md +++ b/docs/records.md @@ -138,6 +138,14 @@ template lane, required template env keys, copied or omitted settings, preview URL/domain env keys, required provider fields, and the declared data transport mode so readiness can fail before Launchplane mutates a provider. +Product profiles may also declare expected config requirements for stable lanes: +runtime-environment key names and managed secret binding keys by context and +instance. These requirements are declarative intent for operator readiness +views. Actual configured, missing, disabled, stale, or unsupported status is +derived from runtime-environment records, managed secret binding records, driver +support, and trust metadata; expected config requirements do not store runtime +values, managed secret IDs, secret plaintext, or ciphertext. + The product key is the durable workspace identity. For example, `sellyouroutboard` is the SellYourOutboard product workspace; `testing`, `prod`, and the preview inventory all appear under that workspace in the operator UI. diff --git a/docs/service-boundary.md b/docs/service-boundary.md index 3de95db7..cf5bf1bc 100644 --- a/docs/service-boundary.md +++ b/docs/service-boundary.md @@ -526,6 +526,14 @@ target identifiers remain evidence metadata; runtime values, secret plaintext, secret ciphertext, and product-specific driver payloads are not exposed as shared top-level fields. +`GET /v1/products/{product}/environments/{environment}/config-status` is a +redacted product/site read under the same action. It compares product-profile +expected runtime keys and managed secret bindings with recorded lane runtime +environment records and managed secret binding metadata. Expected keys describe +product intent; status is derived from records. The response exposes configured, +missing, or disabled status plus key/source metadata only; managed secret IDs +remain out of this readiness view. + Product activity reads are intentionally record-link oriented. They summarize deployments, promotions, rollbacks, backup gates, preview identity/lifecycle, preview feedback, and matching authz-policy changes with driver/action IDs and diff --git a/tests/test_product_environment_read_model.py b/tests/test_product_environment_read_model.py index 2d9b21a4..d92e29df 100644 --- a/tests/test_product_environment_read_model.py +++ b/tests/test_product_environment_read_model.py @@ -10,10 +10,12 @@ from control_plane.contracts.product_environment_read_model import ( ACTION_AUTHZ_BY_ROUTE, build_product_activity_read_model, + build_product_environment_config_status, build_product_environment_detail, build_product_site_overview, ) from control_plane.contracts.product_profile_record import LaunchplaneProductProfileRecord +from control_plane.contracts.runtime_environment_record import RuntimeEnvironmentRecord from control_plane.contracts.secret_record import SecretBinding from control_plane.storage.postgres import PostgresRecordStore @@ -54,6 +56,16 @@ def _site_profile_payload( "context": preview_context, "slug_template": "pr-{number}", }, + "expected_config": { + "runtime_environment_keys": [ + {"key": "PUBLIC_BASE_URL", "context": testing_context, "instance": "testing"}, + {"key": "RESEND_FROM_EMAIL", "context": prod_context, "instance": "prod"}, + ], + "managed_secret_bindings": [ + {"binding_key": "SMTP_PASSWORD", "context": prod_context, "instance": "prod"}, + {"binding_key": "RESEND_API_KEY", "context": prod_context, "instance": "prod"}, + ], + }, "updated_at": "2026-05-02T22:30:00Z", "source": "test", } @@ -641,6 +653,58 @@ def test_product_environment_detail_preserves_disabled_secret_bindings(self) -> self.assertEqual(detail.managed_secrets[0].status, "disabled") self.assertEqual(detail.managed_secrets[0].trust_state, "disabled") + def test_product_environment_config_status_reports_expected_key_states(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + database_path = Path(temporary_directory_name) / "launchplane.sqlite3" + database_url = f"sqlite+pysqlite:///{database_path}" + store = PostgresRecordStore(database_url=database_url) + store.ensure_schema() + profile = LaunchplaneProductProfileRecord.model_validate( + _site_profile_payload(preview_enabled=False, preview_context="") + ) + store.write_product_profile_record(profile) + store.write_runtime_environment_record( + RuntimeEnvironmentRecord( + scope="instance", + context="example-site-prod", + instance="prod", + env={"RESEND_FROM_EMAIL": "noreply@example.invalid"}, + updated_at="2026-05-02T22:32:00Z", + source_label="test", + ) + ) + store.write_secret_binding( + SecretBinding( + binding_id="binding-1", + secret_id="secret-1", + integration="runtime_environment", + binding_key="SMTP_PASSWORD", + context="example-site-prod", + instance="prod", + status="disabled", + created_at="2026-05-02T22:31:00Z", + updated_at="2026-05-02T22:32:00Z", + ) + ) + + config_status = build_product_environment_config_status( + record_store=store, + product=profile.product, + environment="prod", + ) + + runtime_statuses = {item.key: item.status for item in config_status.runtime_settings} + secret_statuses = { + item.binding_key: item.status for item in config_status.managed_secrets + } + self.assertEqual(runtime_statuses, {"RESEND_FROM_EMAIL": "configured"}) + self.assertEqual( + secret_statuses, + {"SMTP_PASSWORD": "disabled", "RESEND_API_KEY": "missing"}, + ) + response_text = config_status.model_dump_json() + self.assertNotIn("noreply@example.invalid", response_text) + def test_product_activity_read_model_aggregates_product_records(self) -> None: profile = LaunchplaneProductProfileRecord.model_validate( _site_profile_payload( diff --git a/tests/test_product_onboarding.py b/tests/test_product_onboarding.py index b2f3b372..ab423b80 100644 --- a/tests/test_product_onboarding.py +++ b/tests/test_product_onboarding.py @@ -76,6 +76,22 @@ def _manifest_payload() -> dict[str, object]: "instance": "prod", } ], + "expected_config": { + "runtime_environment_keys": [ + { + "key": "PUBLIC_BASE_URL", + "context": "example-site-testing", + "instance": "testing", + } + ], + "managed_secret_bindings": [ + { + "binding_key": "SMTP_PASSWORD", + "context": "example-site-prod", + "instance": "prod", + } + ], + }, "updated_at": "2026-05-03T01:30:00Z", "source_label": "test:onboarding", } @@ -111,6 +127,11 @@ def test_apply_product_onboarding_manifest_writes_canonical_records(self) -> Non self.assertEqual(profile.driver_id, "generic-web") self.assertEqual(profile.lanes[0].health_url, "https://testing.example.invalid/api/health") self.assertEqual(profile.lanes[1].health_url, "https://example.invalid/status") + self.assertEqual(profile.expected_config.runtime_environment_keys[0].key, "PUBLIC_BASE_URL") + self.assertEqual( + profile.expected_config.managed_secret_bindings[0].binding_key, + "SMTP_PASSWORD", + ) self.assertEqual(len(targets), 2) self.assertEqual(len(target_ids), 2) self.assertEqual(len(runtime_records), 1) @@ -146,6 +167,20 @@ def test_product_onboarding_manifest_rejects_missing_target_id(self) -> None: with self.assertRaisesRegex(ValueError, "target requires target_id"): ProductOnboardingManifest.model_validate(payload) + def test_product_onboarding_manifest_rejects_duplicate_expected_config_keys( + self, + ) -> None: + payload = _manifest_payload() + payload["expected_config"] = { + "runtime_environment_keys": [ + {"key": "PUBLIC_BASE_URL", "context": "example-site-prod", "instance": "prod"}, + {"key": "PUBLIC_BASE_URL", "context": "example-site-prod", "instance": "prod"}, + ] + } + + with self.assertRaisesRegex(ValueError, "expected runtime config keys must be unique"): + ProductOnboardingManifest.model_validate(payload) + def test_product_onboarding_cli_applies_manifest_without_secret_values(self) -> None: with TemporaryDirectory() as temporary_directory_name: temporary_directory = Path(temporary_directory_name) diff --git a/tests/test_service.py b/tests/test_service.py index 7075726b..5facc52c 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -296,6 +296,16 @@ def _generic_site_profile_payload(product: str = "example-site") -> dict[str, ob "context": product, "slug_template": "pr-{number}", }, + "expected_config": { + "runtime_environment_keys": [ + {"key": "INTERNAL_CALLBACK_URL", "context": product, "instance": "prod"}, + {"key": "RESEND_FROM_EMAIL", "context": product, "instance": "prod"}, + ], + "managed_secret_bindings": [ + {"binding_key": "SMTP_PASSWORD", "context": product, "instance": "prod"}, + {"binding_key": "RESEND_API_KEY", "context": product, "instance": "prod"}, + ], + }, "updated_at": "2026-05-02T22:30:00Z", "source": "test", } @@ -4162,6 +4172,137 @@ def test_product_environment_detail_redacts_runtime_and_secret_values(self) -> N self.assertNotIn("https://internal.example-site.invalid", response_text) self.assertNotIn("super-secret-password", response_text) + def test_product_environment_config_status_endpoint_redacts_expected_config_status( + 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() + store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate(_generic_site_profile_payload()) + ) + store.write_runtime_environment_record( + RuntimeEnvironmentRecord( + scope="instance", + context="example-site", + instance="prod", + env={"INTERNAL_CALLBACK_URL": "https://internal.example-site.invalid"}, + updated_at="2026-05-02T22:32:00Z", + source_label="test", + ) + ) + with patch.dict( + os.environ, + {control_plane_secrets.LAUNCHPLANE_SECRET_MASTER_KEY_ENV_VAR: "test-master-key"}, + clear=True, + ): + control_plane_secrets.write_secret_value( + record_store=store, + scope="context_instance", + integration=control_plane_secrets.RUNTIME_ENVIRONMENT_SECRET_INTEGRATION, + name="SMTP_PASSWORD", + plaintext_value="super-secret-password", + binding_key="SMTP_PASSWORD", + context_name="example-site", + instance_name="prod", + actor="test", + ) + store.close() + policy = LaunchplaneAuthzPolicy.model_validate( + { + "github_actions": [ + { + "repository": "every/verireel", + "workflow_refs": [ + "every/verireel/.github/workflows/preview-control-plane.yml@refs/heads/main" + ], + "event_names": ["pull_request"], + "products": ["example-site"], + "contexts": ["example-site"], + "actions": ["product_environment.read"], + } + ] + } + ) + app = create_launchplane_service_app( + state_dir=root / "state", + verifier=_StubVerifier(_identity()), + authz_policy=policy, + control_plane_root_path=root, + database_url=database_url, + ) + + status_code, payload = _invoke_app( + app, + method="GET", + path="/v1/products/example-site/environments/prod/config-status", + ) + + response_text = json.dumps(payload) + self.assertEqual(status_code, 200) + config_status = payload["config_status"] + runtime_statuses = { + item["key"]: item["status"] for item in config_status["runtime_settings"] + } + secret_statuses = { + item["binding_key"]: item["status"] + for item in config_status["managed_secrets"] + } + self.assertEqual( + runtime_statuses, + {"INTERNAL_CALLBACK_URL": "configured", "RESEND_FROM_EMAIL": "missing"}, + ) + self.assertEqual( + secret_statuses, + {"SMTP_PASSWORD": "configured", "RESEND_API_KEY": "missing"}, + ) + self.assertNotIn("https://internal.example-site.invalid", response_text) + self.assertNotIn("super-secret-password", response_text) + + def test_product_environment_config_status_endpoint_uses_lane_authorization( + self, + ) -> None: + with TemporaryDirectory() as temporary_directory_name: + root = Path(temporary_directory_name) + state_dir = root / "state" + store = FilesystemRecordStore(state_dir=state_dir) + store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate(_generic_site_profile_payload()) + ) + policy = LaunchplaneAuthzPolicy.model_validate( + { + "github_actions": [ + { + "repository": "every/verireel", + "workflow_refs": [ + "every/verireel/.github/workflows/preview-control-plane.yml@refs/heads/main" + ], + "event_names": ["pull_request"], + "products": ["example-site"], + "contexts": ["launchplane"], + "actions": ["product_environment.read"], + } + ] + } + ) + app = create_launchplane_service_app( + state_dir=state_dir, + verifier=_StubVerifier(_identity()), + authz_policy=policy, + control_plane_root_path=root, + ) + + status_code, payload = _invoke_app( + app, + method="GET", + path="/v1/products/example-site/environments/prod/config-status", + ) + + self.assertEqual(status_code, 403) + self.assertEqual(payload["error"]["code"], "authorization_denied") + def test_product_context_cutover_endpoint_updates_profile_for_authorized_workflow(self) -> None: with TemporaryDirectory() as temporary_directory_name: root = Path(temporary_directory_name)