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
103 changes: 99 additions & 4 deletions control_plane/workflows/generic_web_deploy.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from pathlib import Path
from typing import Literal, Protocol
from typing import Callable, Literal, Protocol

import click
from pydantic import BaseModel, ConfigDict, Field, model_validator
Expand All @@ -15,7 +15,7 @@
LaunchplaneProductProfileRecord,
ProductLaneProfile,
)
from control_plane.contracts.promotion_record import HealthcheckEvidence
from control_plane.contracts.promotion_record import HealthcheckEvidence, PostDeployUpdateEvidence
from control_plane.contracts.runtime_identity import RuntimeIdentity
from control_plane.contracts.ship_request import ShipRequest
from control_plane.drivers.registry import read_driver_descriptor
Expand Down Expand Up @@ -73,9 +73,49 @@ class GenericWebDeployResult(BaseModel):
target_name: str = ""
target_type: DokployTargetType | Literal[""] = ""
target_id: str = ""
post_deploy_status: Literal["pass", "fail", "skipped"] = "skipped"
error_message: str = ""


class GenericWebPostDeployContext(BaseModel):
model_config = ConfigDict(extra="forbid")

product: str
context: str
instance: str
deployment_record_id: str
target_name: str
target_type: DokployTargetType
target_id: str
artifact_id: str
source_git_ref: str

@model_validator(mode="after")
def _validate_context(self) -> "GenericWebPostDeployContext":
if not self.product.strip():
raise ValueError("generic web post-deploy context requires product")
if not self.context.strip():
raise ValueError("generic web post-deploy context requires context")
if not self.instance.strip():
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")
if not self.target_name.strip():
raise ValueError("generic web post-deploy context requires target_name")
if not self.target_id.strip():
raise ValueError("generic web post-deploy context requires target_id")
if not self.artifact_id.strip():
raise ValueError("generic web post-deploy context requires artifact_id")
if not self.source_git_ref.strip():
raise ValueError("generic web post-deploy context requires source_git_ref")
return self


GenericWebPostDeployExecutor = Callable[
[Path, GenericWebDeployStore, GenericWebPostDeployContext], PostDeployUpdateEvidence
]


def resolve_generic_web_profile_lane(
*, record_store: GenericWebDeployStore, request: GenericWebDeployRequest
) -> tuple[LaunchplaneProductProfileRecord, ProductLaneProfile]:
Expand Down Expand Up @@ -254,6 +294,7 @@ def execute_generic_web_deploy(
request: GenericWebDeployRequest,
profile: LaunchplaneProductProfileRecord | None = None,
lane: ProductLaneProfile | None = None,
post_deploy_executor: GenericWebPostDeployExecutor | None = None,
) -> GenericWebDeployResult:
resolved_profile = profile
resolved_lane = lane
Expand Down Expand Up @@ -304,6 +345,8 @@ def execute_generic_web_deploy(
error_message=str(exc),
)

