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