Files
KantSites/docs/UPDATE.md
T
kantarciandClaude Opus 5.5 723680b7de Add the central site registry and remote updates
Every installation now reports address and version to sites.kant-it.de
every 15 minutes and picks up update orders there (ADR 123). Reports and
answers are HMAC-signed with a per-installation key sent on first
contact; an order can only install a published release with checksum.
The next request after the file swap runs the migrations, so a remote
update finishes without anyone in the admin.

The platform itself lives in zentrale/ (plain PHP + SQLite, not part of
the release archive): overview with outdated, silent and unassigned
installations, single and bulk updates, per-site detail and history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:43:13 +02:00

18 KiB
Raw Permalink Blame History

Updateverfahren: neue Version über Git ziehen

Stand: 12.09.2026. Umgesetzt (ADR 035): src/Ops/Updater.php, cms:update, cms:health und der Bildschirm „System“ in der Verwaltung. Dieser Text bleibt die Begründung der Reihenfolge; was davon abweicht, steht am Ende unter „Was anders wurde“.

Was ein Update heißt

Ein Update ist ein Wechsel des Codes auf einen bestimmten Git-Tag, gefolgt von einer Datenbankmigration. Drei Dinge werden dabei nie angefasst, weil in ihnen alles steckt, was nicht wiederherstellbar ist:

  • var/cms.db (beziehungsweise später MySQL) – die Inhalte,
  • der Medienordner aus MEDIA_DIRECTORY – die Originale und alle abgeleiteten Größen,
  • .env – Datenbankzugang, APP_SECRET, Ursprungsadresse.

Alle drei stehen in .gitignore. Das ist die Voraussetzung dafür, dass ein git checkout sie nicht berührt, und der erste Punkt, den der Updater prüfen muss, statt ihn anzunehmen.

Die Version

Es braucht eine Angabe, die man ohne Git lesen kann, sonst weiß die Oberfläche nicht, worauf sie steht:

  • Eine Datei VERSION im Projektstamm mit dem Tag, zum Beispiel 1.4.0. Sie ist Teil des Tags selbst.
  • Tags heißen v<major>.<minor>.<patch>. Ein Tag, kein Branch: ein Branch verschiebt sich unter den Füßen, ein Tag nicht.
  • Die Verwaltung zeigt Version, Commit-Kurzhash und das Datum des Tags, sowie die höchste angewandte Migration. Das ist die Angabe, die im Fehlerfall zählt.

Ablauf

