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:
2026-05-20 11:10:33 +10:00
parent 445b7b60d4
commit e3bcd3fc77
3 changed files with 309 additions and 0 deletions

View File

@@ -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