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

@@ -8,7 +8,7 @@ defmodule ArcadiaCloudWeb.DeploymentController do
use ArcadiaCloudWeb, :controller
alias ArcadiaCloud.{Catalog, Deployments, Subscriptions}
alias ArcadiaCloud.{Catalog, Deployments, Provisioning, Subscriptions}
def index(conn, params) do
identity = conn.assigns.current_identity
@@ -30,7 +30,8 @@ defmodule ArcadiaCloudWeb.DeploymentController do
json(conn, %{
deployment: shape(deployment),
subscription: shape_subscription(sub),
events: Enum.map(events, &shape_event/1)
events: Enum.map(events, &shape_event/1),
provisioning: shape_provisioning(deployment.id)
})
else
{:halt, conn} -> conn
@@ -52,15 +53,60 @@ defmodule ArcadiaCloudWeb.DeploymentController do
|> Map.put("tenant_id", tenant_id)
|> Map.put("actor", identity.email)
case Deployments.create_deployment(attrs) do
{:ok, deployment} ->
conn |> put_status(:created) |> json(%{deployment: shape(deployment)})
if truthy(params["provision"]) do
create_and_provision(conn, params, attrs, identity)
else
case Deployments.create_deployment(attrs) do
{:ok, deployment} ->
conn |> put_status(:created) |> json(%{deployment: shape(deployment)})
{:error, changeset} ->
conn |> put_status(:unprocessable_entity) |> json(%{error: errors(changeset)})
{:error, changeset} ->
conn |> put_status(:unprocessable_entity) |> json(%{error: errors(changeset)})
end
end
end
# Creates the deployment row in `provisioning` and kicks off the
# choreography saga. The deployment only reaches `active` once the
# saga's ActivateDeployment step runs; a saga failure rolls it to
# `cancelled`. Returns 202 — provisioning is asynchronous.
defp create_and_provision(conn, params, attrs, identity) do
size = params["size"]
image = params["image"]
cond do
is_nil(size) or is_nil(image) ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "size and image are required to provision"})
true ->
case Deployments.create_deployment(Map.put(attrs, "state", "provisioning")) do
{:ok, deployment} ->
{:ok, saga} =
Provisioning.provision_deployment(deployment,
size: size,
image: image,
region: params["region"] || deployment.region,
dns_domain: params["dns_domain"],
dns_record_name: params["dns_record_name"],
triggered_by: identity.email
)
conn
|> put_status(:accepted)
|> json(%{deployment: shape(deployment), saga_id: saga.id})
{:error, changeset} ->
conn |> put_status(:unprocessable_entity) |> json(%{error: errors(changeset)})
end
end
end
defp truthy(true), do: true
defp truthy("true"), do: true
defp truthy(_), do: false
def transition(conn, %{"id" => id, "to_state" => to_state} = params) do
with {:ok, deployment} <- fetch_scoped(conn, id) do
case Deployments.transition_state(deployment, to_state,
@@ -166,6 +212,37 @@ defmodule ArcadiaCloudWeb.DeploymentController do
}
end
defp shape_provisioning(deployment_id) do
case Provisioning.list_sagas(deployment_id: deployment_id, limit: 1) do
[saga | _] ->
%{
saga_id: saga.id,
kind: saga.kind,
status: saga.status,
current_step_idx: saga.current_step_idx,
started_at: saga.started_at,
completed_at: saga.completed_at,
error: saga.error,
steps:
saga.id
|> Provisioning.list_step_results()
|> Enum.map(fn r ->
%{
step_idx: r.step_idx,
step_name: r.step_name,
status: r.status,
error: r.error,
started_at: r.started_at,
completed_at: r.completed_at
}
end)
}
[] ->
nil
end
end
defp shape_event(e) do
%{
from_state: e.from_state,