diff --git a/control_plane/contracts/product_onboarding_manifest.py b/control_plane/contracts/product_onboarding_manifest.py index f1c8b7a..6147259 100644 --- a/control_plane/contracts/product_onboarding_manifest.py +++ b/control_plane/contracts/product_onboarding_manifest.py @@ -150,6 +150,7 @@ class ProductOnboardingManifest(BaseModel): runtime_port: int = Field(ge=1, le=65535) health_path: str lanes: tuple[ProductOnboardingLaneManifest, ...] + historical_contexts: tuple[str, ...] = () preview: ProductOnboardingPreviewManifest = Field( default_factory=ProductOnboardingPreviewManifest ) @@ -229,9 +230,23 @@ def _validate_manifest(self) -> "ProductOnboardingManifest": f"{target.context}/{target.instance}" ) - allowed_contexts = {lane.context.strip() for lane in self.lanes} + allowed_contexts = {self.product.strip()} + allowed_contexts.update(lane.context.strip() for lane in self.lanes) if self.preview.context.strip(): allowed_contexts.add(self.preview.context.strip()) + historical_contexts: list[str] = [] + for raw_context in self.historical_contexts: + context = raw_context.strip() + if not context: + raise ValueError("product onboarding historical_contexts values must be non-empty") + if context in allowed_contexts: + raise ValueError( + "product onboarding historical_contexts cannot include current product contexts: " + f"{context}" + ) + if context not in historical_contexts: + historical_contexts.append(context) + self.historical_contexts = tuple(historical_contexts) for record in self.runtime_environments: if record.scope == "global": if record.context.strip() or record.instance.strip(): diff --git a/control_plane/workflows/product_onboarding.py b/control_plane/workflows/product_onboarding.py index e717ea7..9f6b20e 100644 --- a/control_plane/workflows/product_onboarding.py +++ b/control_plane/workflows/product_onboarding.py @@ -27,6 +27,8 @@ class ProductOnboardingRecordStore(Protocol): + def read_product_profile_record(self, product: str) -> LaunchplaneProductProfileRecord: ... + def write_product_profile_record(self, record: LaunchplaneProductProfileRecord) -> None: ... def write_dokploy_target_record(self, record: DokployTargetRecord) -> None: ... @@ -78,8 +80,15 @@ class ProductOnboardingSecretBindingPlan(BaseModel): def build_product_profile_record( - *, manifest: ProductOnboardingManifest, updated_at: str + *, + manifest: ProductOnboardingManifest, + updated_at: str, + existing_profile: LaunchplaneProductProfileRecord | None = None, ) -> LaunchplaneProductProfileRecord: + historical_contexts = _merged_historical_contexts( + manifest_contexts=manifest.historical_contexts, + existing_profile=existing_profile, + ) return LaunchplaneProductProfileRecord( product=manifest.product, display_name=manifest.display_name, @@ -101,6 +110,7 @@ def build_product_profile_record( ) for lane in manifest.lanes ), + historical_contexts=historical_contexts, preview=ProductPreviewProfile.model_validate(manifest.preview.model_dump(mode="json")), promotion_workflow=manifest.promotion_workflow, expected_config=manifest.product_expected_config_profile(), @@ -109,6 +119,31 @@ def build_product_profile_record( ) +def _merged_historical_contexts( + *, + manifest_contexts: tuple[str, ...], + existing_profile: LaunchplaneProductProfileRecord | None, +) -> tuple[str, ...]: + historical_contexts: list[str] = [] + for raw_context in ( + *(existing_profile.historical_contexts if existing_profile else ()), + *manifest_contexts, + ): + context = raw_context.strip() + if context and context not in historical_contexts: + historical_contexts.append(context) + return tuple(historical_contexts) + + +def _read_existing_product_profile( + *, record_store: ProductOnboardingRecordStore, product: str +) -> LaunchplaneProductProfileRecord | None: + try: + return record_store.read_product_profile_record(product) + except (FileNotFoundError, KeyError): + return None + + def build_provider_target_records( *, manifest: ProductOnboardingManifest, updated_at: str ) -> tuple[DokployTargetRecord, ...]: @@ -279,7 +314,15 @@ def apply_product_onboarding_manifest( updated_at: str = "", ) -> ProductOnboardingApplyResult: recorded_at = updated_at.strip() or manifest.updated_at.strip() or utc_now_timestamp() - product_profile = build_product_profile_record(manifest=manifest, updated_at=recorded_at) + existing_product_profile = _read_existing_product_profile( + record_store=record_store, + product=manifest.product, + ) + product_profile = build_product_profile_record( + manifest=manifest, + updated_at=recorded_at, + existing_profile=existing_product_profile, + ) provider_targets = build_provider_target_records(manifest=manifest, updated_at=recorded_at) provider_target_ids = build_provider_target_id_records( manifest=manifest, updated_at=recorded_at diff --git a/docs/records.md b/docs/records.md index 7cb85c2..6478ecc 100644 --- a/docs/records.md +++ b/docs/records.md @@ -271,6 +271,11 @@ The same redacted audit is exposed through the Launchplane service at `source_context`, `target_context`, and optional `preview_context` query parameters. The manual `Product Context Cutover Audit` GitHub workflow calls that service route through GitHub OIDC and uploads the redacted JSON artifact. +After cutover, the source context is historical evidence rather than a current +product boundary, so this pre-cutover audit will reject the legacy context. Use +the `Product Legacy Context Cleanup` workflow in `dry_run=true` mode for +post-cutover SYO evidence, then validate live runtime against the canonical +`sellyouroutboard` testing and prod lanes. The manual `Product Legacy Context Cleanup` GitHub workflow calls the matching write route through GitHub OIDC. It defaults to `dry_run=true`, refuses cleanup while the source context is still product-owned, blocks individual mutable diff --git a/import-material/launchplane/seed-imports/catalog.json b/import-material/launchplane/seed-imports/catalog.json index a589097..8298196 100644 --- a/import-material/launchplane/seed-imports/catalog.json +++ b/import-material/launchplane/seed-imports/catalog.json @@ -72,6 +72,117 @@ "source_label": "import-material:discord-blue-product-onboarding" } }, + { + "kind": "product_onboarding", + "import_id": "sellyouroutboard-product-onboarding", + "manifest": { + "product": "sellyouroutboard", + "display_name": "SellYourOutboard.com", + "repository": "cbusillo/sellyouroutboard", + "driver_id": "generic-web", + "image_repository": "ghcr.io/cbusillo/sellyouroutboard", + "runtime_port": 3000, + "health_path": "/api/health", + "lanes": [ + { + "instance": "testing", + "context": "sellyouroutboard", + "base_url": "https://syo-testing.shinycomputers.com" + }, + { + "instance": "prod", + "context": "sellyouroutboard", + "base_url": "https://www.sellyouroutboard.com" + } + ], + "historical_contexts": ["sellyouroutboard-testing"], + "preview": { + "enabled": true, + "context": "sellyouroutboard", + "enable_label": "preview", + "slug_template": "pr-{number}", + "app_name_prefix": "syo-preview", + "template_instance": "testing" + }, + "expected_config": { + "runtime_environment_keys": [ + { + "key": "CONTACT_EMAIL_MODE", + "context": "sellyouroutboard", + "instance": "testing" + }, + { + "key": "CONTACT_FROM_EMAIL", + "context": "sellyouroutboard", + "instance": "testing" + }, + { + "key": "CONTACT_TO_EMAIL", + "context": "sellyouroutboard", + "instance": "testing" + }, + { + "key": "CONTACT_EMAIL_RESEND_TIMEOUT_MS", + "context": "sellyouroutboard", + "instance": "testing" + }, + { + "key": "NEXT_PUBLIC_META_PIXEL_ID", + "context": "sellyouroutboard", + "instance": "testing" + }, + { + "key": "CONTACT_EMAIL_MODE", + "context": "sellyouroutboard", + "instance": "prod" + }, + { + "key": "CONTACT_FROM_EMAIL", + "context": "sellyouroutboard", + "instance": "prod" + }, + { + "key": "CONTACT_TO_EMAIL", + "context": "sellyouroutboard", + "instance": "prod" + }, + { + "key": "CONTACT_EMAIL_RESEND_TIMEOUT_MS", + "context": "sellyouroutboard", + "instance": "prod" + }, + { + "key": "NEXT_PUBLIC_META_PIXEL_ID", + "context": "sellyouroutboard", + "instance": "prod" + } + ], + "managed_secret_bindings": [ + { + "binding_key": "RESEND_API_KEY", + "context": "sellyouroutboard", + "instance": "testing" + }, + { + "binding_key": "META_CONVERSIONS_API_TOKEN", + "context": "sellyouroutboard", + "instance": "testing" + }, + { + "binding_key": "RESEND_API_KEY", + "context": "sellyouroutboard", + "instance": "prod" + }, + { + "binding_key": "META_CONVERSIONS_API_TOKEN", + "context": "sellyouroutboard", + "instance": "prod" + } + ] + }, + "source_label": "import-material:sellyouroutboard-product-onboarding" + } + }, { "kind": "product_onboarding", "import_id": "verireel-product-onboarding", diff --git a/tests/test_product_onboarding.py b/tests/test_product_onboarding.py index 334d52b..59b554a 100644 --- a/tests/test_product_onboarding.py +++ b/tests/test_product_onboarding.py @@ -30,6 +30,13 @@ "ODOO_DB_PASSWORD", "ODOO_MASTER_PASSWORD", ) +SYO_RUNTIME_KEYS = ( + "CONTACT_EMAIL_MODE", + "CONTACT_FROM_EMAIL", + "CONTACT_TO_EMAIL", + "CONTACT_EMAIL_RESEND_TIMEOUT_MS", + "NEXT_PUBLIC_META_PIXEL_ID", +) def _sqlite_database_url(database_path: Path) -> str: @@ -271,6 +278,7 @@ def test_launchplane_seed_import_catalog_validates_contracts(self) -> None: import_ids, { "discord-blue-product-onboarding", + "sellyouroutboard-product-onboarding", "verireel-product-onboarding", "odoo-cm-product-onboarding", "odoo-opw-product-onboarding", @@ -1464,6 +1472,59 @@ def test_seed_import_verireel_onboarding_manifest_enrolls_preview_lifecycle(self ) self.assertEqual(manifest.source_label, "import-material:verireel-product-onboarding") + def test_seed_import_sellyouroutboard_onboarding_manifest_preserves_cutover_contract( + self, + ) -> None: + manifest_payload = _seed_import_manifest("sellyouroutboard-product-onboarding") + manifest = ProductOnboardingManifest.model_validate(manifest_payload) + + self.assertEqual(manifest.product, "sellyouroutboard") + self.assertEqual(manifest.display_name, "SellYourOutboard.com") + self.assertEqual(manifest.repository, "cbusillo/sellyouroutboard") + self.assertEqual(manifest.driver_id, "generic-web") + self.assertEqual(manifest.image_repository, "ghcr.io/cbusillo/sellyouroutboard") + self.assertEqual(manifest.runtime_port, 3000) + self.assertEqual(manifest.health_path, "/api/health") + self.assertEqual( + [(lane.instance, lane.context, lane.base_url) for lane in manifest.lanes], + [ + ("testing", "sellyouroutboard", "https://syo-testing.shinycomputers.com"), + ("prod", "sellyouroutboard", "https://www.sellyouroutboard.com"), + ], + ) + self.assertEqual(manifest.historical_contexts, ("sellyouroutboard-testing",)) + self.assertTrue(manifest.preview.enabled) + self.assertEqual(manifest.preview.context, "sellyouroutboard") + self.assertEqual(manifest.preview.app_name_prefix, "syo-preview") + self.assertEqual(manifest.preview.template_instance, "testing") + self.assertEqual(len(manifest.provider_targets), 0) + self.assertEqual(len(manifest.runtime_environments), 0) + self.assertEqual(len(manifest.secret_bindings), 0) + self.assertEqual( + [ + (requirement.context, requirement.instance, requirement.key) + for requirement in manifest.expected_config.runtime_environment_keys + ], + [("sellyouroutboard", "testing", key) for key in SYO_RUNTIME_KEYS] + + [("sellyouroutboard", "prod", key) for key in SYO_RUNTIME_KEYS], + ) + self.assertEqual( + [ + (requirement.context, requirement.instance, requirement.binding_key) + for requirement in manifest.expected_config.managed_secret_bindings + ], + [ + ("sellyouroutboard", "testing", "RESEND_API_KEY"), + ("sellyouroutboard", "testing", "META_CONVERSIONS_API_TOKEN"), + ("sellyouroutboard", "prod", "RESEND_API_KEY"), + ("sellyouroutboard", "prod", "META_CONVERSIONS_API_TOKEN"), + ], + ) + self.assertEqual( + manifest.source_label, + "import-material:sellyouroutboard-product-onboarding", + ) + def test_seed_import_odoo_cm_onboarding_manifest_encodes_issue_backed_bootstrap_policy( self, ) -> None: @@ -1598,6 +1659,7 @@ def test_apply_product_onboarding_manifest_writes_canonical_records(self) -> Non self.assertEqual(first_result.product, "example-site") self.assertEqual(second_result.product_profile.updated_at, "2026-05-03T02:30:00Z") self.assertEqual(profile.driver_id, "generic-web") + self.assertEqual(profile.historical_contexts, ()) self.assertEqual(profile.lanes[0].health_url, "https://testing.example.invalid/api/health") self.assertTrue(profile.lanes[0].odoo_stable_bootstrap.enabled) self.assertTrue(profile.lanes[0].public_ingress_monitoring.enabled) @@ -1652,6 +1714,69 @@ def test_apply_product_onboarding_manifest_writes_canonical_records(self) -> Non self.assertEqual(secret_bindings[0].created_at, first_result.secret_bindings[0].created_at) self.assertEqual(secret_bindings[0].updated_at, second_result.secret_bindings[0].updated_at) + def test_apply_product_onboarding_manifest_preserves_historical_contexts( + self, + ) -> None: + with TemporaryDirectory() as temporary_directory_name: + store = PostgresRecordStore( + database_url=_sqlite_database_url(Path(temporary_directory_name) / "db.sqlite3") + ) + store.ensure_schema() + manifest_payload = _manifest_payload() + manifest_payload["historical_contexts"] = [ + "example-site-old", + "example-site-preview-old", + ] + manifest = ProductOnboardingManifest.model_validate(manifest_payload) + + result = apply_product_onboarding_manifest(record_store=store, manifest=manifest) + profile = store.read_product_profile_record("example-site") + store.close() + + self.assertEqual( + result.product_profile.historical_contexts, + ("example-site-old", "example-site-preview-old"), + ) + self.assertEqual( + profile.historical_contexts, + ("example-site-old", "example-site-preview-old"), + ) + + def test_apply_product_onboarding_manifest_keeps_existing_historical_contexts( + self, + ) -> None: + with TemporaryDirectory() as temporary_directory_name: + store = PostgresRecordStore( + database_url=_sqlite_database_url(Path(temporary_directory_name) / "db.sqlite3") + ) + store.ensure_schema() + manifest = ProductOnboardingManifest.model_validate(_manifest_payload()) + first_result = apply_product_onboarding_manifest( + record_store=store, + manifest=manifest, + updated_at="2026-05-03T00:20:00Z", + ) + store.write_product_profile_record( + first_result.product_profile.model_copy( + update={ + "historical_contexts": ("example-site-old",), + "updated_at": "2026-05-03T01:20:00Z", + "source": "test:cutover", + } + ) + ) + + second_result = apply_product_onboarding_manifest( + record_store=store, + manifest=manifest, + updated_at="2026-05-03T02:20:00Z", + ) + profile = store.read_product_profile_record("example-site") + store.close() + + self.assertEqual(second_result.product_profile.historical_contexts, ("example-site-old",)) + self.assertEqual(profile.historical_contexts, ("example-site-old",)) + def test_apply_product_onboarding_manifest_preserves_configured_secret_binding( self, ) -> None: diff --git a/tests/test_product_onboarding_service.py b/tests/test_product_onboarding_service.py index 21ea1f9..e60599f 100644 --- a/tests/test_product_onboarding_service.py +++ b/tests/test_product_onboarding_service.py @@ -22,6 +22,12 @@ def __init__(self) -> None: self.runtime_environments: list[RuntimeEnvironmentRecord] = [] self.secret_bindings: list[SecretBinding] = [] + def read_product_profile_record(self, product: str) -> LaunchplaneProductProfileRecord: + for record in reversed(self.product_profiles): + if record.product == product: + return record + raise KeyError(product) + def write_product_profile_record(self, record: LaunchplaneProductProfileRecord) -> None: self.product_profiles.append(record)