Files
arcadia-cloud/lib/arcadia_cloud/deployments.ex
Giuliano Silvestro aee5e07b26 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>
2026-05-20 15:12:15 +10:00

138 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 %{
"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