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>
262 lines
7.9 KiB
Elixir
262 lines
7.9 KiB
Elixir
defmodule ArcadiaCloudWeb.DeploymentController do
|
|
@moduledoc """
|
|
Deployment lifecycle + subscription management.
|
|
|
|
Scope: platform_admin sees/manages all tenants; other identities are
|
|
scoped to their own tenant_id.
|
|
"""
|
|
|
|
use ArcadiaCloudWeb, :controller
|
|
|
|
alias ArcadiaCloud.{Catalog, Deployments, Provisioning, Subscriptions}
|
|
|
|
def index(conn, params) do
|
|
identity = conn.assigns.current_identity
|
|
|
|
opts =
|
|
if platform_admin?(identity),
|
|
do: [state: params["state"]],
|
|
else: [tenant_id: identity.tenant_id, state: params["state"]]
|
|
|
|
deployments = Deployments.list_deployments(opts) |> Enum.map(&shape/1)
|
|
json(conn, %{deployments: deployments, count: length(deployments)})
|
|
end
|
|
|
|
def show(conn, %{"id" => id}) do
|
|
with {:ok, deployment} <- fetch_scoped(conn, id) do
|
|
sub = Subscriptions.get_subscription_for_deployment(deployment.id)
|
|
events = Deployments.list_deployment_events(deployment.id)
|
|
|
|
json(conn, %{
|
|
deployment: shape(deployment),
|
|
subscription: shape_subscription(sub),
|
|
events: Enum.map(events, &shape_event/1),
|
|
provisioning: shape_provisioning(deployment.id)
|
|
})
|
|
else
|
|
{:halt, conn} -> conn
|
|
end
|
|
end
|
|
|
|
def create(conn, params) do
|
|
identity = conn.assigns.current_identity
|
|
# platform_admin may provision for any tenant by passing tenant_id;
|
|
# otherwise (and as the default) the deployment belongs to the caller.
|
|
tenant_id =
|
|
if platform_admin?(identity),
|
|
do: params["tenant_id"] || identity.tenant_id,
|
|
else: identity.tenant_id
|
|
|
|
attrs =
|
|
params
|
|
|> Map.take(["slug", "display_name", "region", "llm_mode", "template_code"])
|
|
|> Map.put("tenant_id", tenant_id)
|
|
|> Map.put("actor", identity.email)
|
|
|
|
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)})
|
|
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,
|
|
reason: params["reason"],
|
|
actor: conn.assigns.current_identity.email,
|
|
notes: params["notes"]
|
|
) do
|
|
{:ok, updated} ->
|
|
json(conn, %{deployment: shape(updated)})
|
|
|
|
{:error, {:invalid_transition, from, to}} ->
|
|
conn
|
|
|> put_status(:unprocessable_entity)
|
|
|> json(%{error: "invalid_transition", from: from, to: to})
|
|
end
|
|
else
|
|
{:halt, conn} -> conn
|
|
end
|
|
end
|
|
|
|
def subscribe(conn, %{"id" => id, "plan_code" => plan_code} = params) do
|
|
with {:ok, deployment} <- fetch_scoped(conn, id) do
|
|
plan = Catalog.get_plan_by_code(plan_code)
|
|
version = plan && Catalog.active_version(plan)
|
|
|
|
cond do
|
|
is_nil(version) ->
|
|
conn |> put_status(:not_found) |> json(%{error: "plan_or_version_not_found"})
|
|
|
|
Subscriptions.get_subscription_for_deployment(deployment.id) ->
|
|
conn |> put_status(:conflict) |> json(%{error: "subscription_exists"})
|
|
|
|
true ->
|
|
{:ok, sub} = Subscriptions.create_subscription(deployment.id, version.id)
|
|
sub = attach_requested_addons(sub, params["addons"] || [])
|
|
conn |> put_status(:created) |> json(%{subscription: shape_subscription(sub)})
|
|
end
|
|
else
|
|
{:halt, conn} -> conn
|
|
end
|
|
end
|
|
|
|
# ---- helpers --------------------------------------------------------------
|
|
|
|
defp attach_requested_addons(sub, codes) do
|
|
Enum.each(codes, fn code -> Subscriptions.attach_addon(sub, code) end)
|
|
Subscriptions.get_subscription(sub.id)
|
|
end
|
|
|
|
defp fetch_scoped(conn, id) do
|
|
identity = conn.assigns.current_identity
|
|
|
|
case Deployments.get_deployment(id) do
|
|
nil ->
|
|
{:halt, conn |> put_status(:not_found) |> json(%{error: "not_found"})}
|
|
|
|
deployment ->
|
|
if platform_admin?(identity) or deployment.tenant_id == identity.tenant_id do
|
|
{:ok, deployment}
|
|
else
|
|
{:halt, conn |> put_status(:forbidden) |> json(%{error: "forbidden"})}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp platform_admin?(%{roles: roles}) when is_list(roles), do: "platform-admin" in roles
|
|
defp platform_admin?(_), do: false
|
|
|
|
defp shape(d) do
|
|
%{
|
|
id: d.id,
|
|
tenant_id: d.tenant_id,
|
|
slug: d.slug,
|
|
display_name: d.display_name,
|
|
region: d.region,
|
|
state: d.state,
|
|
state_since: d.state_since,
|
|
llm_mode: d.llm_mode,
|
|
template_code: d.template_code,
|
|
billing_action_suspended: d.billing_action_suspended
|
|
}
|
|
end
|
|
|
|
defp shape_subscription(nil), do: nil
|
|
|
|
defp shape_subscription(sub) do
|
|
%{
|
|
id: sub.id,
|
|
status: sub.status,
|
|
plan_version_id: sub.plan_version_id,
|
|
current_period_start: sub.current_period_start,
|
|
current_period_end: sub.current_period_end,
|
|
trial_ends_at: sub.trial_ends_at,
|
|
addons:
|
|
Enum.map(sub.addons, fn a ->
|
|
%{
|
|
addon_id: a.addon_id,
|
|
resource_kind: a.resource_kind,
|
|
qty: a.qty,
|
|
price_cents: a.price_cents
|
|
}
|
|
end)
|
|
}
|
|
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,
|
|
to_state: e.to_state,
|
|
reason: e.reason,
|
|
actor: e.actor,
|
|
occurred_at: e.occurred_at
|
|
}
|
|
end
|
|
|
|
defp errors(changeset) do
|
|
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
|
Enum.reduce(opts, msg, fn {k, v}, acc -> String.replace(acc, "%{#{k}}", to_string(v)) end)
|
|
end)
|
|
end
|
|
end
|