diff --git a/control_plane/contracts/lane_summary.py b/control_plane/contracts/lane_summary.py index 7a3fb08..15f3732 100644 --- a/control_plane/contracts/lane_summary.py +++ b/control_plane/contracts/lane_summary.py @@ -41,12 +41,6 @@ class LaunchplaneLaneSummary(BaseModel): @model_validator(mode="after") def _populate_provider_neutral_target(self) -> "LaunchplaneLaneSummary": - if self.provider_target is None: - if self.dokploy_target is not None and self.dokploy_target_id is not None: - self.provider_target = ProviderTargetRecord.from_dokploy_records( - target_record=self.dokploy_target, - target_id_record=self.dokploy_target_id, - ) if self.deployed_target is None and self.latest_deployment is not None: self.deployed_target = self.latest_deployment.deployed_target return self diff --git a/control_plane/contracts/product_environment_read_model.py b/control_plane/contracts/product_environment_read_model.py index 97a2a63..9e896d1 100644 --- a/control_plane/contracts/product_environment_read_model.py +++ b/control_plane/contracts/product_environment_read_model.py @@ -1716,25 +1716,7 @@ def _target_summary(lane_summary: LaunchplaneLaneSummary | None) -> ProductTarge else "", trust_state="recorded", ) - if lane_summary.dokploy_target is None: - return ProductTargetSummary( - artifact_manifest=lane_summary.artifact_manifest, - expected_runtime_identity=expected_identity, - observed_runtime_identity=destination_health.observed_runtime_identity - if destination_health is not None - else None, - runtime_identity_status=destination_health.runtime_identity_status - if destination_health is not None - else "unchecked", - runtime_identity_detail=destination_health.runtime_identity_detail - if destination_health is not None - else "", - trust_state=lane_summary.provenance.freshness_status, - ) return ProductTargetSummary( - target_type=lane_summary.dokploy_target.target_type, - target_name=lane_summary.dokploy_target.target_name, - target_id_recorded=lane_summary.dokploy_target_id is not None, artifact_manifest=lane_summary.artifact_manifest, expected_runtime_identity=expected_identity, observed_runtime_identity=destination_health.observed_runtime_identity @@ -1746,7 +1728,7 @@ def _target_summary(lane_summary: LaunchplaneLaneSummary | None) -> ProductTarge runtime_identity_detail=destination_health.runtime_identity_detail if destination_health is not None else "", - trust_state="recorded", + trust_state="missing", ) diff --git a/control_plane/service.py b/control_plane/service.py index 9cd2ccc..232c8c2 100644 --- a/control_plane/service.py +++ b/control_plane/service.py @@ -8121,6 +8121,7 @@ def create_launchplane_service_app( resolved_ingress_provider_factory = ingress_provider_factory if resolved_ingress_provider_factory is None: if npmplus_ingress_client_factory is not None: + def npmplus_ingress_provider_from_client_factory() -> IngressProvider: return NpmplusIngressProvider(client=npmplus_ingress_client_factory()) @@ -11913,12 +11914,14 @@ def product_action_allowed( authz_policy = updated_policy resolved_authz_policy_sha256 = authz_policy_record.policy_sha256 resolved_authz_policy_source = "db" - result, driver_result = control_plane_authz_grant_service.build_authz_policy_github_actions_removal_service_result( - authz_policy_record=authz_policy_record, - changed=changed, - mode=authz_removal_request.mode, - diff=diff, - audit=audit, + result, driver_result = ( + control_plane_authz_grant_service.build_authz_policy_github_actions_removal_service_result( + authz_policy_record=authz_policy_record, + changed=changed, + mode=authz_removal_request.mode, + diff=diff, + audit=audit, + ) ) elif path == "/v1/authz-policies/github-humans/grants": human_authz_grant_request = control_plane_authz_grant_service.AuthzPolicyGitHubHumanGrantEnvelope.model_validate( @@ -12613,9 +12616,7 @@ def product_action_allowed( ) ) elif path == PROVIDER_TARGET_OPERATIONS_ROUTE: - provider_target_request = ProviderTargetOperationEnvelope.model_validate( - payload - ) + provider_target_request = ProviderTargetOperationEnvelope.model_validate(payload) if not isinstance(record_store, PostgresRecordStore): return _json_response( start_response=start_response, @@ -12665,13 +12666,28 @@ def product_action_allowed( "error": { "code": "authorization_denied", "message": ( - "Workflow cannot run Launchplane provider-target" - " operations." + "Workflow cannot run Launchplane provider-target operations." ), }, }, ) if provider_target_request.mode == "backfill-apply": + if not request_idempotency_key: + return _json_response( + start_response=start_response, + status_code=400, + payload={ + "status": "rejected", + "trace_id": request_trace_id, + "error": { + "code": "idempotency_key_required", + "message": ( + "Provider-target backfill apply requests require" + " an Idempotency-Key header." + ), + }, + }, + ) idempotent_response = _check_idempotent_request( record_store=record_store, scope=request_scope, @@ -12687,9 +12703,7 @@ def product_action_allowed( record_store=record_store, request=provider_target_request, ) - assert isinstance( - provider_target_result, ProviderTargetOperationRouteResult - ) + assert isinstance(provider_target_result, ProviderTargetOperationRouteResult) result = provider_target_result.result driver_result = provider_target_result.driver_result elif path == "/v1/drivers/launchplane/self-deploy": diff --git a/control_plane/storage/postgres.py b/control_plane/storage/postgres.py index 62a410b..5811755 100644 --- a/control_plane/storage/postgres.py +++ b/control_plane/storage/postgres.py @@ -3144,7 +3144,7 @@ def list_dokploy_target_records(self) -> tuple[DokployTargetRecord, ...]: def read_provider_target_record( self, *, context_name: str, instance_name: str ) -> ProviderTargetRecord: - record = self._read_optional_model( + return self._read_model( model_type=ProviderTargetRecord, orm_model=LaunchplaneProviderTargetRow, filters=( @@ -3152,65 +3152,23 @@ def read_provider_target_record( LaunchplaneProviderTargetRow.instance == instance_name, ), ) - if record is not None: - return record - target_record = self.read_dokploy_target_record( - context_name=context_name, - instance_name=instance_name, - ) - target_id_record = self.read_dokploy_target_id_record( - context_name=context_name, - instance_name=instance_name, - ) - return ProviderTargetRecord.from_dokploy_records( - target_record=target_record, - target_id_record=target_id_record, - ) def list_provider_target_records( self, *, provider_id: str = "" ) -> tuple[ProviderTargetRecord, ...]: normalized_provider_id = provider_id.strip().lower() - all_physical_records = list( - self._list_models( - model_type=ProviderTargetRecord, - orm_model=LaunchplaneProviderTargetRow, - order_by=( - LaunchplaneProviderTargetRow.context.asc(), - LaunchplaneProviderTargetRow.instance.asc(), - ), - ) + filters: Sequence[object] = () + if normalized_provider_id: + filters = (LaunchplaneProviderTargetRow.provider_id == normalized_provider_id,) + return self._list_models( + model_type=ProviderTargetRecord, + orm_model=LaunchplaneProviderTargetRow, + filters=filters, + order_by=( + LaunchplaneProviderTargetRow.context.asc(), + LaunchplaneProviderTargetRow.instance.asc(), + ), ) - physical_record_keys = { - (record.context, record.instance) for record in all_physical_records - } - physical_records = list( - record - for record in all_physical_records - if not normalized_provider_id or record.provider_id == normalized_provider_id - ) - target_id_records = { - (record.context, record.instance): record - for record in self.list_dokploy_target_id_records() - } - provider_records: list[ProviderTargetRecord] = [*physical_records] - for target_record in self.list_dokploy_target_records(): - if (target_record.context, target_record.instance) in physical_record_keys: - continue - target_id_record = target_id_records.get( - (target_record.context, target_record.instance) - ) - if target_id_record is None: - continue - provider_record = ProviderTargetRecord.from_dokploy_records( - target_record=target_record, - target_id_record=target_id_record, - ) - if normalized_provider_id and provider_record.provider_id != normalized_provider_id: - continue - provider_records.append(provider_record) - provider_records.sort(key=lambda record: (record.context, record.instance)) - return tuple(provider_records) def list_physical_provider_target_records(self) -> tuple[ProviderTargetRecord, ...]: return self._list_models( diff --git a/control_plane/workflows/generic_web_deploy.py b/control_plane/workflows/generic_web_deploy.py index 1284764..4274339 100644 --- a/control_plane/workflows/generic_web_deploy.py +++ b/control_plane/workflows/generic_web_deploy.py @@ -126,9 +126,7 @@ def _validate_context(self) -> "GenericWebPostDeployContext": raise ValueError("generic web post-deploy context requires instance") if not self.deployment_record_id.strip(): raise ValueError("generic web post-deploy context requires deployment_record_id") - self.target_category = cast( - DeployTargetCategory, self.target_category.strip().lower() - ) + self.target_category = cast(DeployTargetCategory, self.target_category.strip().lower()) self.provider_id = self.provider_id.strip().lower() self.provider_target_type = self.provider_target_type.strip().lower() self.target_type = self.target_type.strip().lower() @@ -326,6 +324,7 @@ def _fallback_ship_request( def _resolve_deploy_target( *, control_plane_root: Path, + record_store: GenericWebDeployStore, request: GenericWebDeployRequest, profile: LaunchplaneProductProfileRecord, lane: ProductLaneProfile, @@ -337,6 +336,7 @@ def _resolve_deploy_target( request_source_git_ref=request.source_git_ref, request_timeout_seconds=request.timeout_seconds, request_no_cache=request.no_cache, + record_store=record_store, profile=profile, lane=lane, normalized_artifact_id=normalize_generic_web_artifact_id( @@ -381,6 +381,7 @@ def execute_generic_web_deploy( try: resolved_deploy_target = _resolve_deploy_target( control_plane_root=control_plane_root, + record_store=record_store, request=request, profile=resolved_profile, lane=resolved_lane, @@ -486,9 +487,7 @@ def execute_generic_web_deploy( updated_at=finished_at, ) ) - target_fields = _deploy_result_target_fields( - resolved_deploy_target=resolved_deploy_target - ) + target_fields = _deploy_result_target_fields(resolved_deploy_target=resolved_deploy_target) return GenericWebDeployResult( deployment_record_id=record_id, deploy_status=deployment_status, diff --git a/control_plane/workflows/generic_web_deploy_provider.py b/control_plane/workflows/generic_web_deploy_provider.py index f34dd2e..ade8bba 100644 --- a/control_plane/workflows/generic_web_deploy_provider.py +++ b/control_plane/workflows/generic_web_deploy_provider.py @@ -1,14 +1,20 @@ from __future__ import annotations from pathlib import Path -from typing import Protocol +from typing import Protocol, cast import click from pydantic import BaseModel, ConfigDict, Field from control_plane import dokploy as control_plane_dokploy from control_plane import runtime_environments as control_plane_runtime_environments -from control_plane.contracts.deploy_target import DeployedTargetReference +from control_plane.contracts.deploy_target import ( + DeployedTargetReference, + DeployTargetCompatibilityType, + ProviderTargetRecord, +) +from control_plane.contracts.dokploy_target_id_record import DokployTargetIdRecord +from control_plane.contracts.dokploy_target_record import DokployTargetRecord from control_plane.contracts.deployment_record import ResolvedTargetEvidence from control_plane.contracts.product_profile_record import ( LaunchplaneProductProfileRecord, @@ -29,6 +35,20 @@ class GenericWebResolvedDeployTarget(BaseModel): deploy_timeout_seconds: int = Field(ge=1) +class DokployGenericWebDeployStore(Protocol): + def read_provider_target_record( + self, *, context_name: str, instance_name: str + ) -> ProviderTargetRecord: ... + + def read_dokploy_target_record( + self, *, context_name: str, instance_name: str + ) -> DokployTargetRecord: ... + + def read_dokploy_target_id_record( + self, *, context_name: str, instance_name: str + ) -> DokployTargetIdRecord: ... + + class GenericWebDeployProvider(Protocol): provider_id: str delegated_executor: str @@ -41,6 +61,7 @@ def resolve_deploy_target( request_source_git_ref: str, request_timeout_seconds: int | None, request_no_cache: bool, + record_store: object, profile: LaunchplaneProductProfileRecord, lane: ProductLaneProfile, normalized_artifact_id: str, @@ -68,31 +89,48 @@ def resolve_deploy_target( request_source_git_ref: str, request_timeout_seconds: int | None, request_no_cache: bool, + record_store: object, profile: LaunchplaneProductProfileRecord, lane: ProductLaneProfile, normalized_artifact_id: str, fallback_target_name: str, ) -> GenericWebResolvedDeployTarget: del request_artifact_id, profile - source_of_truth = control_plane_dokploy.read_control_plane_dokploy_source_of_truth( - control_plane_root=control_plane_root, + dokploy_store = _require_dokploy_deploy_store(record_store) + try: + provider_target = dokploy_store.read_provider_target_record( + context_name=lane.context, + instance_name=lane.instance, + ) + except FileNotFoundError as exc: + raise click.ClickException( + "Missing provider-target authority for " + f"{lane.context}/{lane.instance}. Run provider-target audit/backfill before deploying." + ) from exc + target_record = dokploy_store.read_dokploy_target_record( + context_name=lane.context, + instance_name=lane.instance, ) - target_definition = control_plane_dokploy.find_dokploy_target_definition( - source_of_truth, + target_id_record = dokploy_store.read_dokploy_target_id_record( context_name=lane.context, instance_name=lane.instance, ) - if target_definition is None: - raise click.ClickException( - f"No Dokploy target definition found for {lane.context}/{lane.instance}." - ) + target_definition = _dokploy_target_definition_for_lane( + target_record=target_record, + target_id_record=target_id_record, + ) + _validate_dokploy_provider_target( + provider_target=provider_target, + target_definition=target_definition, + ) + provider_target_type = _dokploy_target_type_from_provider_target( + provider_target=provider_target + ) - environment_values = ( - control_plane_runtime_environments.resolve_runtime_environment_values( - control_plane_root=control_plane_root, - context_name=lane.context, - instance_name=lane.instance, - ) + environment_values = control_plane_runtime_environments.resolve_runtime_environment_values( + control_plane_root=control_plane_root, + context_name=lane.context, + instance_name=lane.instance, ) configured_ship_mode = control_plane_dokploy.resolve_dokploy_ship_mode( lane.context, @@ -101,20 +139,20 @@ def resolve_deploy_target( ) deploy_mode = _resolve_dokploy_deploy_mode( configured_ship_mode=configured_ship_mode, - target_type=target_definition.target_type, + target_type=provider_target_type, ) - target_name = target_definition.target_name.strip() or fallback_target_name + target_name = provider_target.display_name.strip() or fallback_target_name ship_request = ShipRequest( artifact_id=normalized_artifact_id, context=lane.context, instance=lane.instance, source_git_ref=request_source_git_ref, target_name=target_name, - target_type=target_definition.target_type, + target_type=provider_target_type, deploy_mode=deploy_mode, provider_id=self.provider_id, - target_category=target_definition.target_type, - provider_target_type=target_definition.target_type, + target_category=provider_target.target_category, + provider_target_type=provider_target_type, provider_deploy_mode=deploy_mode, wait=True, timeout_seconds=request_timeout_seconds, @@ -123,17 +161,11 @@ def resolve_deploy_target( destination_health=HealthcheckEvidence(status="skipped"), ) resolved_target = ResolvedTargetEvidence( - target_type=target_definition.target_type, - target_id=target_definition.target_id, + target_type=provider_target_type, + target_id=provider_target.target_id, target_name=target_name, ) - deployed_target = DeployedTargetReference( - provider_id=self.provider_id, - target_category=target_definition.target_type, - target_id=target_definition.target_id, - display_name=target_name, - provider_target_type=target_definition.target_type, - ) + deployed_target = provider_target.to_deployed_target_reference() deploy_timeout_seconds = control_plane_dokploy.resolve_ship_timeout_seconds( timeout_override_seconds=request_timeout_seconds, target_definition=target_definition, @@ -171,5 +203,109 @@ def _resolve_dokploy_deploy_mode(*, configured_ship_mode: str, target_type: str) return f"dokploy-{configured_ship_mode}-api" +def _require_dokploy_deploy_store( + record_store: object, +) -> DokployGenericWebDeployStore: + required_methods = ( + "read_provider_target_record", + "read_dokploy_target_record", + "read_dokploy_target_id_record", + ) + missing_methods = tuple( + method_name + for method_name in required_methods + if not callable(getattr(record_store, method_name, None)) + ) + if missing_methods: + raise click.ClickException( + "Generic web Dokploy deploy requires DB-backed provider-target and Dokploy target records. " + f"Missing methods: {', '.join(missing_methods)}." + ) + return cast(DokployGenericWebDeployStore, record_store) + + +def _dokploy_target_definition_for_lane( + *, + target_record: DokployTargetRecord, + target_id_record: DokployTargetIdRecord, +) -> control_plane_dokploy.DokployTargetDefinition: + if target_record.context != target_id_record.context: + raise click.ClickException("Dokploy target context must match target-id context.") + if target_record.instance != target_id_record.instance: + raise click.ClickException("Dokploy target instance must match target-id instance.") + return control_plane_dokploy.DokployTargetDefinition( + context=target_record.context, + instance=target_record.instance, + project_name=target_record.project_name, + target_type=target_record.target_type, + target_id=target_id_record.target_id, + target_name=target_record.target_name, + git_branch=target_record.git_branch, + source_git_ref=target_record.source_git_ref, + source_type=target_record.source_type, + custom_git_url=target_record.custom_git_url, + custom_git_branch=target_record.custom_git_branch, + compose_path=target_record.compose_path, + watch_paths=target_record.watch_paths, + enable_submodules=target_record.enable_submodules, + require_test_gate=target_record.require_test_gate, + require_prod_gate=target_record.require_prod_gate, + deploy_timeout_seconds=target_record.deploy_timeout_seconds, + healthcheck_enabled=target_record.healthcheck_enabled, + healthcheck_path=target_record.healthcheck_path, + healthcheck_timeout_seconds=target_record.healthcheck_timeout_seconds, + env=target_record.env, + domains=target_record.domains, + policies=target_record.policies, + ) + + +def _dokploy_target_type_from_provider_target( + *, provider_target: ProviderTargetRecord +) -> DeployTargetCompatibilityType: + if provider_target.provider_target_type not in {"application", "compose"}: + raise click.ClickException( + "Provider-target type mismatch for " + f"{provider_target.context}/{provider_target.instance}: " + "Dokploy execution only supports provider-target type 'application' or 'compose'." + ) + return cast(DeployTargetCompatibilityType, provider_target.provider_target_type) + + +def _validate_dokploy_provider_target( + *, + provider_target: ProviderTargetRecord, + target_definition: control_plane_dokploy.DokployTargetDefinition, +) -> None: + expected_provider_id = "dokploy" + if provider_target.provider_id != expected_provider_id: + raise click.ClickException( + "Provider-target identity mismatch for " + f"{provider_target.context}/{provider_target.instance}: " + f"expected provider {expected_provider_id!r}, found {provider_target.provider_id!r}." + ) + if provider_target.provider_target_type != target_definition.target_type: + raise click.ClickException( + "Provider-target type mismatch for " + f"{provider_target.context}/{provider_target.instance}: " + f"provider-target has {provider_target.provider_target_type!r}, " + f"Dokploy execution config has {target_definition.target_type!r}." + ) + if provider_target.target_category != target_definition.target_type: + raise click.ClickException( + "Provider-target category mismatch for " + f"{provider_target.context}/{provider_target.instance}: " + f"provider-target has {provider_target.target_category!r}, " + f"Dokploy execution config has {target_definition.target_type!r}." + ) + if provider_target.target_id != target_definition.target_id: + raise click.ClickException( + "Provider-target id mismatch for " + f"{provider_target.context}/{provider_target.instance}: " + f"provider-target has {provider_target.target_id!r}, " + f"Dokploy execution config has {target_definition.target_id!r}." + ) + + def default_generic_web_deploy_provider() -> GenericWebDeployProvider: return DokployGenericWebDeployProvider() diff --git a/docs/records.md b/docs/records.md index e5907fa..7cb85c2 100644 --- a/docs/records.md +++ b/docs/records.md @@ -131,12 +131,11 @@ an ORM column/table or remains only in the evidence payload. - Provider target records define the neutral target inventory contract: `context`, `instance`, `provider_id`, `target_category`, `target_id`, `display_name`, `provider_target_type`, `updated_at`, and payload-only - provider evidence. DB-backed storage uses `launchplane_provider_targets` for - explicit provider-neutral target rows, and read models can still project these - neutral records from paired Dokploy target and target-id records when no - explicit provider-target row exists. Existing Dokploy target and target-id - records remain the active live write/execution path until a later dual-write - or cutover migration. + provider evidence. DB-backed storage uses `launchplane_provider_targets` as + the explicit provider-neutral target authority for current reads. Paired + Dokploy target and target-id records still provide audit/backfill comparison + material and provider execution configuration, but they no longer synthesize + steady-state provider-target authority when an explicit row is missing. - Product onboarding, Dokploy target adoption/creation, product context cutover, and tracked Dokploy target metadata commands now dual-write explicit provider-target rows when a complete Dokploy target and target-id pair exists. @@ -599,11 +598,12 @@ state/ - Live `target_id` values remain a sibling DB-backed record so operators can update route metadata and route identity independently when needed. - Paired Dokploy target and target-id records project to the neutral - `ProviderTargetRecord` read model. Missing halves remain missing; Launchplane - does not fabricate provider-neutral target identity from only route metadata or - only a live id. Explicit `launchplane_provider_targets` rows take precedence in - provider-target read models, but Dokploy records remain the live execution - authority until the write path is migrated. + `ProviderTargetRecord` shape for audit and backfill comparison only. Missing + halves remain missing; Launchplane does not fabricate provider-neutral target + identity from only route metadata or only a live id. Explicit + `launchplane_provider_targets` rows are the provider-target read authority. + Dokploy records remain provider-specific execution configuration and cannot + override provider-target identity after cutover. - Shopify guard values such as protected store keys now belong in `policies.shopify.protected_store_keys` on this record instead of a route map hardcoded in Python. diff --git a/docs/service-boundary.md b/docs/service-boundary.md index 3ed39b8..a62c429 100644 --- a/docs/service-boundary.md +++ b/docs/service-boundary.md @@ -877,11 +877,14 @@ runtime, and audit sanitized key/count evidence. Generic web deploys use `POST /v1/drivers/generic-web/deploy`. The request names the product, target instance, immutable artifact/image reference, and source ref; Launchplane resolves the context from the DB-backed product profile lane and the -runtime target from DB-backed Dokploy target records. -Product environment reads expose a neutral provider-target projection when a -lane has paired DB-backed Dokploy target and target-id records, but generic-web -deploy and live runtime apply still resolve and mutate through those Dokploy -records until the provider-neutral write path exists. +runtime target identity from DB-backed provider-target records. Dokploy target +records remain provider execution configuration for Dokploy-backed lanes and +must agree with the provider-target identity before deploy proceeds. +Product environment reads expose neutral provider-target identity only from +explicit provider-target rows. Paired DB-backed Dokploy target and target-id +records remain visible as provider-specific execution/history metadata and as +audit/backfill comparison material; they no longer synthesize current +provider-target authority when an explicit row is missing. Generic web prod promotion can be exercised directly with `POST /v1/drivers/generic-web/prod-promotion`; browser sessions may only use this diff --git a/tests/test_generic_web_deploy.py b/tests/test_generic_web_deploy.py index c45aef8..339ab92 100644 --- a/tests/test_generic_web_deploy.py +++ b/tests/test_generic_web_deploy.py @@ -4,7 +4,9 @@ import click -from control_plane.contracts.deploy_target import DeployedTargetReference +from control_plane.contracts.deploy_target import DeployedTargetReference, ProviderTargetRecord +from control_plane.contracts.dokploy_target_id_record import DokployTargetIdRecord +from control_plane.contracts.dokploy_target_record import DokployTargetRecord from control_plane.contracts.deployment_record import DeploymentRecord, ResolvedTargetEvidence from control_plane.contracts.environment_inventory import EnvironmentInventory from control_plane.contracts.promotion_record import PostDeployUpdateEvidence @@ -62,6 +64,74 @@ def write_environment_inventory(self, record: EnvironmentInventory) -> None: self.inventories.append(record) +class _DokployGenericWebDeployStore(_GenericWebDeployStore): + def __init__( + self, + profile: LaunchplaneProductProfileRecord, + *, + provider_target: ProviderTargetRecord | None = None, + dokploy_target: DokployTargetRecord | None = None, + dokploy_target_id: DokployTargetIdRecord | None = None, + ) -> None: + super().__init__(profile) + self.provider_target = provider_target or ProviderTargetRecord( + context="sellyouroutboard-testing", + instance="testing", + provider_id="dokploy", + target_category="application", + target_id="target-123", + display_name="provider-target-app", + provider_target_type="application", + updated_at="2026-04-30T22:00:00Z", + source_label="test", + ) + self.dokploy_target = dokploy_target or DokployTargetRecord( + context="sellyouroutboard-testing", + instance="testing", + target_type="application", + target_name="dokploy-config-app", + updated_at="2026-04-30T22:00:00Z", + source_label="test", + ) + self.dokploy_target_id = dokploy_target_id or DokployTargetIdRecord( + context="sellyouroutboard-testing", + instance="testing", + target_id="target-123", + updated_at="2026-04-30T22:00:00Z", + source_label="test", + ) + + def read_provider_target_record( + self, *, context_name: str, instance_name: str + ) -> ProviderTargetRecord: + if ( + context_name == self.provider_target.context + and instance_name == self.provider_target.instance + ): + return self.provider_target + raise FileNotFoundError(f"{context_name}/{instance_name}") + + def read_dokploy_target_record( + self, *, context_name: str, instance_name: str + ) -> DokployTargetRecord: + if ( + context_name == self.dokploy_target.context + and instance_name == self.dokploy_target.instance + ): + return self.dokploy_target + raise FileNotFoundError(f"{context_name}/{instance_name}") + + def read_dokploy_target_id_record( + self, *, context_name: str, instance_name: str + ) -> DokployTargetIdRecord: + if ( + context_name == self.dokploy_target_id.context + and instance_name == self.dokploy_target_id.instance + ): + return self.dokploy_target_id + raise FileNotFoundError(f"{context_name}/{instance_name}") + + def _profile(*, driver_id: str = "generic-web") -> LaunchplaneProductProfileRecord: return LaunchplaneProductProfileRecord( product="sellyouroutboard", @@ -115,12 +185,13 @@ def resolve_deploy_target( request_source_git_ref: str, request_timeout_seconds: int | None, request_no_cache: bool, + record_store: object, profile: LaunchplaneProductProfileRecord, lane: ProductLaneProfile, normalized_artifact_id: str, fallback_target_name: str, ) -> GenericWebResolvedDeployTarget: - del control_plane_root, request_artifact_id, profile, fallback_target_name + del control_plane_root, request_artifact_id, record_store, profile, fallback_target_name resolved = GenericWebResolvedDeployTarget( ship_request=ShipRequest( artifact_id=normalized_artifact_id, @@ -456,6 +527,7 @@ def resolve_deploy_target( request_source_git_ref: str, request_timeout_seconds: int | None, request_no_cache: bool, + record_store: object, profile: LaunchplaneProductProfileRecord, lane: ProductLaneProfile, normalized_artifact_id: str, @@ -467,6 +539,7 @@ def resolve_deploy_target( request_source_git_ref, request_timeout_seconds, request_no_cache, + record_store, profile, lane, normalized_artifact_id, @@ -502,16 +575,11 @@ def test_dokploy_provider_resolves_target_without_generic_web_dokploy_imports( self, ) -> None: provider = DokployGenericWebDeployProvider() + store = _DokployGenericWebDeployStore(_profile()) - with ( - patch( - "control_plane.workflows.generic_web_deploy_provider.control_plane_dokploy.read_control_plane_dokploy_source_of_truth", - return_value=_source_of_truth(), - ), - patch( - "control_plane.workflows.generic_web_deploy_provider.control_plane_runtime_environments.resolve_runtime_environment_values", - return_value={}, - ), + with patch( + "control_plane.workflows.generic_web_deploy_provider.control_plane_runtime_environments.resolve_runtime_environment_values", + return_value={}, ): resolved = provider.resolve_deploy_target( control_plane_root=Path("."), @@ -519,6 +587,7 @@ def test_dokploy_provider_resolves_target_without_generic_web_dokploy_imports( request_source_git_ref="abc123", request_timeout_seconds=45, request_no_cache=True, + record_store=store, profile=_profile(), lane=_profile().lanes[0], normalized_artifact_id="ghcr.io/cbusillo/sellyouroutboard@sha256:abc123", @@ -529,9 +598,101 @@ def test_dokploy_provider_resolves_target_without_generic_web_dokploy_imports( self.assertEqual(resolved.ship_request.deploy_mode, "dokploy-application-api") self.assertEqual(resolved.ship_request.target_category, "application") self.assertEqual(resolved.ship_request.provider_target_type, "application") + self.assertEqual(resolved.ship_request.target_name, "provider-target-app") self.assertEqual(resolved.resolved_target.target_id, "target-123") + self.assertEqual(resolved.resolved_target.target_name, "provider-target-app") + deployed_target = resolved.deployed_target + assert deployed_target is not None + self.assertEqual(deployed_target.display_name, "provider-target-app") self.assertEqual(resolved.deploy_timeout_seconds, 45) + def test_dokploy_provider_blocks_missing_provider_target_authority(self) -> None: + class MissingProviderTargetStore(_DokployGenericWebDeployStore): + def read_provider_target_record( + self, *, context_name: str, instance_name: str + ) -> ProviderTargetRecord: + raise FileNotFoundError(f"{context_name}/{instance_name}") + + provider = DokployGenericWebDeployProvider() + store = MissingProviderTargetStore(_profile()) + + with self.assertRaisesRegex(click.ClickException, "Missing provider-target authority"): + provider.resolve_deploy_target( + control_plane_root=Path("."), + request_artifact_id="ghcr.io/cbusillo/sellyouroutboard@sha256:abc123", + request_source_git_ref="abc123", + request_timeout_seconds=45, + request_no_cache=True, + record_store=store, + profile=_profile(), + lane=_profile().lanes[0], + normalized_artifact_id="ghcr.io/cbusillo/sellyouroutboard@sha256:abc123", + fallback_target_name="fallback-target", + ) + + def test_dokploy_provider_blocks_target_id_mismatch(self) -> None: + provider = DokployGenericWebDeployProvider() + store = _DokployGenericWebDeployStore( + _profile(), + provider_target=ProviderTargetRecord( + context="sellyouroutboard-testing", + instance="testing", + provider_id="dokploy", + target_category="application", + target_id="provider-target-id", + display_name="provider-target-app", + provider_target_type="application", + updated_at="2026-04-30T22:00:00Z", + source_label="test", + ), + ) + + with self.assertRaisesRegex(click.ClickException, "Provider-target id mismatch"): + provider.resolve_deploy_target( + control_plane_root=Path("."), + request_artifact_id="ghcr.io/cbusillo/sellyouroutboard@sha256:abc123", + request_source_git_ref="abc123", + request_timeout_seconds=45, + request_no_cache=True, + record_store=store, + profile=_profile(), + lane=_profile().lanes[0], + normalized_artifact_id="ghcr.io/cbusillo/sellyouroutboard@sha256:abc123", + fallback_target_name="fallback-target", + ) + + def test_dokploy_provider_resolves_selected_lane_without_global_target_scan( + self, + ) -> None: + class StoreWithDangerousListMethods(_DokployGenericWebDeployStore): + def list_dokploy_target_records(self) -> tuple[DokployTargetRecord, ...]: + raise AssertionError("deploy resolution must not scan all Dokploy targets") + + def list_dokploy_target_id_records(self) -> tuple[DokployTargetIdRecord, ...]: + raise AssertionError("deploy resolution must not scan all Dokploy target ids") + + provider = DokployGenericWebDeployProvider() + store = StoreWithDangerousListMethods(_profile()) + + with patch( + "control_plane.workflows.generic_web_deploy_provider.control_plane_runtime_environments.resolve_runtime_environment_values", + return_value={}, + ): + resolved = provider.resolve_deploy_target( + control_plane_root=Path("."), + request_artifact_id="ghcr.io/cbusillo/sellyouroutboard@sha256:abc123", + request_source_git_ref="abc123", + request_timeout_seconds=45, + request_no_cache=True, + record_store=store, + profile=_profile(), + lane=_profile().lanes[0], + normalized_artifact_id="ghcr.io/cbusillo/sellyouroutboard@sha256:abc123", + fallback_target_name="fallback-target", + ) + + self.assertEqual(resolved.resolved_target.target_id, "target-123") + def test_resolve_generic_web_profile_lane_rejects_missing_lane(self) -> None: store = _GenericWebDeployStore(_profile()) diff --git a/tests/test_postgres_store.py b/tests/test_postgres_store.py index ab1da25..2d3b1f5 100644 --- a/tests/test_postgres_store.py +++ b/tests/test_postgres_store.py @@ -25,7 +25,11 @@ ) from control_plane.contracts.authz_policy_record import LaunchplaneAuthzPolicyRecord from control_plane.contracts.backup_gate_record import BackupGateRecord -from control_plane.contracts.deploy_target import DeployedTargetReference, ProviderTargetRecord +from control_plane.contracts.deploy_target import ( + DeployedTargetReference, + DeployTargetCategory, + ProviderTargetRecord, +) from control_plane.contracts.deployment_record import DeploymentRecord, ResolvedTargetEvidence from control_plane.contracts.dokploy_target_record import DokployTargetRecord from control_plane.contracts.dokploy_target_id_record import DokployTargetIdRecord @@ -397,17 +401,19 @@ def _provider_target_record( context: str = "syo", instance: str = "prod", provider_id: str = "dokploy", + target_category: DeployTargetCategory = "application", target_id: str = "app-syo-prod", + provider_target_type: str = "application", updated_at: str = "2026-04-21T18:35:00Z", ) -> ProviderTargetRecord: return ProviderTargetRecord( context=context, instance=instance, provider_id=provider_id, - target_category="application", + target_category=target_category, target_id=target_id, display_name=f"{context}-{instance}", - provider_target_type="application", + provider_target_type=provider_target_type, provider_evidence={"project_name": f"{context}-project"}, updated_at=updated_at, source_label="test:provider-target", @@ -1166,7 +1172,7 @@ def test_dokploy_target_records_round_trip(self) -> None: self.assertEqual(len(listed), 1) self.assertEqual(listed[0].context, "opw") - def test_provider_target_records_project_from_dokploy_records(self) -> None: + def test_provider_target_records_require_physical_authority(self) -> None: with TemporaryDirectory() as temporary_directory_name: database_path = Path(temporary_directory_name) / "launchplane.sqlite3" store = PostgresRecordStore(database_url=_sqlite_database_url(database_path)) @@ -1187,21 +1193,14 @@ def test_provider_target_records_project_from_dokploy_records(self) -> None: ) ) - loaded = store.read_provider_target_record(context_name="syo", instance_name="prod") + with self.assertRaises(FileNotFoundError): + store.read_provider_target_record(context_name="syo", instance_name="prod") listed = store.list_provider_target_records() filtered = store.list_provider_target_records(provider_id=" DOKPLOY ") store.close() - self.assertEqual(loaded.provider_id, "dokploy") - self.assertEqual(loaded.context, "syo") - self.assertEqual(loaded.instance, "prod") - self.assertEqual(loaded.target_category, "compose") - self.assertEqual(loaded.target_id, "app-syo-prod") - self.assertEqual(loaded.provider_target_type, "compose") - self.assertEqual(loaded.provider_evidence["project_name"], "syo-prod-project") - self.assertEqual(len(listed), 1) - self.assertEqual(listed[0], loaded) - self.assertEqual(filtered, (loaded,)) + self.assertEqual(listed, ()) + self.assertEqual(filtered, ()) def test_provider_target_records_round_trip_from_physical_storage(self) -> None: with TemporaryDirectory() as temporary_directory_name: @@ -1345,7 +1344,33 @@ def test_provider_target_filter_suppresses_shadowed_dokploy_projection(self) -> self.assertEqual(dokploy_records, ()) self.assertEqual(future_provider_records, (physical_record,)) - def test_provider_target_list_combines_physical_and_projected_records(self) -> None: + def test_read_lane_summary_does_not_project_provider_target_from_dokploy_pair( + self, + ) -> None: + with TemporaryDirectory() as temporary_directory_name: + database_path = Path(temporary_directory_name) / "launchplane.sqlite3" + store = PostgresRecordStore(database_url=_sqlite_database_url(database_path)) + store.ensure_schema() + + store.write_dokploy_target_record( + _dokploy_target_record(context="syo", instance="prod") + ) + store.write_dokploy_target_id_record( + _dokploy_target_id_record( + context="syo", + instance="prod", + target_id="legacy-dokploy-syo-prod", + ) + ) + + summary = store.read_lane_summary(context_name="syo", instance_name="prod") + store.close() + + self.assertIsNone(summary.provider_target) + self.assertIsNotNone(summary.dokploy_target) + self.assertIsNotNone(summary.dokploy_target_id) + + def test_provider_target_list_returns_only_physical_records(self) -> None: with TemporaryDirectory() as temporary_directory_name: database_path = Path(temporary_directory_name) / "launchplane.sqlite3" store = PostgresRecordStore(database_url=_sqlite_database_url(database_path)) @@ -1378,7 +1403,6 @@ def test_provider_target_list_combines_physical_and_projected_records(self) -> N self.assertEqual( [(record.context, record.instance, record.target_id) for record in listed], [ - ("syo", "prod", "app-syo-prod"), ("verireel", "prod", "app-verireel-prod"), ], ) @@ -1412,11 +1436,8 @@ def test_read_lane_summary_keeps_deployment_target_evidence_separate(self) -> No finished_at="2026-04-20T15:32:00Z", ) ) - store.write_dokploy_target_record( - _dokploy_target_record(context="opw", instance="testing") - ) - store.write_dokploy_target_id_record( - _dokploy_target_id_record( + store.write_provider_target_record( + _provider_target_record( context="opw", instance="testing", target_id="current-compose-id", @@ -2827,6 +2848,15 @@ def test_read_lane_summary_uses_repository_queries_for_gui_state(self) -> None: store.write_dokploy_target_record( _dokploy_target_record(context="opw", instance="testing") ) + store.write_provider_target_record( + _provider_target_record( + context="opw", + instance="testing", + target_category="compose", + target_id="compose-123", + provider_target_type="compose", + ) + ) store.write_runtime_environment_record( _runtime_environment_record( scope="global", diff --git a/tests/test_product_environment_read_model.py b/tests/test_product_environment_read_model.py index 3fbe850..f1f8c32 100644 --- a/tests/test_product_environment_read_model.py +++ b/tests/test_product_environment_read_model.py @@ -14,6 +14,7 @@ ArtifactImageReference, ) from control_plane.contracts.environment_inventory import EnvironmentInventory +from control_plane.contracts.deploy_target import ProviderTargetRecord from control_plane.contracts.dokploy_target_id_record import DokployTargetIdRecord from control_plane.contracts.dokploy_target_record import DokployTargetRecord from control_plane.contracts.preview_record import PreviewRecord @@ -1168,7 +1169,7 @@ def test_driver_lane_summary_exposes_artifact_build_provenance(self) -> None: ) self.assertIn("odoo-devkit", detail.model_dump_json()) - def test_product_environment_detail_exposes_provider_target_projection(self) -> None: + def test_product_environment_detail_exposes_physical_provider_target(self) -> None: with TemporaryDirectory() as temporary_directory_name: database_path = Path(temporary_directory_name) / "launchplane.sqlite3" database_url = f"sqlite+pysqlite:///{database_path}" @@ -1198,6 +1199,20 @@ def test_product_environment_detail_exposes_provider_target_projection(self) -> source_label="test", ) ) + store.write_provider_target_record( + ProviderTargetRecord( + context="example-site-prod", + instance="prod", + provider_id="dokploy", + target_category="application", + target_id="app-example-prod", + display_name="example-site-prod", + provider_target_type="application", + provider_evidence={"project_name": "example-site-prod-project"}, + updated_at="2026-05-02T22:32:00Z", + source_label="test", + ) + ) detail = build_product_environment_detail( record_store=store, @@ -1215,6 +1230,55 @@ def test_product_environment_detail_exposes_provider_target_projection(self) -> self.assertTrue(detail.target.target_id_recorded) self.assertEqual(detail.target.trust_state, "recorded") + def test_product_environment_detail_does_not_project_provider_target_from_dokploy_pair( + 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_dokploy_target_record( + DokployTargetRecord( + context="example-site-prod", + instance="prod", + project_name="example-site-prod-project", + target_type="application", + target_name="example-site-prod", + updated_at="2026-05-02T22:30:00Z", + source_label="test", + ) + ) + store.write_dokploy_target_id_record( + DokployTargetIdRecord( + context="example-site-prod", + instance="prod", + target_id="app-example-prod", + updated_at="2026-05-02T22:31:00Z", + source_label="test", + ) + ) + + detail = build_product_environment_detail( + record_store=store, + product=profile.product, + environment="prod", + action_allowed=lambda *_: False, + ) + store.close() + + self.assertEqual(detail.target.provider, "dokploy") + self.assertEqual(detail.target.target_type, "") + self.assertEqual(detail.target.target_name, "") + self.assertEqual(detail.target.target_id, "") + self.assertEqual(detail.target.provider_target_type, "") + self.assertFalse(detail.target.target_id_recorded) + self.assertEqual(detail.target.trust_state, "missing") + 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" diff --git a/tests/test_service.py b/tests/test_service.py index 99c378f..d59ce00 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -12319,9 +12319,7 @@ def test_provider_target_operation_endpoint_backfills_route(self) -> None: self.assertEqual(dry_run_payload["result"]["report"]["counts"], {"would-create": 1}) self.assertEqual(dry_run_rerun_status, 202) self.assertNotIn("replayed", dry_run_rerun_payload) - self.assertEqual( - dry_run_rerun_payload["result"]["report"]["counts"], {"would-create": 1} - ) + self.assertEqual(dry_run_rerun_payload["result"]["report"]["counts"], {"would-create": 1}) self.assertEqual(apply_status, 202) self.assertEqual(apply_payload["result"]["report"]["counts"], {"created": 1}) self.assertEqual(audit_status, 202) @@ -12395,6 +12393,79 @@ def test_provider_target_operation_endpoint_rejects_apply_without_authz( self.assertEqual(status_code, 403) self.assertEqual(payload["error"]["code"], "authorization_denied") + def test_provider_target_operation_endpoint_rejects_apply_without_idempotency_key( + self, + ) -> None: + with TemporaryDirectory() as temporary_directory_name: + root = Path(temporary_directory_name) + database_url = _sqlite_database_url(root / "launchplane.sqlite3") + _seed_tracked_target_records( + database_url=database_url, + context="verireel", + instance="testing", + target_id="app-verireel-testing", + target_type="application", + target_name="ver-testing-app", + ) + policy = LaunchplaneAuthzPolicy.model_validate( + { + "github_actions": [ + { + "repository": "cbusillo/launchplane", + "workflow_refs": [ + "cbusillo/launchplane/.github/workflows/provider-target-operations.yml@refs/heads/main" + ], + "event_names": ["workflow_dispatch"], + "products": ["launchplane"], + "contexts": ["launchplane"], + "actions": [ + "provider_target.audit", + "provider_target.backfill", + ], + } + ] + } + ) + app = create_launchplane_service_app( + state_dir=root / "state", + verifier=_StubVerifier( + _identity( + repository="cbusillo/launchplane", + workflow_ref=( + "cbusillo/launchplane/.github/workflows/provider-target-operations.yml@refs/heads/main" + ), + event_name="workflow_dispatch", + ) + ), + authz_policy=policy, + control_plane_root_path=root, + database_url=database_url, + ) + + status_code, payload = _invoke_app( + app, + method="POST", + path="/v1/provider-targets/operations", + payload={ + "schema_version": 1, + "mode": "backfill-apply", + "product": "launchplane", + "provider_id": "dokploy", + "context": "verireel", + "instance": "testing", + "reason": "Seed provider-target row for Phase Two cutover.", + }, + ) + store = PostgresRecordStore(database_url=database_url) + try: + provider_targets = store.list_provider_target_records() + finally: + store.close() + + self.assertEqual(status_code, 400) + self.assertEqual(payload["error"]["code"], "idempotency_key_required") + self.assertEqual(provider_targets, ()) + def test_product_overview_endpoint_is_generic_web_profile_driven(self) -> None: with TemporaryDirectory() as temporary_directory_name: root = Path(temporary_directory_name) @@ -12694,6 +12765,19 @@ def test_product_environment_detail_redacts_runtime_and_secret_values(self) -> N source_label="test", ) ) + store.write_provider_target_record( + ProviderTargetRecord( + context="example-site", + instance="prod", + provider_id="dokploy", + target_category="application", + target_id="app-prod-123", + display_name="example-site-prod", + provider_target_type="application", + updated_at="2026-05-02T22:32:00Z", + source_label="test", + ) + ) store.write_runtime_environment_record( RuntimeEnvironmentRecord( scope="instance",