Defdo.DDNS.DesiredStateStore (defdo_ddns v0.5.0)

Copy Markdown

File-backed owner of DDNS desired state.

The file is authoritative once it exists. Environment variables are a seed for the first boot and nothing more — leaving them as a live fallback is how two sources of truth appear and quietly disagree.

No process

The slice specified starting this before the monitor. It is implemented as an on-demand reader instead, with no entry in the supervision tree, for one reason: a store that "fails loudly" at boot is a new way for the application to die on startup and stay dead. That is exactly the failure 0.3.4 fixed, where a transient Cloudflare error killed the monitor, exhausted the restart intensity and shut everything down unnoticed for eleven days. Reading a small file on a five-minute tick costs nothing and cannot brick a boot.

"Fails loudly" is therefore scoped: it is an error from load/0 and seed/0, raised where a caller asked for desired state, not where the release starts.

Disabled by default

With no DDNS_DESIRED_STATE_PATH configured the store reports :disabled and callers fall back to the existing env-driven accessors. Adoption of the file is opt-in per deployment rather than a flag day.

Summary

Functions

Declare a CNAME record as managed. Idempotent.

The default path a deployment should use when it adopts the file.

Whether a desired-state file is configured for this deployment.

Read desired state.

Configured file path, or nil when the store is disabled.

Persist a document, replacing whatever is on disk.

Write the file from the current environment configuration, once.

Counts and metadata only — never hostnames, targets or flag values.

Read, transform, write — the path every mutation takes, including adoption promoting a record into desired state.

Functions

declare(record)

@spec declare(map()) :: {:ok, Defdo.DDNS.DesiredState.t()} | {:error, term()}

Declare a CNAME record as managed. Idempotent.

Two paths reach this: adoption promoting a record an operator accepted, and the provisioning API declaring a record this deployment just created. They must write the entry identically — if the shapes diverged, the same record could be declared twice, or fail to match itself on the next reconcile and reappear as drift. Sharing one function is what prevents that.

Returns {:error, :disabled} when no desired-state file is configured, which callers are expected to treat as "nothing to record", not as a failure.

default_path()

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

The default path a deployment should use when it adopts the file.

enabled?()

@spec enabled?() :: boolean()

Whether a desired-state file is configured for this deployment.

load()

@spec load() :: {:ok, Defdo.DDNS.DesiredState.t()} | {:error, term()}

Read desired state.

  • {:ok, doc} — the file exists and is canonical.
  • {:error, :disabled} — no path configured; callers use the env accessors.
  • {:error, :missing_desired_state} — a path is configured, there is no file, and there is nothing in the environment to seed from. Loud on purpose: treating "no intent anywhere" as an empty document would silently unmanage every record in the estate. When the file is absent but the environment does carry config, the file is seeded from it on this first read (see seed_on_missing/1) — the release runs no mix, so this is what lets a fresh deployment produce the file without an operator step.

path()

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

Configured file path, or nil when the store is disabled.

persist(doc)

@spec persist(Defdo.DDNS.DesiredState.t()) ::
  {:ok, Defdo.DDNS.DesiredState.t()} | {:error, term()}

Persist a document, replacing whatever is on disk.

seed(opts \\ [])

@spec seed(keyword()) :: {:ok, Defdo.DDNS.DesiredState.t()} | {:error, term()}

Write the file from the current environment configuration, once.

Refuses when the file already exists: the file is authoritative, and silently overwriting it from env would resurrect whatever the env still says long after someone edited the file deliberately. Pass force: true to re-seed knowingly.

status()

@spec status() :: map()

Counts and metadata only — never hostnames, targets or flag values.

update(fun)

@spec update((Defdo.DDNS.DesiredState.t() -> Defdo.DDNS.DesiredState.t())) ::
  {:ok, Defdo.DDNS.DesiredState.t()} | {:error, term()}

Read, transform, write — the path every mutation takes, including adoption promoting a record into desired state.