Jeder Schritt hat eine Abbruchbedingung. Bricht ein Schritt ab, bleibt der Wartungszustand an und der Updater sagt, welcher Schritt gescheitert ist – er versucht nichts zu reparieren, was er nicht selbst angelegt hat.

  1. Vorprüfung. git vorhanden und aufrufbar (proc_open ist auf geteiltem Hosting oft abgeschaltet – dann endet der Updater hier mit einer klaren Meldung und dem Konsolenweg). Arbeitsverzeichnis unverändert (git status --porcelain leer), sonst würde ein Checkout lokale Änderungen verwerfen. Schreibrechte auf Projektstamm, var/ und Medienordner. PHP-Version erfüllt composer.json. Freier Plattenplatz mindestens die Größe der Datenbank plus 100 MB.
  2. Ziel bestimmen. git fetch --tags origin, dann den höchsten v*-Tag nach Semantik, oder einen ausdrücklich gewählten. Die Herkunft (origin) steht in der Konfiguration, nicht in der Anfrage: ein Updater, der eine Adresse aus dem Formular zieht, ist eine Fernsteuerung für fremden Code.
  3. Sichern. Datenbank kopieren (bei SQLite Datei, bei MySQL mysqldump), dazu .env und den aktuellen Commit-Hash, nach var/backups/update-<zeitstempel>/. Der Medienordner wird nicht mitkopiert – er ist zu groß, und kein Update schreibt darin. Das steht auch so in der Oberfläche, damit niemand die Sicherung für vollständig hält.
  4. Wartungszustand an. Eine Datei var/maintenance. Öffentliche Anfragen antworten mit 503, einer erklärenden Seite und Retry-After; /admin bleibt erreichbar, sonst kann niemand das Update beenden. Suchmaschinen verstehen 503 als „später wieder“, nicht als „weg“.
  5. Code wechseln. git checkout --force <tag>. Danach prüfen, dass VERSION den erwarteten Wert hat – erst das beweist, dass der Wechsel wirklich stattgefunden hat.
  6. Abhängigkeiten. composer install --no-dev --optimize-autoloader, wenn Composer auf dem Server aufrufbar ist. Wenn nicht – der wahrscheinliche Fall auf geteiltem Hosting – gilt: ändert dieses Update composer.lock, bricht der Updater hier ab und verweist auf das Releasearchiv, das vendor/ mitbringt (scripts/build-release.php). Ändert das Update die Abhängigkeiten nicht, läuft es ohne Composer durch. Der Vergleich läuft über die Prüfsumme der Datei vor und nach dem Checkout, nicht über Vertrauen.
  7. Migrieren. doctrine-migrations migrate --no-interaction. Die Migrationen sind bewusst nicht umkehrbar: down() würde Spalten entfernen, in denen Inhalte stehen. Der Rückweg ist die Sicherung aus Schritt 3, nicht ein down().
  8. Cache. cache:clear --env=prod und Aufwärmen. Der Grund steht in docs/QUALITY.md: ein laufender Prozess kann einen älteren kompilierten Container halten und dann Seiten mit 500 beantworten, obwohl der Code richtig ist. Bei PHP-FPM gehört ein Neuladen dazu.
  9. Rauchtest. Intern /, /admin/login und /sitemap.xml abrufen und auf 200 prüfen, dazu eine Abfrage auf page und revision. Schlägt das fehl, gilt das Update als gescheitert, auch wenn jeder Schritt davor gemeldet hat, dass er fertig ist.
  10. Wartungszustand aus, Protokoll schreiben. Nach var/updates.log: Zeitstempel, alte und neue Version, angewandte Migrationen, Dauer, Ergebnis.

Wenn es schiefgeht

Der Rückweg ist derselbe Weg rückwärts, in dieser Reihenfolge, und er ist genauso Teil des Plans wie der Hinweg:

  1. Wartungszustand bleibt an.
  2. git checkout --force <alter Commit>.
  3. Abhängigkeiten des alten Stands wiederherstellen (derselbe Fall wie Schritt 6).
  4. Datenbank aus der Sicherung zurückschreiben – aber nur, wenn Schritt 7 begonnen hatte. Sonst nicht: ein Rückschreiben verliert alles, was seit dem Update entstanden ist, und wenn keine Migration lief, ist auch nichts zu reparieren.
  5. Cache räumen, Rauchtest, Wartungszustand aus.

Der teuerste Fall ist ein Update, das durchläuft und erst Tage später als falsch erkannt wird. Dagegen hilft kein Updater, sondern die Versionsangabe in der Verwaltung und das Protokoll: man muss ohne Rätselraten sagen können, was wann eingespielt wurde.

Was gebaut wird

  • src/Ops/Updater.php – die Schritte als einzeln aufrufbare Methoden, jede mit einer Prüfung statt einer Annahme, und ein Protokoll je Schritt.
  • src/Command/UpdateCommand.php (cms:update) – der Weg, der immer funktioniert, auch ohne Weboberfläche. --check prüft nur, --to=v1.4.0 wählt ein Ziel, --dry-run läuft ohne Checkout und Migration.
  • Ein Bildschirm unter „Einstellungen“ oder „System“, nur für Administratoren, mit CSRF: aktuelle Version, verfügbare Version, Protokoll der letzten Updates, ein Knopf. Kein automatisches Update nach Zeitplan – ein Update ist eine Entscheidung, nicht ein Hintergrunddienst.
  • Wartungszustand als Abonnent, der vor allem anderen greift.
  • Tests: Vorprüfung schlägt bei unsauberem Arbeitsverzeichnis fehl; ein gescheitertes Update lässt den Wartungszustand an; die Sicherung entsteht vor dem Checkout; ein erfolgreiches Update schreibt Protokoll und neue Version. Geprüft gegen ein wegwerfbares Git-Repository im Temp-Verzeichnis, nicht gegen das Projekt selbst.

