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

@@ -148,6 +148,54 @@ defmodule ArcadiaCloud.Provisioning do
})
end
alias ArcadiaCloud.Provisioning.Steps
@doc """
Assembles + starts the full deployment-provisioning choreography saga
for a deployment that was created in the `provisioning` state.
Steps: 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`).
Required opts: :size, :image. Optional: :region (falls back to the
deployment's region), :dns_domain, :dns_record_name (falls back to the
deployment slug), :triggered_by.
"""
def provision_deployment(deployment, opts \\ []) do
region = opts[:region] || deployment.region
inputs = %{
droplet_name: opts[:droplet_name] || "dep-#{deployment.slug}",
droplet_region: region,
droplet_size: opts[:size],
droplet_image: opts[:image],
droplet_tags: [
"deployment:#{deployment.id}",
"tenant:#{deployment.tenant_id}"
],
dns_domain: opts[:dns_domain],
dns_record_name: opts[:dns_record_name] || deployment.slug
}
start_saga(%{
kind: "provision",
deployment_id: deployment.id,
triggered_by: opts[:triggered_by] || "manual",
step_modules: [
Steps.MarkDeploymentProvisioning,
Steps.CreateDroplet,
Steps.WaitDropletActive,
Steps.RegisterDroplet,
Steps.LinkDeploymentResource,
Steps.PointDeploymentDns,
Steps.ActivateDeployment
],
inputs: inputs
})
end
def get_saga(id), do: Repo.get(SagaRun, id)
def get_saga!(id), do: Repo.get!(SagaRun, id)