milestone 20: declarative configuration for iac
Gates / test-aarch64 (push) Successful in 6m45s
Gates / frontend (push) Successful in 51s
Gates / test (push) Successful in 1m37s
Gates / container (push) Failing after 7m31s
Gates / package (push) Failing after 15m14s
CI / gates (push) Failing after 24m27s
Gates / test-aarch64 (push) Successful in 6m45s
Gates / frontend (push) Successful in 51s
Gates / test (push) Successful in 1m37s
Gates / container (push) Failing after 7m31s
Gates / package (push) Failing after 15m14s
CI / gates (push) Failing after 24m27s
This commit is contained in:
+212
-48
@@ -9,9 +9,10 @@ of truth: `src/cli.zig`.
|
||||
|
||||
Every flag takes both spellings, `--flag value` and `--flag=value`. An attached
|
||||
value that is empty (`--config=`) is a missing value, not an empty path.
|
||||
`--force` is boolean and takes no value at all, so `--force=1` is not a spelling
|
||||
of any flag this program has. A flag is rejected by the subcommand that has no
|
||||
use for it: `--web-dev` outside `run` is an unknown flag, not a no-op.
|
||||
`--allow-delete` is boolean and takes no value at all, so `--allow-delete=1` is
|
||||
not a spelling of any flag this program has. A flag is rejected by the
|
||||
subcommand that has no use for it: `--web-dev` outside `run` is an unknown flag,
|
||||
not a no-op.
|
||||
|
||||
## `run`
|
||||
|
||||
@@ -20,12 +21,81 @@ Serves DNS until SIGINT or SIGTERM.
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory (default `/var/lib/nxdns`). Created at mode 0700 if missing. |
|
||||
| `--config FILE` | Seed configuration file (default `/etc/nxdns/config.zon`). Read only when the database has never been configured. |
|
||||
| `--config FILE` | Make FILE the sole source of configuration and reconcile the database onto it at every start. No default: without this flag the database is the configuration and no file is read. |
|
||||
| `--web-dev DIR` | Serve the web interface from DIR instead of the embedded assets, with no cache headers. Development only. |
|
||||
|
||||
A seed file that is unparseable, oversized or invalid prints its diagnostics and
|
||||
exits 2 — the same code `check` and `import` give for the same file. See
|
||||
[exit codes](#exit-codes).
|
||||
### Which authority the invocation selects
|
||||
|
||||
The presence of `--config` picks the authority, and nothing else does. There is
|
||||
no default path, no probe of `/etc/nxdns`, and nothing recorded in the database:
|
||||
a configuration file sitting at `/etc/nxdns/config.zon` that no flag names
|
||||
changes nothing at all.
|
||||
|
||||
| Invocation | Authority | What a start does |
|
||||
| --- | --- | --- |
|
||||
| `nxdns run` | The database | Serves what `config.db` holds. Nothing reads a file. |
|
||||
| `nxdns run --config FILE` | FILE | Reads and validates FILE, reconciles the database onto it, then serves. |
|
||||
|
||||
The first log line after the migrations names the mode, so a journal says which
|
||||
authority was live:
|
||||
|
||||
```
|
||||
info(nxdns): authority: database
|
||||
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
```
|
||||
|
||||
In file mode the reconcile prints what it changed before that line — per-table
|
||||
inserted (`+`), updated (`~`) and deleted (`-`) counts, the settings keys whose
|
||||
values changed, and any change to whether the admin password is set:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0;
|
||||
settings keys changed: dns.bind_ipv4 dns.port web.bind web.port …
|
||||
web authentication is now enabled
|
||||
```
|
||||
|
||||
A start whose file matches the database writes nothing and says so:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': no changes
|
||||
```
|
||||
|
||||
Blocklist state is not declarative and survives every reconcile: a source whose
|
||||
URL the file still names keeps its row id, its checksum, its counters and its
|
||||
compiled `<id>.list` and `<id>.wild`, so a restart in file mode downloads
|
||||
nothing. Editing a source's URL is a new identity — a new row, a new id, and a
|
||||
fresh download.
|
||||
|
||||
### Failing to start in file mode
|
||||
|
||||
File mode fails closed. A file that is missing, unreadable, unparseable,
|
||||
oversized or invalid stops the start; nxdns never falls back to the database,
|
||||
because a fallback turns a deploy typo into a silently stale configuration.
|
||||
|
||||
```
|
||||
FAIL /etc/nxdns/config.zon: no such file
|
||||
nxdns run failed: ManagedConfigUnreadable
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
That is exit 2, and `check --config` on the same path agrees. Only path-class
|
||||
open failures map that way — the file is not there, or the process may not read
|
||||
it. An open that fails for a reason a retry could clear, such as
|
||||
file-descriptor exhaustion or an I/O error, is exit 1: the box is wrong, not the
|
||||
configuration. See [exit codes](#exit-codes).
|
||||
|
||||
The exit-2 agreement covers `run --config` and `check --config`, which read the
|
||||
managed file through one shared helper. It does **not** extend to `import`'s
|
||||
positional argument: a missing file there is `import failed: FileNotFound`, exit
|
||||
1. That is deliberate rather than an oversight — the managed file is a
|
||||
declarative input an operator deploys, so its absence is a fact about the
|
||||
configuration, while `import`'s argument is a path typed at a prompt, and a
|
||||
mistyped path is a failed command rather than a verdict on anything.
|
||||
|
||||
Run `nxdns check --config FILE` before restarting anything that deploys a file.
|
||||
It grades every declarative fault `run` would hit — read, parse, size,
|
||||
validation — through the same code, which is what makes it a usable precondition
|
||||
in an Ansible handler.
|
||||
|
||||
## `check`
|
||||
|
||||
@@ -52,7 +122,7 @@ first. What it checks, in order:
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory to look for `config.db` in. |
|
||||
| `--config FILE` | Check this file instead of the database. |
|
||||
| `--config FILE` | Grade this file instead of the database. |
|
||||
|
||||
### Failures and warnings
|
||||
|
||||
@@ -71,23 +141,38 @@ The last line is a summary, and it never contradicts the lines above it:
|
||||
|
||||
### Source selection
|
||||
|
||||
- `--config FILE` given explicitly: check that file, nothing else.
|
||||
- Otherwise, if `<data-dir>/config.db` exists: check the database — the right
|
||||
default, since the database is the truth on a configured server. It is opened
|
||||
immutable, so `check` writes nothing to it; see
|
||||
[what `check` does not do](#what-check-does-not-do).
|
||||
- Otherwise, if the default config file path exists: check it.
|
||||
- Otherwise: "nothing to check", exit 2.
|
||||
The flag decides, exactly as it does for `run`. There is no fallback and no
|
||||
probing of a default path:
|
||||
|
||||
The first line of output always names which source was checked. A file larger
|
||||
than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is
|
||||
- `--config FILE`: grade that file, and never open the database.
|
||||
- No `--config`: grade `<data-dir>/config.db`. It is opened immutable, so
|
||||
`check` writes nothing to it; see
|
||||
[what `check` does not do](#what-check-does-not-do).
|
||||
|
||||
So `nxdns check` and `nxdns check --config FILE` grade what the matching `run`
|
||||
invocation would serve. That is what makes `check` a pre-restart gate rather
|
||||
than an approximation of one.
|
||||
|
||||
A bare `check` on a box with no database says so and names both ways out of the
|
||||
state, exit 2:
|
||||
|
||||
```
|
||||
no config database at /var/lib/nxdns/config.db
|
||||
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
||||
```
|
||||
|
||||
The first line of output otherwise always names which source was checked. A file
|
||||
larger than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is
|
||||
reported with its line and column.
|
||||
|
||||
A named file that is missing or unreadable is a finding like any other, not an
|
||||
I/O failure that escapes the run: `FAIL <path>: no such file` or
|
||||
`FAIL <path>: not readable`, exit 2. That is the same code the implicit path
|
||||
gives when there is nothing to check, so naming the file does not change what a
|
||||
missing file costs.
|
||||
`FAIL <path>: not readable`, exit 2.
|
||||
|
||||
What `check --config` cannot see is the reconcile itself. It needs no database,
|
||||
so faults that only a write can produce — a disk that is full, a lock held by a
|
||||
restart that started first — are invisible to it. Those are runtime failures,
|
||||
exit 1, and they are not verdicts on the file.
|
||||
|
||||
### What `check` does not do
|
||||
|
||||
@@ -133,6 +218,15 @@ there. A directory that exists without a `config.db` is not that case: the
|
||||
database is opened with create semantics, so an empty `config.db` is created and
|
||||
migrated, and the export is of a default configuration.
|
||||
|
||||
The output is the same in both authority modes — nothing marks a file as
|
||||
exported from a file-mode box. That is what lets `export` be the adoption tool:
|
||||
the file you check is the file you deploy.
|
||||
|
||||
`web.password` is always written as `null` and `web.password_hash` carries the
|
||||
stored value, so an export re-imports without anyone knowing the password. A
|
||||
`null` password is not the same statement as an empty one: see
|
||||
[`web.password` and `web.password_hash`](configuration.md#password-and-hash).
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory holding `config.db`. |
|
||||
@@ -142,25 +236,79 @@ See [back up and restore](../how-to/back-up-and-restore.md).
|
||||
|
||||
## `import FILE`
|
||||
|
||||
Validates FILE and replaces the whole configuration with it in one transaction.
|
||||
Prints every validation problem; a failed import leaves the database untouched.
|
||||
Refuses a database that already holds configuration unless `--force` is given
|
||||
(`DatabaseNotEmpty`, exit 2). The check measures what an operator set, not what
|
||||
the network did: client rows the DNS path materialised from traffic never
|
||||
trigger the refusal on their own. Creates the data directory at mode 0700 if it
|
||||
is missing.
|
||||
|
||||
Client history survives the replacement. An address the database already knew
|
||||
keeps its first-seen and last-seen even when FILE names it; only an address it
|
||||
has never seen takes the import's clock. A client FILE leaves out is removed,
|
||||
history included.
|
||||
Converges the database onto FILE in one transaction — the same reconcile a
|
||||
file-mode `run` performs, done once from the command line. Prints every
|
||||
validation problem; a failed import leaves the database untouched. Creates the
|
||||
data directory at mode 0700 if it is missing.
|
||||
|
||||
`FILE` is positional and may appear before or after the flags.
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory holding `config.db` (created if missing). |
|
||||
| `--force` | Replace a database that already holds configuration. |
|
||||
| `--allow-delete` | Apply a file whose diff deletes rows. |
|
||||
|
||||
**`import` is a stop-first operation.** It rewrites configuration underneath a
|
||||
process that read it at startup, and a running server notices only some of it:
|
||||
filtering picks up the imported rows at the next reload, while upstreams,
|
||||
listeners and settings stay at their boot values until a restart.
|
||||
|
||||
### The delete gate
|
||||
|
||||
Rows the database holds and FILE does not name are deleted. That is the point of
|
||||
a declarative apply, and it is also how a mistaken `nxdns import ./wrong.zon`
|
||||
empties a configured server, so it takes a flag:
|
||||
|
||||
```
|
||||
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
|
||||
import failed: DestructiveImport
|
||||
```
|
||||
|
||||
That is exit 2, and the transaction rolls back. The message names every table
|
||||
with a non-zero delete count, so you can tell an intended pruning from a wrong
|
||||
file before applying anything.
|
||||
|
||||
An import that only adds rows, or only edits them, needs no flag. "Edit" here
|
||||
means a change to a row nxdns can still recognise as the same row. Each table
|
||||
has one column, or one tuple, that establishes identity:
|
||||
|
||||
| Table | Identity |
|
||||
| --- | --- |
|
||||
| `blocklist_sources`, `upstreams` | `url` |
|
||||
| `groups` | `name` |
|
||||
| `clients` | `ip` |
|
||||
| `client_prefixes` | `prefix` |
|
||||
| `forward_zones` | `zone` |
|
||||
| `local_records` | `(name, rtype, value)` |
|
||||
| `rules` | `(group, pattern, kind, action)` |
|
||||
|
||||
Change anything else on a row — a source's name, a group's `safe_search`, a
|
||||
client's group — and it is an edit, applied without a flag. Change the identity
|
||||
itself, such as renaming a group or correcting a typo in an upstream URL, and
|
||||
the engine sees a row that vanished and a row that appeared: that needs
|
||||
`--allow-delete`.
|
||||
|
||||
### What survives an import
|
||||
|
||||
Runtime state is not declarative and is preserved by identity, not by luck. A
|
||||
blocklist source whose URL is unchanged keeps its row id, its checksum, its
|
||||
counters and its compiled files, so an import costs no downloads. Clients the
|
||||
DNS path materialised from traffic are kept whole; naming one in the file
|
||||
promotes that row in place, keeping its first-seen and last-seen. Observed
|
||||
clients whose group the file no longer declares are moved to the `default`
|
||||
group rather than deleted with it, and none of that ever trips the delete gate.
|
||||
|
||||
### `import` against a file-mode box
|
||||
|
||||
It behaves like any other import. Nothing in the database records that a file
|
||||
governs it — authority lives in the invocation — so `import` neither detects nor
|
||||
refuses that case. The next restart's reconcile converges the database back to
|
||||
the file and its summary reports what it corrected. A source the import deleted
|
||||
comes back with a new row id, which means a fresh download of the whole list.
|
||||
|
||||
If a restart and an import race for the write lock, one of them simply wins: both
|
||||
take `BEGIN IMMEDIATE` under a 5-second busy timeout, so the outcome is an
|
||||
ordering, never a corrupted database.
|
||||
|
||||
## `version`
|
||||
|
||||
@@ -185,28 +333,44 @@ Code 2 means the same thing from every subcommand. `src/config/faults.zig`
|
||||
holds the one list of errors that mean "the configuration the operator supplied
|
||||
is wrong", and `run`, `check` and `import` all ask it, so a rejected file exits
|
||||
2 whichever command read it. The list is every error the validator raises, plus
|
||||
`ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams` and `BadCertificate`. In
|
||||
practice that covers a seed file with a syntax error, one larger than 4 MiB, one
|
||||
with no `default` group (`MissingDefaultGroup`), one with no enabled upstream
|
||||
(`NoUpstreams`), a bad bind address, a bad rate limit, an unusable certificate,
|
||||
and `password` and `password_hash` set together.
|
||||
`ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams`, `BadCertificate` and
|
||||
`ManagedConfigUnreadable`. In practice that covers a file with a syntax error,
|
||||
one larger than 4 MiB, one with no `default` group (`MissingDefaultGroup`), one
|
||||
with no enabled upstream (`NoUpstreams`), a bad bind address, a bad rate limit,
|
||||
an unusable certificate, `password` and `password_hash` set together, and a
|
||||
`--config` path that is absent or unreadable.
|
||||
|
||||
When `run` exits 2 it points at the diagnosis on stderr, whether the fault came
|
||||
from the seed file or from the database it loaded:
|
||||
The last of those is the one deliberate seam. A file nxdns cannot open is a
|
||||
configuration fault only when the *path* is the problem — the file is missing,
|
||||
permissions deny it, a path component is not a directory. Every other open
|
||||
failure, such as running out of file descriptors, is exit 1. The distinction
|
||||
earns its keep under the shipped systemd unit, which stops the service on exit 2
|
||||
rather than restarting it: a transient box fault graded as a configuration fault
|
||||
would take the resolver down until someone noticed.
|
||||
|
||||
When `run` exits 2 it points at the diagnosis on stderr, whichever authority the
|
||||
fault came from:
|
||||
|
||||
```
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
`check` exits 2 for those faults and also when a probed upstream failed, when a
|
||||
named configuration file is missing or unreadable, when the database cannot be
|
||||
read or is not at this binary's schema version, and when there was nothing to
|
||||
check. Warnings never contribute.
|
||||
On a box with no configuration at all, `run` and `check` add the line that names
|
||||
both ways to get one:
|
||||
|
||||
`import` exits 2 for those faults and for `DatabaseNotEmpty`. That last one is
|
||||
deliberately not a configuration fault — it reports the state of the database
|
||||
rather than the content of a file — and `import` decides it for itself; the
|
||||
answer to it is `--force`, not an edit.
|
||||
```
|
||||
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
||||
```
|
||||
|
||||
`check` exits 2 for those faults and also when a probed upstream failed, when a
|
||||
named configuration file is missing or unreadable, and when the database cannot
|
||||
be read, is absent, or is not at this binary's schema version. Warnings never
|
||||
contribute.
|
||||
|
||||
`import` exits 2 for those faults and for `DestructiveImport`. That last one is
|
||||
deliberately not a configuration fault — it reports what applying the file would
|
||||
delete, rather than anything wrong with its content — and `import` decides it
|
||||
for itself; the answer to it is `--allow-delete`, not an edit.
|
||||
|
||||
`OutOfMemory` is exit 1 even when problems were recorded, because the report is
|
||||
then incomplete. Every other error is 1.
|
||||
|
||||
Reference in New Issue
Block a user