Defdo.DDNS.Adoption (defdo_ddns v0.5.0)

Copy Markdown

Durable holding area for records that exist in Cloudflare but were never declared here.

Discovery is automatic; adoption never is. A record found by Defdo.DDNS.Reconcile.Inventory enters as pending and stays there until an operator decides. There is no configuration flag that skips that step — the whole point is that absorbing someone else's record, or deciding a record is ours to converge, is a judgement call with consequences.

Three states, and the transitions are one-way:

pending accept accepted
        reject rejected

A rejected record must never resurface as pending, or every refresh would ask the same question again and the answer would stop being read. That is why an entry is keyed by a derived, stable id rather than something regenerated per run, and why refresh/1 only ever inserts entries it has never seen.

Accepting records the decision and promotes the record into Defdo.DDNS.DesiredStateStore, atomically — see accept/2. Adoption therefore requires the desired-state file to be configured: without somewhere durable to promote into, "accepted" would be a label with no effect on what DDNS converges.

Summary

Functions

Accept a pending record: record the decision, then promote it into desired state so the monitor starts converging it.

The stable id for a record: "<type>:<name>", lowercased.

Fetch one entry by id.

Entries in a given state, or all of them. Ordered by id for stable output.

Where the adoption file lives.

Run an inventory and file every undeclared record not already known.

Reject a pending record. Durable: it never returns to pending.

Types

entry()

@type entry() :: map()

Functions

accept(id, meta \\ %{})

@spec accept(String.t(), map()) :: {:ok, entry()} | {:error, term()}

Accept a pending record: record the decision, then promote it into desired state so the monitor starts converging it.

Both halves or neither. If the desired-state write fails the decision rolls back to pending, because "accepted but absent from desired state" is indistinguishable from a rejection at the next sync — the record would be silently dropped while the log said it was adopted.

Deciding twice is a no-op returning the existing entry, not an error: the intent is already recorded, and failing would only make an idempotent workflow look broken. Promotion is idempotent too — a record already declared is left alone rather than duplicated.

entry_id(record)

@spec entry_id(map()) :: String.t()

The stable id for a record: "<type>:<name>", lowercased.

Derived rather than random, so rediscovering the same record maps to the same entry and a decision about it survives.

get(id)

@spec get(String.t()) :: entry() | nil

Fetch one entry by id.

list(filter \\ :all)

@spec list(:pending | :accepted | :rejected | :all) :: [entry()]

Entries in a given state, or all of them. Ordered by id for stable output.

path()

@spec path() :: String.t()

Where the adoption file lives.

refresh(domain)

@spec refresh(String.t()) ::
  {:ok, %{added: non_neg_integer(), unchanged: non_neg_integer()}}
  | {:error, term()}

Run an inventory and file every undeclared record not already known.

Returns {:ok, %{added: n, unchanged: n}}. An inventory failure propagates unchanged and writes nothing: a transient Cloudflare error must not be able to file adoptions, and it must not be able to look like the zone went quiet.

reject(id, meta \\ %{})

@spec reject(String.t(), map()) :: {:ok, entry()} | {:error, term()}

Reject a pending record. Durable: it never returns to pending.