Ein Chart für Garage (Deuxfleurs/garage), ein S3-kompatibler Objektspeicher in Rust, gebaut für Selbsthosting über ungleiche Knoten.
Abnehmer ist Stalwarts Blob-Ablage in jlab-cluster/cluster-config — also die Nachrichtenkörper des Mailservers. Der Plan dort: In-Cluster-S3 auf longhorn-encrypted-ns, damit der Verschlüsselungsschlüssel im eigenen Tresor liegt, und Longhorn sichert das verschlüsselte Volume nach OVH. Nachgeprüft: Longhorn verschlüsselt auf Blockebene mit LUKS2 und die Sicherungsmaschine liest unter der Entschlüsselungsschicht, der Sicherungsspeicher sieht also nur Chiffrat.
Dies ist noch kein freigegebener Plan. Anders als in #60 entfällt der Plan-Kommentar aus dem verbindlichen Arbeitsablauf hier nicht — mehrere Entscheidungen unten sind offen und gehören vor der Umsetzung beantwortet.
Der offizielle Chart ist Referenz, nicht Vorlage
Garage liefert einen eigenen Helm-Chart mit, aber nur im Quellbaum unter script/helm, installiert über einen lokalen Pfad:
Kein Index, kein OCI-Verweis, kein .tgz — nachgesehen in der offiziellen Kubernetes-Dokumentation. Genau deshalb braucht Fleet einen eigenen Chart: Es zieht über repo: plus version: aus einem Index oder über oci://, nicht aus einem Git-Arbeitsbaum.
Dieser Chart wird nicht kopiert. Er ist die beste verfügbare Quelle dafür, was die Anwendung braucht — welche Ports, welche Volumes, die Discovery-CRD, das RPC-Geheimnis, die Reihenfolge beim ersten Start. Er ist keine Vorlage für die Form. Gebaut wird aus template-chart/ nach CLAUDE.md („Creating a new chart") und dem Stilleitfaden in README.md („Chart style guidelines").
Wo Upstream und unsere Vorgaben auseinandergehen
Das ist die eigentliche Arbeit, und es ist der Grund, warum Kopieren mehr kostet als Neubauen:
Vorgabe hier
Was Upstream stattdessen macht
Objektnamen {{ .Release.Name }}-{{ .Chart.Name }}-<kind>, Dateien garage.<kind>.yaml, StatefulSet als garage.sfs.yaml, Services mit -service-Suffix
eigene Namensgebung
Geheimnisse als OnePasswordItem über secrets.<name>SecretRef mit op://-Pfad
garage.rpc.secret.create legt ein gewöhnliches Secret an
Requests als Werte-Vorgabe, Limits berechnet über den limitFactor-Helfer (Vorgabe 3), keine CPU-Limits ohne ausdrückliche Angabe
einfacher resources-Block
Liveness, Readiness und Startup auf jeder Arbeitslast, werte-konfigurierbar mit kalibrierten Vorgaben
nicht durchgängig
replicas je Arbeitslast plus chart-globales scaleDown
deployment.kind + replicaCount
checksum/config an jeder Pod-Vorlage, die eine ConfigMap liest (#82) — sonst startet eine geänderte Konfiguration nichts neu
nicht vorhanden
Härtung: automountServiceAccountToken: false, non-root, readOnlyRootFilesystem: true, Capabilities fallen, schreibbare Pfade als emptyDir; jede durch das Abbild erzwungene Abweichung im values.yaml kommentiert
teils vorhanden, nicht nach diesem Muster
values.schema.json mit additionalProperties: false plus die null-sicheren Fallback-Helfer, validiert gegen die echten Bundle-Werte, nicht nur gegen die Vorgaben
kein Schema
Versionierung version: <chart-semver>+up<appVersion>, im selben Commit angehoben — jeder Push auf main, der das Verzeichnis berührt, veröffentlicht
—
Falls eine Weboberfläche veröffentlicht werden soll, zusätzlich die Ingress-Konvention: ingress.domain und ingress.clusterIssuerrequiredohne Vorgabe, ingress.className mit Vorgabe nginx, TLS-Secret tls-<release>-<chart>-ingress.
Verifizierte Fakten
Gegen Docker Hub und die Upstream-Dokumentation geprüft, nicht aus der Erinnerung:
Apache 2.0, keine bezahlten Stufen, keine Funktionsschranken
Chart-Version daraus
1.0.0+upv2.4.1 (führendes v im appVersion, wie bei stalwart)
Eine Falle für Renovate: Neben den Versionstags schiebt Garage laufend Commit-SHA-Tags (die neuesten vom 03.10.2026). Der customManagers-Block in renovate.json5 braucht deshalb eine Versionsbeschränkung, sonst bietet Renovate 40-stellige Hex-Tags als „Update" an.
Was der Upstream-Chart über die Anwendung verrät
Schlüssel dort
Bedeutung
deployment.kind
Vorgabe StatefulSet, auf DaemonSet umstellbar
garage.replicationFactor
Vorgabe "3"
garage.rpc.bindAddr
[::]:3901
garage.rpc.secret.create
erzeugt das RPC-Geheimnis beim Ausrollen
garage.metadataAutoSnapshotInterval
periodische Schnappschüsse der Metadaten-Datenbank
garage.kubernetesSkipCrd
lässt die CRD garagenodes.deuxfleurs.fr für kubernetes_discovery außen vor
Dazu zwei dokumentierte Eigenheiten: Der Chart legt zwei PVCs aus StatefulSet-Vorlagen an (Daten und Metadaten), weshalb das Vergrößern umständlich ist; und nach der Installation muss ein Cluster-Layout angewandt werden, was sich per Job automatisieren lässt.
Offene Entscheidungen für den Plan-Kommentar
Ein Knoten mit replicationFactor: 1 oder ein Cluster? Für den bekannten Abnehmer spricht alles für einen Knoten: longhorn-encrypted-ns repliziert schon dreifach und verschlüsselt. Garage-Replikation 3 über Longhorn-Replikation 3 wäre neunfache Rohdatenmenge, und zwei Schichten, die nichts voneinander wissen.
Ein Volume oder zwei? Longhorn-Schnappschüsse sind je Volume atomar, nicht volumenübergreifend. Mit getrennten Daten- und Metadaten-Volumes kann eine Sicherung Metadaten erfassen, die auf Objekte zeigen, deren Blöcke noch nicht geschrieben waren — bei einem Mailspeicher genau die Fehlerart „Postfach listet eine Nachricht, deren Inhalt fehlt". Zu klären: Lässt Garage beides in einem Verzeichnis?
Die kubernetes_discovery-CRD. Cluster-weit, zwingt im abnehmenden Bundle also defaultNamespace: statt namespace:. Bei einem Knoten ist Discovery vermutlich entbehrlich — dann gehört sie gar nicht in den Chart.
Erststart: Cluster-Layout, Bucket und Zugangsschlüssel. Als Job im Chart, oder Handarbeit mit NOTES.txt als Prüfliste?
LMDB als Metadaten-Engine auf einem netzwerkreplizierten Blockgerät. Die Dokumentation bevorzugt SSD. Messen, nicht annehmen.
Ingress ja oder nein? Die S3-API wird clusterintern von Stalwart gesprochen. Eine Admin-Oberfläche von außen ist eine eigene Entscheidung.
CI-Verdrahtung, nach CLAUDE.md
.github/workflows/publish-garage.yml — eine bestehende Hülle kopieren (z. B. publish-outline.yml), damit uses:, registry-owner und secrets: inherit stimmen. Aus dem Kopf geschrieben stimmt es nicht.
garage in die chart-name-Auswahlliste von .github/workflows/change-app-version.yml eintragen.
Install-Test-Fixtures unter ci/garage/ (test-values.yaml, fixtures.yaml) — oder ein begründeter Eintrag in ci/excluded-charts.txt. Fehlt beides, scheitert der Install-Test im PR.
customManagers-Block in renovate.json5 für das Abbild, mit der Versionsbeschränkung aus der Renovate-Falle oben.
Was nicht in diesen Chart gehört
Die Sicherungs-Label am Volume (recurring-job.longhorn.io/daily-backup und …/daily-snapshot) sind Sache des abnehmenden Bundles in cluster-config, nicht des Charts. Sie greifen nicht automatisch — Stand 02.10.2026 tragen 41 von 62 Volumes im Cluster keine. Hier steht es nur, damit es beim Ausrollen nicht untergeht: Ohne diese Label existiert die OVH-Hälfte des Plans nicht, sie sieht nur so aus.
Ebenso gehört das volume-encryption-Bundle für den Namensraum nach cluster-config — longhorn-encrypted-ns löst ${pvc.namespace}-pvc-encryption-secret auf, und ohne diesen Eintrag bleibt die PVC Pending.
Ein Chart für **Garage** (`Deuxfleurs/garage`), ein S3-kompatibler Objektspeicher in Rust, gebaut für Selbsthosting über ungleiche Knoten.
**Abnehmer ist Stalwarts Blob-Ablage** in `jlab-cluster/cluster-config` — also die Nachrichtenkörper des Mailservers. Der Plan dort: In-Cluster-S3 auf `longhorn-encrypted-ns`, damit der Verschlüsselungsschlüssel im eigenen Tresor liegt, und Longhorn sichert das verschlüsselte Volume nach OVH. Nachgeprüft: Longhorn verschlüsselt auf Blockebene mit LUKS2 und die Sicherungsmaschine liest *unter* der Entschlüsselungsschicht, der Sicherungsspeicher sieht also nur Chiffrat.
> **Dies ist noch kein freigegebener Plan.** Anders als in #60 entfällt der Plan-Kommentar aus dem verbindlichen Arbeitsablauf hier **nicht** — mehrere Entscheidungen unten sind offen und gehören vor der Umsetzung beantwortet.
## Der offizielle Chart ist Referenz, nicht Vorlage
Garage liefert einen eigenen Helm-Chart mit, aber **nur im Quellbaum** unter `script/helm`, installiert über einen lokalen Pfad:
```
git clone https://git.deuxfleurs.fr/Deuxfleurs/garage
cd garage/script/helm
helm install --create-namespace --namespace garage garage ./garage
```
Kein Index, kein OCI-Verweis, kein `.tgz` — nachgesehen in der offiziellen Kubernetes-Dokumentation. Genau deshalb braucht Fleet einen eigenen Chart: Es zieht über `repo:` plus `version:` aus einem Index oder über `oci://`, nicht aus einem Git-Arbeitsbaum.
**Dieser Chart wird nicht kopiert.** Er ist die beste verfügbare Quelle dafür, *was die Anwendung braucht* — welche Ports, welche Volumes, die Discovery-CRD, das RPC-Geheimnis, die Reihenfolge beim ersten Start. Er ist **keine** Vorlage für die Form. Gebaut wird aus `template-chart/` nach `CLAUDE.md` („Creating a new chart") und dem Stilleitfaden in `README.md` („Chart style guidelines").
### Wo Upstream und unsere Vorgaben auseinandergehen
Das ist die eigentliche Arbeit, und es ist der Grund, warum Kopieren mehr kostet als Neubauen:
| Vorgabe hier | Was Upstream stattdessen macht |
|---|---|
| Objektnamen `{{ .Release.Name }}-{{ .Chart.Name }}-<kind>`, Dateien `garage.<kind>.yaml`, StatefulSet als `garage.sfs.yaml`, Services mit **`-service`**-Suffix | eigene Namensgebung |
| Geheimnisse als `OnePasswordItem` über `secrets.<name>SecretRef` mit `op://`-Pfad | `garage.rpc.secret.create` legt ein gewöhnliches Secret an |
| Requests als Werte-Vorgabe, **Limits berechnet** über den `limitFactor`-Helfer (Vorgabe 3), keine CPU-Limits ohne ausdrückliche Angabe | einfacher `resources`-Block |
| Liveness, Readiness **und** Startup auf jeder Arbeitslast, werte-konfigurierbar mit kalibrierten Vorgaben | nicht durchgängig |
| `replicas` je Arbeitslast plus chart-globales `scaleDown` | `deployment.kind` + `replicaCount` |
| `checksum/config` an jeder Pod-Vorlage, die eine ConfigMap liest (#82) — sonst startet eine geänderte Konfiguration nichts neu | nicht vorhanden |
| Härtung: `automountServiceAccountToken: false`, non-root, `readOnlyRootFilesystem: true`, Capabilities fallen, schreibbare Pfade als `emptyDir`; jede durch das Abbild erzwungene Abweichung **im `values.yaml` kommentiert** | teils vorhanden, nicht nach diesem Muster |
| `values.schema.json` mit `additionalProperties: false` plus die null-sicheren Fallback-Helfer, **validiert gegen die echten Bundle-Werte**, nicht nur gegen die Vorgaben | kein Schema |
| Versionierung `version: <chart-semver>+up<appVersion>`, im selben Commit angehoben — jeder Push auf `main`, der das Verzeichnis berührt, veröffentlicht | — |
Falls eine Weboberfläche veröffentlicht werden soll, zusätzlich die Ingress-Konvention: `ingress.domain` und `ingress.clusterIssuer` `required` **ohne** Vorgabe, `ingress.className` mit Vorgabe `nginx`, TLS-Secret `tls-<release>-<chart>-ingress`.
## Verifizierte Fakten
Gegen Docker Hub und die Upstream-Dokumentation geprüft, nicht aus der Erinnerung:
| | |
|---|---|
| Abbild | `dxflrs/garage` (340 Tags) |
| Neueste Version | **`v2.4.1`**, 08.09.2026 — davor `v2.4.0`, `v2.3.0`, `v2.2.0`, `v2.1.0`, `v2.0.0` |
| Größe | **26,97 MB komprimiert** — angenehm klein |
| Lizenz | Apache 2.0, keine bezahlten Stufen, keine Funktionsschranken |
| Chart-Version daraus | `1.0.0+upv2.4.1` (führendes `v` im `appVersion`, wie bei `stalwart`) |
**Eine Falle für Renovate:** Neben den Versionstags schiebt Garage **laufend Commit-SHA-Tags** (die neuesten vom 03.10.2026). Der `customManagers`-Block in `renovate.json5` braucht deshalb eine Versionsbeschränkung, sonst bietet Renovate 40-stellige Hex-Tags als „Update" an.
### Was der Upstream-Chart über die Anwendung verrät
| Schlüssel dort | Bedeutung |
|---|---|
| `deployment.kind` | Vorgabe `StatefulSet`, auf `DaemonSet` umstellbar |
| `garage.replicationFactor` | Vorgabe **`"3"`** |
| `garage.rpc.bindAddr` | `[::]:3901` |
| `garage.rpc.secret.create` | erzeugt das RPC-Geheimnis beim Ausrollen |
| `garage.metadataAutoSnapshotInterval` | periodische Schnappschüsse der Metadaten-Datenbank |
| `garage.kubernetesSkipCrd` | lässt die CRD `garagenodes.deuxfleurs.fr` für `kubernetes_discovery` außen vor |
Dazu zwei dokumentierte Eigenheiten: Der Chart legt **zwei** PVCs aus StatefulSet-Vorlagen an (Daten und Metadaten), weshalb das Vergrößern umständlich ist; und nach der Installation muss ein Cluster-Layout angewandt werden, was sich per Job automatisieren lässt.
## Offene Entscheidungen für den Plan-Kommentar
1. **Ein Knoten mit `replicationFactor: 1` oder ein Cluster?** Für den bekannten Abnehmer spricht alles für einen Knoten: `longhorn-encrypted-ns` repliziert schon dreifach und verschlüsselt. Garage-Replikation 3 **über** Longhorn-Replikation 3 wäre neunfache Rohdatenmenge, und zwei Schichten, die nichts voneinander wissen.
2. **Ein Volume oder zwei?** Longhorn-Schnappschüsse sind **je Volume** atomar, nicht volumenübergreifend. Mit getrennten Daten- und Metadaten-Volumes kann eine Sicherung Metadaten erfassen, die auf Objekte zeigen, deren Blöcke noch nicht geschrieben waren — bei einem Mailspeicher genau die Fehlerart „Postfach listet eine Nachricht, deren Inhalt fehlt". Zu klären: Lässt Garage beides in einem Verzeichnis?
3. **Die `kubernetes_discovery`-CRD.** Cluster-weit, zwingt im abnehmenden Bundle also `defaultNamespace:` statt `namespace:`. Bei einem Knoten ist Discovery vermutlich entbehrlich — dann gehört sie gar nicht in den Chart.
4. **Erststart:** Cluster-Layout, Bucket und Zugangsschlüssel. Als Job im Chart, oder Handarbeit mit `NOTES.txt` als Prüfliste?
5. **LMDB als Metadaten-Engine auf einem netzwerkreplizierten Blockgerät.** Die Dokumentation bevorzugt SSD. Messen, nicht annehmen.
6. **Ingress ja oder nein?** Die S3-API wird clusterintern von Stalwart gesprochen. Eine Admin-Oberfläche von außen ist eine eigene Entscheidung.
## CI-Verdrahtung, nach `CLAUDE.md`
1. `.github/workflows/publish-garage.yml` — eine bestehende Hülle kopieren (z. B. `publish-outline.yml`), damit `uses:`, `registry-owner` und `secrets: inherit` stimmen. Aus dem Kopf geschrieben stimmt es nicht.
2. `garage` in die `chart-name`-Auswahlliste von `.github/workflows/change-app-version.yml` eintragen.
3. Install-Test-Fixtures unter `ci/garage/` (`test-values.yaml`, `fixtures.yaml`) — **oder** ein begründeter Eintrag in `ci/excluded-charts.txt`. Fehlt beides, scheitert der Install-Test im PR.
4. `customManagers`-Block in `renovate.json5` für das Abbild, mit der Versionsbeschränkung aus der Renovate-Falle oben.
## Was nicht in diesen Chart gehört
Die **Sicherungs-Label** am Volume (`recurring-job.longhorn.io/daily-backup` und `…/daily-snapshot`) sind Sache des abnehmenden Bundles in `cluster-config`, nicht des Charts. Sie greifen **nicht** automatisch — Stand 02.10.2026 tragen 41 von 62 Volumes im Cluster keine. Hier steht es nur, damit es beim Ausrollen nicht untergeht: Ohne diese Label existiert die OVH-Hälfte des Plans nicht, sie sieht nur so aus.
Ebenso gehört das `volume-encryption`-Bundle für den Namensraum nach `cluster-config` — `longhorn-encrypted-ns` löst `${pvc.namespace}-pvc-encryption-secret` auf, und ohne diesen Eintrag bleibt die PVC `Pending`.
Die sechs offenen Entscheidungen sind beantwortet: zwei vom Betreuer, vier aus der Dokumentation. Dabei hat sich die Sicherungsprämisse des Issue-Bodys an einer tragenden Stelle als unvollständig erwiesen — das steht unten zuerst, weil der Rest darauf aufbaut.
Die Korrektur: Longhorn sichert die Metadaten nicht
Der Issue-Body baut darauf, dass Longhorn das verschlüsselte Volume nach OVH sichert. Für die Datenblöcke stimmt das. Für die Metadaten nicht: Dateisystem-Schnappschüsse von metadata_dir garantieren laut Garage keine Konsistenz — wird während eines Metadaten-Schreibvorgangs geschnappschosst, ist der Schnappschuss selbst beschädigt und als Rücksetzpunkt unbrauchbar. Garage empfiehlt deshalb in allen Fällen seinen eigenen metadata_auto_snapshot_interval, der die Datenbank im Betrieb konsistent kopiert (Known issues, Durability & Repairs).
Einzuplanen: Garage behält zwei Schnappschüsse, der erste etwa drei Stunden nach dem Start. Bei "6h" ist das Rücksetzfenster rund 6–12 Stunden — wird eine Beschädigung später entdeckt, ist sie älter als jeder Schnappschuss.
Und ein Standardwert, der die Härtung aushebelt: metadata_fsync ist standardmäßig aus. Longhorns Blockschicht hilft nicht, wenn die Engine nie fsync aufruft. Garages eigene Beispielkonfiguration setzt metadata_fsync = true neben metadata_auto_snapshot_interval = "6h". Bei uns wird es gesetzt, nicht geerbt.
Entscheidung 1: ein Knoten, replication_factor = 1
Die Begründung des Issues trägt für die Datenblöcke: longhorn-encrypted-ns repliziert dreifach und verschlüsselt, Garage-RF-3 darüber wäre neunfache Rohdatenmenge und zwei Schichten, die nichts voneinander wissen.
Was dabei bewusst in Kauf genommen wird, und es gehört benannt: Upstream schreibt zu replication_factor = 1 ausdrücklich „Do not use this for anything else than test deployments", und die Known-Issues-Seite nennt den Grund — derselbe replication_factor gilt auch für die Metadaten, RF 1 ist also besonders anfällig für Metadaten-Beschädigung, und eine beschädigte Datenbankdatei ist „generally not recoverable". Longhorn hilft dagegen nicht: es repliziert eine beschädigte Datei dreifach. Redundanz gegen Geräteausfall ja, gegen Dateibeschädigung nein.
Das ist eine abgewogene Annahme des Risikos, keine Übersehung. Die Gegenmaßnahmen, die bei RF 1 überhaupt greifen, sind alle Pflicht und nicht optional:
metadata_fsync = true
metadata_auto_snapshot_interval gesetzt
die Schnappschüsse über metadata_snapshots_dir auf einem anderen Volume als metadata_dir
Der Chart führt replicationFactor als Wert, damit eine späterer Erhöhung nicht am Chart scheitert. Nicht als Ausbauweg dokumentieren, solange nicht belegt ist, wie eine Erhöhung in Garage abläuft — eine Faktor-Erhöhung ist ein Layout-Vorgang mit Datenbewegung, kein Schalter.
Entscheidung 2: db_engine = "lmdb", abgesichert
Garages Vorgabe seit 0.9 und die schnellere Engine. Der Preis ist benannt: die gemeldete Beschädigung nach unsauberem Herunterfahren bleibt möglich, die Absicherung senkt nur die Wahrscheinlichkeit, und bei RF 1 gibt es keinen Mitknoten zur Wiederherstellung — nur den letzten Schnappschuss. Die Empfehlung für LMDB ist in der Doku an RF ≥ 2 gebunden; dass wir sie bei RF 1 fahren, gehört als Kommentar an den Wert, zusammen mit sqlite als der Alternative und dem Grund, warum sie nicht gewählt wurde (Metadaten-Durchsatz, bei einem Mailserver als einzigem Abnehmer kaum die Grenze).
Entscheidung 3: zwei Volumes, anders aufgeteilt als gedacht
Nicht „Daten und Metadaten getrennt", sondern:
PVC
Inhalt
Begründung
1
metadata_dirunddata_dir (zwei Unterverzeichnisse)
Longhorn-Schnappschüsse sind je Volume atomar, also bleiben Index und Blöcke zueinander konsistent — genau der Fehler „Postfach listet eine Nachricht, deren Inhalt fehlt" wird damit vermieden
2
metadata_snapshots_dir
Der konsistente Rücksetzpunkt darf nicht mit dem Volume sterben, dessen Beschädigung er heilen soll
Der exakte Schlüsselname metadata_snapshots_dir ist gegen die Konfigurationsreferenz zu bestätigen, nicht zu übernehmen.
Entscheidung 4: keine Discovery-CRD
Ein einzelner Knoten hat keine Mitglieder zu finden. Die CRD garagenodes.deuxfleurs.fr ist cluster-weit und würde das abnehmende Bundle auf defaultNamespace: statt namespace: zwingen — für nichts. Bleibt draußen; kubernetes_discovery wird nicht konfiguriert.
Entscheidung 5: Erststart von Hand, NOTES.txt als Prüfliste
Nicht Bequemlichkeit, sondern zweifach erzwungen:
garage layout apply --version <N+1> verlangt genau die Vorgängerversion plus eins, und „never make multiple calls … with the same value of the --version flag". Ein Job, der bei helm upgrade erneut anläuft, ist also gefährlich, nicht bloß unnötig.
Der Zugangsschlüssel, den Garage erzeugt, muss in das 1Password-Item wandern, damit Stalwart ihn liest. Das kann kein Job.
NOTES.txt trägt die Schritte in der richtigen Reihenfolge mit den echten Befehlen: garage status, garage layout assign …, garage layout show, garage layout apply --version <N+1>, Bucket anlegen, Zugangsschlüssel erzeugen, Schlüssel an den Bucket binden, und zum Schluss die Felder, die ins Tresor-Item gehören.
Entscheidung 6: kein Ingress
Die S3-API (3900) spricht Stalwart clusterintern, die Admin-API (3903) ebenso. Der Wert kommt als ingress.enabled: false mit, damit er existiert, wenn die Oberfläche doch veröffentlicht werden soll — dann nach unserer Ingress-Konvention (domain und clusterIssuer required ohne Vorgabe, className mit Vorgabe nginx, TLS-Secret tls-<release>-garage-ingress).
Verifizierte Eckdaten
Ports
S3 [::]:3900, RPC [::]:3901, Web [::]:3902, Admin 0.0.0.0:3903
Engines
lmdb (Vorgabe seit 0.9.0), sqlite, fjall (experimentell)
RPC-Geheimnis
32 Byte hex; rpc_secret_file existiert — kommt als Datei aus einem OnePasswordItem, nicht in die ConfigMap
Version
1.0.0+upv2.4.1, appVersion v2.4.1
Umfang
Gebaut aus template-chart/ nach CLAUDE.md und dem Stilleitfaden, nicht aus Upstreams Chart kopiert — der ist Quelle für was die Anwendung braucht, nicht für die Form. StatefulSet (garage.sfs.yaml), ConfigMap mit checksum/config, zwei PVCs, Service, OnePasswordItem für das RPC-Geheimnis, Probes auf allen drei Arten, berechnete Limits über den limitFactor-Helfer, scaleDown, strenges values.schema.json mit den null-sicheren Fallback-Helfern, validiert gegen die echten Bundle-Werte.
CI: publish-garage.yml aus einer bestehenden Hülle kopiert; garage in die chart-name-Liste von change-app-version.yml; Fixtures unter ci/garage/ oder ein begründeter Ausschluss; customManagers-Block in renovate.json5mit Versionsbeschränkung, denn Garage schiebt laufend Commit-SHA-Tags und Renovate bietet sonst 40-stellige Hex-Tags als Update an.
Wiki im selben Durchgang: neue Chart-Seite garage mit Werte-Tabelle, der Erststart-Prüfliste und einem Traps-Abschnitt (die Metadaten-Sicherungslücke, metadata_fsync aus Vorgabe, das Zwei-Schnappschuss-Fenster, die Nicht-Idempotenz von layout apply, RF 1 und Metadaten); Zeile in Charts; Eintrag in _Sidebar.md; Lessons-Learned für die allgemeine Form „der Schnappschuss, der keinen Rücksetzpunkt war" — die wiederholt sich bei jeder eingebetteten Datenbank auf einem Volume mit Schnappschüssen.
Offen, am laufenden Dienst zu messen
Arbeitet Garage mit metadata_dir und data_dir als Unterverzeichnisse einer PVC ohne Beanstandung?
Greift metadata_auto_snapshot_interval wirklich und landet in metadata_snapshots_dir?
Startet die S3-API vor dem Anwenden des Layouts, oder antwortet sie bis dahin mit Fehler? Davon hängt ab, worauf die Readiness-Probe zeigen darf — eine Probe, die vor dem Layout grün wird, verspricht einen Dienst, der nichts annimmt.
Hält readOnlyRootFilesystem: true mit den zwei PVCs und welche weiteren Schreibpfade als emptyDir nötig sind.
Nicht in diesem Chart
Die Sicherungs-Label am Volume (recurring-job.longhorn.io/daily-backup, …/daily-snapshot) und das volume-encryption-Bundle für den Namensraum gehören nach cluster-config. Ohne die Label existiert die OVH-Hälfte des Plans nicht, sie sieht nur so aus; ohne den Verschlüsselungs-Eintrag bleibt die PVC Pending.
# Freigegebener Umsetzungsplan
Die sechs offenen Entscheidungen sind beantwortet: zwei vom Betreuer, vier aus der Dokumentation. Dabei hat sich die **Sicherungsprämisse des Issue-Bodys an einer tragenden Stelle als unvollständig erwiesen** — das steht unten zuerst, weil der Rest darauf aufbaut.
## Die Korrektur: Longhorn sichert die Metadaten nicht
Der Issue-Body baut darauf, dass Longhorn das verschlüsselte Volume nach OVH sichert. Für die **Datenblöcke** stimmt das. Für die **Metadaten nicht**: Dateisystem-Schnappschüsse von `metadata_dir` garantieren laut Garage keine Konsistenz — wird während eines Metadaten-Schreibvorgangs geschnappschosst, ist der Schnappschuss selbst beschädigt und als Rücksetzpunkt unbrauchbar. Garage empfiehlt deshalb **in allen Fällen** seinen eigenen `metadata_auto_snapshot_interval`, der die Datenbank im Betrieb konsistent kopiert ([Known issues](https://garagehq.deuxfleurs.fr/documentation/reference-manual/known-issues/), [Durability & Repairs](https://garagehq.deuxfleurs.fr/documentation/operations/durability-repairs/)).
Einzuplanen: Garage behält **zwei** Schnappschüsse, der erste etwa drei Stunden nach dem Start. Bei `"6h"` ist das Rücksetzfenster rund 6–12 Stunden — wird eine Beschädigung später entdeckt, ist sie älter als jeder Schnappschuss.
**Und ein Standardwert, der die Härtung aushebelt: `metadata_fsync` ist standardmäßig aus.** Longhorns Blockschicht hilft nicht, wenn die Engine nie `fsync` aufruft. Garages eigene Beispielkonfiguration setzt `metadata_fsync = true` neben `metadata_auto_snapshot_interval = "6h"`. Bei uns wird es **gesetzt, nicht geerbt**.
## Entscheidung 1: ein Knoten, `replication_factor = 1`
Die Begründung des Issues trägt für die **Datenblöcke**: longhorn-encrypted-ns repliziert dreifach und verschlüsselt, Garage-RF-3 darüber wäre neunfache Rohdatenmenge und zwei Schichten, die nichts voneinander wissen.
**Was dabei bewusst in Kauf genommen wird, und es gehört benannt:** Upstream schreibt zu `replication_factor = 1` ausdrücklich „Do not use this for anything else than test deployments", und die Known-Issues-Seite nennt den Grund — **derselbe `replication_factor` gilt auch für die Metadaten**, RF 1 ist also besonders anfällig für Metadaten-Beschädigung, und eine beschädigte Datenbankdatei ist „generally not recoverable". Longhorn hilft dagegen **nicht**: es repliziert eine beschädigte Datei dreifach. Redundanz gegen Geräteausfall ja, gegen Dateibeschädigung nein.
Das ist eine abgewogene Annahme des Risikos, keine Übersehung. Die Gegenmaßnahmen, die bei RF 1 überhaupt greifen, sind alle Pflicht und nicht optional:
- `metadata_fsync = true`
- `metadata_auto_snapshot_interval` gesetzt
- die Schnappschüsse über `metadata_snapshots_dir` auf einem **anderen Volume** als `metadata_dir`
Der Chart führt `replicationFactor` als Wert, damit eine späterer Erhöhung nicht am Chart scheitert. **Nicht** als Ausbauweg dokumentieren, solange nicht belegt ist, wie eine Erhöhung in Garage abläuft — eine Faktor-Erhöhung ist ein Layout-Vorgang mit Datenbewegung, kein Schalter.
## Entscheidung 2: `db_engine = "lmdb"`, abgesichert
Garages Vorgabe seit 0.9 und die schnellere Engine. Der Preis ist benannt: die gemeldete Beschädigung nach unsauberem Herunterfahren bleibt möglich, die Absicherung senkt nur die Wahrscheinlichkeit, und bei RF 1 gibt es keinen Mitknoten zur Wiederherstellung — nur den letzten Schnappschuss. Die Empfehlung für LMDB ist in der Doku an RF ≥ 2 gebunden; dass wir sie bei RF 1 fahren, gehört als Kommentar an den Wert, zusammen mit `sqlite` als der Alternative und dem Grund, warum sie nicht gewählt wurde (Metadaten-Durchsatz, bei einem Mailserver als einzigem Abnehmer kaum die Grenze).
## Entscheidung 3: zwei Volumes, anders aufgeteilt als gedacht
Nicht „Daten und Metadaten getrennt", sondern:
| PVC | Inhalt | Begründung |
| --- | --- | --- |
| 1 | `metadata_dir` **und** `data_dir` (zwei Unterverzeichnisse) | Longhorn-Schnappschüsse sind je Volume atomar, also bleiben Index und Blöcke zueinander konsistent — genau der Fehler „Postfach listet eine Nachricht, deren Inhalt fehlt" wird damit vermieden |
| 2 | `metadata_snapshots_dir` | Der konsistente Rücksetzpunkt darf nicht mit dem Volume sterben, dessen Beschädigung er heilen soll |
Der exakte Schlüsselname `metadata_snapshots_dir` ist gegen die Konfigurationsreferenz zu bestätigen, nicht zu übernehmen.
## Entscheidung 4: keine Discovery-CRD
Ein einzelner Knoten hat keine Mitglieder zu finden. Die CRD `garagenodes.deuxfleurs.fr` ist cluster-weit und würde das abnehmende Bundle auf `defaultNamespace:` statt `namespace:` zwingen — für nichts. Bleibt draußen; `kubernetes_discovery` wird nicht konfiguriert.
## Entscheidung 5: Erststart von Hand, `NOTES.txt` als Prüfliste
Nicht Bequemlichkeit, sondern zweifach erzwungen:
1. `garage layout apply --version <N+1>` verlangt **genau** die Vorgängerversion plus eins, und „never make multiple calls … with the same value of the `--version` flag". Ein Job, der bei `helm upgrade` erneut anläuft, ist also gefährlich, nicht bloß unnötig.
2. Der Zugangsschlüssel, den Garage erzeugt, muss in das 1Password-Item wandern, damit Stalwart ihn liest. Das kann kein Job.
`NOTES.txt` trägt die Schritte in der richtigen Reihenfolge mit den echten Befehlen: `garage status`, `garage layout assign …`, `garage layout show`, `garage layout apply --version <N+1>`, Bucket anlegen, Zugangsschlüssel erzeugen, Schlüssel an den Bucket binden, und zum Schluss die Felder, die ins Tresor-Item gehören.
## Entscheidung 6: kein Ingress
Die S3-API (3900) spricht Stalwart clusterintern, die Admin-API (3903) ebenso. Der Wert kommt als `ingress.enabled: false` mit, damit er existiert, wenn die Oberfläche doch veröffentlicht werden soll — dann nach unserer Ingress-Konvention (`domain` und `clusterIssuer` required ohne Vorgabe, `className` mit Vorgabe `nginx`, TLS-Secret `tls-<release>-garage-ingress`).
## Verifizierte Eckdaten
| | |
| --- | --- |
| Ports | S3 `[::]:3900`, RPC `[::]:3901`, Web `[::]:3902`, Admin `0.0.0.0:3903` |
| Engines | `lmdb` (Vorgabe seit 0.9.0), `sqlite`, `fjall` (experimentell) |
| RPC-Geheimnis | 32 Byte hex; **`rpc_secret_file` existiert** — kommt als Datei aus einem `OnePasswordItem`, nicht in die ConfigMap |
| Version | `1.0.0+upv2.4.1`, appVersion `v2.4.1` |
## Umfang
Gebaut aus `template-chart/` nach CLAUDE.md und dem Stilleitfaden, **nicht** aus Upstreams Chart kopiert — der ist Quelle für *was die Anwendung braucht*, nicht für die Form. StatefulSet (`garage.sfs.yaml`), ConfigMap mit `checksum/config`, zwei PVCs, Service, `OnePasswordItem` für das RPC-Geheimnis, Probes auf allen drei Arten, berechnete Limits über den `limitFactor`-Helfer, `scaleDown`, strenges `values.schema.json` mit den null-sicheren Fallback-Helfern, validiert **gegen die echten Bundle-Werte**.
CI: `publish-garage.yml` aus einer bestehenden Hülle **kopiert**; `garage` in die `chart-name`-Liste von `change-app-version.yml`; Fixtures unter `ci/garage/` oder ein begründeter Ausschluss; `customManagers`-Block in `renovate.json5` **mit Versionsbeschränkung**, denn Garage schiebt laufend Commit-SHA-Tags und Renovate bietet sonst 40-stellige Hex-Tags als Update an.
Wiki im selben Durchgang: neue Chart-Seite `garage` mit Werte-Tabelle, der Erststart-Prüfliste und einem Traps-Abschnitt (die Metadaten-Sicherungslücke, `metadata_fsync` aus Vorgabe, das Zwei-Schnappschuss-Fenster, die Nicht-Idempotenz von `layout apply`, RF 1 und Metadaten); Zeile in `Charts`; Eintrag in `_Sidebar.md`; `Lessons-Learned` für die allgemeine Form „der Schnappschuss, der keinen Rücksetzpunkt war" — die wiederholt sich bei jeder eingebetteten Datenbank auf einem Volume mit Schnappschüssen.
## Offen, am laufenden Dienst zu messen
- Arbeitet Garage mit `metadata_dir` und `data_dir` als Unterverzeichnisse **einer** PVC ohne Beanstandung?
- Greift `metadata_auto_snapshot_interval` wirklich und landet in `metadata_snapshots_dir`?
- Startet die S3-API vor dem Anwenden des Layouts, oder antwortet sie bis dahin mit Fehler? Davon hängt ab, worauf die Readiness-Probe zeigen darf — eine Probe, die vor dem Layout grün wird, verspricht einen Dienst, der nichts annimmt.
- Hält `readOnlyRootFilesystem: true` mit den zwei PVCs und welche weiteren Schreibpfade als emptyDir nötig sind.
## Nicht in diesem Chart
Die Sicherungs-Label am Volume (`recurring-job.longhorn.io/daily-backup`, `…/daily-snapshot`) und das `volume-encryption`-Bundle für den Namensraum gehören nach `cluster-config`. Ohne die Label existiert die OVH-Hälfte des Plans nicht, sie sieht nur so aus; ohne den Verschlüsselungs-Eintrag bleibt die PVC `Pending`.
Anhalten vor der Umsetzung: zwei Funde am freigegebenen Plan
Gegen den Quellbaum von v2.4.1 geprüft (codeload.github.com/deuxfleurs-org/garage am Tag v2.4.1, dem Spiegel des Deuxfleurs-Gitea — das Original liegt hinter Anubis und ist aus dem Workspace nicht abrufbar). Zwei Punkte des Plans halten der Quelle nicht stand, einer davon so, dass er in der vorgesehenen Form nicht startet. Gebaut ist noch nichts.
Fund 1 — rpc_secret_file aus einem Kubernetes-Secret ist im gehärteten Pod nicht lesbar
Der Plan hält fest: „rpc_secret_fileexistiert — kommt als Datei aus einem OnePasswordItem, nicht in die ConfigMap". Der Schlüssel existiert (src/util/config.rs:96), aber die Datei kann unser Container nicht lesen. Das ist keine Konfigurationsfrage, es sind zwei Prüfungen, die sich widersprechen:
Garage, src/garage/secrets.rs:135-147:
ifmetadata.mode()&0o077!=0{returnErr(format!("File {} is world-readable! (mode: 0{:o}, expected 0600)\n\ Refusing to start until this is fixed, or environment variable \ GARAGE_ALLOW_WORLD_READABLE_SECRETS is set to true.",...).into());}
Die Maske ist 0o077 — sie verwirft jedes Gruppen- oder Fremdbit, nicht nur die Fremdbits, trotz des Wortes „world-readable" in der Meldung.
Das Kubelet, pkg/volume/volume_linux.go:72-104 (geprüft an release-1.31): sobald fsGroup gesetzt ist, läuft über jede Datei des Volumes
Das Gruppen-Lesebit wird also hinzu-ge-ODERt, unabhängig von defaultMode. Damit ist mode & 0o077 != 0 immer wahr und Garage verweigert den Start.
Und es gibt keine Kombination, die beides erfüllt, weil Kubernetes an einer Secret-Volume-Datei nur die Gruppe setzen kann, niemals den besitzenden Benutzer:
Variante
Modus / Eigentümer
Ergebnis
fsGroup gesetzt, defaultMode beliebig
root:1000, Gruppenbit immer gesetzt
Garage verweigert den Start (0o077-Prüfung)
kein fsGroup, defaultMode: 0400
root:root 0400
Prüfung besteht, aber uid 1000 bekommt EACCES — „Failed to read secret file"
kein fsGroup, Vorgabe 0644
root:root 0644
Garage verweigert den Start
runAsUser: 0
—
widerspricht der Härtung
Zwei Wege hinaus, und sie sind nicht gleichwertig:
(a) GARAGE_RPC_SECRET als env.valueFrom.secretKeyRef.src/garage/secrets.rs:22-28 führt den Wert mit env = "GARAGE_RPC_SECRET", und src/garage/main.rs:50 flacht Secrets in die globalen Optionen ein — die Variable gilt also für garage server genauso wie für kubectl exec … -- /garage status, das dieselbe Berechtigung braucht. Keine Datei, keine Dateirechte, keine abgeschaltete Prüfung, und das eigentliche Ziel des Plans ist erfüllt: das Geheimnis bleibt ein Secret und landet nicht in der ConfigMap.
(b) rpc_secret_file + allow_world_readable_secrets = true. Funktioniert, kostet aber die Prüfung für alle Geheimnisdateien dieses Prozesses — und die Datei ist dabei gar nicht fremdlesbar, Garage kann es nur nicht unterscheiden. Upstream kennt den Fall übrigens: der Doc-Kommentar am Wert lautet „Skip the permission check of secret files. Useful when POSIX ACLs (or more complex chmods) are used." (src/util/config.rs:86-89) — der Schalter ist für genau diese Lage gedacht, nur eben pauschal.
Mein Vorschlag: (a), mit dem Mechanismus als Kommentar an secrets.rpcSecretRef in values.yaml, plus ein Lessons-Learned-Eintrag zur allgemeinen Form: eine Rechteprüfung, die annimmt, ihr Dateisystem zu besitzen, trifft auf eine Plattform, die nur die Gruppe setzen kann — das wiederholt sich bei jedem Programm mit *_secret_file und einem 0o077-Test (Garage selbst hat drei davon: rpc_secret_file, admin_token_file, metrics_token_file).
Fund 2 — garage server --single-node macht genau das, wofür Entscheidung 5 Handarbeit vorsieht, und entkräftet beide Begründungen
Entscheidung 5 steht auf zwei Prämissen. Beide treffen für v2.4.1 bei einem Knoten nicht mehr zu.
Die Schalter, src/garage/cli/structs.rs:95-108:
pubstructServerOpt{/// Automatically configure a single-node layout in the cluster.
/// Garage will refuse to run if the cluster already has other nodes.
#[structopt(long = "single-node")]pub(crate)single_node: bool,/// Configure a default S3 API key using environment variables
/// `GARAGE_DEFAULT_ACCESS_KEY` and `GARAGE_DEFAULT_SECRET_KEY`. Requires `--single-node`.
#[structopt(long = "default-access-key")]pub(crate)default_access_key: bool,/// Configure a default bucket using environment variable `GARAGE_DEFAULT_BUCKET`.
/// Implies `--default-access-key`. Requires `--single-node`.
#[structopt(long = "default-bucket")]pub(crate)default_bucket: bool,}
Prämisse 1 („layout apply --version N+1 ist nicht idempotent, ein Job bei helm upgrade ist gefährlich") fällt.initial_config in src/garage/server.rs:190-256 prüft die Layout-Version im Prozess, nicht über einen wiederholbaren CLI-Aufruf:
replication_factor != 1 → Abbruch mit „Single-node mode requires replication_factor = 1 in the configuration file." (genau unsere Konfiguration, also kein Hindernis)
Layout-Version 0 → Layout wird angelegt: Zone dc1, Kapazität aus der erkannten Plattengröße, Tag default, dann apply_staged_changes(1)
Layout-Version 1 → nichts passiert, der Start läuft normal weiter
Layout-Version > 1 → Abbruch mit „Refusing to run in single-node mode: layout version is already superior to 1. Remove the --single-node flag to run the server in full mode."
fremde Knoten bekannt → Abbruch mit „Refusing to run in single-node mode: more nodes are already present in the cluster."
Das ist über Neustarts hinweg idempotent, und die Falle, vor der der Plan zu Recht warnt, entsteht gar nicht: es gibt keinen --version-Parameter, den jemand zweimal mit demselben Wert aufrufen könnte.
Prämisse 2 („Der Zugangsschlüssel, den Garage erzeugt, muss in das 1Password-Item wandern. Das kann kein Job.") fällt und kehrt sich um.--default-access-key liest GARAGE_DEFAULT_ACCESS_KEY und GARAGE_DEFAULT_SECRET_KEY aus der Umgebung und importiert den Schlüssel (server.rs:259-300, Key::import in Zeile 291). Die Richtung ist damit die bessere: der Betreuer erzeugt das Schlüsselpaar im Tresor, das Item ist die Quelle der Wahrheit, und Garage übernimmt es — statt dass Garage es erzeugt und es von Hand hinterherkopiert wird. Der Wiederholungsfall ist in der Quelle abgesichert: existierender Schlüssel mit anderem Geheimnis → Fehler; im Cluster gelöschter Schlüssel → Fehler („cannot add it back"). --default-bucket legt zusätzlich den Bucket an und bindet den Schlüssel daran.
Dazu die Reihenfolge, die für die Readiness-Entscheidung zählt:initial_config(&garage, opt).await? steht in server.rs:54 — vor dem Start von S3-, Web- und Admin-Server (server.rs:83 bzw. 124). Mit --single-node ist der Knoten also layout-fertig, bevor er überhaupt auf einem Port antwortet.
Und die Folge für die CI, die mir die wichtigste ist: mit --single-node erreicht der Pod von allein Ready, der Install-Test im PR wird dadurch aussagekräftig und schließt drei der vier offenen Messpunkte bei jedem PR. Ohne ihn kann kein Install-Test bestehen — eine ehrliche Readiness kann vor dem Layout von Hand nicht grün werden —, und die Fixture-Entscheidung müsste auf einen begründeten Ausschluss in ci/excluded-charts.txt hinauslaufen, der nichts belegt.
Mein Vorschlag: Wert garage.singleNode, Vorgabe true (RF 1 ist ohnehin gesetzt, und der Schalter verlangt genau das); --default-access-key/--default-bucket über eigene Werte dazu, gespeist aus demselben OnePasswordItem, das Stalwart liest. NOTES.txt behält die Handarbeits-Prüfliste für singleNode: false und führt im Vorgabefall die Tresor-Felder, die jetzt hineingehen statt heraus.
Was der Plan unverändert richtig hat (gegen die Quelle bestätigt)
metadata_snapshots_dir heißt genau so, ist oberste Ebene, Option<PathBuf>, Vorgabe <metadata_dir>/snapshots (src/util/config.rs:35, src/model/snapshot.rs:44-50).
Das zweite Volume trägt. Der Schnappschuss ist eine echte Kopie mit Kompaktierung (src/db/lmdb_adapter.rs:162-168: copy_to_path(path, CompactionOption::Enabled)) und das Zielverzeichnis wird mit create_dir_all angelegt (snapshot.rs:52). Kein rename, also kein EXDEV über die Dateisystemgrenze — ein eigenes PVC für die Schnappschüsse funktioniert.
Das Zwei-Schnappschuss-Fenster stimmt, mit einem Zusatz: KEEP_SNAPSHOTS = 2 (snapshot.rs:17), der erste nach interval/2 (AutoSnapshotWorker::new: now + interval/2), jeder weitere mit einem Zufallsfaktor 1,0–1,2 (snapshot.rs, rand_factor). Bei "6h" also erster Schnappschuss nach ~3 h, Fenster danach ~6–14,4 h statt 6–12 h.
metadata_dir und data_dir als zwei Unterverzeichnisse einer PVC geht, und Garage legt beide selbst an (src/model/garage.rs:100-104, create_dir_all). Das ist hier nicht nur bequem, sondern notwendig: das Abbild ist FROM scratch mit genau einer Datei, /garage (Upstream-Dockerfile). Es gibt keine Shell, kein mkdir — ein Init-Container wäre ein zweites Abbild für etwas, das die Anwendung selbst tut, und exec-Probes sind in diesem Chart grundsätzlich unmöglich.
Kein Ingress, keine Discovery-CRD, db_engine = "lmdb", metadata_fsync = true — unverändert.
Die Readiness-Frage ist aus der Quelle beantwortet
Der Plan fragt, ob die S3-API vor dem Layout antwortet. Sie tut es: der S3-Server wird unabhängig vom Layout-Zustand gestartet (server.rs:83-95), der Socket bindet also und antwortet — mit Fehlern. Eine TCP-Probe auf 3900 wäre damit genau die Lüge, vor der der Plan warnt.
Ehrlich ist /health auf dem Admin-Port: ohne Token (src/api/admin/router_v2.rs:31, @special), und src/rpc/system.rs:462-470 liefert für ein nicht konfiguriertes Layout den Sonderfall ClusterHealthStatus::Unavailable, den src/api/admin/special.rs:75-96 auf 503 abbildet („Quorum is not available for some/all partitions, reads and writes will fail"); Healthy und Degraded ergeben 200. Also: Readiness auf GET /health:3903, und Liveness und Startup ausdrücklich nicht — ein Knoten, der nur auf sein Layout wartet, würde sonst endlos neu gestartet (README, Leitlinie 12.3, derselbe Grund wie bei zipline). Die beiden gehen auf eine TCP-Probe des Admin-Ports.
Zwei Fragen, kurz zu beantworten
RPC-Geheimnis:GARAGE_RPC_SECRET per secretKeyRef (a), oder rpc_secret_file mit allow_world_readable_secrets = true (b)?
Erststart:garage.singleNode mit Vorgabe true samt --default-access-key/--default-bucket aus dem Tresor-Item — oder bleibt es bei Handarbeit nach NOTES.txt, und wenn ja, soll die Fixture-Entscheidung dann der Ausschluss in ci/excluded-charts.txt sein?
Bis dahin ist nichts gebaut, und der Branch ist nicht angelegt.
## Anhalten vor der Umsetzung: zwei Funde am freigegebenen Plan
Gegen den Quellbaum von **v2.4.1** geprüft (`codeload.github.com/deuxfleurs-org/garage` am Tag `v2.4.1`, dem Spiegel des Deuxfleurs-Gitea — das Original liegt hinter Anubis und ist aus dem Workspace nicht abrufbar). Zwei Punkte des Plans halten der Quelle nicht stand, einer davon so, dass er in der vorgesehenen Form **nicht startet**. Gebaut ist noch nichts.
---
### Fund 1 — `rpc_secret_file` aus einem Kubernetes-Secret ist im gehärteten Pod nicht lesbar
Der Plan hält fest: „`rpc_secret_file` **existiert** — kommt als Datei aus einem `OnePasswordItem`, nicht in die ConfigMap". Der Schlüssel existiert (`src/util/config.rs:96`), aber die Datei kann unser Container nicht lesen. Das ist keine Konfigurationsfrage, es sind zwei Prüfungen, die sich widersprechen:
**Garage, `src/garage/secrets.rs:135-147`:**
```rust
if metadata.mode() & 0o077 != 0 {
return Err(format!("File {} is world-readable! (mode: 0{:o}, expected 0600)\n\
Refusing to start until this is fixed, or environment variable \
GARAGE_ALLOW_WORLD_READABLE_SECRETS is set to true.", ...).into());
}
```
Die Maske ist `0o077` — sie verwirft **jedes** Gruppen- *oder* Fremdbit, nicht nur die Fremdbits, trotz des Wortes „world-readable" in der Meldung.
**Das Kubelet, `pkg/volume/volume_linux.go:72-104`** (geprüft an `release-1.31`): sobald `fsGroup` gesetzt ist, läuft über jede Datei des Volumes
```go
err := os.Lchown(filename, -1, int(*fsGroup))
mask := rwMask // 0660
if readonly { mask = roMask } // 0440 — Secret-Volumes melden ReadOnly: true
err = os.Chmod(filename, info.Mode()|mask)
```
Das Gruppen-Lesebit wird also **hinzu-ge-ODERt**, unabhängig von `defaultMode`. Damit ist `mode & 0o077 != 0` immer wahr und Garage verweigert den Start.
Und es gibt keine Kombination, die beides erfüllt, weil Kubernetes an einer Secret-Volume-Datei nur die **Gruppe** setzen kann, niemals den besitzenden Benutzer:
| Variante | Modus / Eigentümer | Ergebnis |
| --- | --- | --- |
| `fsGroup` gesetzt, `defaultMode` beliebig | `root:1000`, Gruppenbit immer gesetzt | Garage verweigert den Start (`0o077`-Prüfung) |
| kein `fsGroup`, `defaultMode: 0400` | `root:root 0400` | Prüfung besteht, aber uid 1000 bekommt `EACCES` — „Failed to read secret file" |
| kein `fsGroup`, Vorgabe `0644` | `root:root 0644` | Garage verweigert den Start |
| `runAsUser: 0` | — | widerspricht der Härtung |
**Zwei Wege hinaus, und sie sind nicht gleichwertig:**
**(a) `GARAGE_RPC_SECRET` als `env.valueFrom.secretKeyRef`.** `src/garage/secrets.rs:22-28` führt den Wert mit `env = "GARAGE_RPC_SECRET"`, und `src/garage/main.rs:50` flacht `Secrets` in die **globalen** Optionen ein — die Variable gilt also für `garage server` genauso wie für `kubectl exec … -- /garage status`, das dieselbe Berechtigung braucht. Keine Datei, keine Dateirechte, keine abgeschaltete Prüfung, und das eigentliche Ziel des Plans ist erfüllt: das Geheimnis bleibt ein Secret und landet **nicht** in der ConfigMap.
**(b) `rpc_secret_file` + `allow_world_readable_secrets = true`.** Funktioniert, kostet aber die Prüfung für **alle** Geheimnisdateien dieses Prozesses — und die Datei ist dabei gar nicht fremdlesbar, Garage kann es nur nicht unterscheiden. Upstream kennt den Fall übrigens: der Doc-Kommentar am Wert lautet „Skip the permission check of secret files. Useful when POSIX ACLs (or more complex chmods) are used." (`src/util/config.rs:86-89`) — der Schalter ist für genau diese Lage gedacht, nur eben pauschal.
**Mein Vorschlag: (a)**, mit dem Mechanismus als Kommentar an `secrets.rpcSecretRef` in `values.yaml`, plus ein `Lessons-Learned`-Eintrag zur allgemeinen Form: *eine Rechteprüfung, die annimmt, ihr Dateisystem zu besitzen, trifft auf eine Plattform, die nur die Gruppe setzen kann* — das wiederholt sich bei jedem Programm mit `*_secret_file` und einem `0o077`-Test (Garage selbst hat drei davon: `rpc_secret_file`, `admin_token_file`, `metrics_token_file`).
---
### Fund 2 — `garage server --single-node` macht genau das, wofür Entscheidung 5 Handarbeit vorsieht, und entkräftet beide Begründungen
Entscheidung 5 steht auf zwei Prämissen. Beide treffen für v2.4.1 bei einem Knoten nicht mehr zu.
**Die Schalter, `src/garage/cli/structs.rs:95-108`:**
```rust
pub struct ServerOpt {
/// Automatically configure a single-node layout in the cluster.
/// Garage will refuse to run if the cluster already has other nodes.
#[structopt(long = "single-node")]
pub(crate) single_node: bool,
/// Configure a default S3 API key using environment variables
/// `GARAGE_DEFAULT_ACCESS_KEY` and `GARAGE_DEFAULT_SECRET_KEY`. Requires `--single-node`.
#[structopt(long = "default-access-key")]
pub(crate) default_access_key: bool,
/// Configure a default bucket using environment variable `GARAGE_DEFAULT_BUCKET`.
/// Implies `--default-access-key`. Requires `--single-node`.
#[structopt(long = "default-bucket")]
pub(crate) default_bucket: bool,
}
```
**Prämisse 1 („`layout apply --version N+1` ist nicht idempotent, ein Job bei `helm upgrade` ist gefährlich") fällt.** `initial_config` in `src/garage/server.rs:190-256` prüft die Layout-Version **im Prozess**, nicht über einen wiederholbaren CLI-Aufruf:
- `replication_factor != 1` → Abbruch mit „Single-node mode requires replication_factor = 1 in the configuration file." (genau unsere Konfiguration, also kein Hindernis)
- Layout-Version `0` → Layout wird angelegt: Zone `dc1`, Kapazität aus der erkannten Plattengröße, Tag `default`, dann `apply_staged_changes(1)`
- Layout-Version `1` → **nichts passiert**, der Start läuft normal weiter
- Layout-Version `> 1` → Abbruch mit „Refusing to run in single-node mode: layout version is already superior to 1. Remove the --single-node flag to run the server in full mode."
- fremde Knoten bekannt → Abbruch mit „Refusing to run in single-node mode: more nodes are already present in the cluster."
Das ist über Neustarts hinweg idempotent, und die Falle, vor der der Plan zu Recht warnt, entsteht gar nicht: es gibt keinen `--version`-Parameter, den jemand zweimal mit demselben Wert aufrufen könnte.
**Prämisse 2 („Der Zugangsschlüssel, den Garage erzeugt, muss in das 1Password-Item wandern. Das kann kein Job.") fällt und kehrt sich um.** `--default-access-key` liest `GARAGE_DEFAULT_ACCESS_KEY` und `GARAGE_DEFAULT_SECRET_KEY` aus der Umgebung und **importiert** den Schlüssel (`server.rs:259-300`, `Key::import` in Zeile 291). Die Richtung ist damit die bessere: der Betreuer erzeugt das Schlüsselpaar im Tresor, das Item ist die Quelle der Wahrheit, und Garage übernimmt es — statt dass Garage es erzeugt und es von Hand hinterherkopiert wird. Der Wiederholungsfall ist in der Quelle abgesichert: existierender Schlüssel mit **anderem** Geheimnis → Fehler; im Cluster gelöschter Schlüssel → Fehler („cannot add it back"). `--default-bucket` legt zusätzlich den Bucket an und bindet den Schlüssel daran.
**Dazu die Reihenfolge, die für die Readiness-Entscheidung zählt:** `initial_config(&garage, opt).await?` steht in `server.rs:54` — **vor** dem Start von S3-, Web- und Admin-Server (`server.rs:83` bzw. `124`). Mit `--single-node` ist der Knoten also layout-fertig, bevor er überhaupt auf einem Port antwortet.
**Und die Folge für die CI, die mir die wichtigste ist:** mit `--single-node` erreicht der Pod von allein `Ready`, der Install-Test im PR wird dadurch **aussagekräftig** und schließt drei der vier offenen Messpunkte bei *jedem* PR. Ohne ihn kann kein Install-Test bestehen — eine ehrliche Readiness kann vor dem Layout von Hand nicht grün werden —, und die Fixture-Entscheidung müsste auf einen begründeten Ausschluss in `ci/excluded-charts.txt` hinauslaufen, der nichts belegt.
**Mein Vorschlag:** Wert `garage.singleNode`, Vorgabe `true` (RF 1 ist ohnehin gesetzt, und der Schalter verlangt genau das); `--default-access-key`/`--default-bucket` über eigene Werte dazu, gespeist aus demselben `OnePasswordItem`, das Stalwart liest. `NOTES.txt` behält die Handarbeits-Prüfliste für `singleNode: false` und führt im Vorgabefall die Tresor-Felder, die jetzt **hinein**gehen statt heraus.
---
### Was der Plan unverändert richtig hat (gegen die Quelle bestätigt)
- **`metadata_snapshots_dir` heißt genau so**, ist oberste Ebene, `Option<PathBuf>`, Vorgabe `<metadata_dir>/snapshots` (`src/util/config.rs:35`, `src/model/snapshot.rs:44-50`).
- **Das zweite Volume trägt.** Der Schnappschuss ist eine echte Kopie mit Kompaktierung (`src/db/lmdb_adapter.rs:162-168`: `copy_to_path(path, CompactionOption::Enabled)`) und das Zielverzeichnis wird mit `create_dir_all` angelegt (`snapshot.rs:52`). Kein `rename`, also kein `EXDEV` über die Dateisystemgrenze — ein eigenes PVC für die Schnappschüsse funktioniert.
- **Das Zwei-Schnappschuss-Fenster stimmt**, mit einem Zusatz: `KEEP_SNAPSHOTS = 2` (`snapshot.rs:17`), der erste nach `interval/2` (`AutoSnapshotWorker::new`: `now + interval/2`), jeder weitere mit einem Zufallsfaktor 1,0–1,2 (`snapshot.rs`, `rand_factor`). Bei `"6h"` also erster Schnappschuss nach ~3 h, Fenster danach ~6–14,4 h statt 6–12 h.
- **`metadata_dir` und `data_dir` als zwei Unterverzeichnisse *einer* PVC geht**, und Garage legt beide selbst an (`src/model/garage.rs:100-104`, `create_dir_all`). Das ist hier nicht nur bequem, sondern notwendig: das Abbild ist **`FROM scratch`** mit genau einer Datei, `/garage` (Upstream-`Dockerfile`). Es gibt keine Shell, kein `mkdir` — ein Init-Container wäre ein zweites Abbild für etwas, das die Anwendung selbst tut, und `exec`-Probes sind in diesem Chart grundsätzlich unmöglich.
- **Kein Ingress, keine Discovery-CRD, `db_engine = "lmdb"`, `metadata_fsync = true`** — unverändert.
### Die Readiness-Frage ist aus der Quelle beantwortet
Der Plan fragt, ob die S3-API vor dem Layout antwortet. Sie tut es: der S3-Server wird unabhängig vom Layout-Zustand gestartet (`server.rs:83-95`), der Socket bindet also und antwortet — mit Fehlern. Eine TCP-Probe auf 3900 wäre damit genau die Lüge, vor der der Plan warnt.
Ehrlich ist `/health` auf dem Admin-Port: ohne Token (`src/api/admin/router_v2.rs:31`, `@special`), und `src/rpc/system.rs:462-470` liefert für ein nicht konfiguriertes Layout den Sonderfall `ClusterHealthStatus::Unavailable`, den `src/api/admin/special.rs:75-96` auf **503** abbildet („Quorum is not available for some/all partitions, reads and writes will fail"); `Healthy` und `Degraded` ergeben 200. Also: **Readiness auf `GET /health:3903`**, und **Liveness und Startup ausdrücklich nicht** — ein Knoten, der nur auf sein Layout wartet, würde sonst endlos neu gestartet (README, Leitlinie 12.3, derselbe Grund wie bei `zipline`). Die beiden gehen auf eine TCP-Probe des Admin-Ports.
### Zwei Fragen, kurz zu beantworten
1. **RPC-Geheimnis:** `GARAGE_RPC_SECRET` per `secretKeyRef` (a), oder `rpc_secret_file` mit `allow_world_readable_secrets = true` (b)?
2. **Erststart:** `garage.singleNode` mit Vorgabe `true` samt `--default-access-key`/`--default-bucket` aus dem Tresor-Item — oder bleibt es bei Handarbeit nach `NOTES.txt`, und wenn ja, soll die Fixture-Entscheidung dann der Ausschluss in `ci/excluded-charts.txt` sein?
Bis dahin ist nichts gebaut, und der Branch ist nicht angelegt.
Entscheidungen zu den beiden Funden — der Plan wird an zwei Stellen korrigiert
Beide Funde sind gegen den Quelltext am Tag v2.4.1 nachgeprüft und tragen.
Fund 1, src/garage/secrets.rs:135-146:
ifmetadata.mode()&0o077!=0{returnErr(format!("File {} is world-readable! (mode: 0{:o}, expected 0600)…
Die Maske verwirft jedes Gruppen- oder Fremdbit; die Meldung sagt „world-readable", geprüft wird aber auch die Gruppe. fsGroup setzt genau dieses Gruppen-Lesebit — ohne es kann uid 1000 die Datei nicht lesen, mit ihm verweigert Garage den Start. Kubernetes kann an einer Secret-Datei nur die Gruppe setzen, nie den Eigentümer, also existiert keine funktionierende Kombination.
Fund 2, src/garage/cli/structs.rs:95-108: --single-node, --default-access-key (GARAGE_DEFAULT_ACCESS_KEY/GARAGE_DEFAULT_SECRET_KEY), --default-bucket (GARAGE_DEFAULT_BUCKET), mit dem dokumentierten Schutz „Garage will refuse to run if the cluster already has other nodes."
Entscheidung 1: GARAGE_RPC_SECRET als Umgebungsvariable
Per valueFrom.secretKeyRef aus dem OnePasswordItem. Das eigentliche Ziel des Plans — nichts in der ConfigMap — ist damit erfüllt, und keine Prüfung wird abgeschaltet. Es ist das überwiegende Muster dieses Repositories; paperless-ngx und stalwart holen ihre Geheimnisse genauso.
Der Preis gehört an den Wert geschrieben: das Geheimnis steht in der Prozessumgebung des Containers. Unser valkey-Chart argumentiert ausdrücklich dagegen („the password never appears in the process list, in an environment variable"). Dass es hier anders ausfällt, hat einen Grund und der gehört dokumentiert: bei valkey war die Datei ohnehin der natürliche Weg, weil die Konfiguration sie includet; hier ist der Dateiweg strukturell versperrt, nicht nur unbequem. Garage führt den Wert mit hide_env_values = true, gibt ihn also in eigenen Ausgaben nicht preis.
Verworfen:
allow_world_readable_secrets = true — würde die Prüfung für alle drei Geheimnisdateien aufheben (rpc_secret_file, admin_token_file, metrics_token_file), auch für künftige, die noch niemand gesetzt hat. Eine Sicherheitsprüfung abzuschalten, um ein Berechtigungsmodell zu bedienen, ist der schlechtere Tausch.
Init-Container, der die Datei mit Modus 0600 kopiert — technisch sauber und die Prüfung bliebe aktiv, aber das Hauptabbild ist FROM scratch ohne Shell, der Init bräuchte also ein eigenes Abbild zum Pinnen und Verfolgen, plus ein emptyDir, in dem ein Geheimnis liegt.
Entscheidung 2: --single-node statt Handarbeit — mein Plan war an dieser Stelle falsch begründet
Ich hatte „Erststart von Hand" mit zwei Gründen belegt, und beide fallen:
„layout apply ist nicht idempotent" gilt für den CLI-Befehl, aber --single-node nimmt diesen Weg nicht: es prüft die Layout-Version im Prozess und hat keinen --version-Parameter, der zweimal gleich aufgerufen werden könnte. Voraussetzung ist RF 1 — genau unsere Konfiguration.
„Den Zugangsschlüssel kann kein Job besorgen" kehrt sich um: --default-access-keyimportiert den Schlüssel aus der Umgebung. Das Tresor-Item wird damit Quelle der Wahrheit statt Ablage für etwas, das hinterher hineinkopiert wird.
Entscheidend ist die Folge für die Prüfbarkeit: initial_config läuft vor dem S3- und dem Admin-Server, der Pod erreicht also von allein Ready. Damit wird der Install-Test aussagekräftig und schließt drei der vier Messpunkte bei jedem PR. Ohne das Flag kann kein Install-Test bestehen, und die Fixture-Entscheidung wäre auf einen Ausschluss hinausgelaufen, der nichts belegt.
Umsetzung: garage.singleNode als Wert mit Vorgabe true — ein Standard, der nicht Ready wird, ist kein Standard. --default-access-key und --default-bucket werden mitgeschaltet, die vier Tresor-Felder (RPC-Geheimnis, Access Key, Secret Key, Bucket-Name) stehen in NOTES.txt. Der Schutz „refuse to run if the cluster already has other nodes" gehört als Zusicherung dazu: das Flag kann einen bestehenden Cluster nicht beschädigen.
Damit entfällt die Fixture-Frage:ci/garage/ wird gebaut, kein Ausschluss.
Entscheidung 3: die Probes, wie hergeleitet
Die S3-API bindet vor dem Layout (server.rs:83-95), eine TCP-Probe auf 3900 wäre also genau die Lüge, vor der der Plan warnte.
Readiness auf GET /health an Port 3903 — ohne Token, 503 bei unkonfiguriertem Layout, 200 bei Healthy/Degraded.
Liveness und Startup ausdrücklich nicht darauf, sondern TCP auf den Admin-Port. Ein Knoten, der auf sein Layout wartet, würde sonst endlos neu gestartet — dieselbe Lehre wie bei zipline, README 12.3.
Was mitkorrigiert wird
Das Abbild ist FROM scratch mit genau einer Datei /garage. Keine Shell: exec-Probes sind in diesem Chart grundsätzlich unmöglich, und jeder Init-Container bräuchte ein eigenes Abbild. Gehört in values.yaml und auf die Wiki-Seite, weil es jede künftige Runde betrifft.
Schnappschuss-Fenster: erster nach interval/2, danach Zufallsfaktor 1,0–1,2, KEEP_SNAPSHOTS = 2. Bei "6h" also ~3 h und danach ~6–14,4 h — die Angabe „6–12 h" im Plan war zu eng.
metadata_snapshots_dir heißt genau so, oberste Ebene, Vorgabe <metadata_dir>/snapshots — das zweite PVC bleibt richtig, und der Schnappschuss ist eine echte Kopie mit Kompaktierung, also kein rename über eine Dateisystemgrenze.
metadata_dir und data_dir als Unterverzeichnisse einer PVC trägt; Garage legt beide selbst an.
Upstreams eigener Chart pinnt dxflrs/amd64_garage; das im Issue verifizierte dxflrs/garage ist das Multi-Arch-Abbild und bleibt richtig.
Alles Übrige aus dem Plan-Kommentar bleibt unverändert gültig: RF 1 mit benanntem Restrisiko, lmdb mit metadata_fsync = true und Auto-Schnappschuss, zwei PVCs in der beschriebenen Aufteilung, kein Ingress, keine Discovery-CRD.
# Entscheidungen zu den beiden Funden — der Plan wird an zwei Stellen korrigiert
Beide Funde sind gegen den Quelltext am Tag `v2.4.1` nachgeprüft und tragen.
**Fund 1**, `src/garage/secrets.rs:135-146`:
```rust
if metadata.mode() & 0o077 != 0 {
return Err(format!("File {} is world-readable! (mode: 0{:o}, expected 0600)…
```
Die Maske verwirft **jedes** Gruppen- *oder* Fremdbit; die Meldung sagt „world-readable", geprüft wird aber auch die Gruppe. `fsGroup` setzt genau dieses Gruppen-Lesebit — ohne es kann uid 1000 die Datei nicht lesen, mit ihm verweigert Garage den Start. Kubernetes kann an einer Secret-Datei nur die **Gruppe** setzen, nie den Eigentümer, also existiert keine funktionierende Kombination.
**Fund 2**, `src/garage/cli/structs.rs:95-108`: `--single-node`, `--default-access-key` (`GARAGE_DEFAULT_ACCESS_KEY`/`GARAGE_DEFAULT_SECRET_KEY`), `--default-bucket` (`GARAGE_DEFAULT_BUCKET`), mit dem dokumentierten Schutz „Garage will refuse to run if the cluster already has other nodes."
## Entscheidung 1: `GARAGE_RPC_SECRET` als Umgebungsvariable
Per `valueFrom.secretKeyRef` aus dem `OnePasswordItem`. Das eigentliche Ziel des Plans — **nichts in der ConfigMap** — ist damit erfüllt, und keine Prüfung wird abgeschaltet. Es ist das überwiegende Muster dieses Repositories; `paperless-ngx` und `stalwart` holen ihre Geheimnisse genauso.
**Der Preis gehört an den Wert geschrieben:** das Geheimnis steht in der Prozessumgebung des Containers. Unser `valkey`-Chart argumentiert ausdrücklich dagegen („the password never appears in the process list, in an environment variable"). Dass es hier anders ausfällt, hat einen Grund und der gehört dokumentiert: bei `valkey` war die Datei ohnehin der natürliche Weg, weil die Konfiguration sie `include`t; hier ist der Dateiweg **strukturell versperrt**, nicht nur unbequem. Garage führt den Wert mit `hide_env_values = true`, gibt ihn also in eigenen Ausgaben nicht preis.
Verworfen:
- **`allow_world_readable_secrets = true`** — würde die Prüfung für **alle drei** Geheimnisdateien aufheben (`rpc_secret_file`, `admin_token_file`, `metrics_token_file`), auch für künftige, die noch niemand gesetzt hat. Eine Sicherheitsprüfung abzuschalten, um ein Berechtigungsmodell zu bedienen, ist der schlechtere Tausch.
- **Init-Container, der die Datei mit Modus 0600 kopiert** — technisch sauber und die Prüfung bliebe aktiv, aber das Hauptabbild ist `FROM scratch` ohne Shell, der Init bräuchte also ein **eigenes Abbild** zum Pinnen und Verfolgen, plus ein emptyDir, in dem ein Geheimnis liegt.
## Entscheidung 2: `--single-node` statt Handarbeit — mein Plan war an dieser Stelle falsch begründet
Ich hatte „Erststart von Hand" mit zwei Gründen belegt, und **beide fallen**:
1. „`layout apply` ist nicht idempotent" gilt für den CLI-Befehl, aber `--single-node` nimmt diesen Weg nicht: es prüft die Layout-Version im Prozess und hat **keinen `--version`-Parameter**, der zweimal gleich aufgerufen werden könnte. Voraussetzung ist RF 1 — genau unsere Konfiguration.
2. „Den Zugangsschlüssel kann kein Job besorgen" kehrt sich um: `--default-access-key` **importiert** den Schlüssel aus der Umgebung. Das Tresor-Item wird damit **Quelle der Wahrheit** statt Ablage für etwas, das hinterher hineinkopiert wird.
Entscheidend ist die Folge für die Prüfbarkeit: `initial_config` läuft **vor** dem S3- und dem Admin-Server, der Pod erreicht also von allein `Ready`. Damit wird der **Install-Test aussagekräftig** und schließt drei der vier Messpunkte bei jedem PR. Ohne das Flag kann kein Install-Test bestehen, und die Fixture-Entscheidung wäre auf einen Ausschluss hinausgelaufen, der nichts belegt.
Umsetzung: `garage.singleNode` als Wert mit Vorgabe **`true`** — ein Standard, der nicht `Ready` wird, ist kein Standard. `--default-access-key` und `--default-bucket` werden mitgeschaltet, die vier Tresor-Felder (RPC-Geheimnis, Access Key, Secret Key, Bucket-Name) stehen in `NOTES.txt`. Der Schutz „refuse to run if the cluster already has other nodes" gehört als Zusicherung dazu: das Flag kann einen bestehenden Cluster nicht beschädigen.
**Damit entfällt die Fixture-Frage:** `ci/garage/` wird gebaut, kein Ausschluss.
## Entscheidung 3: die Probes, wie hergeleitet
Die S3-API bindet **vor** dem Layout (`server.rs:83-95`), eine TCP-Probe auf 3900 wäre also genau die Lüge, vor der der Plan warnte.
- **Readiness** auf `GET /health` an Port 3903 — ohne Token, 503 bei unkonfiguriertem Layout, 200 bei `Healthy`/`Degraded`.
- **Liveness und Startup ausdrücklich nicht darauf**, sondern TCP auf den Admin-Port. Ein Knoten, der auf sein Layout wartet, würde sonst endlos neu gestartet — dieselbe Lehre wie bei `zipline`, README 12.3.
## Was mitkorrigiert wird
- Das Abbild ist **`FROM scratch`** mit genau einer Datei `/garage`. Keine Shell: **`exec`-Probes sind in diesem Chart grundsätzlich unmöglich**, und jeder Init-Container bräuchte ein eigenes Abbild. Gehört in `values.yaml` und auf die Wiki-Seite, weil es jede künftige Runde betrifft.
- Schnappschuss-Fenster: erster nach `interval/2`, danach Zufallsfaktor 1,0–1,2, `KEEP_SNAPSHOTS = 2`. Bei `"6h"` also ~3 h und danach ~6–14,4 h — die Angabe „6–12 h" im Plan war zu eng.
- `metadata_snapshots_dir` heißt genau so, oberste Ebene, Vorgabe `<metadata_dir>/snapshots` — das zweite PVC bleibt richtig, und der Schnappschuss ist eine echte Kopie mit Kompaktierung, also kein `rename` über eine Dateisystemgrenze.
- `metadata_dir` und `data_dir` als Unterverzeichnisse **einer** PVC trägt; Garage legt beide selbst an.
- Upstreams eigener Chart pinnt `dxflrs/amd64_garage`; das im Issue verifizierte `dxflrs/garage` ist das Multi-Arch-Abbild und bleibt richtig.
Alles Übrige aus dem Plan-Kommentar bleibt unverändert gültig: RF 1 mit benanntem Restrisiko, `lmdb` mit `metadata_fsync = true` und Auto-Schnappschuss, zwei PVCs in der beschriebenen Aufteilung, kein Ingress, keine Discovery-CRD.
garage/ liegt auf main, Version 1.0.0+upv2.4.1, veröffentlicht. PR #82 gemergt am 2026-10-04 um 15:09 mit Part of #81 — dieses Issue ist deshalb bewusst offen.
In der CI belegt (Lauf 447, alle sechs Kontexte grün): der Container execed mit drop: [ALL] + add: [NET_BIND_SERVICE] gegen das setcap +ep des Abbilds — der capabilities(7)-Schluss war vorher nur aus der Spezifikation abgeleitet, jetzt ist er beobachtet; /health wird auf dem Recovery-Listener bedient und antwortet einer Kubelet-Probe; readOnlyRootFilesystem: true hält mit den drei emptyDirs; die authSecret-Indirektion über eine Umgebungsvariable löst auf, das Passwort steht nirgends in der ConfigMap.
Offen bleibt eine Prüfung, und sie ist nicht CI-schließbar:
Landet der Metadaten-Schnappschuss wirklich in metadata_snapshots_dir? Der erste ist nach interval/2 fällig — länger als jeder CI-Lauf, und ein Intervall von Sekunden würde den Timer prüfen statt den Mechanismus. Schritt 6 der Prüfliste auf der Wiki-Seite.
Das ist der Punkt, an dem die Sicherungsgeschichte hängt: Dateisystem-Schnappschüsse von metadata_dir sind kein gültiger Rücksetzpunkt (wird während eines Schreibvorgangs geschnappschosst, ist der Schnappschuss selbst beschädigt), deshalb liegt der konsistente Rücksetzpunkt auf dem zweiten Volume.
Teilweise offen: Longhorns Schnappschuss-Semantik, auf der die Ein-PVC-Aufteilung ruht, ist eine Eigenschaft der Speicherschicht — der Runner hat local-path. Die Haltbarkeit von metadata_fsync ist im Container grundsätzlich nicht beobachtbar. Und es hat nirgends ein echter S3-Schreibvorgang stattgefunden: dass Bucket und Zugangsschlüssel existieren, folgt aus dem Start, dass ein PUT durchgeht, ist ungeprüft.
## Stand nach dem Merge (2026-10-04)
`garage/` liegt auf `main`, Version **1.0.0+upv2.4.1**, veröffentlicht. PR #82 gemergt am 2026-10-04 um 15:09 mit `Part of #81` — dieses Issue ist deshalb bewusst **offen**.
**In der CI belegt** (Lauf 447, alle sechs Kontexte grün): der Container **execed** mit `drop: [ALL]` + `add: [NET_BIND_SERVICE]` gegen das `setcap +ep` des Abbilds — der `capabilities(7)`-Schluss war vorher nur aus der Spezifikation abgeleitet, jetzt ist er beobachtet; `/health` wird **auf dem Recovery-Listener** bedient und antwortet einer Kubelet-Probe; `readOnlyRootFilesystem: true` hält mit den drei emptyDirs; die `authSecret`-Indirektion über eine Umgebungsvariable löst auf, das Passwort steht nirgends in der ConfigMap.
**Offen bleibt eine Prüfung, und sie ist nicht CI-schließbar:**
**Landet der Metadaten-Schnappschuss wirklich in `metadata_snapshots_dir`?** Der erste ist nach `interval/2` fällig — länger als jeder CI-Lauf, und ein Intervall von Sekunden würde den Timer prüfen statt den Mechanismus. Schritt 6 der Prüfliste auf der Wiki-Seite.
Das ist der Punkt, an dem die Sicherungsgeschichte hängt: Dateisystem-Schnappschüsse von `metadata_dir` sind **kein** gültiger Rücksetzpunkt (wird während eines Schreibvorgangs geschnappschosst, ist der Schnappschuss selbst beschädigt), deshalb liegt der konsistente Rücksetzpunkt auf dem zweiten Volume.
**Teilweise offen:** Longhorns Schnappschuss-Semantik, auf der die Ein-PVC-Aufteilung ruht, ist eine Eigenschaft der Speicherschicht — der Runner hat `local-path`. Die Haltbarkeit von `metadata_fsync` ist im Container grundsätzlich nicht beobachtbar. Und **es hat nirgends ein echter S3-Schreibvorgang stattgefunden**: dass Bucket und Zugangsschlüssel *existieren*, folgt aus dem Start, dass ein `PUT` durchgeht, ist ungeprüft.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Ein Chart für Garage (
Deuxfleurs/garage), ein S3-kompatibler Objektspeicher in Rust, gebaut für Selbsthosting über ungleiche Knoten.Abnehmer ist Stalwarts Blob-Ablage in
jlab-cluster/cluster-config— also die Nachrichtenkörper des Mailservers. Der Plan dort: In-Cluster-S3 auflonghorn-encrypted-ns, damit der Verschlüsselungsschlüssel im eigenen Tresor liegt, und Longhorn sichert das verschlüsselte Volume nach OVH. Nachgeprüft: Longhorn verschlüsselt auf Blockebene mit LUKS2 und die Sicherungsmaschine liest unter der Entschlüsselungsschicht, der Sicherungsspeicher sieht also nur Chiffrat.Der offizielle Chart ist Referenz, nicht Vorlage
Garage liefert einen eigenen Helm-Chart mit, aber nur im Quellbaum unter
script/helm, installiert über einen lokalen Pfad:Kein Index, kein OCI-Verweis, kein
.tgz— nachgesehen in der offiziellen Kubernetes-Dokumentation. Genau deshalb braucht Fleet einen eigenen Chart: Es zieht überrepo:plusversion:aus einem Index oder überoci://, nicht aus einem Git-Arbeitsbaum.Dieser Chart wird nicht kopiert. Er ist die beste verfügbare Quelle dafür, was die Anwendung braucht — welche Ports, welche Volumes, die Discovery-CRD, das RPC-Geheimnis, die Reihenfolge beim ersten Start. Er ist keine Vorlage für die Form. Gebaut wird aus
template-chart/nachCLAUDE.md(„Creating a new chart") und dem Stilleitfaden inREADME.md(„Chart style guidelines").Wo Upstream und unsere Vorgaben auseinandergehen
Das ist die eigentliche Arbeit, und es ist der Grund, warum Kopieren mehr kostet als Neubauen:
{{ .Release.Name }}-{{ .Chart.Name }}-<kind>, Dateiengarage.<kind>.yaml, StatefulSet alsgarage.sfs.yaml, Services mit-service-SuffixOnePasswordItemübersecrets.<name>SecretRefmitop://-Pfadgarage.rpc.secret.createlegt ein gewöhnliches Secret anlimitFactor-Helfer (Vorgabe 3), keine CPU-Limits ohne ausdrückliche Angaberesources-Blockreplicasje Arbeitslast plus chart-globalesscaleDowndeployment.kind+replicaCountchecksum/configan jeder Pod-Vorlage, die eine ConfigMap liest (#82) — sonst startet eine geänderte Konfiguration nichts neuautomountServiceAccountToken: false, non-root,readOnlyRootFilesystem: true, Capabilities fallen, schreibbare Pfade alsemptyDir; jede durch das Abbild erzwungene Abweichung imvalues.yamlkommentiertvalues.schema.jsonmitadditionalProperties: falseplus die null-sicheren Fallback-Helfer, validiert gegen die echten Bundle-Werte, nicht nur gegen die Vorgabenversion: <chart-semver>+up<appVersion>, im selben Commit angehoben — jeder Push aufmain, der das Verzeichnis berührt, veröffentlichtFalls eine Weboberfläche veröffentlicht werden soll, zusätzlich die Ingress-Konvention:
ingress.domainundingress.clusterIssuerrequiredohne Vorgabe,ingress.classNamemit Vorgabenginx, TLS-Secrettls-<release>-<chart>-ingress.Verifizierte Fakten
Gegen Docker Hub und die Upstream-Dokumentation geprüft, nicht aus der Erinnerung:
dxflrs/garage(340 Tags)v2.4.1, 08.09.2026 — davorv2.4.0,v2.3.0,v2.2.0,v2.1.0,v2.0.01.0.0+upv2.4.1(führendesvimappVersion, wie beistalwart)Eine Falle für Renovate: Neben den Versionstags schiebt Garage laufend Commit-SHA-Tags (die neuesten vom 03.10.2026). Der
customManagers-Block inrenovate.json5braucht deshalb eine Versionsbeschränkung, sonst bietet Renovate 40-stellige Hex-Tags als „Update" an.Was der Upstream-Chart über die Anwendung verrät
deployment.kindStatefulSet, aufDaemonSetumstellbargarage.replicationFactor"3"garage.rpc.bindAddr[::]:3901garage.rpc.secret.creategarage.metadataAutoSnapshotIntervalgarage.kubernetesSkipCrdgaragenodes.deuxfleurs.frfürkubernetes_discoveryaußen vorDazu zwei dokumentierte Eigenheiten: Der Chart legt zwei PVCs aus StatefulSet-Vorlagen an (Daten und Metadaten), weshalb das Vergrößern umständlich ist; und nach der Installation muss ein Cluster-Layout angewandt werden, was sich per Job automatisieren lässt.
Offene Entscheidungen für den Plan-Kommentar
replicationFactor: 1oder ein Cluster? Für den bekannten Abnehmer spricht alles für einen Knoten:longhorn-encrypted-nsrepliziert schon dreifach und verschlüsselt. Garage-Replikation 3 über Longhorn-Replikation 3 wäre neunfache Rohdatenmenge, und zwei Schichten, die nichts voneinander wissen.kubernetes_discovery-CRD. Cluster-weit, zwingt im abnehmenden Bundle alsodefaultNamespace:stattnamespace:. Bei einem Knoten ist Discovery vermutlich entbehrlich — dann gehört sie gar nicht in den Chart.NOTES.txtals Prüfliste?CI-Verdrahtung, nach
CLAUDE.md.github/workflows/publish-garage.yml— eine bestehende Hülle kopieren (z. B.publish-outline.yml), damituses:,registry-ownerundsecrets: inheritstimmen. Aus dem Kopf geschrieben stimmt es nicht.garagein diechart-name-Auswahlliste von.github/workflows/change-app-version.ymleintragen.ci/garage/(test-values.yaml,fixtures.yaml) — oder ein begründeter Eintrag inci/excluded-charts.txt. Fehlt beides, scheitert der Install-Test im PR.customManagers-Block inrenovate.json5für das Abbild, mit der Versionsbeschränkung aus der Renovate-Falle oben.Was nicht in diesen Chart gehört
Die Sicherungs-Label am Volume (
recurring-job.longhorn.io/daily-backupund…/daily-snapshot) sind Sache des abnehmenden Bundles incluster-config, nicht des Charts. Sie greifen nicht automatisch — Stand 02.10.2026 tragen 41 von 62 Volumes im Cluster keine. Hier steht es nur, damit es beim Ausrollen nicht untergeht: Ohne diese Label existiert die OVH-Hälfte des Plans nicht, sie sieht nur so aus.Ebenso gehört das
volume-encryption-Bundle für den Namensraum nachcluster-config—longhorn-encrypted-nslöst${pvc.namespace}-pvc-encryption-secretauf, und ohne diesen Eintrag bleibt die PVCPending.Freigegebener Umsetzungsplan
Die sechs offenen Entscheidungen sind beantwortet: zwei vom Betreuer, vier aus der Dokumentation. Dabei hat sich die Sicherungsprämisse des Issue-Bodys an einer tragenden Stelle als unvollständig erwiesen — das steht unten zuerst, weil der Rest darauf aufbaut.
Die Korrektur: Longhorn sichert die Metadaten nicht
Der Issue-Body baut darauf, dass Longhorn das verschlüsselte Volume nach OVH sichert. Für die Datenblöcke stimmt das. Für die Metadaten nicht: Dateisystem-Schnappschüsse von
metadata_dirgarantieren laut Garage keine Konsistenz — wird während eines Metadaten-Schreibvorgangs geschnappschosst, ist der Schnappschuss selbst beschädigt und als Rücksetzpunkt unbrauchbar. Garage empfiehlt deshalb in allen Fällen seinen eigenenmetadata_auto_snapshot_interval, der die Datenbank im Betrieb konsistent kopiert (Known issues, Durability & Repairs).Einzuplanen: Garage behält zwei Schnappschüsse, der erste etwa drei Stunden nach dem Start. Bei
"6h"ist das Rücksetzfenster rund 6–12 Stunden — wird eine Beschädigung später entdeckt, ist sie älter als jeder Schnappschuss.Und ein Standardwert, der die Härtung aushebelt:
metadata_fsyncist standardmäßig aus. Longhorns Blockschicht hilft nicht, wenn die Engine niefsyncaufruft. Garages eigene Beispielkonfiguration setztmetadata_fsync = truenebenmetadata_auto_snapshot_interval = "6h". Bei uns wird es gesetzt, nicht geerbt.Entscheidung 1: ein Knoten,
replication_factor = 1Die Begründung des Issues trägt für die Datenblöcke: longhorn-encrypted-ns repliziert dreifach und verschlüsselt, Garage-RF-3 darüber wäre neunfache Rohdatenmenge und zwei Schichten, die nichts voneinander wissen.
Was dabei bewusst in Kauf genommen wird, und es gehört benannt: Upstream schreibt zu
replication_factor = 1ausdrücklich „Do not use this for anything else than test deployments", und die Known-Issues-Seite nennt den Grund — derselbereplication_factorgilt auch für die Metadaten, RF 1 ist also besonders anfällig für Metadaten-Beschädigung, und eine beschädigte Datenbankdatei ist „generally not recoverable". Longhorn hilft dagegen nicht: es repliziert eine beschädigte Datei dreifach. Redundanz gegen Geräteausfall ja, gegen Dateibeschädigung nein.Das ist eine abgewogene Annahme des Risikos, keine Übersehung. Die Gegenmaßnahmen, die bei RF 1 überhaupt greifen, sind alle Pflicht und nicht optional:
metadata_fsync = truemetadata_auto_snapshot_intervalgesetztmetadata_snapshots_dirauf einem anderen Volume alsmetadata_dirDer Chart führt
replicationFactorals Wert, damit eine späterer Erhöhung nicht am Chart scheitert. Nicht als Ausbauweg dokumentieren, solange nicht belegt ist, wie eine Erhöhung in Garage abläuft — eine Faktor-Erhöhung ist ein Layout-Vorgang mit Datenbewegung, kein Schalter.Entscheidung 2:
db_engine = "lmdb", abgesichertGarages Vorgabe seit 0.9 und die schnellere Engine. Der Preis ist benannt: die gemeldete Beschädigung nach unsauberem Herunterfahren bleibt möglich, die Absicherung senkt nur die Wahrscheinlichkeit, und bei RF 1 gibt es keinen Mitknoten zur Wiederherstellung — nur den letzten Schnappschuss. Die Empfehlung für LMDB ist in der Doku an RF ≥ 2 gebunden; dass wir sie bei RF 1 fahren, gehört als Kommentar an den Wert, zusammen mit
sqliteals der Alternative und dem Grund, warum sie nicht gewählt wurde (Metadaten-Durchsatz, bei einem Mailserver als einzigem Abnehmer kaum die Grenze).Entscheidung 3: zwei Volumes, anders aufgeteilt als gedacht
Nicht „Daten und Metadaten getrennt", sondern:
metadata_dirunddata_dir(zwei Unterverzeichnisse)metadata_snapshots_dirDer exakte Schlüsselname
metadata_snapshots_dirist gegen die Konfigurationsreferenz zu bestätigen, nicht zu übernehmen.Entscheidung 4: keine Discovery-CRD
Ein einzelner Knoten hat keine Mitglieder zu finden. Die CRD
garagenodes.deuxfleurs.frist cluster-weit und würde das abnehmende Bundle aufdefaultNamespace:stattnamespace:zwingen — für nichts. Bleibt draußen;kubernetes_discoverywird nicht konfiguriert.Entscheidung 5: Erststart von Hand,
NOTES.txtals PrüflisteNicht Bequemlichkeit, sondern zweifach erzwungen:
garage layout apply --version <N+1>verlangt genau die Vorgängerversion plus eins, und „never make multiple calls … with the same value of the--versionflag". Ein Job, der beihelm upgradeerneut anläuft, ist also gefährlich, nicht bloß unnötig.NOTES.txtträgt die Schritte in der richtigen Reihenfolge mit den echten Befehlen:garage status,garage layout assign …,garage layout show,garage layout apply --version <N+1>, Bucket anlegen, Zugangsschlüssel erzeugen, Schlüssel an den Bucket binden, und zum Schluss die Felder, die ins Tresor-Item gehören.Entscheidung 6: kein Ingress
Die S3-API (3900) spricht Stalwart clusterintern, die Admin-API (3903) ebenso. Der Wert kommt als
ingress.enabled: falsemit, damit er existiert, wenn die Oberfläche doch veröffentlicht werden soll — dann nach unserer Ingress-Konvention (domainundclusterIssuerrequired ohne Vorgabe,classNamemit Vorgabenginx, TLS-Secrettls-<release>-garage-ingress).Verifizierte Eckdaten
[::]:3900, RPC[::]:3901, Web[::]:3902, Admin0.0.0.0:3903lmdb(Vorgabe seit 0.9.0),sqlite,fjall(experimentell)rpc_secret_fileexistiert — kommt als Datei aus einemOnePasswordItem, nicht in die ConfigMap1.0.0+upv2.4.1, appVersionv2.4.1Umfang
Gebaut aus
template-chart/nach CLAUDE.md und dem Stilleitfaden, nicht aus Upstreams Chart kopiert — der ist Quelle für was die Anwendung braucht, nicht für die Form. StatefulSet (garage.sfs.yaml), ConfigMap mitchecksum/config, zwei PVCs, Service,OnePasswordItemfür das RPC-Geheimnis, Probes auf allen drei Arten, berechnete Limits über denlimitFactor-Helfer,scaleDown, strengesvalues.schema.jsonmit den null-sicheren Fallback-Helfern, validiert gegen die echten Bundle-Werte.CI:
publish-garage.ymlaus einer bestehenden Hülle kopiert;garagein diechart-name-Liste vonchange-app-version.yml; Fixtures unterci/garage/oder ein begründeter Ausschluss;customManagers-Block inrenovate.json5mit Versionsbeschränkung, denn Garage schiebt laufend Commit-SHA-Tags und Renovate bietet sonst 40-stellige Hex-Tags als Update an.Wiki im selben Durchgang: neue Chart-Seite
garagemit Werte-Tabelle, der Erststart-Prüfliste und einem Traps-Abschnitt (die Metadaten-Sicherungslücke,metadata_fsyncaus Vorgabe, das Zwei-Schnappschuss-Fenster, die Nicht-Idempotenz vonlayout apply, RF 1 und Metadaten); Zeile inCharts; Eintrag in_Sidebar.md;Lessons-Learnedfür die allgemeine Form „der Schnappschuss, der keinen Rücksetzpunkt war" — die wiederholt sich bei jeder eingebetteten Datenbank auf einem Volume mit Schnappschüssen.Offen, am laufenden Dienst zu messen
metadata_dirunddata_dirals Unterverzeichnisse einer PVC ohne Beanstandung?metadata_auto_snapshot_intervalwirklich und landet inmetadata_snapshots_dir?readOnlyRootFilesystem: truemit den zwei PVCs und welche weiteren Schreibpfade als emptyDir nötig sind.Nicht in diesem Chart
Die Sicherungs-Label am Volume (
recurring-job.longhorn.io/daily-backup,…/daily-snapshot) und dasvolume-encryption-Bundle für den Namensraum gehören nachcluster-config. Ohne die Label existiert die OVH-Hälfte des Plans nicht, sie sieht nur so aus; ohne den Verschlüsselungs-Eintrag bleibt die PVCPending.Anhalten vor der Umsetzung: zwei Funde am freigegebenen Plan
Gegen den Quellbaum von v2.4.1 geprüft (
codeload.github.com/deuxfleurs-org/garageam Tagv2.4.1, dem Spiegel des Deuxfleurs-Gitea — das Original liegt hinter Anubis und ist aus dem Workspace nicht abrufbar). Zwei Punkte des Plans halten der Quelle nicht stand, einer davon so, dass er in der vorgesehenen Form nicht startet. Gebaut ist noch nichts.Fund 1 —
rpc_secret_fileaus einem Kubernetes-Secret ist im gehärteten Pod nicht lesbarDer Plan hält fest: „
rpc_secret_fileexistiert — kommt als Datei aus einemOnePasswordItem, nicht in die ConfigMap". Der Schlüssel existiert (src/util/config.rs:96), aber die Datei kann unser Container nicht lesen. Das ist keine Konfigurationsfrage, es sind zwei Prüfungen, die sich widersprechen:Garage,
src/garage/secrets.rs:135-147:Die Maske ist
0o077— sie verwirft jedes Gruppen- oder Fremdbit, nicht nur die Fremdbits, trotz des Wortes „world-readable" in der Meldung.Das Kubelet,
pkg/volume/volume_linux.go:72-104(geprüft anrelease-1.31): sobaldfsGroupgesetzt ist, läuft über jede Datei des VolumesDas Gruppen-Lesebit wird also hinzu-ge-ODERt, unabhängig von
defaultMode. Damit istmode & 0o077 != 0immer wahr und Garage verweigert den Start.Und es gibt keine Kombination, die beides erfüllt, weil Kubernetes an einer Secret-Volume-Datei nur die Gruppe setzen kann, niemals den besitzenden Benutzer:
fsGroupgesetzt,defaultModebeliebigroot:1000, Gruppenbit immer gesetzt0o077-Prüfung)fsGroup,defaultMode: 0400root:root 0400EACCES— „Failed to read secret file"fsGroup, Vorgabe0644root:root 0644runAsUser: 0Zwei Wege hinaus, und sie sind nicht gleichwertig:
(a)
GARAGE_RPC_SECRETalsenv.valueFrom.secretKeyRef.src/garage/secrets.rs:22-28führt den Wert mitenv = "GARAGE_RPC_SECRET", undsrc/garage/main.rs:50flachtSecretsin die globalen Optionen ein — die Variable gilt also fürgarage servergenauso wie fürkubectl exec … -- /garage status, das dieselbe Berechtigung braucht. Keine Datei, keine Dateirechte, keine abgeschaltete Prüfung, und das eigentliche Ziel des Plans ist erfüllt: das Geheimnis bleibt ein Secret und landet nicht in der ConfigMap.(b)
rpc_secret_file+allow_world_readable_secrets = true. Funktioniert, kostet aber die Prüfung für alle Geheimnisdateien dieses Prozesses — und die Datei ist dabei gar nicht fremdlesbar, Garage kann es nur nicht unterscheiden. Upstream kennt den Fall übrigens: der Doc-Kommentar am Wert lautet „Skip the permission check of secret files. Useful when POSIX ACLs (or more complex chmods) are used." (src/util/config.rs:86-89) — der Schalter ist für genau diese Lage gedacht, nur eben pauschal.Mein Vorschlag: (a), mit dem Mechanismus als Kommentar an
secrets.rpcSecretRefinvalues.yaml, plus einLessons-Learned-Eintrag zur allgemeinen Form: eine Rechteprüfung, die annimmt, ihr Dateisystem zu besitzen, trifft auf eine Plattform, die nur die Gruppe setzen kann — das wiederholt sich bei jedem Programm mit*_secret_fileund einem0o077-Test (Garage selbst hat drei davon:rpc_secret_file,admin_token_file,metrics_token_file).Fund 2 —
garage server --single-nodemacht genau das, wofür Entscheidung 5 Handarbeit vorsieht, und entkräftet beide BegründungenEntscheidung 5 steht auf zwei Prämissen. Beide treffen für v2.4.1 bei einem Knoten nicht mehr zu.
Die Schalter,
src/garage/cli/structs.rs:95-108:Prämisse 1 („
layout apply --version N+1ist nicht idempotent, ein Job beihelm upgradeist gefährlich") fällt.initial_configinsrc/garage/server.rs:190-256prüft die Layout-Version im Prozess, nicht über einen wiederholbaren CLI-Aufruf:replication_factor != 1→ Abbruch mit „Single-node mode requires replication_factor = 1 in the configuration file." (genau unsere Konfiguration, also kein Hindernis)0→ Layout wird angelegt: Zonedc1, Kapazität aus der erkannten Plattengröße, Tagdefault, dannapply_staged_changes(1)1→ nichts passiert, der Start läuft normal weiter> 1→ Abbruch mit „Refusing to run in single-node mode: layout version is already superior to 1. Remove the --single-node flag to run the server in full mode."Das ist über Neustarts hinweg idempotent, und die Falle, vor der der Plan zu Recht warnt, entsteht gar nicht: es gibt keinen
--version-Parameter, den jemand zweimal mit demselben Wert aufrufen könnte.Prämisse 2 („Der Zugangsschlüssel, den Garage erzeugt, muss in das 1Password-Item wandern. Das kann kein Job.") fällt und kehrt sich um.
--default-access-keyliestGARAGE_DEFAULT_ACCESS_KEYundGARAGE_DEFAULT_SECRET_KEYaus der Umgebung und importiert den Schlüssel (server.rs:259-300,Key::importin Zeile 291). Die Richtung ist damit die bessere: der Betreuer erzeugt das Schlüsselpaar im Tresor, das Item ist die Quelle der Wahrheit, und Garage übernimmt es — statt dass Garage es erzeugt und es von Hand hinterherkopiert wird. Der Wiederholungsfall ist in der Quelle abgesichert: existierender Schlüssel mit anderem Geheimnis → Fehler; im Cluster gelöschter Schlüssel → Fehler („cannot add it back").--default-bucketlegt zusätzlich den Bucket an und bindet den Schlüssel daran.Dazu die Reihenfolge, die für die Readiness-Entscheidung zählt:
initial_config(&garage, opt).await?steht inserver.rs:54— vor dem Start von S3-, Web- und Admin-Server (server.rs:83bzw.124). Mit--single-nodeist der Knoten also layout-fertig, bevor er überhaupt auf einem Port antwortet.Und die Folge für die CI, die mir die wichtigste ist: mit
--single-nodeerreicht der Pod von alleinReady, der Install-Test im PR wird dadurch aussagekräftig und schließt drei der vier offenen Messpunkte bei jedem PR. Ohne ihn kann kein Install-Test bestehen — eine ehrliche Readiness kann vor dem Layout von Hand nicht grün werden —, und die Fixture-Entscheidung müsste auf einen begründeten Ausschluss inci/excluded-charts.txthinauslaufen, der nichts belegt.Mein Vorschlag: Wert
garage.singleNode, Vorgabetrue(RF 1 ist ohnehin gesetzt, und der Schalter verlangt genau das);--default-access-key/--default-bucketüber eigene Werte dazu, gespeist aus demselbenOnePasswordItem, das Stalwart liest.NOTES.txtbehält die Handarbeits-Prüfliste fürsingleNode: falseund führt im Vorgabefall die Tresor-Felder, die jetzt hineingehen statt heraus.Was der Plan unverändert richtig hat (gegen die Quelle bestätigt)
metadata_snapshots_dirheißt genau so, ist oberste Ebene,Option<PathBuf>, Vorgabe<metadata_dir>/snapshots(src/util/config.rs:35,src/model/snapshot.rs:44-50).src/db/lmdb_adapter.rs:162-168:copy_to_path(path, CompactionOption::Enabled)) und das Zielverzeichnis wird mitcreate_dir_allangelegt (snapshot.rs:52). Keinrename, also keinEXDEVüber die Dateisystemgrenze — ein eigenes PVC für die Schnappschüsse funktioniert.KEEP_SNAPSHOTS = 2(snapshot.rs:17), der erste nachinterval/2(AutoSnapshotWorker::new:now + interval/2), jeder weitere mit einem Zufallsfaktor 1,0–1,2 (snapshot.rs,rand_factor). Bei"6h"also erster Schnappschuss nach ~3 h, Fenster danach ~6–14,4 h statt 6–12 h.metadata_dirunddata_dirals zwei Unterverzeichnisse einer PVC geht, und Garage legt beide selbst an (src/model/garage.rs:100-104,create_dir_all). Das ist hier nicht nur bequem, sondern notwendig: das Abbild istFROM scratchmit genau einer Datei,/garage(Upstream-Dockerfile). Es gibt keine Shell, keinmkdir— ein Init-Container wäre ein zweites Abbild für etwas, das die Anwendung selbst tut, undexec-Probes sind in diesem Chart grundsätzlich unmöglich.db_engine = "lmdb",metadata_fsync = true— unverändert.Die Readiness-Frage ist aus der Quelle beantwortet
Der Plan fragt, ob die S3-API vor dem Layout antwortet. Sie tut es: der S3-Server wird unabhängig vom Layout-Zustand gestartet (
server.rs:83-95), der Socket bindet also und antwortet — mit Fehlern. Eine TCP-Probe auf 3900 wäre damit genau die Lüge, vor der der Plan warnt.Ehrlich ist
/healthauf dem Admin-Port: ohne Token (src/api/admin/router_v2.rs:31,@special), undsrc/rpc/system.rs:462-470liefert für ein nicht konfiguriertes Layout den SonderfallClusterHealthStatus::Unavailable, densrc/api/admin/special.rs:75-96auf 503 abbildet („Quorum is not available for some/all partitions, reads and writes will fail");HealthyundDegradedergeben 200. Also: Readiness aufGET /health:3903, und Liveness und Startup ausdrücklich nicht — ein Knoten, der nur auf sein Layout wartet, würde sonst endlos neu gestartet (README, Leitlinie 12.3, derselbe Grund wie beizipline). Die beiden gehen auf eine TCP-Probe des Admin-Ports.Zwei Fragen, kurz zu beantworten
GARAGE_RPC_SECRETpersecretKeyRef(a), oderrpc_secret_filemitallow_world_readable_secrets = true(b)?garage.singleNodemit Vorgabetruesamt--default-access-key/--default-bucketaus dem Tresor-Item — oder bleibt es bei Handarbeit nachNOTES.txt, und wenn ja, soll die Fixture-Entscheidung dann der Ausschluss inci/excluded-charts.txtsein?Bis dahin ist nichts gebaut, und der Branch ist nicht angelegt.
Entscheidungen zu den beiden Funden — der Plan wird an zwei Stellen korrigiert
Beide Funde sind gegen den Quelltext am Tag
v2.4.1nachgeprüft und tragen.Fund 1,
src/garage/secrets.rs:135-146:Die Maske verwirft jedes Gruppen- oder Fremdbit; die Meldung sagt „world-readable", geprüft wird aber auch die Gruppe.
fsGroupsetzt genau dieses Gruppen-Lesebit — ohne es kann uid 1000 die Datei nicht lesen, mit ihm verweigert Garage den Start. Kubernetes kann an einer Secret-Datei nur die Gruppe setzen, nie den Eigentümer, also existiert keine funktionierende Kombination.Fund 2,
src/garage/cli/structs.rs:95-108:--single-node,--default-access-key(GARAGE_DEFAULT_ACCESS_KEY/GARAGE_DEFAULT_SECRET_KEY),--default-bucket(GARAGE_DEFAULT_BUCKET), mit dem dokumentierten Schutz „Garage will refuse to run if the cluster already has other nodes."Entscheidung 1:
GARAGE_RPC_SECRETals UmgebungsvariablePer
valueFrom.secretKeyRefaus demOnePasswordItem. Das eigentliche Ziel des Plans — nichts in der ConfigMap — ist damit erfüllt, und keine Prüfung wird abgeschaltet. Es ist das überwiegende Muster dieses Repositories;paperless-ngxundstalwartholen ihre Geheimnisse genauso.Der Preis gehört an den Wert geschrieben: das Geheimnis steht in der Prozessumgebung des Containers. Unser
valkey-Chart argumentiert ausdrücklich dagegen („the password never appears in the process list, in an environment variable"). Dass es hier anders ausfällt, hat einen Grund und der gehört dokumentiert: beivalkeywar die Datei ohnehin der natürliche Weg, weil die Konfiguration sieincludet; hier ist der Dateiweg strukturell versperrt, nicht nur unbequem. Garage führt den Wert mithide_env_values = true, gibt ihn also in eigenen Ausgaben nicht preis.Verworfen:
allow_world_readable_secrets = true— würde die Prüfung für alle drei Geheimnisdateien aufheben (rpc_secret_file,admin_token_file,metrics_token_file), auch für künftige, die noch niemand gesetzt hat. Eine Sicherheitsprüfung abzuschalten, um ein Berechtigungsmodell zu bedienen, ist der schlechtere Tausch.FROM scratchohne Shell, der Init bräuchte also ein eigenes Abbild zum Pinnen und Verfolgen, plus ein emptyDir, in dem ein Geheimnis liegt.Entscheidung 2:
--single-nodestatt Handarbeit — mein Plan war an dieser Stelle falsch begründetIch hatte „Erststart von Hand" mit zwei Gründen belegt, und beide fallen:
layout applyist nicht idempotent" gilt für den CLI-Befehl, aber--single-nodenimmt diesen Weg nicht: es prüft die Layout-Version im Prozess und hat keinen--version-Parameter, der zweimal gleich aufgerufen werden könnte. Voraussetzung ist RF 1 — genau unsere Konfiguration.--default-access-keyimportiert den Schlüssel aus der Umgebung. Das Tresor-Item wird damit Quelle der Wahrheit statt Ablage für etwas, das hinterher hineinkopiert wird.Entscheidend ist die Folge für die Prüfbarkeit:
initial_configläuft vor dem S3- und dem Admin-Server, der Pod erreicht also von alleinReady. Damit wird der Install-Test aussagekräftig und schließt drei der vier Messpunkte bei jedem PR. Ohne das Flag kann kein Install-Test bestehen, und die Fixture-Entscheidung wäre auf einen Ausschluss hinausgelaufen, der nichts belegt.Umsetzung:
garage.singleNodeals Wert mit Vorgabetrue— ein Standard, der nichtReadywird, ist kein Standard.--default-access-keyund--default-bucketwerden mitgeschaltet, die vier Tresor-Felder (RPC-Geheimnis, Access Key, Secret Key, Bucket-Name) stehen inNOTES.txt. Der Schutz „refuse to run if the cluster already has other nodes" gehört als Zusicherung dazu: das Flag kann einen bestehenden Cluster nicht beschädigen.Damit entfällt die Fixture-Frage:
ci/garage/wird gebaut, kein Ausschluss.Entscheidung 3: die Probes, wie hergeleitet
Die S3-API bindet vor dem Layout (
server.rs:83-95), eine TCP-Probe auf 3900 wäre also genau die Lüge, vor der der Plan warnte.GET /healthan Port 3903 — ohne Token, 503 bei unkonfiguriertem Layout, 200 beiHealthy/Degraded.zipline, README 12.3.Was mitkorrigiert wird
FROM scratchmit genau einer Datei/garage. Keine Shell:exec-Probes sind in diesem Chart grundsätzlich unmöglich, und jeder Init-Container bräuchte ein eigenes Abbild. Gehört invalues.yamlund auf die Wiki-Seite, weil es jede künftige Runde betrifft.interval/2, danach Zufallsfaktor 1,0–1,2,KEEP_SNAPSHOTS = 2. Bei"6h"also ~3 h und danach ~6–14,4 h — die Angabe „6–12 h" im Plan war zu eng.metadata_snapshots_dirheißt genau so, oberste Ebene, Vorgabe<metadata_dir>/snapshots— das zweite PVC bleibt richtig, und der Schnappschuss ist eine echte Kopie mit Kompaktierung, also keinrenameüber eine Dateisystemgrenze.metadata_dirunddata_dirals Unterverzeichnisse einer PVC trägt; Garage legt beide selbst an.dxflrs/amd64_garage; das im Issue verifiziertedxflrs/garageist das Multi-Arch-Abbild und bleibt richtig.Alles Übrige aus dem Plan-Kommentar bleibt unverändert gültig: RF 1 mit benanntem Restrisiko,
lmdbmitmetadata_fsync = trueund Auto-Schnappschuss, zwei PVCs in der beschriebenen Aufteilung, kein Ingress, keine Discovery-CRD.Stand nach dem Merge (2026-10-04)
garage/liegt aufmain, Version 1.0.0+upv2.4.1, veröffentlicht. PR #82 gemergt am 2026-10-04 um 15:09 mitPart of #81— dieses Issue ist deshalb bewusst offen.In der CI belegt (Lauf 447, alle sechs Kontexte grün): der Container execed mit
drop: [ALL]+add: [NET_BIND_SERVICE]gegen dassetcap +epdes Abbilds — dercapabilities(7)-Schluss war vorher nur aus der Spezifikation abgeleitet, jetzt ist er beobachtet;/healthwird auf dem Recovery-Listener bedient und antwortet einer Kubelet-Probe;readOnlyRootFilesystem: truehält mit den drei emptyDirs; dieauthSecret-Indirektion über eine Umgebungsvariable löst auf, das Passwort steht nirgends in der ConfigMap.Offen bleibt eine Prüfung, und sie ist nicht CI-schließbar:
Landet der Metadaten-Schnappschuss wirklich in
metadata_snapshots_dir? Der erste ist nachinterval/2fällig — länger als jeder CI-Lauf, und ein Intervall von Sekunden würde den Timer prüfen statt den Mechanismus. Schritt 6 der Prüfliste auf der Wiki-Seite.Das ist der Punkt, an dem die Sicherungsgeschichte hängt: Dateisystem-Schnappschüsse von
metadata_dirsind kein gültiger Rücksetzpunkt (wird während eines Schreibvorgangs geschnappschosst, ist der Schnappschuss selbst beschädigt), deshalb liegt der konsistente Rücksetzpunkt auf dem zweiten Volume.Teilweise offen: Longhorns Schnappschuss-Semantik, auf der die Ein-PVC-Aufteilung ruht, ist eine Eigenschaft der Speicherschicht — der Runner hat
local-path. Die Haltbarkeit vonmetadata_fsyncist im Container grundsätzlich nicht beobachtbar. Und es hat nirgends ein echter S3-Schreibvorgang stattgefunden: dass Bucket und Zugangsschlüssel existieren, folgt aus dem Start, dass einPUTdurchgeht, ist ungeprüft.