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>
139 lines
4.2 KiB
Elixir
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
|