Was ausdrücklich nicht gebaut wird

  • Kein Updater, der eine Adresse, einen Branch oder ein ZIP aus dem Formular lädt. Die Herkunft steht in der Konfiguration.
  • Kein automatisches Update im Hintergrund.
  • Kein down()-Rückweg für Migrationen. Der Rückweg ist die Sicherung, und das ist eine Entscheidung, nicht eine Lücke.
  • Kein Update ohne Sicherung, auch nicht mit Häkchen „ich weiß, was ich tue“.

Der zweite Weg: Update aus dem Releasearchiv

Alles bis hierhin setzt eine Git-Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht – dort wird über install.php aus dem Releasearchiv installiert, und danach stand in der Verwaltung „Dieses Verzeichnis ist keine Git-Arbeitskopie". src/Ops/ArchiveUpdate.php ist der Weg für genau diese Installationen (ADR 068). Er steht im Bildschirm „System" als eigene Karte neben dem Update über Git.

Der Ablauf ist derselbe wie oben, nur ohne git:

  1. Fassung ermitteln. Die Releaseliste nennt den neuesten Tag. Antwortet sie nicht – viele Hoster lassen keine ausgehenden Verbindungen zu –, endet es hier, und es wurde nichts angefasst.
  2. Sichern. Datenbank und Medien nach var/backups/, vor dem ersten Handgriff.
  3. Archiv laden und Prüfsumme vergleichen. Stimmt sie nicht, wird nicht entpackt. Die Adresse steht in der Konfiguration, nicht in der Anfrage.
  4. Entpacken und auf Vollständigkeit prüfen. Fehlen VERSION oder vendor/, ist es kein brauchbares Release – und das fällt auf, bevor der Wartungszustand angeht.
  5. Wartungszustand an, Dateien ersetzen. bin, config, migrations, src, templates, vendor, composer.json, composer.lock, migrations.php, cli-config.php, VERSION. Der alte Stand wandert nach var/update/<zeitstempel>/alt/, er wird nicht gelöscht.
  6. public/ wird zusammengeführt, nicht ersetzt. Dort liegen Dateien der Anwendung und Dateien des Betreibers nebeneinander: assets/fonts (hochgeladene Schriften) und assets/build (das erzeugte Stylesheet-Bündel) bleiben unangetastet.
  7. Zwischenspeicher leeren.
  8. Zweiter Knopf: Migrationen. Sie laufen in einer eigenen Anfrage, weil ein PHP-Prozess, der sich selbst ausgetauscht hat, mit den Klassen weiterarbeitet, die er beim Start geladen hat. Erst danach geht der Wartungszustand aus.

Unberührt bleiben in jedem Fall var/, .env und .env.local. Scheitert das Ersetzen mittendrin, schiebt der Updater zurück, was er bewegt hat, und nimmt den Wartungszustand wieder heraus.

Geprüft wird ohne Netz (tests/ArchiveUpdateTest.php): eine Releaseseite im Speicher, echte Zip-Archive, echte Prüfsummen, echte Umbenennungen in einem Wegwerfverzeichnis. Ein Test hält zusätzlich die Ersetzungsliste gegen die Mitnahmeliste von scripts/build-release.php: Was neu ins Archiv kommt, muss beim Update auch ersetzt werden.

Der dritte Weg: von Hand über FTP

