Feature Specification: Resource postgresql
Created: 2026-10-07
Status: Implemented
Input: Reconstructed from the code at 093e68b6f (core/resources/postgresql/default.nix, core/resources/postgresql/postgresql-provider.nix, core/resources/postgresql/wait-password-file.sh, core/resources/postgresql/converge-test.nix, core/setups/wiring/postgresql-roster.nix, core/setups/wiring/resource-routing.nix, core/setups/hosts-common.nix, core/checks/integrations/postgresql/), pull requests #1184, #1238 and #1392, and issues #527 and #1205.
User Scenarios & Testing (mandatory)
User Story 1 - Give an application a database and a rotated credential (Priority: P1)
A component needs a PostgreSQL database. It states a request with a
database, a consumerName and a username. The policy routes the request
to a PostgreSQL provider. The provider creates the database and the role.
OpenBao rotates the password of the role. The OpenBao agent on the consumer
node renders the password and a DSN to files. The client module of the
component reads the paths of these files from
contracts.postgresql.results.<app>.default.
Why this priority: most applications of core store their data in
PostgreSQL. Without this resource, they do not start.
Independent Test: contracts-postgresql (a NixOS test of the provider in
core/checks/integrations/postgresql/postgres-behavior.nix) and
contracts-postgresql-rotation.
Acceptance Scenarios:
- Given a routed request in
rotatingmode, When the deployment is evaluated, Then the consumer node gets two openbao-agent templates frompostgresql/static-creds/<username>: the password at/run/vars/secret/<username>-db/passwordand the DSN at/run/vars/secret/<username>-db/url, where<username>is the namespaced role name. - Given a routed request in
dynamicmode, When the deployment is evaluated, Then the consumer node gets one DSN template frompostgresql/creds/<username>. - Given OpenBao rotates a password, When the agent renders the new file, Then the agent runs
systemctl reset-failedandsystemctl restart --no-blockon thereloadUnitsof the request. - Given a request with
urlEnvironment, When the node starts, Then the oneshot<consumerName>-db-envwrites<variable>=<DSN>to/run/<consumerName>-db-env/env, and each unit inurlEnvironment.unitsreads it as anEnvironmentFile.
User Story 2 - Give each deployment of a group its own database (Priority: P1)
A hosting provider deploys one operator group many times, once per tenant.
Each deployment must get its own database and role. The policy prepends the
group with namespacePrefix and appends the deployment with
namespaceSuffix. The shared provider creates these databases through the
OpenTofu graph (provisioning = "tf"), so a destroy drops them again.
Why this priority: without a per-deployment name, two deployments of one group share one database.
Independent Test: nix-unit-resources-postgresql-converge checks the
names and the converge commands for the netbox, windmill, mastodon and
peertube requests. nix-unit-setups-postgresql-roster checks the roster
that the provider and the OpenBao engine read.
Acceptance Scenarios:
- Given
namespacePrefix = "<group>"andnamespaceSuffix = "<deployment>", When a request asks for databasex, Then the database and the role are<group>-x-<deployment>. - Given a non-empty
namespaceSuffixandprovisioning = "in-nix", When the deployment is evaluated, Then the evaluation fails, because the provider node drops a database only when its own group deploys, so the database outlives the deployment. - Given
provisioning = "tf"and a request withcredentialMode = "dynamic", When the deployment is evaluated, Then the evaluation fails, because thetfpath does not create the owner role of the lease.
User Story 3 - Run the PostgreSQL daemon on a provider node (Priority: P2)
A group with its own postgresql node runs the daemon itself
(provisioning = "in-nix"). The provider module reconciles the databases and
the roles with OpenTofu (postgresql-provision), sets the admin password, and
accepts TLS connections from the private subnets. The reconciled objects are
the options services.postgresql.runtime.roles and .databases, typed from
the provider schema of the declarative-runtime pairing.
Why this priority: the development groups and the hosting provider run their own PostgreSQL node.
Independent Test: contracts-postgresql, integrations-postgresql-provision.
Acceptance Scenarios:
- Given a node that provides
postgresql, When it is built, Then it runs the daemon withenableTCPIP, opens the port in the firewall, and takes its certificate fromcontracts.ssl.results.fediversity.<sslDomain>. - Given TLS is on, When an application connects from an address in
appSubnets, Thenpg_hbaaccepts it withhostsslandscram-sha-256. - Given an empty admin password file, When
postgresql-set-passwordsruns, Then the unit fails and restarts after 10 seconds. - Given a database or a role in the roster that exists but is not in the state of
postgresql-provision, When the unit runs, Then it adopts the object with animportblock and keeps its data. - Given a database or a role that leaves the roster, When
postgresql-provisionruns, Then it drops the database with its data, then the role. - Given an engine that rotated a password or an app whose
bootstrapSQLgranted a membership or set a role setting, Whenpostgresql-provisionruns again, Then it changes nothing. - Given a state with one
for_eachresource per type, from before each object had its own resource, Whenpostgresql-provisionruns, Then itsmovedblocks move each object to its own resource, and it changes no object.
User Story 4 - Hold a unit until its credential exists (Priority: P3)
A unit that reads the password must not start before the agent renders it.
The request lists such units in gateUnits.
Why this priority: a unit that starts too early fails, and can burn its start limit.
Independent Test: no check covers gateUnits alone.
Acceptance Scenarios:
- Given a unit in
gateUnits, When it starts, Then anExecStartPrerunswait-password-file.sh, which waits up to 60 attempts of 1 second for each credential file to be non-empty.
Edge Cases
- No provider is routed for the request: the resource emits no consumer configuration and no provider want.
bootstrapSQLorgroupRoleson atfwant:postgresql-roster.nixfails the evaluation, because the OpenTofu graph does not run the SQL, and the provider node does not converge on the database and the login role of the want.- Two requests declare one group role with different
bypassRowLevelSecurity:postgresql-roster.nixfails the evaluation. Requests that agree share the role, and their memberships and privileges merge. - A group role has the name of a login role or of an owner role: the evaluation of the provider node fails.
- A
dynamicrequest has no fixed login role. The engine grants thegrantToUsergroups to each lease role, throughdynamicRoleGrants. bootstrapSQLruns on every start ofpostgresql-app-bootstrap-sql. It must be idempotent.dynamicOwnerRolenames a role thatbootstrapSQLalso creates:postgresql-provisioncreates the role first and resets the attributes thatbootstrapSQLsets.- An object that left the roster before the first run of
postgresql-provisionis in neither the config nor the state, so it is not adopted and not dropped. - The admin role is not in the config of
postgresql-provision:ensureUserscreates it andpostgresql-set-passwordssets its password. The per-lease roles ofdynamicrequests are the engine's. - The resource prepends
namespacePrefixtoconsumerName, but keeps the unprefixed name asconsumer.consumeris the app node name, so the per-host grant of the SPIFFEhost/<name>role keys on it.
Requirements (mandatory)
Functional Requirements
- FR-001: The request MUST accept
enable,database,consumerName,username(defaultconsumerName),passwordFileOwner(defaultroot),passwordFileMode(default0640),credentialMode,reloadUnits,urlEnvironment,bootstrapSQL,groupRoles,dynamicRoleGrants,dynamicOwnerRole(default<username>_owner) andgateUnits. The request MUST contain only data, so that it can be written to JSON. - FR-002:
credentialModeMUST berotatingordynamic, withrotatingas the default. - FR-003: The policy MUST accept
enable,domain,host(defaultdomain),port(default5432),sslMode(defaultrequire),namespacePrefix,namespaceSuffix,provisioning(in-nixortf, defaultin-nix) androuting. - FR-004: The resource MUST name the database and the role
<namespacePrefix>-<name>-<namespaceSuffix>, where an empty prefix or suffix adds nothing. - FR-005: The evaluation MUST fail when
namespaceSuffixis set withoutprovisioning = "tf", and whenprovisioning = "tf"meets adynamicrequest. - FR-006: The resource MUST emit consumer configuration only for a request that the routing gives a provider.
- FR-007: A rotating consumer MUST read
postgresql/static-creds/<username>, and a dynamic consumer MUST readpostgresql/creds/<username>. - FR-008: The DSN MUST have the form
postgres://<user>:<password>@<host>:<port>/<database>?sslmode=<sslMode>. - FR-009: A re-render of a credential MUST reset and restart the units that read it, without a block on the agent.
- FR-010: The
<consumerName>-db-envoneshot MUST restart on failure every 10 seconds with no start limit, and MUST run before the units inurlEnvironment.units. - FR-011: Under
provisioning = "in-nix", the resource MUST put a want oncontracts.postgresql.want.<prefixed consumerName>.defaultof the provider node. - FR-012: The resource MUST publish a provider roster entry per request with
role,ownerRole,provisioning,roleStem,database,consumer,credentialMode,bootstrapSQL,groupRolesanddynamicRoleGrants. - FR-013: The provider node MUST generate its admin password with
openssl rand -hex 32into thepostgresql-adminvars instance (ownerpostgres, mode0440). - FR-014: The provider MUST only accept remote connections over TLS with
scram-sha-256, from the subnets inappSubnets. - FR-015: The OpenBao engine mount MUST be
postgresql, with a rotation period of 24 hours. - FR-016: Under
provisioning = "in-nix", the provider node MUST create each database, each fixed login role, each dynamic owner role and each group role withpostgresql-provision, throughservices.postgresql.runtime.{roles,databases,grants,grant_roles}, and a login role MUST own its database when the names match. On a dynamic database, it MUST grantCREATEandUSAGEon thepublicschema topublic, andCONNECT,CREATEandTEMPORARYto the owner role. It MUST grant thedatabasePrivilegesof a group role on the database of each request that declares it, eachmemberOfgroup to the group role, and eachgrantToUsergroup to the login role of arotatingrequest. - FR-017:
postgresql-provisionMUST adopt the declared objects that exist, MUST drop the objects that leave its config, and MUST NOT manage role passwords, the memberships that it does not declare, or the attributes that the engine andbootstrapSQLset. - FR-018: The resource MUST give each routed request to the consumer node through the
postgresqlcontract: thebridgeof the cross-node evaluation setscontracts.postgresql.instances.<consumer>.defaultto the resolved request and result, socontracts.postgresql.results.<consumer>.defaultholds the result. The request fields that only the consumer side reads (username,credentialMode, the file owner and mode, the reload and gate units,urlEnvironment) MUST be atfediversity.postgresqlConsumers.<consumer>. The static moduleconsumer.nixMUST render the templates, the<consumer>-db-envoneshot and the start gate from these two values only. The values MUST contain no store paths and no functions, so that they can be written to JSON. - FR-019: The setups MUST give the full roster, the requests of the operator groups and the engine roles only to the provider node of the central group (the one group whose roster provides
varsBackend). In a group with no route forpostgresql,hostMUST be the FQDN of the provider node of the central group.
Key Entities
- Request: what one consumer needs: database, role, credential mode, files and units.
- Provider roster: the list of all routed requests, read by the provider node (
dynamicRoles,dynamicOwners,bootstrapDbSQL,groupRoles,groupMemberships,groupDatabasePrivileges) and by the OpenBao engine (roles,consumerRoles). Seepostgresql-roster.nix. - Resolved request: the data that
consumer.nixrenders on the consumer node:consumer,username,credentialMode,passwordFileOwner,passwordFileMode,reloadUnits,gateUnits,urlEnvironmentand the contractresult. - Credential files:
/run/vars/secret/<username>-db/passwordand/run/vars/secret/<username>-db/url(namespacedusername), rendered by the openbao-agentdefaultinstance. - Provisioning mode:
in-nix(the provider node creates the objects) ortf(the OpenTofu graph creates them on a shared node).
Success Criteria (mandatory)
Measurable Outcomes
- SC-001:
nix-unit-resources-postgresql-converge,nix-unit-setups-postgresql-roster,nix-unit-setups-resource-routingandnix-unit-resources-plain-requestspass. - SC-002:
contracts-postgresql,contracts-postgresql-rotationandintegrations-postgresql-provisionpass. - SC-003: Two deployments of one group get two different databases on the shared provider.
Assumptions
- The generic flow of a request through a policy is in the data model. The node selection and the routing table are in setups and wiring.
- The OpenBao
postgresqlengine and its roles come from the OpenBao component. This resource only supplies the roster. - The credential delivery uses the openbao-agent
defaultinstance thatgenerateFilesconfigures.
Known Deviations
- A TODO in
core/resources/postgresql/default.nixsays thatdynamicmode does not cover a consumer with a fixed user, which needs a connection pooler. - A FIXME in
core/resources/default.nixasks which more of the resources cross-node contracts can carry.