Phase 2: DNS record write primitives
DigitalOcean.Client DNS record CRUD: - list_domain_records/2, create_domain_record/3, update_domain_record/4, delete_domain_record/3 on /v2/domains/:domain/records. Steps.UpsertDnsRecord — idempotent ensure-record: - (type, name) absent -> create - present, data matches -> no-op - present, data differs -> update, stashing prior state Match comparison normalizes trailing dots (DO appends them to CNAMEs) so a re-run doesn't false-positive into an update. compensate: deletes records it created, restores prior data/ttl for records it updated. Steps.DeleteDnsRecord — deletes a record by (type, name); idempotent (already-absent succeeds); compensate recreates from stashed prior state. Both read dns_domain / dns_record_type / dns_record_name / dns_record_data / dns_record_ttl from saga inputs. Live smoke verified against sky-ai.com with TXT records (non-disruptive): - [UpsertDnsRecord, Fail]: record created then compensation deleted it (saga rolled_back, record absent after). - [UpsertDnsRecord] twice: first outcome "created", second "noop" (idempotency holds). - [DeleteDnsRecord]: record deleted, absent after. Both test records cleaned up — zero leftover. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,43 @@ defmodule ArcadiaCloud.DigitalOcean.Client do
|
||||
request(:delete, "/snapshots/#{snapshot_id}", purpose: opts[:purpose] || "provisioning")
|
||||
end
|
||||
|
||||
# ---- DNS records ----------------------------------------------------------
|
||||
|
||||
def list_domain_records(domain, opts \\ []) do
|
||||
list_paginated("/domains/#{domain}/records", "domain_records",
|
||||
Keyword.put(opts, :purpose, opts[:purpose] || "sync_full"))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Create a DNS record. `attrs` must include type + name + data; optional
|
||||
ttl, priority, port, weight, flags, tag. Returns {:ok, record}.
|
||||
"""
|
||||
def create_domain_record(domain, attrs, opts \\ []) do
|
||||
case request(:post, "/domains/#{domain}/records",
|
||||
body: attrs,
|
||||
purpose: opts[:purpose] || "provisioning"
|
||||
) do
|
||||
{:ok, %{"domain_record" => record}} -> {:ok, record}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def update_domain_record(domain, record_id, attrs, opts \\ []) do
|
||||
case request(:put, "/domains/#{domain}/records/#{record_id}",
|
||||
body: attrs,
|
||||
purpose: opts[:purpose] || "provisioning"
|
||||
) do
|
||||
{:ok, %{"domain_record" => record}} -> {:ok, record}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def delete_domain_record(domain, record_id, opts \\ []) do
|
||||
request(:delete, "/domains/#{domain}/records/#{record_id}",
|
||||
purpose: opts[:purpose] || "provisioning"
|
||||
)
|
||||
end
|
||||
|
||||
# ---- billing --------------------------------------------------------------
|
||||
|
||||
def get_balance(opts \\ []) do
|
||||
|
||||
101
lib/arcadia_cloud/provisioning/steps/delete_dns_record.ex
Normal file
101
lib/arcadia_cloud/provisioning/steps/delete_dns_record.ex
Normal file
@@ -0,0 +1,101 @@
|
||||
defmodule ArcadiaCloud.Provisioning.Steps.DeleteDnsRecord do
|
||||
@moduledoc """
|
||||
Deletes a DNS record, identified by (type, name) within a zone.
|
||||
|
||||
Saga inputs:
|
||||
dns_domain — required; the zone
|
||||
dns_record_type — required
|
||||
dns_record_name — required
|
||||
|
||||
Idempotent: if the record is already gone, succeeds.
|
||||
|
||||
Compensation: recreates the record from the stashed prior state. If
|
||||
the record didn't exist at execute time there's nothing to restore.
|
||||
"""
|
||||
|
||||
@behaviour ArcadiaCloud.Provisioning.Step
|
||||
|
||||
alias ArcadiaCloud.DigitalOcean.Client
|
||||
alias ArcadiaCloud.Provisioning.SagaState
|
||||
|
||||
@impl true
|
||||
def name, do: "delete_dns_record"
|
||||
|
||||
@impl true
|
||||
def execute(state) do
|
||||
domain = SagaState.get_input(state, :dns_domain)
|
||||
type = SagaState.get_input(state, :dns_record_type)
|
||||
rname = SagaState.get_input(state, :dns_record_name)
|
||||
|
||||
cond do
|
||||
is_nil(domain) or is_nil(type) or is_nil(rname) ->
|
||||
{:error, :missing_dns_inputs}
|
||||
|
||||
true ->
|
||||
case find_record(domain, type, rname) do
|
||||
{:ok, nil} ->
|
||||
{:ok, SagaState.put_output(state, :dns_deleted, "absent")}
|
||||
|
||||
{:ok, record} ->
|
||||
do_delete(state, domain, record)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def compensate(state) do
|
||||
domain = SagaState.get_input(state, :dns_domain)
|
||||
|
||||
case SagaState.get_output(state, :dns_deleted_record) do
|
||||
nil ->
|
||||
:ok
|
||||
|
||||
prior ->
|
||||
attrs = %{
|
||||
type: prior["type"],
|
||||
name: prior["name"],
|
||||
data: prior["data"],
|
||||
ttl: prior["ttl"]
|
||||
}
|
||||
|
||||
case Client.create_domain_record(domain, attrs) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp do_delete(state, domain, record) do
|
||||
case Client.delete_domain_record(domain, record["id"]) do
|
||||
{:ok, _} ->
|
||||
{:ok,
|
||||
state
|
||||
|> SagaState.put_output(:dns_deleted, "deleted")
|
||||
|> SagaState.put_output(:dns_deleted_record, %{
|
||||
"type" => record["type"],
|
||||
"name" => record["name"],
|
||||
"data" => record["data"],
|
||||
"ttl" => record["ttl"]
|
||||
})}
|
||||
|
||||
{:error, {:http, 404, _}} ->
|
||||
{:ok, SagaState.put_output(state, :dns_deleted, "absent")}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_record(domain, type, rname) do
|
||||
case Client.list_domain_records(domain) do
|
||||
{:ok, records} ->
|
||||
{:ok, Enum.find(records, &(&1["type"] == type and &1["name"] == rname))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
171
lib/arcadia_cloud/provisioning/steps/upsert_dns_record.ex
Normal file
171
lib/arcadia_cloud/provisioning/steps/upsert_dns_record.ex
Normal file
@@ -0,0 +1,171 @@
|
||||
defmodule ArcadiaCloud.Provisioning.Steps.UpsertDnsRecord do
|
||||
@moduledoc """
|
||||
Idempotently ensures a DNS record exists with the desired data.
|
||||
|
||||
Saga inputs:
|
||||
dns_domain — required; the zone (e.g. "sky-ai.com")
|
||||
dns_record_type — required; "A" | "CNAME" | "TXT" | ...
|
||||
dns_record_name — required; subdomain ("@" for apex)
|
||||
dns_record_data — required; the record value
|
||||
dns_record_ttl — optional; defaults to 1800
|
||||
|
||||
Behaviour:
|
||||
- record (type, name) absent → create it
|
||||
- present with matching data → no-op
|
||||
- present with different data → update it, stashing the prior state
|
||||
|
||||
Compensation:
|
||||
- if we created the record → delete it
|
||||
- if we updated it → restore the prior data/ttl
|
||||
- if it was a no-op → nothing
|
||||
"""
|
||||
|
||||
@behaviour ArcadiaCloud.Provisioning.Step
|
||||
|
||||
alias ArcadiaCloud.DigitalOcean.Client
|
||||
alias ArcadiaCloud.Provisioning.SagaState
|
||||
|
||||
@default_ttl 1800
|
||||
|
||||
@impl true
|
||||
def name, do: "upsert_dns_record"
|
||||
|
||||
@impl true
|
||||
def execute(state) do
|
||||
with {:ok, domain, type, rname, data, ttl} <- read_inputs(state) do
|
||||
case find_record(domain, type, rname) do
|
||||
{:ok, nil} ->
|
||||
create(state, domain, type, rname, data, ttl)
|
||||
|
||||
{:ok, existing} ->
|
||||
if record_matches?(existing, data, ttl) do
|
||||
{:ok, record_outcome(state, existing["id"], "noop", nil)}
|
||||
else
|
||||
update(state, domain, existing, type, rname, data, ttl)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def compensate(state) do
|
||||
domain = SagaState.get_input(state, :dns_domain)
|
||||
outcome = SagaState.get_output(state, :dns_outcome)
|
||||
record_id = SagaState.get_output(state, :dns_record_id)
|
||||
|
||||
case outcome do
|
||||
"created" ->
|
||||
delete_record(domain, record_id)
|
||||
|
||||
"updated" ->
|
||||
restore_record(state, domain, record_id)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# ---- execute helpers ------------------------------------------------------
|
||||
|
||||
defp create(state, domain, type, rname, data, ttl) do
|
||||
attrs = %{type: type, name: rname, data: data, ttl: ttl}
|
||||
|
||||
case Client.create_domain_record(domain, attrs) do
|
||||
{:ok, %{"id" => id}} -> {:ok, record_outcome(state, id, "created", nil)}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp update(state, domain, existing, type, rname, data, ttl) do
|
||||
prior = %{
|
||||
"type" => existing["type"],
|
||||
"name" => existing["name"],
|
||||
"data" => existing["data"],
|
||||
"ttl" => existing["ttl"]
|
||||
}
|
||||
|
||||
attrs = %{type: type, name: rname, data: data, ttl: ttl}
|
||||
|
||||
case Client.update_domain_record(domain, existing["id"], attrs) do
|
||||
{:ok, %{"id" => id}} -> {:ok, record_outcome(state, id, "updated", prior)}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp record_outcome(state, record_id, outcome, prior) do
|
||||
state
|
||||
|> SagaState.put_output(:dns_record_id, record_id)
|
||||
|> SagaState.put_output(:dns_outcome, outcome)
|
||||
|> SagaState.put_output(:dns_prior, prior)
|
||||
end
|
||||
|
||||
# ---- compensate helpers ---------------------------------------------------
|
||||
|
||||
defp delete_record(domain, record_id) do
|
||||
case Client.delete_domain_record(domain, record_id) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, {:http, 404, _}} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp restore_record(state, domain, record_id) do
|
||||
case SagaState.get_output(state, :dns_prior) do
|
||||
nil ->
|
||||
:ok
|
||||
|
||||
prior ->
|
||||
attrs = %{
|
||||
type: prior["type"],
|
||||
name: prior["name"],
|
||||
data: prior["data"],
|
||||
ttl: prior["ttl"]
|
||||
}
|
||||
|
||||
case Client.update_domain_record(domain, record_id, attrs) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, {:http, 404, _}} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ---- shared ---------------------------------------------------------------
|
||||
|
||||
defp read_inputs(state) do
|
||||
domain = SagaState.get_input(state, :dns_domain)
|
||||
type = SagaState.get_input(state, :dns_record_type)
|
||||
rname = SagaState.get_input(state, :dns_record_name)
|
||||
data = SagaState.get_input(state, :dns_record_data)
|
||||
ttl = SagaState.get_input(state, :dns_record_ttl) || @default_ttl
|
||||
|
||||
if domain && type && rname && data do
|
||||
{:ok, domain, type, rname, data, ttl}
|
||||
else
|
||||
{:error, :missing_dns_inputs}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_record(domain, type, rname) do
|
||||
case Client.list_domain_records(domain) do
|
||||
{:ok, records} ->
|
||||
{:ok, Enum.find(records, &(&1["type"] == type and &1["name"] == rname))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# DO normalizes record data (e.g. appends trailing dot to CNAMEs).
|
||||
# Compare loosely on the trimmed/dotless form.
|
||||
defp record_matches?(existing, data, ttl) do
|
||||
normalize(existing["data"]) == normalize(data) and existing["ttl"] == ttl
|
||||
end
|
||||
|
||||
defp normalize(nil), do: ""
|
||||
defp normalize(v) when is_binary(v), do: v |> String.trim() |> String.trim_trailing(".")
|
||||
defp normalize(v), do: to_string(v)
|
||||
end
|
||||
Reference in New Issue
Block a user