Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion control_plane/contracts/product_onboarding_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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():
Expand Down
47 changes: 45 additions & 2 deletions control_plane/workflows/product_onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
Expand All @@ -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, ...]:
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/records.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions import-material/launchplane/seed-imports/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading