Stateful services¶
A stack can declare a database (PostgreSQL today) with the schema and roles to ensure exist. This is the hardest deploy gap, because it sits on a tension the rest of the system avoids: everything Punix pins is content-addressed and immutable, but application state is neither. Read the rollback contract below before relying on stateful deploys — it is the soul of this feature.
Declaring a database¶
module MirrorStack {
backend = "systemd"
services = ["Postgres", "Mirror"]
databases = [
{ engine = "postgresql"
service = "postgres" # the stack service running the engine
ensureDatabases = ["mirror"]
ensureUsers = [
{ name = "mirror"
ensureDBOwnership = ["mirror"]
password = { from_vault = "secret/mirror#db" } }
] }
]
}
The declared schema is pure config; applying it (CREATE DATABASE/ROLE) is an effect. Punix renders an idempotent, additive-only provisioning script into a systemd oneshot ordered after the engine service — it never opens a DB connection from the deploy host. The deploy only writes the unit; running it requires activation (--activate), or it fires on next boot.
Idempotent and additive-only¶
ensureDatabases/ensureUsers are create-if-absent (catalog-guarded). Punix never emits DROP:
- Removing an entry from the declaration does not drop the database — orphans are logged, never acted on (retraction is irreversibly lossy).
- It does not reconcile drift: a pre-existing role with different attributes is left as-is. (NixOS's
ensure*has the identical limitation.) - The one exception:
ALTER ROLE … PASSWORDruns every deploy, so a rotated secret takes effect.
A pg_isready gate (ExecStartPre) makes the oneshot wait for the socket to accept connections, so it never races the engine regardless of restart ordering.
DB-user passwords¶
A password must be a secret reference ({from_env=…} / {from_file=…} / {from_vault=…}) — a plaintext literal is a compose error (it would enter the source and the hash). The delivery is hardened end to end:
- the superuser leg uses peer/ident auth over the unix socket — no password at all;
- a user password is resolved at deploy into a
0600EnvironmentFile, read by psql's\getenvand bound with:'…'— never in the SQL text, never on argv (so not inpsor the journal), never ingen-NNN.json; - the
ALTER ROLEruns with statement logging suppressed, so the cleartext can't land in the Postgres server log.
(psql ≥ 16 is required for \getenv.)
State lives outside the store¶
The data dir is a stable host path via systemd StateDirectory= (service surface). It is never a hash input, never pushed, never a GC root, never collected — GC roots are exactly the store paths a generation pins, and a state dir is not one. The generation records that the stack is stateful and the managed state-dir shape (paths, never contents).
Rollback reverts code, not data¶
A rollback is an atomic flip over binary + config. The data dir is shared across generations and is not reverted. So:
- An older binary may run against a newer (migrated) schema and refuse to start — a fundamental property of atomic-deploy-with-mutable-state, not a Punix bug.
service rollbackwarns loudly on any stateful transition across the rollback span — including rolling back to a stateless generation, where the data dir is orphaned (not deleted), and aStateDirectoryrename, where systemd would create a new empty dir and orphan the old data.- The warning informs, it does not block — you may know the transition is safe.
The one-liner: Punix gives you atomic, reversible code and idempotent, additive state setup; it does not give you reversible state. Reversible data is a backup/restore concern — Punix can schedule backups (a Litestream replica, or a pg_dump oneshot on a timer), but the bytes are operator-owned.
Scope¶
PostgreSQL ships. openbao (a startup-time unseal oracle — a different lifecycle) and harmonia (a plain signing-key secret — no provisioner needed) are deferred. The oneshot model covers one-time idempotent setup only.
Where in the code¶
src/punix/deploy/generators/provision.py— the provisioning oneshot + idempotent SQL.src/punix/deploy/manifest.py—Generation.stateful/state_dirs.
Related¶
- Activation — the provisioning oneshot runs only when units are activated.
- Secrets at deploy — where DB passwords come from.
- Rollback · Reference: decisions — ADR-016.