Phase 4a: deployment-provisioning choreography saga

Wire a full tenant deployment as one orchestrated, compensating saga:
mark → create droplet → wait active → register in inventory → link to
deployment → point DNS → activate. A failure anywhere rolls the whole
thing back — droplet destroyed, DNS reverted, deployment moved to
cancelled.

- New lifecycle state `provisioning`; deployments created via the
  provision path enter here and only reach `active` once the saga's
  ActivateDeployment step runs.
- Four new steps: MarkDeploymentProvisioning (owns the deployment's
  failure state), LinkDeploymentResource, PointDeploymentDns,
  ActivateDeployment.
- Provisioning.provision_deployment/2 assembles + starts the saga.
- DeploymentController: POST /deployments with provision:true creates
  in `provisioning` and kicks the saga (202); GET /deployments/:id now
  returns the provisioning saga + per-step progress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 20:44:49 +10:00
parent c10b847324
commit 29f4ad97d6
8 changed files with 480 additions and 8 deletions

View File

@@ -0,0 +1,48 @@
defmodule ArcadiaCloud.Provisioning.Steps.ActivateDeployment do
@moduledoc """
Final step of the deployment-provisioning choreography: moves the
deployment from `provisioning` to `active` now that its infra is up,
registered, linked, and reachable by DNS.
Idempotent: `transition_state/3` returns `{:ok, deployment}` when the
deployment is already `active`.
No compensation: this is the last step, so the saga only ever rolls
back from a step BEFORE this one — meaning this step never ran and the
deployment is still `provisioning` (cancelled by
MarkDeploymentProvisioning's compensate).
"""
@behaviour ArcadiaCloud.Provisioning.Step
alias ArcadiaCloud.Deployments
@impl true
def name, do: "activate_deployment"
@impl true
def execute(state) do
with {:ok, deployment} <- fetch_deployment(state) do
case Deployments.transition_state(deployment, "active",
reason: "provisioning_complete",
actor: "saga:#{state.saga_id}"
) do
{:ok, _} -> {:ok, state}
{:error, reason} -> {:error, {:activate_failed, reason}}
end
end
end
defp fetch_deployment(state) do
case state.saga && state.saga.deployment_id do
nil ->
{:error, :saga_has_no_deployment}
id ->
case Deployments.get_deployment(id) do
nil -> {:error, :deployment_not_found}
deployment -> {:ok, deployment}
end
end
end
end