defmodule ArcadiaCloud.Provisioning do @moduledoc """ Context for saga orchestration — provisioning, suspension, offboarding, updates, anything that wants compensation-based rollback over Oban. Pattern: caller assembles a step list (per template or hand-rolled), calls `start_saga/1`, the Runner Oban worker walks the steps, persisting results and rolling back on failure. """ import Ecto.Query, warn: false alias ArcadiaCloud.Repo alias ArcadiaCloud.Provisioning.{SagaRun, SagaStepResult} @doc """ Inserts a saga_runs row + enqueues the Runner job. Required: :kind — provision | suspend | offboard | update | rollback | test :step_modules — ordered list of step module atoms or fully-qualified strings :inputs — map of saga inputs (stored in context.__inputs__) Optional: :deployment_id — links the saga to a deployment (nil for skyai-internal) :triggered_by — user_id or "system:" """ def start_saga(opts) when is_list(opts) do start_saga(Map.new(opts)) end def start_saga(%{} = attrs) do step_modules = Enum.map(attrs[:step_modules] || [], &to_string/1) inputs = attrs[:inputs] || %{} saga_attrs = %{ kind: attrs[:kind], step_modules: step_modules, deployment_id: attrs[:deployment_id], triggered_by: attrs[:triggered_by], context: %{"__inputs__" => inputs} } with {:ok, saga} <- create_saga(saga_attrs), {:ok, _job} <- %{"saga_id" => saga.id} |> ArcadiaCloud.Provisioning.Runner.new() |> Oban.insert() do {:ok, saga} end end def create_saga(attrs) do %SagaRun{} |> SagaRun.changeset(attrs) |> Repo.insert() end def get_saga(id), do: Repo.get(SagaRun, id) def get_saga!(id), do: Repo.get!(SagaRun, id) def update_saga(%SagaRun{} = saga, attrs) do saga |> SagaRun.changeset(attrs) |> Repo.update() end def list_sagas(opts \\ []) do base = from(s in SagaRun, order_by: [desc: s.inserted_at]) base |> maybe_filter(:status, opts[:status]) |> maybe_filter(:kind, opts[:kind]) |> maybe_filter(:deployment_id, opts[:deployment_id]) |> maybe_limit(opts[:limit]) |> Repo.all() end def list_step_results(saga_id) do from(r in SagaStepResult, where: r.saga_id == ^saga_id, order_by: [asc: r.step_idx] ) |> Repo.all() end def cancel_saga(%SagaRun{} = saga) do saga |> SagaRun.changeset(%{cancel_requested: true}) |> Repo.update() end def upsert_step_result(saga_id, step_idx, attrs) do case Repo.get_by(SagaStepResult, saga_id: saga_id, step_idx: step_idx) do nil -> %SagaStepResult{} |> SagaStepResult.changeset(Map.merge(attrs, %{saga_id: saga_id, step_idx: step_idx})) |> Repo.insert() existing -> existing |> SagaStepResult.changeset(attrs) |> Repo.update() end end defp maybe_filter(q, _f, nil), do: q defp maybe_filter(q, field, value), do: from(s in q, where: field(s, ^field) == ^value) defp maybe_limit(q, nil), do: q defp maybe_limit(q, n), do: from(s in q, limit: ^n) end