diff --git a/docs/superpowers/specs/2026-08-04-release-decke-design.md b/docs/superpowers/specs/2026-08-04-release-decke-design.md new file mode 100644 index 0000000..7ec9312 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-release-decke-design.md @@ -0,0 +1,194 @@ +# Release-Decke: gezielt vorwärts festnageln — Entwurf + +**Stand:** 2026-08-04 +**Zustand:** Vom Betreiber abschnittsweise freigegeben. Umfang, Semantik, +Ansatz, Fehlerverhalten und Testschnitt sind entschieden. + +## Worum es geht + +Ein CluPilot-Server nimmt heute immer die **neueste** Version. Wer zehn Server +betreibt, kann eine Auslieferung nicht staffeln: entweder alle bekommen v1.8.1, +oder niemand. Der einzige Griff, der eine bestimmte Version setzt, ist + +``` +sudo -u clupilot env RELEASE=vX.Y.Z bash /opt/clupilot/deploy/update.sh +``` + +— Handarbeit auf der Kommandozeile, zehnmal. + +Gewünscht ist derselbe Griff in der Konsole. + +## Was das ausdrücklich NICHT ist + +**Kein Zurückrollen.** `deploy/update.sh:222` weist einen Rückschritt +ausdrücklich ab, und das bleibt so: + +> *„Backwards is not an update. The database has already been migrated forward, +> and older code against a newer schema is the one failure this script exists to +> prevent — with the added cruelty that the migrations needed to roll back are +> not in the older checkout at all."* + +Die Fehlermeldung des Skripts verweist auf einen Datenbank-Schnappschuss „taken +before the upgrade" — **den nimmt heute niemand.** In `update.sh` steht kein +`mysqldump`, kein `mariadb-dump`, kein Schnappschuss irgendeiner Art. Der +Ausweg, den das Skript nennt, existiert nicht. + +Echtes Zurückrollen ist deshalb eine **eigene Baustelle**, und ihr fehlendes +Stück ist der Schnappschuss, nicht der Knopf. Sie ist hier bewusst nicht +enthalten. + +## Was entschieden ist + +| Frage | Entscheidung | +|---|---| +| Umfang | Gezielt **vorwärts** festnageln, kein Rückschritt | +| Semantik | **Stehende Obergrenze**, keine einmalige Bewegung | +| Ablage | Datei `storage/app/deploy/release-ceiling` im Checkout | +| Wirkung | Agent klemmt `TARGET_RELEASE`/`BEHIND` — alles Weitere folgt | +| Fehlerverhalten | Die Decke **fällt zu, nicht auf** | +| Berechtigung | `site.manage`, Operator-Guard (R21) | + +### Warum eine stehende Obergrenze und kein einmaliger Sprung + +`clupilot:auto-update` nimmt im Wartungsfenster, was verfügbar ist, und legt +dieselbe Anfrage ab wie der Knopf — **ohne** `RELEASE`, also immer auf die +neueste Version. Ein einmaliger Sprung auf v1.8.0 wäre beim nächsten +Fensteröffnen sofort wieder auf v1.8.1 gehoben. Festnageln, das nicht hält, ist +kein Festnageln. + +### Warum eine Datei und keine Einstellung in der Datenbank + +Der Agent läuft auf dem **Wirt** und sieht die Datenbank nicht. Er ist aber die +Stelle, die `behind` und `target_release` ausrechnet. Läge die Decke in der +Datenbank, müssten Knopf *und* Automatik sie jeweils selbst anwenden — zwei +Wege, zwei Regelwerke, die auseinanderlaufen. `AutoUpdate.php` reklamiert im +Kopfkommentar ausdrücklich das Gegenteil für sich: + +> *„There is no second path into a deployment, because a second path is a second +> set of rules to keep in step with the first."* + +Schlimmer noch: `behind`/`available` würden weiter gegen den neuesten Tag +gerechnet. Die Konsole meldete „1 Aktualisierung verfügbar" für eine Version, +die sie nie installieren wird. + +### Warum nicht die bestehende `release_mode.source` + +Die Datei gibt es schon und sie hält den festgenagelten Ref — aber +**`update.sh` schreibt sie nach jedem Lauf selbst**. Eine Decke, die dort läge, +würde von genau der Auslieferung überschrieben, die sie begrenzen soll. Und sie +vermischte „wo stehe ich" mit „wie weit darf ich". + +## Wie es wirkt + +Der Angelpunkt ist eine Zeile, die es schon gibt — +`deploy/update-agent.sh:669`: + +```bash +RELEASE="$TARGET_RELEASE" "$ROOT/deploy/update.sh" > "$RUNLOG" 2>&1 +``` + +Der Agent übergibt sein Ziel bereits ausdrücklich. Wird `TARGET_RELEASE` auf die +Decke geklemmt, geht ein Lauf exakt dorthin — ohne dass `update.sh` etwas von +Decken wissen muss. + +Und weil `BEHIND` aus derselben Rechnung fällt, sagt `available` von selbst die +Wahrheit. Knopf und Automatik lesen beide `UpdateChannel::state()`; keiner von +beiden bekommt eine Sonderregel. + +## Bauteile + +| Bauteil | Änderung | +|---|---| +| `deploy/lib/release.sh` | `release_newest_tag` und `release_tags_ahead` bekommen ein optionales Decken-Argument. Beide liegen schon dort, „so a test can run them". | +| `deploy/update-agent.sh` | Liest `release-ceiling`, prüft sie, klemmt `NEWEST_TAG`/`BEHIND`, meldet zusätzlich `ceiling`, `ceiling_error` und `releases` in `update-status.json`. | +| `UpdateChannel` | `setCeiling(string $by, ?string $tag)` schreibt bzw. entfernt die Datei **atomar** und legt zusätzlich eine `KIND_CHECK`-Anfrage ab. `state()` reicht `ceiling`, `ceiling_error`, `releases` durch. | +| `Settings.php` + Blade | Auswahlfeld aus `releases`, „Festnageln" / „Decke abnehmen", Bestätigung im Modal (R23). | + +### Datenfluss + +1. Konsole schreibt `release-ceiling` (www-data im Container, Bind-Mount). +2. Konsole legt zusätzlich eine `KIND_CHECK`-Anfrage ab. +3. Die Pfad-Einheit `clupilot-update-agent.path` weckt den Agenten binnen einer + Sekunde — sonst zöge die Anzeige erst beim nächsten Takt nach. +4. Agent rechnet `behind`/`target_release` gegen die Decke, schreibt den Status. +5. Konsole zeigt „festgenagelt auf v1.8.0". + +Die Decke selbst ist **keine Anfrage an den Agenten**, sondern Zustand, den er +liest. Die `KIND_CHECK`-Anfrage daneben dient nur der Auffrischung der Anzeige. + +### Wählbare Versionen + +Der Agent meldet die Tags, die als Decke in Frage kommen: alle `v*` ab der +ausgelieferten Version aufwärts, auf die **20 neuesten** begrenzt. Ohne Grenze +wüchse die Statusdatei mit der Tag-Historie mit. + +**Die ausgelieferte Version selbst steht mit in der Liste**, und sie ist der +häufigste Fall: „hier einfrieren, nichts Neues nehmen". Genau das braucht der +Besitzer für die neun Server, die auf v1.8.0 bleiben sollen, während einer +v1.8.1 bekommt. + +## Zwei Zustände, die klar lesen müssen + +Beides sind gültige Lagen, keine Fehler: + +- **Decke unter dem Ausgelieferten** (festgenagelt auf v1.8.0, läuft v1.8.1): + `behind = 0`. Die Konsole sagt „festgenagelt, aber bereits weiter" — **nicht** + „aktuell". Zurück geht es nicht, siehe oben. + + Über das Auswahlfeld ist das nicht erreichbar (es bietet erst ab der + ausgelieferten Version an). Erreichbar ist es trotzdem, und zwar auf dem Weg, + den es weiter geben wird: die Decke steht auf v1.8.0, jemand fährt auf der + Kommandozeile `RELEASE=v1.8.1 update.sh`. Die Lage muss also lesbar sein, + statt als Fehler zu erscheinen. +- **Decke gleich dem Neuesten**: verhält sich wie heute ohne Decke. + +## Fehlerbehandlung + +**Der Kern: die Decke fällt zu, nicht auf.** + +Ist die Datei unlesbar, halb geschrieben, oder zeigt sie auf einen Tag, den es +nicht (mehr) gibt, darf der Agent **nicht** stillschweigend auf „neueste +Version" zurückfallen — das installierte genau das, wovon der Besitzer +weggenagelt hat. Stattdessen: `behind = 0`, nichts wird angeboten, und +`ceiling_error` geht in die Statusdatei, damit die Konsole es sagen kann. + +Eine Sicherung, die im Zweifel öffnet, ist keine. + +Dass ein Tag verschwindet, ist hier kein Randfall: der Release-Prozess sieht +ausdrücklich vor, dass ein **falscher Tag gelöscht und die Nummer übersprungen** +wird. + +Weiter: + +- **Atomar schreiben.** Konsole schreibt `.tmp` und benennt um — dasselbe Muster + wie `write_alive`/`write_unblock` im Agenten. Sonst liest der Agent + irgendwann eine halbe Zeile. +- **Form und Existenz sind zwei Fragen.** `^v[0-9]+(\.[0-9]+)*$` *und* der Tag + muss vorhanden sein. +- **Ein laufender Lauf liest die Decke nicht neu.** Der nächste Takt korrigiert. + Keine zusätzliche Sperre — siehe `WatchdogLockContentionTest` dazu, warum + Sperren hier sparsam eingesetzt gehören. + +## Tests + +| Ebene | Was geprüft wird | +|---|---| +| `ReleaseComparisonTest` (besteht) | `release_newest_tag`/`release_tags_ahead` mit Decke — die echten Bash-Helfer | +| neu, Agent | Echtes Skript gegen ein Wegwerf-Repo mit Tags: Decke gesetzt → `behind` zählt nur bis dorthin, `target_release` **ist** die Decke | +| neu, fällt zu | Decke zeigt auf gelöschten Tag → `behind = 0` **und** `ceiling_error`, nicht „neueste" | +| neu, Automatik | `clupilot:auto-update` mit gesetzter Decke tut nichts | +| neu, Konsole | Festnageln schreibt die Datei, „Decke abnehmen" löscht sie, ohne `site.manage` geht beides nicht | + +Der Automatik-Test ist der wichtigste: er belegt, dass Knopf und Automatik +denselben Zustand lesen — die Behauptung, auf der der ganze Entwurf ruht. Ohne +ihn ist „es gibt keinen zweiten Weg" eine Absichtserklärung im Kommentar. + +## Was hier bewusst fehlt + +- **Zurückrollen** und der dafür nötige Datenbank-Schnappschuss — eigene + Baustelle, siehe oben. +- **Flottenweites Setzen** („alle zehn Server auf v1.8.0"). Jeder Server hat + seine eigene Konsole; eine flottenweite Steuerung ist ein anderes Produkt. +- **Zeitgesteuertes Anheben** („ab Freitag v1.8.1 erlauben"). Das + Wartungsfenster gibt es schon; eine zweite Zeitsteuerung daneben wäre eine + zweite Regel, die mit der ersten in Takt gehalten werden müsste.