Feature Specification: Resource garage
Created: 2026-10-07
Status: Implemented
Input: Reconstructed from the code at 093e68b6f (core/resources/garage/default.nix, core/resources/garage/restic-s3.nix, core/resources/garage-forward-compat-test.nix, core/setups/wiring/garage-kv-stems.nix, core/setups/wiring/minted-garage-keys.nix, core/setups/wiring/supporting-requests.nix, core/setups/wiring/resource-routing.nix, core/setups/hosts-common.nix, core/components/operator/testing/lib.nix), pull requests #1184, #1238, #1246, #1266, #1361 and #1818, and issues #527, #1205 and #1206.
User Scenarios & Testing (mandatory)
User Story 1 - Give an application buckets and an S3 key on a shared garage (Priority: P1)
An operator application stores media in S3. Its component states
ensureBuckets and ensureKeys. The policy prefixes every name with the
tenant, so two deployments on one garage cluster do not collide. Under
provisioning = "tf", the operator OpenTofu graph creates the buckets and
the key on the shared garage node through its admin API. The graph then
writes the S3 pair to OpenBao KV. The consumer node reads it back.
Why this priority: the Fediverse applications keep their media in S3.
Independent Test: deployment-tf-incus-operator runs the garage and
OpenBao half of the operator graph. nix-unit-setups-garage-kv-stems checks
the provisioning shape and the KV grants. nix-unit-setups-minted-garage-keys
checks which keys have no minter, and nix-unit-setups-garage-key-minter
checks that the deploy target then fails.
Acceptance Scenarios:
- Given
tenant = "<deployment>"and a bucketmedia, When the deployment is evaluated, Then the physical bucket is<deployment>-media, and the node gets that name ascontracts.s3.results.media.default.bucket. - Given a key with
kvName = "mastodon", When the graph mints the pair, Then it publishesvars/public/mastodon-s3/accessKeyandvars/secret/mastodon-s3/secretKey. The stem carries no tenant prefix. - Given the requests of a group, When
provisioningOfruns, Then each bucket keeps onlynameandserveAsWebsite, and each key keepsname,kvNameand the per-bucketread,writeandownergrants. - Given the same requests, When
kvStemsOfruns, Then it returns one<kvName>-s3stem per key without duplicates. The deployer token may write each stem, and the app node may read its own. - Given
provisioning = "tf"and a key with pair files that nogenerateFilesinstance of the group mints, When theallorcombinedtarget of atf-hosts,tf-incus-hostsorssh-hostsdeployment is evaluated, Then the evaluation fails and names the key. Only thetf-incus-operator-hostsgraph mints pairs.
User Story 2 - Provision buckets and keys on the group's own garage node (Priority: P1)
A group with its own garage node provisions its own requests on that node.
Examples are the buckets of the telemetry app and the cache-backup bucket of
each valkey namespace. An OpenTofu reconciler (core/resources/garage/provision.nix)
on the declarative-runtime library holds the buckets, the keys and the grants,
with its state local to the node. The oneshot garage-provision-own applies
it. The buckets are the option services.garage.runtime.buckets, typed from
the provider schema of the declarative-runtime pairing, with one resource per
bucket. The keys and the grants are each one for_each resource, because the
config of a key depends on files that land at apply time. A key with no pair
files has no other minter, so the garage node mints it with generateFiles.
Why this priority: the operator graph mints only the requests of the operator groups. Without the node-side route, a bucket of the group has no key.
Independent Test: nix-unit-setups-minted-garage-keys, integrations-garage-provision.
Acceptance Scenarios:
- Given an own key with
s3AccessKeyFile = nullands3SecretKeyFile = null, When the garage node is evaluated, ThenmintedGarageKeyInstancesnames<kvName>-s3, and the node generatesaccessKey(GKplus 24 hex digits, mode0444) andsecretKey(64 hex digits, mode0400). - Given such a minted key, When
withMintedGarageKeyFilesruns, Then the key reads its pair from/run/vars/public/<kvName>-s3/accessKeyand/run/vars/secret/<kvName>-s3/secretKey, andgarage-provision-ownimports it. - Given a key whose pair files are set, When
withMintedGarageKeyFilesruns, Then the key stays the same. - Given garage is not healthy, When
garage-provision-ownruns, Then it polls/healthof the admin API on127.0.0.1:3903for up to 120 seconds, fails, and restarts after 30 seconds. - Given a key whose pair files are not there yet, When
garage-provision-ownruns, Then it applies everything else and writes the key to/var/lib/garage-provision/pending. Once the files are there, the timer ofgarage-provision-late-keysapplies the key within a minute. The unit exits0while garage is down. - Given a bucket or a key that exists in garage but not in the state, When the reconciler runs, Then it adopts the object with an
importblock and keeps its id. - Given a bucket, a key or a grant that leaves the config, When the reconciler runs, Then it deletes it. It empties a bucket before it deletes it.
- Given a key in the state whose pair files went missing, When the reconciler runs, Then the key stays, with the id that the state holds.
- Given a state with one
for_eachresource for the buckets, from before each bucket had its own resource, When the reconciler runs, Then itsmovedblocks move each bucket to its own resource, and it changes no bucket.
User Story 3 - Run a co-located garage for a single-app test (Priority: P2)
A single-app check runs garage on the app node. It sets tenant = "", a
local domain and provisioning = "in-nix". The app mints its own S3 pair
through generateFiles. The garage-layout-init oneshot sets the layout. The
ensure-garage oneshot then creates the buckets, applies the CORS rules and
imports the key.
Why this priority: the application checks run without a hosting provider.
Independent Test: contracts-s3-minio covers the s3 contract. The
single-app test-*-service checks use this path through
core/components/operator/testing/lib.nix.
Acceptance Scenarios:
- Given
provisioning = "in-nix"and a key with pair files, When the consumer node is evaluated, Then it wantscontracts.generateFiles.want.<kvName>-s3withaccessKey(public) andsecretKey(secret). - Given a bucket with
corsRules.enable = true, Whenensure-garageruns, Then it sets the rules withbucket-cors.shover the S3 API on loopback, with a key that it mints over the admin API and deletes again. A bucket withcorsRules.enable = falseloses its rules.
User Story 4 - Read the old website option (Priority: P3)
A stored configuration uses the old option website. The bucket option
renames it to serveAsWebsite, so the old shape still evaluates.
Why this priority: stored configurations must keep their meaning.
Independent Test: nix-unit-resources-garage-forward-compat.
Acceptance Scenarios:
- Given a bucket with
website = true, When it is evaluated, ThenserveAsWebsiteistrue, and the result equals the new shape. - Given a bucket with neither option, When it is evaluated, Then
serveAsWebsiteisfalse.
User Story 5 - Back up the media buckets of an application (Priority: P2)
An operator enables fediversity.mediaBackup.instances.<app> on the node of
Mastodon, Pixelfed or PeerTube. Each day,
media-backup-<app>.service copies the media buckets of the application into
the prefix media/ of its <app>-backups bucket. rclone crypt encrypts the
name and the content of each object. A replaced or deleted object moves to the
history of that run. The media-backup-<app> command restores the buckets,
lists the history and erases one object from the backup.
Three measures remove from the backup the media that the application or an erasure request deletes:
- The history expires after
retentionDays. Its default for an application is 30 days, the retention of all the backups of the application (backupRetention.application), so that the expiry removes the objects that the application deletes after the same period as the database backups. - For an erasure request (GDPR Art. 17),
media-backup-<app> eraseremoves the object from the backup and from all of its history at once. - After a restore of the media without the database, the operator runs the
cleanup of orphan media of the application, for example
tootctl media remove-orphansof Mastodon, so that the objects of deleted records do not come back.
Why this priority: the buckets hold the uploads of the end users, and nothing else holds a copy.
Independent Test: integrations-media-backup.
Acceptance Scenarios:
- Given an enabled instance, When the timer starts the service, Then the target holds one encrypted object for each source object that
excludesdoes not match, and a second run with no change transfers nothing. - Given a credential that arrives empty, When the service starts, Then it fails and names the credential.
- Given a deleted or overwritten source object, or a deleted bucket that the provisioning made again, When
media-backup-<app> restoreruns, Then the object of the last backup is back with its metadata. - Given a stamp from
media-backup-<app> stamps, Whenrestore --at <stamp>runs, Then each object is the version of that backup, and an object made later stays. - Given an erasure request, When
media-backup-<app> erase <bucket> <key>runs, Then no restore can put back that object. - Given a history older than
retentionDays, When a backup runs, Then it deletes that history.
Edge Cases
corsRulesapply under each provisioning mode: on the co-located test path, in the operator graph and on the node reconciler (provisionFor). The v2 admin API has no verb for them, so each sets them over S3 with a temporary key (bucket-cors.sh). No key is shared between deployments or kept after the run.- An
ensureAccessentry that sets none ofread,writeandownerstops the evaluation. The error names the key and the bucket. garage-layout-initassigns the single-node layout only when the live layout version is0. Later runs change nothing. The co-located path uses it too, andensure-garagestarts after it, so a run ofensure-garagemakes no new layout version.- The S3 API has no TLS (
api.urlishttp://<api.domain>:3900). The website endpoint ishttps://. - The wildcard names
*.<api.domain>and*.<web.rootDomain>need a DNS-01 certificate. - A
destroyof an operator deployment empties and deletes all of its buckets, the<app>-backupsbuckets too. No restore is possible after it, and an export for a migration must finish before it. A grace period for the backups (#2097) is not implemented.
Requirements (mandatory)
Functional Requirements
- FR-001: The request MUST accept
enable,ensureBucketsandensureKeys. It MUST hold only data. - FR-002: A bucket MUST accept
serveAsWebsite,corsRules(enable,allowedHeaders,allowedMethods,allowedOrigins), and MUST acceptwebsiteas a renamed form ofserveAsWebsite. - FR-003: A key MUST accept
s3AccessKeyFile,s3SecretKeyFile(both nullable),kvName(default the key name) andensureAccess.<bucket>withread,writeandowner. - FR-004: The policy MUST accept
enable,domain,tenant(default empty),provisioning(in-nixortf, defaulttf),rpc.port(3901),api.domain(s3.<domain>),api.port(3900),web.rootDomain(web.<domain>) andweb.internalPort(3902). - FR-005: The resource MUST prefix bucket, key and
ensureAccessnames as<tenant>-<name>whentenantis not empty, and MUST NOT prefixkvName. - FR-006: For each bucket of each request, the consumer side MUST set the
wantand the resolved instance of thes3contract under the logical name, with the requestbucket(the logical name) and the resultbucket(the prefixed bucket name),endpoint(api.url),port(api.port),region(garage),apiDomain,webDomainandwebUrl. A component reads its buckets fromcontracts.s3.results.<bucket>.defaultin a static module that sets nothing when its bucket has no result. - FR-007: Under
provisioning = "in-nix", the consumer MUST want agenerateFilesinstance<kvName>-s3for each key with pair files. - FR-008: Every key pair MUST publish to the KV stem
<kvName>-s3, withaccessKeypublic andsecretKeysecret, whoever mints it. - FR-009:
provisioningOfMUST drop the key file paths, MUST keepserveAsWebsite, and MUST givecorsRuleswithoutenablewhen it is enabled, elsenull. - FR-010:
kvStemsOfMUST return the stems of all keys of the requests, without duplicates and without regard to the minter. - FR-011: The garage node MUST mint a pair for each own key whose two pair files are
null, and MUST leave a key with set files alone. - FR-012: The garage node MUST run
garage_2withreplication_factor = 1, and MUST read its admin token from thegarage-adminvars instance (openssl rand -base64 32) throughLoadCredential. - FR-013: The garage node MUST open the ports
80,443,api.port,rpc.portand the admin port3903. - FR-014:
bucketEndpointVhostsMUST proxy*.<web.rootDomain>to port3902and*.<api.domain>to port3900on the local host. - FR-015:
cacheBackupRequestForMUST give one request per namespace, with a bucket and a key named<namespace>-cache-backupsthat hasread,writeandowner. - FR-016: The resource MUST request certificates for
api.domain,web.rootDomainand their wildcards. - FR-017:
ensure-garageMUST fail when a step fails. It MUST import a key only when garage does not have it, and MUST fail when a key file stays missing or empty. - FR-018:
ensure-garageMUST allow the website of a bucket withserveAsWebsite = true, and MUST deny it for a bucket withserveAsWebsite = false. - FR-019:
applyMUST fail the evaluation when anensureAccessentry sets none ofread,writeandowner. - FR-020: The
<app>-backupskey of Mastodon, Pixelfed and PeerTube MUST havereadon each media bucket of its application. - FR-021: Each of these applications MUST want a
generateFilesinstance<app>-media-backupwith the secret filespasswordandsalt, also when the media backup is off. - FR-022:
fediversity.mediaBackup.instances.<app>MUST be off by default. The resource MUST have no default forretentionDays, andmkMediaBackupMUST set its default tobackupRetention.application.days. When it is off, the node MUST want no credential for it. - FR-023: The service MUST load only the
<app>-backupskey and the encryption keys. Only the transient unitmedia-backup-run-<app>of the command MUST load the<app>key, which may write the source buckets. - FR-024: Each component that requests
<app>-backupsMUST want agenerateFilesinstance<app>-backups-resticwith the secret filepassword, and each valkey node MUST want<namespace>-cache-backups-resticfor each namespace when its backup is on.restic-s3.nixMUST give this file to the restic job as the credentialRESTIC_PASSWORD. Only the backups ofopenbaoandterraform-backendMUST use a fixed password. - FR-025: When a repository has a key for the password
ReallySafePasswordTrustMe39, the restic job MUST add a key for its own password and remove the key of the old password before it runs restic. - FR-026: Under
provisioning = "tf", theallandcombineddeploy targets of a deployment that does not usetf-incus-operator-hostsMUST fail the evaluation when a key with pair files has nogenerateFilesinstance in the group. - FR-027: All the backups of Mastodon, Pixelfed and PeerTube, the valkey backups, and the database backups of lldap, Authelia and the panel MUST keep their data for the same period,
backupRetention.applicationofcore/components/operator/app-resources.nix(30 days). restic MUST prune them with--keep-within, so that no snapshot stays that is older than this period before the newest snapshot.
Key Entities
- Tenant: the prefix of all physical names on a shared cluster. The setup uses the deployment namespace, or the group when there is none.
- KV stem:
<kvName>-s3, the OpenBaovarsinstance of one key pair. s3contract result: the plain data of one requested bucket on the consumer node, incontracts.s3.results.<bucket>.default(nix/contracts/lib/contracts/definitions/s3.nix).- Provisioning shape: the JSON-safe form of the requests that the operator graph reads (
provisioningOf). - Minted key: an own key with no pair files, which the group's garage node generates.
- Media backup:
media-backup.nixandmedia-backup.sh, which keepcurrent/<bucket>andhistory/<stamp>/<bucket>below the target prefix, encrypted. - restic helper:
restic-s3.nix, which gives a restic job the pair of a stem asAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY, withAWS_DEFAULT_REGION=garage, and the repository password asRESTIC_PASSWORD.
Success Criteria (mandatory)
Measurable Outcomes
- SC-001:
nix-unit-resources-garage-consumer,nix-unit-resources-garage-forward-compat,nix-unit-resources-plain-requests,nix-unit-setups-garage-kv-stems,nix-unit-setups-minted-garage-keys,nix-unit-setups-operator-config,nix-unit-setups-valkey-config,nix-unit-setups-backup-retentionandpanel-api-frontendpass. - SC-002:
deployment-tf-incus-operator,contracts-s3-minioandintegrations-media-backuppass. - SC-003: No bucket that a provisioner creates is left without a key, and every published stem has a write grant for its publisher and a read grant for its consumer.
Assumptions
- Garage has no entry in the
providedtable or inproviderModulesof setups and wiring. Theprovisioningpolicy decides the route. - The node that holds the buckets is the node whose component provides
objectStore. Thegaragecomponent declares it. - The key pairs travel through OpenBao KV with
generateFiles. - The cache-backup keys feed the backups of the valkey component.
- The admin port is not a setting. The
garageresource,garage-webuiand the operator TF effect read it from one file (core/resources/garage/admin-port.nix). The operator TF effect passes it asgarage_admin_port, because its graph cannot read the settings of a node in another group.
Known Deviations
- The media backup does not encrypt the metadata of an object, for example its content type. rclone
cryptencrypts only the name and the content. - After a restore of the media, the next backup uploads all of the objects again. The backup compares the modification times of the server (
--use-server-modtime --update), and the restored objects are newer than their copies. - A restore needs the bucket. The provisioning must make a deleted bucket again before
media-backup-<app> restoreruns. - FR-025 keeps the old password
ReallySafePasswordTrustMe39in the Nix store of each node with a generated password, also after each repository has changed its key. The migration does nothing when the repository has no key for the old password.