Der Weg für den einen Fall, in dem die Karte im Bildschirm „System" noch nicht existiert: das Update auf die Fassung, die sie mitbringt. Eine Installation der Fassung 1.4.2 kennt sie nicht – sie muss einmal von Hand nachgezogen werden, danach gibt es den Knopf.

  1. Sichern. Verwaltung → System → „Sicherung anlegen". Zusätzlich per FTP var/ und .env herunterladen. Migrationen sind nicht umkehrbar; der Rückweg ist die Sicherung.
  2. Archiv holen und auspacken. kantsites-<fassung>.zip von der Releaseseite, auf dem eigenen Rechner entpacken. Darin liegt ein Ordner kantsites-<fassung>/.
  3. Wartungszustand einschalten. Verwaltung → System → „Wartung". Sonst sieht ein Besucher währenddessen eine halb ersetzte Anwendung.
  4. Diese Einträge hochladen und ersetzen: bin, config, migrations, src, templates, vendor, composer.json, composer.lock, migrations.php, cli-config.php, VERSION. Verzeichnisse ersetzen, nicht hineinkopieren – eine Datei, die es in der neuen Fassung nicht mehr gibt, muss weg.
  5. public/ Datei für Datei – und public/assets/fonts und public/assets/build auslassen. Dort liegen die hochgeladenen Schriften und das erzeugte Stylesheet-Bündel; beide gehören dem Betreiber und stehen in keinem Archiv.
  6. Nicht anfassen: var/, .env, .env.local. Dort stecken Inhalte, Medien und APP_SECRET.
  7. var/cache vollständig löschen. Sonst läuft die neue Fassung mit den Wegweisern der alten und antwortet mit 500, obwohl der Code richtig ist.
  8. Migrationen ausführen. Verwaltung → System → Karte „Migrationen stehen aus" → Knopf. Die Karte erscheint nur, wenn welche ausstehen. Wer eine Konsole hat, kann stattdessen php vendor/bin/doctrine-migrations migrate --no-interaction aufrufen.
  9. Wartungszustand aus, Startseite und Verwaltung einmal aufrufen, Fassungsnummer im Bildschirm „System" prüfen.

Das ist einmalig: Ab 1.4.3 kennt jede Installation den Weg über das Archiv, und dann entfällt das alles. Dann steht im selben Bildschirm die Karte „Update aus dem Releasearchiv", und die Schritte 2 bis 8 erledigt sie selbst.

Der vierte Weg: aus der Zentrale

Ab 1.36.0 meldet sich jede Installation bei sites.kant-it.de und holt dort angestoßene Updates ab. Es läuft derselbe Archivweg wie über den Knopf: Sicherung, Archiv mit Prüfsumme, Dateitausch, Migrationen mit dem neuen Code, Prüfung der Startseite, sonst Zurückschalten. Den zweiten Schritt erledigt die nächste Anfrage an die Website; die Aufgabe ruft dafür die eigene Startseite einmal auf. Git-Arbeitskopien lehnen Aufträge ab. Einzelheiten: ADR 123, zentrale/README.md.

Ein Release veröffentlichen

php scripts/build-release.php baut auf einem Rechner mit Composer das Archiv – Anwendung plus vendor/ ohne Entwicklungspakete – und schreibt die SHA-256-Summe in eine Kopie von install.php. Beides gehört anschließend an das Git-Release des passenden Tags:

var/releases/kantsites-<fassung>.zip          → Release-Anhang
var/releases/kantsites-<fassung>.zip.sha256   → Release-Anhang
var/releases/install.php                      → Release-Anhang

install.php erwartet das Archiv unter https://git.kant-it.de/kantarci/KantSites/releases/download/v<fassung>/kantsites-<fassung>.zip. Genau dort landet es, wenn es als Anhang am Release dieses Tags hängt – eine andere Adresse lässt sich beim Bauen mit --url= setzen.

Ein privates Repository liefert diese Adresse nicht aus. Ohne Anmeldung antwortet sie mit 404, und install.php kann nichts herunterladen; das ist der Zweck der Prüfsumme, nicht ihr Fehler. Damit die Installation auf einem fremden Hosting funktioniert, müssen Archiv und Installer ohne Anmeldung erreichbar sein – entweder weil das Repository öffentlich ist, oder weil beide Dateien auf einem eigenen Webspace liegen und das Release mit --url= darauf gebaut wurde.

Offen, bevor das gebaut wird

  1. Composer auf dem Server oder vendor/ im Tag? Entschieden: vendor/ gehört nicht ins Repository, sondern in ein Releasearchiv, das ein Build-Rechner erzeugt (scripts/build-release.php, SHA-256 daneben). Der Updater über Git bleibt für Installationen mit Composer und für Updates ohne Abhängigkeitsänderung.
  2. Wie kommt der Code auf den Server? Git-Klon im Projektstamm setzt Shell-Zugriff voraus. Gibt es den nicht, ist der Updater in dieser Form nicht möglich, und der Weg ist ein Deployment von außen (rsync eines Releaseverzeichnisses, Umschalten eines Symlinks). Das ist derselbe Ablauf, nur mit einem anderen Schritt 5.
  3. Ein Repository oder zwei? Für den Piloten reicht eines. Sobald mehrere Websites dasselbe CMS nutzen, gehört der Code in ein eigenes Repository und die Inhalte bleiben je Installation.

