Files
arcadia-cloud/lib/arcadia_cloud/deployments.ex
Giuliano Silvestro 29f4ad97d6 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>
2026-05-20 20:44:49 +10:00

139 lines
4.2 KiB
Elixir

defmodule ArcadiaCloud.Deployments do
@moduledoc """
Deployment lifecycle — a deployment is the billable unit (one app
instance). Tenant has 1..N deployments.
Lifecycle state machine (see project_arcadia_cloud memory):
trial ──→ active ⇄ paused
├─→ past_due ──→ suspended ──→ cancelled ──→ archived
│ │ │
│ └→ active └→ active
└─→ cancelled
Every transition is validated against @transitions and recorded in
cloud_deployment_events. Phase 3 deployments are created straight in
`active` (trial is wired but no flow enters it yet).
"""
import Ecto.Query, warn: false
alias ArcadiaCloud.Repo
alias ArcadiaCloud.Deployments.{CloudDeployment, CloudDeploymentEvent}
# from_state => [allowed to_states]
@transitions %{
"provisioning" => ~w(active cancelled),
"trial" => ~w(active cancelled),
"active" => ~w(paused past_due cancelled),
"paused" => ~w(active cancelled),
"past_due" => ~w(active suspended cancelled),
"suspended" => ~w(active cancelled),
"cancelled" => ~w(active archived),
"archived" => []
}
def transitions, do: @transitions
# ---- CRUD -----------------------------------------------------------------
def create_deployment(attrs) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
initial_state = attrs[:state] || attrs["state"] || "active"
attrs =
attrs
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|> Map.put("state", initial_state)
|> Map.put("state_since", now)
Repo.transaction(fn ->
case %CloudDeployment{} |> CloudDeployment.changeset(attrs) |> Repo.insert() do
{:ok, deployment} ->
write_event(deployment, nil, initial_state, "created", attrs["actor"])
deployment
{:error, changeset} ->
Repo.rollback(changeset)
end
end)
end
def get_deployment(id), do: Repo.get(CloudDeployment, id)
def get_deployment!(id), do: Repo.get!(CloudDeployment, id)
def get_deployment_by_slug(tenant_id, slug) do
Repo.get_by(CloudDeployment, tenant_id: tenant_id, slug: slug)
end
def list_deployments(opts \\ []) do
from(d in CloudDeployment, order_by: [desc: d.inserted_at])
|> filter(:tenant_id, opts[:tenant_id])
|> filter(:state, opts[:state])
|> Repo.all()
end
def list_deployment_events(deployment_id) do
from(e in CloudDeploymentEvent,
where: e.deployment_id == ^deployment_id,
order_by: [asc: e.occurred_at]
)
|> Repo.all()
end
# ---- state machine --------------------------------------------------------
@doc """
Transition a deployment to `to_state`, validated against the lifecycle
state machine. Records a cloud_deployment_events row. Returns
{:ok, deployment} | {:error, {:invalid_transition, from, to}}.
"""
def transition_state(%CloudDeployment{} = deployment, to_state, opts \\ []) do
from_state = deployment.state
allowed = Map.get(@transitions, from_state, [])
cond do
to_state == from_state ->
{:ok, deployment}
to_state in allowed ->
do_transition(deployment, to_state, opts)
true ->
{:error, {:invalid_transition, from_state, to_state}}
end
end
defp do_transition(deployment, to_state, opts) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
Repo.transaction(fn ->
{:ok, updated} =
deployment
|> CloudDeployment.changeset(%{state: to_state, state_since: now})
|> Repo.update()
write_event(deployment, deployment.state, to_state, opts[:reason], opts[:actor], opts[:notes])
updated
end)
end
defp write_event(deployment, from_state, to_state, reason, actor, notes \\ nil) do
%CloudDeploymentEvent{}
|> CloudDeploymentEvent.changeset(%{
deployment_id: deployment.id,
from_state: from_state,
to_state: to_state,
reason: reason,
actor: actor,
notes: notes,
occurred_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
|> Repo.insert!()
end
defp filter(query, _field, nil), do: query
defp filter(query, field, value), do: from(d in query, where: field(d, ^field) == ^value)
end