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:
@@ -32,6 +32,38 @@ defmodule ArcadiaCloud.DigitalOcean.Client do
|
||||
list_paginated("/droplets/#{droplet_id}/snapshots", "snapshots", opts)
|
||||
end
|
||||
|
||||
# ---- droplet lifecycle ----------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Create a droplet. `attrs` must include name + region + size + image;
|
||||
optional ssh_keys, tags, backups, ipv6, user_data, vpc_uuid.
|
||||
Returns {:ok, droplet} — droplet is created async, poll get_droplet/2
|
||||
until status is "active".
|
||||
"""
|
||||
def create_droplet(attrs, opts \\ []) do
|
||||
case request(:post, "/droplets", body: attrs, purpose: opts[:purpose] || "provisioning") do
|
||||
{:ok, %{"droplet" => droplet}} -> {:ok, droplet}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def get_droplet(droplet_id, opts \\ []) do
|
||||
case request(:get, "/droplets/#{droplet_id}", purpose: opts[:purpose] || "provisioning") do
|
||||
{:ok, %{"droplet" => droplet}} -> {:ok, droplet}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Lists droplets filtered by a tag — used for saga idempotency recovery."
|
||||
def list_droplets_by_tag(tag, opts \\ []) do
|
||||
list_paginated("/droplets", "droplets",
|
||||
Keyword.merge(opts, params: [tag_name: tag], purpose: opts[:purpose] || "provisioning"))
|
||||
end
|
||||
|
||||
def destroy_droplet(droplet_id, opts \\ []) do
|
||||
request(:delete, "/droplets/#{droplet_id}", purpose: opts[:purpose] || "provisioning")
|
||||
end
|
||||
|
||||
# ---- write actions --------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
@@ -175,11 +207,12 @@ defmodule ArcadiaCloud.DigitalOcean.Client do
|
||||
|
||||
defp list_paginated(path, root_key, opts) do
|
||||
purpose = opts[:purpose] || "sync_full"
|
||||
do_paginate(path, root_key, purpose, [], 1)
|
||||
extra_params = opts[:params] || []
|
||||
do_paginate(path, root_key, purpose, extra_params, [], 1)
|
||||
end
|
||||
|
||||
defp do_paginate(path, root_key, purpose, acc, page) do
|
||||
params = [page: page, per_page: @page_size]
|
||||
defp do_paginate(path, root_key, purpose, extra_params, acc, page) do
|
||||
params = [page: page, per_page: @page_size] ++ extra_params
|
||||
|
||||
case request(:get, path, params: params, purpose: purpose) do
|
||||
{:ok, %{} = body} ->
|
||||
@@ -187,7 +220,7 @@ defmodule ArcadiaCloud.DigitalOcean.Client do
|
||||
new_acc = acc ++ items
|
||||
|
||||
if has_next?(body) do
|
||||
do_paginate(path, root_key, purpose, new_acc, page + 1)
|
||||
do_paginate(path, root_key, purpose, extra_params, new_acc, page + 1)
|
||||
else
|
||||
{:ok, new_acc}
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user