defmodule ArcadiaCloud.Provisioning.Steps.CreateDroplet do @moduledoc """ Creates a DO droplet. Saga inputs: droplet_name — required droplet_region — required (e.g. "syd1") droplet_size — required (e.g. "s-1vcpu-1gb") droplet_image — required (e.g. "ubuntu-24-04-x64") droplet_tags — optional list of extra tags droplet_ssh_keys — optional list of SSH key ids/fingerprints Idempotency: every droplet is tagged `arcadia-saga-`. On re-run the step checks context for `droplet_id`, then queries DO for a droplet carrying the saga tag — so a crash between POST and context-save adopts the existing droplet instead of creating a second. Compensation: destroys the droplet. """ @behaviour ArcadiaCloud.Provisioning.Step alias ArcadiaCloud.DigitalOcean.Client alias ArcadiaCloud.Provisioning.SagaState @impl true def name, do: "create_droplet" @impl true def execute(state) do with {:ok, attrs} <- read_inputs(state) do saga_tag = saga_tag(state) cond do SagaState.get_output(state, :droplet_id) -> {:ok, state} true -> case find_by_saga_tag(saga_tag) do {:ok, %{"id" => id}} -> {:ok, record(state, id, attrs.name)} :not_found -> create(state, attrs, saga_tag) {:error, reason} -> {:error, reason} end end end end @impl true def compensate(state) do case SagaState.get_output(state, :droplet_id) do nil -> :ok droplet_id -> case Client.destroy_droplet(droplet_id) do {:ok, _} -> :ok {:error, {:http, 404, _}} -> :ok {:error, reason} -> {:error, reason} end end end # ---- internals ------------------------------------------------------------ defp create(state, attrs, saga_tag) do body = %{ name: attrs.name, region: attrs.region, size: attrs.size, image: attrs.image, tags: [saga_tag, "managed-by-arcadia-cloud" | attrs.tags], ssh_keys: attrs.ssh_keys } case Client.create_droplet(body) do {:ok, %{"id" => id}} -> {:ok, record(state, id, attrs.name)} {:error, reason} -> {:error, reason} end end defp record(state, droplet_id, name) do state |> SagaState.put_output(:droplet_id, droplet_id) |> SagaState.put_output(:droplet_name, name) end defp find_by_saga_tag(tag) do case Client.list_droplets_by_tag(tag) do {:ok, [droplet | _]} -> {:ok, droplet} {:ok, []} -> :not_found {:error, reason} -> {:error, reason} end end defp saga_tag(state) do "arcadia-saga-" <> (state.saga_id |> to_string() |> String.slice(0, 8)) end defp read_inputs(state) do attrs = %{ name: SagaState.get_input(state, :droplet_name), region: SagaState.get_input(state, :droplet_region), size: SagaState.get_input(state, :droplet_size), image: SagaState.get_input(state, :droplet_image), tags: SagaState.get_input(state, :droplet_tags) || [], ssh_keys: SagaState.get_input(state, :droplet_ssh_keys) || [] } if attrs.name && attrs.region && attrs.size && attrs.image do {:ok, attrs} else {:error, :missing_droplet_inputs} end end end