The most load-bearing write workflow — droplet provisioning is the spine
of phase 4a deployment onboarding.
DigitalOcean.Client: create_droplet, get_droplet, list_droplets_by_tag,
destroy_droplet. list_paginated/3 now threads caller-supplied params
(opts[:params]) through pagination so tag-filtered listing works.
Four droplet saga steps:
- CreateDroplet — POST a droplet, tagged arcadia-saga-<saga8> +
managed-by-arcadia-cloud. Idempotency: re-run checks context for
droplet_id, then queries DO by the saga tag, so a crash between POST
and context-save adopts the existing droplet. compensate destroys it.
- WaitDropletActive — polls get_droplet until status "active" (96x5s);
records the public IP. No compensation (waiting has no side effect).
- RegisterDroplet — fetches the droplet, upserts it into cloud_resources
(inventory consistent immediately, not at next 15-min sync) and writes
cloud_provisioned desired-state {size_slug, region, image}. compensate
removes the DB rows (the droplet itself is destroyed by CreateDroplet's
compensate).
- DestroyDroplet — DELETE the droplet + mark its cloud_resources row
deleted. Terminal/irreversible: compensate is a logged noop, per the
saga design destroy-class steps don't roll back.
Provisioning helpers:
- provision_droplet/1 — [CreateDroplet, WaitDropletActive, RegisterDroplet]
- destroy_droplet/2 — [DestroyDroplet]
Live smoke verified end-to-end (full create + destroy on a real
s-1vcpu-512mb-10gb droplet in syd1):
- provision saga completed: droplet 572017320 created, reached active
with public IP, registered into cloud_resources (status=active) +
cloud_provisioned (spec recorded).
- destroy saga completed: cloud_resources row marked deleted; droplet
confirmed 404 on DO afterward. Account back to its original 5
droplets, zero leftover, ~1 cent total cost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
121 lines
3.3 KiB
Elixir
121 lines
3.3 KiB
Elixir
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-<saga8>`. 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
|