Files
Giuliano Silvestro b9fc4f9cf3 Phase 2: droplet create/destroy saga
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>
2026-05-20 13:31:47 +10:00

69 lines
2.0 KiB
Elixir

defmodule ArcadiaCloud.Provisioning.Steps.DestroyDroplet do
@moduledoc """
Destroys a droplet and marks its cloud_resources row deleted.
Saga inputs:
droplet_provider_id — required; the DO numeric droplet id
This is a terminal, irreversible action. `compensate/1` is therefore a
noop with a log line — a destroyed droplet cannot be un-destroyed.
Per the saga design, destroy-class steps don't roll back; a saga that
needs to fail-safe should sequence DestroyDroplet last.
Idempotent: a droplet already gone (404) is treated as success.
"""
@behaviour ArcadiaCloud.Provisioning.Step
import Ecto.Query
require Logger
alias ArcadiaCloud.Cloud.CloudResource
alias ArcadiaCloud.DigitalOcean.Client
alias ArcadiaCloud.Provisioning.SagaState
alias ArcadiaCloud.Repo
@impl true
def name, do: "destroy_droplet"
@impl true
def execute(state) do
droplet_id = SagaState.get_input(state, :droplet_provider_id)
cond do
is_nil(droplet_id) ->
{:error, :missing_droplet_provider_id}
true ->
case Client.destroy_droplet(droplet_id) do
{:ok, _} ->
mark_resource_deleted(droplet_id)
{:ok, SagaState.put_output(state, :destroyed_droplet_id, droplet_id)}
{:error, {:http, 404, _}} ->
mark_resource_deleted(droplet_id)
{:ok, SagaState.put_output(state, :destroyed_droplet_id, droplet_id)}
{:error, reason} ->
{:error, reason}
end
end
end
@impl true
def compensate(state) do
Logger.warning(
"[saga #{state.saga_id}] DestroyDroplet cannot be compensated — droplet destruction is terminal"
)
:ok
end
defp mark_resource_deleted(provider_id) do
from(r in CloudResource,
where: r.provider == "digitalocean" and r.kind == "droplet" and r.provider_id == ^to_string(provider_id)
)
|> Repo.update_all(set: [deleted_at: DateTime.utc_now() |> DateTime.truncate(:second)])
end
end