deploy_completed = False
post_deploy_update = PostDeployUpdateEvidence()
try:
host, token = control_plane_dokploy.read_dokploy_config(
control_plane_root=control_plane_root
Expand All @@ -321,22 +364,53 @@ def execute_generic_web_deploy(
deployment_record_id=record_id,
),
)
deploy_completed = True
if post_deploy_executor is not None:
post_deploy_update = _terminal_post_deploy_update(
post_deploy_executor(
control_plane_root,
record_store,
GenericWebPostDeployContext(
product=resolved_profile.product,
context=resolved_lane.context,
instance=resolved_lane.instance,
deployment_record_id=record_id,
target_name=resolved_target.target_name,
target_type=resolved_target.target_type,
target_id=resolved_target.target_id,
artifact_id=ship_request.artifact_id,
source_git_ref=ship_request.source_git_ref,
),
)
)
if post_deploy_update.status == "fail":
raise click.ClickException(
post_deploy_update.detail or "Generic web post-deploy extension failed."
)
except click.ClickException as exc:
finished_at = utc_now_timestamp()
deployment_status: Literal["pass", "fail"] = "pass" if deploy_completed else "fail"
if deploy_completed and post_deploy_update.status == "skipped":
post_deploy_update = PostDeployUpdateEvidence(
attempted=True,
status="fail",
detail=str(exc),
)
record_store.write_deployment_record(
build_deployment_record(
request=ship_request,
record_id=record_id,
deployment_id="control-plane-dokploy",
deployment_status="fail",
deployment_status=deployment_status,
started_at=started_at,
finished_at=finished_at,
resolved_target=resolved_target,
post_deploy_update=post_deploy_update,
)
)
return GenericWebDeployResult(
deployment_record_id=record_id,
deploy_status="fail",
deploy_status=deployment_status,
deploy_started_at=started_at,
deploy_finished_at=finished_at,
product=resolved_profile.product,
Expand All @@ -345,6 +419,7 @@ def execute_generic_web_deploy(
target_name=resolved_target.target_name,
target_type=resolved_target.target_type,
target_id=resolved_target.target_id,
post_deploy_status=_generic_web_deploy_post_deploy_status(post_deploy_update),
error_message=str(exc),
)

Expand All @@ -357,6 +432,7 @@ def execute_generic_web_deploy(
started_at=started_at,
finished_at=finished_at,
resolved_target=resolved_target,
post_deploy_update=post_deploy_update,
runtime_identity=_build_runtime_identity(
profile=resolved_profile,
lane=resolved_lane,
Expand All @@ -383,4 +459,23 @@ def execute_generic_web_deploy(
target_name=resolved_target.target_name,
target_type=resolved_target.target_type,
target_id=resolved_target.target_id,
post_deploy_status=_generic_web_deploy_post_deploy_status(post_deploy_update),
)


def _generic_web_deploy_post_deploy_status(
post_deploy_update: PostDeployUpdateEvidence,
) -> Literal["pass", "fail", "skipped"]:
if post_deploy_update.status == "pending":
return "fail"
return post_deploy_update.status


def _terminal_post_deploy_update(
post_deploy_update: PostDeployUpdateEvidence,
) -> PostDeployUpdateEvidence:
if post_deploy_update.status == "pending":
raise click.ClickException(
"Generic web post-deploy extensions must return terminal evidence."
)
return post_deploy_update
10 changes: 9 additions & 1 deletion control_plane/workflows/generic_web_rollback.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from control_plane.workflows.generic_web_deploy import (
GenericWebDeployRequest,
GenericWebDeployStore,
GenericWebPostDeployExecutor,
execute_generic_web_deploy,
)

Expand Down Expand Up @@ -70,6 +71,7 @@ def execute_generic_web_rollback(
control_plane_root: Path,
record_store: GenericWebRollbackApplyStore,
request: GenericWebRollbackPlanRequest,
post_deploy_executor: GenericWebPostDeployExecutor | None = None,
) -> GenericWebRollbackApplyResult:
plan = build_generic_web_rollback_plan(record_store=record_store, request=request)
record_store.write_generic_web_rollback_plan_record(plan)
Expand All @@ -95,11 +97,17 @@ def execute_generic_web_rollback(
timeout_seconds=planned_deploy.timeout_seconds,
no_cache=planned_deploy.no_cache,
),
post_deploy_executor=post_deploy_executor,
)
return GenericWebRollbackApplyResult(
plan_id=plan.plan_id,
deployment_record_id=deploy_result.deployment_record_id,
rollback_status="pass" if deploy_result.deploy_status == "pass" else "fail",
rollback_status=(
"pass"
if deploy_result.deploy_status == "pass"
and deploy_result.post_deploy_status in {"pass", "skipped"}
else "fail"
),
deploy_status=deploy_result.deploy_status,
product=plan.product,
context=plan.context,
Expand Down
7 changes: 7 additions & 0 deletions docs/dokploy-service-deployments.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ A normal service deploy does this:
4. Launchplane updates the Dokploy application image and triggers deployment.
5. Launchplane writes a deployment record with the resolved target and status.

Generic-web deploy records post-deploy evidence as `skipped` by default. A
driver that inherits from generic-web can provide a product post-deploy
extension for work that must happen after the provider deployment succeeds. That
extension writes terminal post-deploy evidence without changing the underlying
deploy status, so operators can distinguish "image deploy failed" from "image
deploy passed but product maintenance failed".

Promotion uses the same artifact identity and target records. When health URLs
exist, Launchplane verifies the source and destination lane health around the
deployment and writes promotion evidence.
Expand Down
12 changes: 10 additions & 2 deletions docs/driver-descriptors.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,12 @@ transport fields.

The `stable_deploy` action routes to `POST /v1/drivers/generic-web/deploy`. The
route resolves product lane context from DB-backed product profile records and
runtime target bindings from DB-backed Dokploy target records.
runtime target bindings from DB-backed Dokploy target records. Generic-web deploy
records post-deploy evidence as `skipped` unless a based driver explicitly
provides a product post-deploy extension. That extension point is the boundary
for product-only work after a provider deploy succeeds; it must return terminal
post-deploy evidence and must keep deploy status distinct from post-deploy
status.

The `prod_promotion` action routes to
`POST /v1/drivers/generic-web/prod-promotion`. It promotes a generic-web
Expand All @@ -131,7 +136,10 @@ and persisted record remain generic-web-owned.
The `prod_rollback` action routes to
`POST /v1/drivers/generic-web/prod-rollback`. It re-runs the same rollback-plan
validation, persists the plan record, and applies ready plans through the normal
generic-web deploy path using the previous immutable artifact identity. Product
generic-web deploy path using the previous immutable artifact identity. Generic
rollback also forwards the generic deploy post-deploy extension hook, so a
based driver can keep product-only post-deploy checks while reusing the common
rollback deployment path once its other invariants are represented. Product
drivers keep their own `prod_rollback` action only when they need additional
product-specific gates, such as Odoo backup, release tuple, manifest, migration,
or post-deploy checks. Odoo keeps `POST /v1/drivers/odoo/prod-rollback` as its
Expand Down
13 changes: 13 additions & 0 deletions docs/driver-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ Product drivers that reuse common web behavior should declare
than copying preview/deploy logic. The product-specific behavior still needs
named capabilities and named routes.

Generic-web stable deploy exposes a product post-deploy extension point for
based drivers. The generic driver owns target resolution, artifact deployment,
deployment records, and inventory writes; product drivers can pass an extension
executor only for work that must happen after a provider deploy succeeds, such
as Odoo override/application maintenance. The extension must return terminal
`PostDeployUpdateEvidence` and must not hide provider deploy status: a failed
extension can fail the lifecycle action while the deployment record still shows
the underlying image deploy as `pass` and the post-deploy evidence as `fail`.
Do not move a product apply route onto generic deploy until its remaining
release, backup, promotion, migration, and post-deploy invariants are either
represented in generic contracts or still explicitly wrapped by the product
driver.

## Capability Design

Use capability names to describe operator-visible behavior, not implementation
Expand Down
Loading