Phase 3: deployment model + subscriptions
cloud_deployments — the billable unit (one app instance). A tenant has 1..N deployments; cloud_resources.deployment_id ties resources to one. Fields: tenant_id, slug (unique per tenant), display_name, region, state, llm_mode, billing_action_suspended (operator override), template_code/version (nullable — formal templates land in phase 4). Lifecycle state machine in ArcadiaCloud.Deployments — states trial / active / past_due / paused / suspended / cancelled / archived. Every transition is validated against an explicit @transitions map and recorded in cloud_deployment_events. create_deployment defaults to `active` (trial is wired but no flow enters it yet). subscriptions — one per deployment, binds it to a plan_version. status active/paused/cancelled, current period dates, trial_ends_at. subscription_addons — addons attached to a subscription with price + qty SNAPSHOTTED at attach time, so a later catalog price change can't retroactively reprice an existing subscriber. ArcadiaCloud.Subscriptions context: create_subscription (period defaults to current calendar month), attach_addon (snapshots from the live Addon), change_plan_version (migrate to a new version — price changes / up-down grades), get_subscription_for_deployment. API (platform_admin sees all tenants; others scoped to own tenant_id): - GET/POST /api/v1/deployments - GET /api/v1/deployments/:id (with subscription + events) - POST /api/v1/deployments/:id/transition - POST /api/v1/deployments/:id/subscribe (plan_code + optional addons) Smoke verified: created a deployment, transitioned active->paused (events logged with actor), rejected an invalid paused->archived transition (422), subscribed to Studio with the storage_50gb addon — addon price snapshotted at 750c/qty 50; show returns deployment + subscription + event history. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
184
lib/arcadia_cloud_web/controllers/deployment_controller.ex
Normal file
184
lib/arcadia_cloud_web/controllers/deployment_controller.ex
Normal file
@@ -0,0 +1,184 @@
|
||||
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, 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)
|
||||
})
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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_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
|
||||
Reference in New Issue
Block a user