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