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