Was anders wurde

  • Kein symfony/process, sondern ein eigener Runner über proc_open mit Befehl als Array. Grund: eine Abhängigkeit weniger, kein Shell-Quoting, und „proc_open ist abgeschaltet“ wird die erste Antwort der Vorprüfung statt einer Überraschung im dritten Schritt.
  • Der Rückweg endet online, wenn er kann. Geplant war, den Wartungszustand nach einem Fehlschlag anzulassen. Umgesetzt ist: nach dem Zurücksetzen läuft derselbe Rauchtest; antwortet der alte Stand, geht die Website wieder an. Nur wenn auch der alte Stand nicht antwortet, bleibt der Wartungszustand – dann ist eine ehrliche Meldung besser als eine kaputte Seite.
  • Die Datenbank wird nur zurückgeschrieben, wenn die Migration schon begonnen hatte. Sonst würde der Rückweg Inhalte verlieren, die während des Updates entstanden sind.
  • Der Rauchtest ist cms:health und läuft als eigener Prozess, weil nur ein frischer Prozess den neuen Code ausführt.
  • Noch offen: MySQL wird gesichert, indem mysqldump aufgerufen wird – das ist nicht umgesetzt, weil noch keine MySQL-Installation existiert (Arbeitspaket J). Bei einer Datenbank, die nicht als Datei vorliegt, sichert der Updater derzeit nur die Umgebungsdateien und sagt das im Schritt „Sicherung“. Vor dem ersten Livegang mit MySQL muss das nachgezogen werden.
  • Inzwischen umgesetzt: der Installer (/install, ADR 041) und der Weg ohne Git über das Releasearchiv (scripts/install.php, System → Update aus dem Archiv). Offen bleibt allein die MySQL-Sicherung.

Übergang auf 1.7.0: .env gehört zur Installation

Der Archiv-Updater erhält die Konfiguration auch bei diesem Übergang. Der erste Start führt eine bestehende .env.local mit .env zusammen und sichert beide Originale in var/backups/environment-*. Danach ist allein .env maßgeblich; tatsächliche Servervariablen bleiben möglich. Datenbank, Medienordner und Secret bleiben die bisher wirksamen Werte. Siehe ADR 076 und README.

In alten Git-Installationen war .env noch versioniert. Beim erstmaligen Wechsel kann ein alter Git-Updater diese neue Schutzlogik noch nicht besitzen. Nutze dafür den Archiv-Updater. Bei einem manuellen Git-Checkout zuerst .env und gegebenenfalls .env.local außerhalb des Checkout-Verzeichnisses sichern, beide nach dem Checkout wiederherstellen und erst danach Setup/Migration/Cache starten. Ab 1.7.0 erhält der Git-Updater die Dateien selbst vor dem ersten neuen PHP-Prozess. .env nicht erneut ins Repository aufnehmen.

Bei FTP-Updates die .env aus dem neuen Archiv überspringen. Die Vorlage im Archiv ist ausschließlich für Erstinstallationen bestimmt. Ein Rückweg benötigt den alten Code plus die beiden gesicherten Konfigurationsdateien.

MySQL und MariaDB (seit 16.09.2026, ADR 104)

Sicherung und Wiederherstellung brauchen auf einer MySQL-Installation die Clients mysqldump und mysql im Pfad des PHP-Prozesses (auf geteiltem Hosting beim Anbieter erfragen; ein voller Pfad lässt sich nicht eintragen, aber mysqldump.phar/mysql.phar im Projektstamm würden gefunden). Die Sicherung schreibt datenbank.sql mit --single-transaction; die Wiederherstellung spielt sie mit mysql --execute=source ein. Fehlt der Client zum Einspielen, sagt die Wiederherstellung das und nennt den Pfad des Dumps. Der Archiv-Updater und der Git-Updater sichern vorher über denselben Weg und spielen beim Rückweg dieselbe Sicherung ein. Beide Wege sperren sich gegenseitig über var/update.lock; ein zweiter Aufruf bekommt die Meldung „Ein anderes Update läuft gerade“.