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>
This commit is contained in:
@@ -108,6 +108,46 @@ defmodule ArcadiaCloud.Provisioning do
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts a droplet-provisioning saga: create → wait active → register
|
||||
in inventory + record desired-state.
|
||||
|
||||
Required opts: :name, :region, :size, :image.
|
||||
Optional: :tags, :ssh_keys, :triggered_by.
|
||||
"""
|
||||
def provision_droplet(opts) do
|
||||
start_saga(%{
|
||||
kind: "provision",
|
||||
step_modules: [
|
||||
ArcadiaCloud.Provisioning.Steps.CreateDroplet,
|
||||
ArcadiaCloud.Provisioning.Steps.WaitDropletActive,
|
||||
ArcadiaCloud.Provisioning.Steps.RegisterDroplet
|
||||
],
|
||||
inputs: %{
|
||||
droplet_name: opts[:name],
|
||||
droplet_region: opts[:region],
|
||||
droplet_size: opts[:size],
|
||||
droplet_image: opts[:image],
|
||||
droplet_tags: opts[:tags] || [],
|
||||
droplet_ssh_keys: opts[:ssh_keys] || []
|
||||
},
|
||||
triggered_by: opts[:triggered_by] || "manual"
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts a droplet-destroy saga. `droplet_provider_id` is the DO numeric
|
||||
droplet id (string).
|
||||
"""
|
||||
def destroy_droplet(droplet_provider_id, opts \\ []) do
|
||||
start_saga(%{
|
||||
kind: "offboard",
|
||||
step_modules: [ArcadiaCloud.Provisioning.Steps.DestroyDroplet],
|
||||
inputs: %{droplet_provider_id: to_string(droplet_provider_id)},
|
||||
triggered_by: opts[:triggered_by] || "manual"
|
||||
})
|
||||
end
|
||||
|
||||
def get_saga(id), do: Repo.get(SagaRun, id)
|
||||
def get_saga!(id), do: Repo.get!(SagaRun, id)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user