Updater: neue Fassung über Git, mit Vorprüfung und Rückweg
Aus dem Plan in docs/UPDATE.md ist Code geworden: cms:update, cms:health, der Bildschirm "System" und der Wartungszustand. Die Vorprüfung ist die halbe Arbeit. Sie fragt nach proc_open, Git, sauberer Arbeitskopie, Herkunft, Schreibrechten und Platz - und danach, ob Datenbank und Medienordner wirklich von Git ausgenommen sind. Das ist der Punkt, an dem ein Update Inhalte zerstören könnte, also wird er geprüft und nicht angenommen. Jede Prüfung sagt, was zu tun ist, nicht bloß "fehlgeschlagen". Der Rückweg ist Teil des Wegs: scheitert ein Schritt, wird auf den vorherigen Commit zurückgesetzt. Die Datenbank nur dann zurückgeschrieben, wenn die Migration schon begonnen hatte - sonst verlöre der Rückweg Inhalte, die während des Updates entstanden sind. Antwortet der alte Stand im Rauchtest, geht die Website wieder online; antwortet er nicht, bleibt der Wartungszustand, weil eine kaputte Seite schlechter ist als eine ehrliche Meldung. Der Rauchtest läuft als eigener Prozess, denn nur ein frischer Prozess führt den neuen Code aus. Ein Test im laufenden Prozess hätte den alten geprüft und "alles in Ordnung" gemeldet. Kein symfony/process: der Runner sind dreißig Zeilen über proc_open mit dem Befehl als Array - kein Shell, nichts zu maskieren, und "proc_open ist abgeschaltet" wird die erste Antwort statt der Überraschung im dritten Schritt. 285 PHP-Tests / 1244 Assertions. Sechs davon fahren den Updater gegen echte Git-Repositorys in einem Temp-Verzeichnis, mit Platzhalterskripten für die zwei Befehle - inklusive des Fehlschlags, nach dem Code und Datenbank zurückgesetzt sein müssen. Nicht gegen dieses Projekt: ein Test, der die Arbeitskopie auscheckt, in der er läuft, ist ein Weg, Arbeit zu verlieren. Offen bleibt die Sicherung einer MySQL-Datenbank per mysqldump - dafür fehlt die Installation; der Updater sagt das im Schritt "Sicherung". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99bad61179
commit
75245f6ee1
19 files changed
+1289
-2
No files matched your search
@@ -22,3 +22,13 @@ services:
|
|||||||
App\Controller\SeoController:
|
App\Controller\SeoController:
|
||||||
arguments:
|
arguments:
|
||||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||||
|
App\Command\HealthCommand:
|
||||||
|
arguments:
|
||||||
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
App\Ops\Updater:
|
||||||
|
arguments:
|
||||||
|
$root: '%kernel.project_dir%'
|
||||||
|
$databaseUrl: '%env(resolve:DATABASE_URL)%'
|
||||||
|
App\EventSubscriber\MaintenanceSubscriber:
|
||||||
|
arguments:
|
||||||
|
$projectDir: '%kernel.project_dir%'
|
||||||
@@ -439,3 +439,21 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
|
|||||||
**Der Preis steht auf dem Bildschirm.** Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist keine Lücke, sondern die Folge der Entscheidung: all das braucht Wiedererkennung. Wer es braucht, braucht eine Einwilligung – die Gruppe `statistics` dafür gibt es schon, sie wäre dann ein eigener Baustein.
|
**Der Preis steht auf dem Bildschirm.** Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist keine Lücke, sondern die Folge der Entscheidung: all das braucht Wiedererkennung. Wer es braucht, braucht eine Einwilligung – die Gruppe `statistics` dafür gibt es schon, sie wäre dann ein eigener Baustein.
|
||||||
|
|
||||||
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
|
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
|
||||||
|
|
||||||
|
## ADR 035 – Der Updater: jeder Schritt prüft, der Rückweg ist Teil des Wegs (12.09.2026)
|
||||||
|
|
||||||
|
Umgesetzt wie in `docs/UPDATE.md` geplant, mit drei Dingen, die in der Umsetzung wichtiger wurden als im Plan.
|
||||||
|
|
||||||
|
**Die Vorprüfung ist die halbe Arbeit.** Sie fragt nach `proc_open` (auf geteiltem Hosting oft abgeschaltet), nach Git, nach einer sauberen Arbeitskopie, nach der Herkunft, nach Schreibrechten, nach Platz – und danach, ob Datenbank und Medienordner wirklich von Git ausgenommen sind. Das Letzte ist der Punkt, an dem ein Update Inhalte zerstören könnte, und es wird geprüft, nicht angenommen. Jede Prüfung liefert einen Satz, der sagt, was zu tun ist, nicht bloß „fehlgeschlagen“.
|
||||||
|
|
||||||
|
**Abgebrochen wird auf den vorherigen Stand zurück, und dann geht die Website wieder an.** Ob der Wartungszustand bestehen bleibt, entscheidet der Rauchtest des alten Stands: antwortet er, wird der Zustand aufgehoben – die Website läuft dann auf der alten Fassung, was der Sinn eines Rückwegs ist. Antwortet er nicht, bleibt sie im Wartungszustand, weil eine kaputte Seite schlechter ist als eine ehrliche Meldung. Die Datenbank wird nur zurückgeschrieben, wenn die Migration schon begonnen hatte: sonst würde das Zurückschreiben Inhalte verlieren, die seit dem Start des Updates entstanden sind.
|
||||||
|
|
||||||
|
**Der Rauchtest läuft als eigener Prozess.** `cms:health` prüft Datenbank, Migrationstabelle und ob die Startseite veröffentlicht lesbar ist – in einem frischen PHP-Prozess, denn nur der führt den neuen Code aus. Ein Test im laufenden Prozess hätte den alten geprüft und „alles in Ordnung“ gemeldet.
|
||||||
|
|
||||||
|
Dazu drei kleinere Entscheidungen:
|
||||||
|
|
||||||
|
- **Kein `symfony/process`.** Der Runner sind dreißig Zeilen über `proc_open` mit einem Befehl als Array – kein Shell, nichts zu maskieren. Das vermeidet eine neue Abhängigkeit und macht den Fall „proc_open ist abgeschaltet“ zur ersten Antwort statt zur Überraschung.
|
||||||
|
- **`var/maintenance` als Schalter**, ausgewertet vor dem Router. Öffentliche Anfragen erhalten 503 mit `Retry-After`, damit eine Suchmaschine „später wieder“ liest statt „hier steht jetzt eine Entschuldigung“. `/admin` bleibt erreichbar, sonst könnte niemand das Update beenden oder zurücknehmen.
|
||||||
|
- **Der Bildschirm sagt, dass die Konsole verlässlicher ist.** Ein Update im Browser muss innerhalb des Zeitlimits des Hostings fertig werden, und das kann diese Anwendung nicht versprechen.
|
||||||
|
|
||||||
|
Geprüft wird der Updater gegen **echte Git-Repositorys in einem Temp-Verzeichnis**, nicht gegen dieses Projekt: ein Test, der die Arbeitskopie auscheckt, in der er läuft, ist ein Weg, Arbeit zu verlieren. Die zwei Befehle, die der Updater aufruft, sind dort Platzhalterskripte – so prüft der Test die Reihenfolge und den Rückweg mit echtem Git, echten Dateikopien und echtem Scheitern, ohne zwei Minuten Composer.
|
||||||
@@ -187,6 +187,8 @@ Abnahme: keine Liste in der Verwaltung, die mehr Einträge hat als sie zeigt, oh
|
|||||||
|
|
||||||
### N – Installer (Nutzerauftrag vom 12.09.2026)
|
### N – Installer (Nutzerauftrag vom 12.09.2026)
|
||||||
|
|
||||||
|
**Teilweise erledigt am 12.09.2026:** der *Updater* ist gebaut (ADR 035), der *Installer* nicht. Beide teilen die Systemprüfung, die jetzt existiert und dort wiederverwendet wird.
|
||||||
|
|
||||||
*Braucht: die Hostingentscheidung aus Arbeitspaket J für den Schritt „Datenbank“.*
|
*Braucht: die Hostingentscheidung aus Arbeitspaket J für den Schritt „Datenbank“.*
|
||||||
|
|
||||||
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ Heute braucht eine Installation `bin/setup.php` und Konsolenzugriff – auf geteiltem Hosting ist das genau die Hürde, an der es scheitert.
|
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ Heute braucht eine Installation `bin/setup.php` und Konsolenzugriff – auf geteiltem Hosting ist das genau die Hürde, an der es scheitert.
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||||
|
|
||||||
|
## Ergänzung: Updater, 12.09.2026
|
||||||
|
|
||||||
|
- **285 PHP-Tests / 1244 Assertions** unter PHP 8.2.12 und 8.4.22, Playwright-Editortest (axe auf dreizehn Bildschirmen, null Verstöße), öffentliche Browserprüfung.
|
||||||
|
- Sechs Tests fahren den Updater gegen **echte Git-Repositorys** in einem Temp-Verzeichnis: ein bares Repository als Herkunft, ein Autorenklon, ein Installationsklon. Die zwei Befehle, die der Updater aufruft, sind dort Platzhalterskripte – so wird die Reihenfolge und der Rückweg mit echtem Git und echten Dateikopien geprüft, ohne zwei Minuten Composer.
|
||||||
|
- Der wichtigste davon ist der Fehlschlag: die neue Fassung bringt ein Migrationsskript, das mit Exitcode 1 endet. Geprüft wird danach, dass der Code auf dem alten Commit steht, die Datenbank den Inhalt von vor dem Update enthält, der Wartungszustand aufgehoben ist (weil der alte Stand antwortet) und im Protokoll `FEHLER` steht.
|
||||||
|
- Die Reihenfolge der Schritte ist als Liste festgehalten, nicht nur ihr Ergebnis: Vorprüfung, Ziel, Sicherung, Wartung, Checkout, Fassung prüfen, Abhängigkeiten, Migration, Cache, Rauchtest, Fertig. Wer sie umstellt, bricht den Test.
|
||||||
|
- Geprüft ist außerdem, dass die Sicherung vor dem Checkout entsteht, dass sie die Datenbank enthält und den Medienordner **nicht**, und dass Datenbank und Medienordner der Installation unverändert bleiben.
|
||||||
|
- **Am echten Repository durchgespielt**, nicht nur im Test – siehe den Bericht in STATUS: Tag v1.0.0 auf `git.kant-it.de` gesetzt, Installation darauf gestellt, v1.0.1 veröffentlicht, Update ausgeführt.
|
||||||
|
- **Nicht geprüft:** MySQL (dafür fehlt die Installation, siehe `docs/UPDATE.md`, „Was anders wurde“), ein Hosting ohne `proc_open` (die Vorprüfung meldet es, der Fall selbst ist nicht durchgespielt), und ein Update über die Weboberfläche unter einem knappen Zeitlimit.
|
||||||
|
|
||||||
## Ergänzung: Statistik, 12.09.2026
|
## Ergänzung: Statistik, 12.09.2026
|
||||||
|
|
||||||
- **279 PHP-Tests / 1119 Assertions** unter PHP 8.2.12 und PHP 8.4.22, Playwright-Editortest (axe auf jetzt zwölf Bildschirmen, null Verstöße), öffentliche Browserprüfung.
|
- **279 PHP-Tests / 1119 Assertions** unter PHP 8.2.12 und PHP 8.4.22, Playwright-Editortest (axe auf jetzt zwölf Bildschirmen, null Verstöße), öffentliche Browserprüfung.
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||||
|
|
||||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
|||||||
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
|
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
|
||||||
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
|
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
|
||||||
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
|
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
|
||||||
|
- **Update über Git** (ADR 035, Arbeitspaket N/UPDATE.md): `cms:update` und ein Bildschirm „System“ zeigen Fassung, Commit, verfügbaren Tag und eine Vorprüfung, die jede Bedingung einzeln nennt – unter anderem, ob Datenbank und Medienordner von Git ausgenommen sind. Der Ablauf: sichern, Wartungszustand (503 mit `Retry-After`, `/admin` bleibt offen), Checkout des Tags, Fassung prüfen, Composer, Migration, Cache, Rauchtest als eigener Prozess (`cms:health`). Scheitert ein Schritt, wird auf den vorherigen Commit zurückgesetzt, die Datenbank nur dann zurückgeschrieben, wenn die Migration schon begonnen hatte – und wenn der alte Stand antwortet, geht die Website wieder online. `--check`, `--dry-run`, `--to=v1.2.0` und `--maintenance=an|aus` gibt es auf der Konsole.
|
||||||
- **Statistik** (ADR 034, Arbeitspaket L): Aufrufe je Tag und Stunde, meistaufgerufene Seiten, Verweise von außen und nicht gefundene Adressen, als CSV exportierbar. Gespeichert wird **ein Zähler je (Tag, Stunde, Pfad, Status, Verweis-Domain)**, kein Datensatz je Aufruf – keine IP-Adresse, keine Kennung, kein Cookie, auch nicht gehasht. Deshalb braucht diese Messung keine Einwilligung. Diagramme zeichnet der Server als SVG, ohne Skript. Was sie nicht kann, steht auf dem Bildschirm: Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Nach 60 Tagen werden Stunden zu Tageswerten verschmolzen, nach 425 Tagen gelöscht (`cms:stats:prune`).
|
- **Statistik** (ADR 034, Arbeitspaket L): Aufrufe je Tag und Stunde, meistaufgerufene Seiten, Verweise von außen und nicht gefundene Adressen, als CSV exportierbar. Gespeichert wird **ein Zähler je (Tag, Stunde, Pfad, Status, Verweis-Domain)**, kein Datensatz je Aufruf – keine IP-Adresse, keine Kennung, kein Cookie, auch nicht gehasht. Deshalb braucht diese Messung keine Einwilligung. Diagramme zeichnet der Server als SVG, ohne Skript. Was sie nicht kann, steht auf dem Bildschirm: Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Nach 60 Tagen werden Stunden zu Tageswerten verschmolzen, nach 425 Tagen gelöscht (`cms:stats:prune`).
|
||||||
- **Einwilligung** (ADR 032, Dokumentschema v14): serverseitig gerendertes Banner ohne JavaScript, „Alle akzeptieren“ und „Nur notwendige“ gleich groß nebeneinander, Auswahl je Gruppe im Aufklappfeld. Die Entscheidung steht in einem eigenen Cookie – keine Kennung, kein Serverabgleich, ein halbes Jahr. Unter `/einwilligung` jederzeit änderbar, von jeder Seite verlinkt. **Der neue Block „Externer Inhalt“ lädt ohne Einwilligung nichts:** kein iframe, keine Adresse im Markup, `frame-src 'none'`. Der Platzhalter nennt den Anbieter und gewährt auf Knopfdruck genau diese Gruppe. Ist der Hinweis abgeschaltet, laden die Inhalte – sonst würde das Abschalten jede Seite stillschweigend leeren.
|
- **Einwilligung** (ADR 032, Dokumentschema v14): serverseitig gerendertes Banner ohne JavaScript, „Alle akzeptieren“ und „Nur notwendige“ gleich groß nebeneinander, Auswahl je Gruppe im Aufklappfeld. Die Entscheidung steht in einem eigenen Cookie – keine Kennung, kein Serverabgleich, ein halbes Jahr. Unter `/einwilligung` jederzeit änderbar, von jeder Seite verlinkt. **Der neue Block „Externer Inhalt“ lädt ohne Einwilligung nichts:** kein iframe, keine Adresse im Markup, `frame-src 'none'`. Der Platzhalter nennt den Anbieter und gewährt auf Knopfdruck genau diese Gruppe. Ist der Hinweis abgeschaltet, laden die Inhalte – sonst würde das Abschalten jede Seite stillschweigend leeren.
|
||||||
- **Datenschutz und Impressum sind Pflicht, nicht Einstellung** (ADR 033): fehlen sie, warnt die Verwaltung und legt sie auf Knopfdruck als Gerüst mit `NOCH AUSZUFÜLLEN`-Markierungen an. Verlinkt werden sie aus dem Template, nicht aus dem Fußbereich, damit der Link nicht versehentlich gelöscht werden kann.
|
- **Datenschutz und Impressum sind Pflicht, nicht Einstellung** (ADR 033): fehlen sie, warnt die Verwaltung und legt sie auf Knopfdruck als Gerüst mit `NOCH AUSZUFÜLLEN`-Markierungen an. Verlinkt werden sie aus dem Template, nicht aus dem Fußbereich, damit der Link nicht versehentlich gelöscht werden kann.
|
||||||
@@ -72,6 +73,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
|||||||
- 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
- 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||||
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
|
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
|
||||||
- Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab.
|
- Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab.
|
||||||
|
- Updater geprüft: sechs Tests gegen echte Git-Repositorys in einem Temp-Verzeichnis – Vorprüfung benennt geänderte Dateien und eine nicht ausgenommene Datenbank, ein Update wechselt den Tag in der geprüften Reihenfolge und hebt den Wartungszustand auf, ein gescheiterter Schritt setzt Code **und** Datenbank zurück und bringt die Website auf dem alten Stand wieder online, ein Probelauf verändert nichts, ein unbekannter Tag wird beim Checkout abgewiesen, und eine bereits installierte Fassung ist kein Fehler. Zusätzlich am echten Repository durchgespielt (siehe QUALITY).
|
||||||
- Statistik geprüft: 32 Tests, darunter die Spaltenliste der Tabelle gegen die Zusage „keine Spalte, aus der eine Person hervorgeht“, 14 Anfragearten (Verwaltung, Bild, Stylesheet, Feed, Weiterleitung, POST, Googlebot, Überwachungsdienst, Headless, curl – alle nicht gezählt), sieben Verweisformen (nur der Host bleibt, die eigene Domain gilt nicht als Verweis), sechs Adressformen (Query verworfen, erfundene 404 gesammelt) und das Verschmelzen und Löschen alter Tage. Dazu durch echte Anfragen: fünf Aufrufe werden ein Zähler, ein 404 zählt nicht als Aufruf, Verwaltung und Feed zählen nicht.
|
- Statistik geprüft: 32 Tests, darunter die Spaltenliste der Tabelle gegen die Zusage „keine Spalte, aus der eine Person hervorgeht“, 14 Anfragearten (Verwaltung, Bild, Stylesheet, Feed, Weiterleitung, POST, Googlebot, Überwachungsdienst, Headless, curl – alle nicht gezählt), sieben Verweisformen (nur der Host bleibt, die eigene Domain gilt nicht als Verweis), sechs Adressformen (Query verworfen, erfundene 404 gesammelt) und das Verschmelzen und Löschen alter Tage. Dazu durch echte Anfragen: fünf Aufrufe werden ein Zähler, ein 404 zählt nicht als Aufruf, Verwaltung und Feed zählen nicht.
|
||||||
- Einwilligung geprüft: 26 PHP-Tests, darunter sieben Cookiezustände (gewährt, abgelehnt, andere Gruppe, abgelaufen, ältere Fassung der Frage, Unsinn, unbekannte Gruppe) und zehn abgewiesene Einbettungsadressen (http, protokollrelativ, `javascript:`, `data:`, mit Port, mit Zugangsdaten, Leerzeichen im Host, ohne Titel, unbekannte Gruppe, zu geringe Höhe). Im Browsertest zusätzlich: Banner erscheint, kein iframe und keine Adresse im Markup, Einwilligen über den Platzhalter lädt den Inhalt, Widerruf hält ihn wieder zurück, axe ohne Verstöße auf Banner und Einwilligungsseite.
|
- Einwilligung geprüft: 26 PHP-Tests, darunter sieben Cookiezustände (gewährt, abgelehnt, andere Gruppe, abgelaufen, ältere Fassung der Frage, Unsinn, unbekannte Gruppe) und zehn abgewiesene Einbettungsadressen (http, protokollrelativ, `javascript:`, `data:`, mit Port, mit Zugangsdaten, Leerzeichen im Host, ohne Titel, unbekannte Gruppe, zu geringe Höhe). Im Browsertest zusätzlich: Banner erscheint, kein iframe und keine Adresse im Markup, Einwilligen über den Platzhalter lädt den Inhalt, Widerruf hält ihn wieder zurück, axe ohne Verstöße auf Banner und Einwilligungsseite.
|
||||||
- Verwaltung geprüft: axe ohne Verstöße auf **elf** Bildschirmen – Anmeldung, Seitenübersicht, Benutzer, eigenes Konto, Mediathek, Menüs, Formulare, Bereiche, Design, Einstellungen und Editor in Desktop und Mobil. Vorher waren es drei.
|
- Verwaltung geprüft: axe ohne Verstöße auf **elf** Bildschirmen – Anmeldung, Seitenübersicht, Benutzer, eigenes Konto, Mediathek, Menüs, Formulare, Bereiche, Design, Einstellungen und Editor in Desktop und Mobil. Vorher waren es drei.
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,6 @@
|
|||||||
# Updateverfahren: neue Version über Git ziehen
|
# Updateverfahren: neue Version über Git ziehen
|
||||||
|
|
||||||
Stand: 12.09.2026. **Das ist ein Plan, noch keine Umsetzung.** Nichts davon ist gebaut; die Reihenfolge und die Entscheidungen stehen hier, damit sie vor dem Bauen kritisierbar sind.
|
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
|
## Was ein Update heißt
|
||||||
|
|
||||||
@@ -67,3 +67,12 @@ Der teuerste Fall ist ein Update, das durchläuft und erst Tage später als fals
|
|||||||
1. **Composer auf dem Server oder `vendor/` im Tag?** Hängt am Hosting aus Arbeitspaket J.
|
1. **Composer auf dem Server oder `vendor/` im Tag?** Hängt am Hosting aus Arbeitspaket J.
|
||||||
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.
|
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.
|
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.
|
||||||
|
- **Ebenfalls offen:** der Installer aus Arbeitspaket N. Die Systemprüfung, die er braucht, existiert jetzt.
|
||||||
@@ -578,3 +578,25 @@ details.card > summary:hover { background: #fafbf8 }
|
|||||||
.stats-list li:first-child { border-top: 0 }
|
.stats-list li:first-child { border-top: 0 }
|
||||||
.stats-list span, .stats-list a { overflow-wrap: anywhere; min-width: 0 }
|
.stats-list span, .stats-list a { overflow-wrap: anywhere; min-width: 0 }
|
||||||
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap }
|
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap }
|
||||||
|
|
||||||
|
/* ---- system: preflight, update steps, log ---------------------------------------------- */
|
||||||
|
|
||||||
|
.facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
|
||||||
|
.facts dt { color: var(--muted) }
|
||||||
|
.facts dd { margin: 0; overflow-wrap: anywhere }
|
||||||
|
.check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 }
|
||||||
|
.check-list li { display: flex; gap: 11px; align-items: flex-start }
|
||||||
|
.check-mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.check-ok .check-mark { background: #e7f0d6; color: #2c5340 }
|
||||||
|
.check-bad .check-mark { background: #fff0eb; color: var(--danger) }
|
||||||
|
.check-bad { color: var(--danger) }
|
||||||
|
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
|
||||||
|
.log-list code { font-size: 11px; overflow-wrap: anywhere }
|
||||||
@@ -355,6 +355,7 @@ try {
|
|||||||
['menus', '/admin/menus'],
|
['menus', '/admin/menus'],
|
||||||
['forms', '/admin/forms'],
|
['forms', '/admin/forms'],
|
||||||
['statistics', '/admin/statistik'],
|
['statistics', '/admin/statistik'],
|
||||||
|
['system and update', '/admin/system'],
|
||||||
['areas', '/admin/partials'],
|
['areas', '/admin/partials'],
|
||||||
['design', '/admin/theme'],
|
['design', '/admin/theme'],
|
||||||
['settings', '/admin/settings'],
|
['settings', '/admin/settings'],
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Content\PageRepository;
|
||||||
|
use App\Content\SiteSettings;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this installation answering?
|
||||||
|
*
|
||||||
|
* Run as a separate process after an update, so it exercises the new code rather than the code
|
||||||
|
* that is already in memory. It touches exactly the things that break when an update goes wrong:
|
||||||
|
* the database schema, the settings, and whether a published page can still be read.
|
||||||
|
*/
|
||||||
|
#[AsCommand(name: 'cms:health', description: 'Checks database, schema and one published page. Used as the smoke test after an update.')]
|
||||||
|
final class HealthCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $db,
|
||||||
|
private PageRepository $pages,
|
||||||
|
private SiteSettings $settings,
|
||||||
|
private string $projectDir,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$problems = [];
|
||||||
|
$facts = [];
|
||||||
|
try {
|
||||||
|
$this->db->fetchOne('SELECT 1');
|
||||||
|
$facts[] = 'Datenbank erreichbar';
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
$problems[] = 'Datenbank: '.$error->getMessage();
|
||||||
|
}
|
||||||
|
if ([] === $problems) {
|
||||||
|
try {
|
||||||
|
$version = $this->db->fetchOne('SELECT MAX(version) FROM migration_versions');
|
||||||
|
$facts[] = 'Migration '.(is_string($version) ? substr($version, -14) : 'unbekannt');
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
$problems[] = 'Migrationstabelle: '.$error->getMessage();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$home = $this->settings->homePath();
|
||||||
|
$page = $this->pages->findPublished($home);
|
||||||
|
null === $page
|
||||||
|
? $problems[] = sprintf('Die Startseite /%s ist nicht veröffentlicht abrufbar.', $home)
|
||||||
|
: $facts[] = sprintf('Startseite /%s lesbar', $home);
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
$problems[] = 'Startseite: '.$error->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$file = $this->projectDir.'/VERSION';
|
||||||
|
$facts[] = 'Fassung '.(is_readable($file) ? trim((string) file_get_contents($file)) : 'unbekannt');
|
||||||
|
|
||||||
|
if ([] !== $problems) {
|
||||||
|
$output->writeln('FEHLER: '.implode(' | ', $problems));
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$output->writeln('OK: '.implode(', ', $facts));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Ops\Updater;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The way that always works, also without a web interface - and the one to reach for when the
|
||||||
|
* administration itself is what is broken.
|
||||||
|
*/
|
||||||
|
#[AsCommand(name: 'cms:update', description: 'Pulls a new version from the Git repository: preflight, backup, maintenance, checkout, migration, smoke test.')]
|
||||||
|
final class UpdateCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private Updater $updater)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('check', null, InputOption::VALUE_NONE, 'Nur die Vorprüfung und die verfügbare Fassung zeigen.')
|
||||||
|
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Alles prüfen, aber nichts verändern.')
|
||||||
|
->addOption('to', null, InputOption::VALUE_REQUIRED, 'Ein bestimmter Tag, zum Beispiel v1.2.0.')
|
||||||
|
->addOption('maintenance', null, InputOption::VALUE_REQUIRED, 'Wartungszustand von Hand setzen: an oder aus.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$switch = $input->getOption('maintenance');
|
||||||
|
if (null !== $switch) {
|
||||||
|
if (!in_array($switch, ['an', 'aus'], true)) {
|
||||||
|
$io->error('--maintenance erwartet „an“ oder „aus“.');
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
$this->updater->maintenance('an' === $switch);
|
||||||
|
$io->success(sprintf('Wartungszustand ist %s.', 'an' === $switch ? 'eingeschaltet – die Website antwortet mit 503' : 'aufgehoben'));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $this->updater->state();
|
||||||
|
$io->definitionList(
|
||||||
|
['Fassung' => $state['version']],
|
||||||
|
['Stand' => $state['describe'].' ('.$state['commit'].')'],
|
||||||
|
['Wartung' => $state['maintenance'] ? 'eingeschaltet' : 'aus'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$checks = $this->updater->check();
|
||||||
|
$io->table(['Prüfung', '', 'Befund'], array_map(
|
||||||
|
static fn (array $check): array => [$check['name'], $check['ok'] ? 'ok' : 'FEHLT', $check['detail']],
|
||||||
|
$checks,
|
||||||
|
));
|
||||||
|
$failed = array_values(array_filter($checks, static fn (array $check): bool => !$check['ok']));
|
||||||
|
|
||||||
|
if ($input->getOption('check')) {
|
||||||
|
$available = $this->updater->availableVersion();
|
||||||
|
$io->writeln(sprintf('Verfügbar: %s', $available ?? 'kein Tag der Form v1.2.3 gefunden'));
|
||||||
|
|
||||||
|
return [] === $failed ? Command::SUCCESS : Command::FAILURE;
|
||||||
|
}
|
||||||
|
if ([] !== $failed) {
|
||||||
|
$io->error('Die Vorprüfung ist nicht bestanden; es wird nichts verändert.');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $input->getOption('to');
|
||||||
|
$result = $this->updater->update(is_string($target) ? $target : null, (bool) $input->getOption('dry-run'));
|
||||||
|
foreach ($result['steps'] as $step) {
|
||||||
|
$io->writeln(sprintf('%s %s: %s', $step['ok'] ? ' ok ' : ' FEHLER', $step['step'], $step['detail']));
|
||||||
|
}
|
||||||
|
if ($result['ok']) {
|
||||||
|
$io->success(sprintf('Update auf %s abgeschlossen.', $result['target'] ?? 'die aktuelle Fassung'));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
$io->error('Das Update ist gescheitert. Die Schritte oben sagen, wo.');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Ops\Updater;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version, preflight and the update itself.
|
||||||
|
*
|
||||||
|
* Administrators only, and every write with a token. The console stays the more reliable way: an
|
||||||
|
* update in a browser request has to finish within whatever the hosting allows, and that is not
|
||||||
|
* something this controller can promise - so it says so instead.
|
||||||
|
*/
|
||||||
|
final class SystemController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(private Updater $updater)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/system', name: 'admin_system', methods: ['GET'])]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||||
|
$checks = $this->updater->check();
|
||||||
|
|
||||||
|
return $this->render('admin/system.html.twig', [
|
||||||
|
'state' => $this->updater->state(),
|
||||||
|
'checks' => $checks,
|
||||||
|
'ready' => [] === array_filter($checks, static fn (array $check): bool => !$check['ok']),
|
||||||
|
// Asking the remote costs a network round trip, so only when it is wanted.
|
||||||
|
'available' => $request->query->getBoolean('pruefen') ? $this->updater->availableVersion() : null,
|
||||||
|
'asked' => $request->query->getBoolean('pruefen'),
|
||||||
|
'steps' => $request->getSession()->remove('update_steps') ?: [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/system/update', name: 'admin_system_update', methods: ['POST'])]
|
||||||
|
public function update(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||||
|
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||||
|
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||||
|
}
|
||||||
|
// Checkout, composer and migration take longer than a page usually may. Where the hosting
|
||||||
|
// allows it, the limit is lifted; where it does not, the console is the way.
|
||||||
|
@set_time_limit(0);
|
||||||
|
$target = $request->request->getString('target');
|
||||||
|
$result = $this->updater->update('' === $target ? null : $target, $request->request->getBoolean('dryRun'));
|
||||||
|
$request->getSession()->set('update_steps', $result['steps']);
|
||||||
|
$this->addFlash(
|
||||||
|
$result['ok'] ? 'success' : 'error',
|
||||||
|
$result['ok']
|
||||||
|
? sprintf('Update auf %s abgeschlossen.', $result['target'] ?? 'die aktuelle Fassung')
|
||||||
|
: 'Das Update ist gescheitert. Die Schritte unten sagen, wo – und ob die Website wieder online ist.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_system');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/system/wartung', name: 'admin_system_maintenance', methods: ['POST'])]
|
||||||
|
public function maintenance(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||||
|
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||||
|
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||||
|
}
|
||||||
|
$on = 'an' === $request->request->getString('state');
|
||||||
|
$this->updater->maintenance($on);
|
||||||
|
|
||||||
|
return $this->back($on
|
||||||
|
? 'Der Wartungszustand ist eingeschaltet: die Website antwortet mit 503, die Verwaltung bleibt erreichbar.'
|
||||||
|
: 'Der Wartungszustand ist aufgehoben.', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function back(string $message, string $type = 'error'): Response
|
||||||
|
{
|
||||||
|
$this->addFlash($type, $message);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_system');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventSubscriber;
|
||||||
|
|
||||||
|
use App\Ops\Updater;
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpKernel\KernelEvents;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While an update runs, the website answers with 503 and the administration stays open.
|
||||||
|
*
|
||||||
|
* 503 and not 200 with a notice: a search engine reads 503 with Retry-After as "come back later"
|
||||||
|
* and keeps the page in its index, while a 200 would teach it that the page now contains an
|
||||||
|
* excuse. The administration has to stay reachable, because otherwise nobody could finish or
|
||||||
|
* undo the update.
|
||||||
|
*/
|
||||||
|
final class MaintenanceSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public function __construct(private Environment $twig, private string $projectDir)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
// Before the router: during maintenance no controller should run at all.
|
||||||
|
return [KernelEvents::REQUEST => ['onRequest', 40]];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$event->isMainRequest() || !is_file($this->projectDir.'/'.Updater::MAINTENANCE_FILE)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$path = $event->getRequest()->getPathInfo();
|
||||||
|
if (preg_match('#^/(admin|assets/|media/|favicon)#', $path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
|
||||||
|
$response = new Response(
|
||||||
|
$this->twig->render('maintenance.html.twig', ['since' => trim((string) $since)]),
|
||||||
|
Response::HTTP_SERVICE_UNAVAILABLE,
|
||||||
|
[
|
||||||
|
'Retry-After' => '600',
|
||||||
|
'Cache-Control' => 'no-store',
|
||||||
|
'Content-Type' => 'text/html; charset=UTF-8',
|
||||||
|
'Content-Security-Policy' => "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$event->setResponse($response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Ops;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an external command, or says plainly that it cannot.
|
||||||
|
*
|
||||||
|
* `proc_open` is disabled on a lot of shared hosting, and that is not an edge case to discover
|
||||||
|
* halfway through an update - it is the first thing the updater has to ask. The command is passed
|
||||||
|
* as an array, so there is no shell and nothing to quote: an argument can contain a space, a
|
||||||
|
* quote or a semicolon without becoming a second command.
|
||||||
|
*/
|
||||||
|
final class CommandRunner
|
||||||
|
{
|
||||||
|
public function available(): bool
|
||||||
|
{
|
||||||
|
if (!function_exists('proc_open')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
|
||||||
|
|
||||||
|
return !in_array('proc_open', $disabled, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $command
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, exit: int, output: string}
|
||||||
|
*/
|
||||||
|
public function run(array $command, string $cwd, int $timeout = 300): array
|
||||||
|
{
|
||||||
|
if (!$this->available()) {
|
||||||
|
return ['ok' => false, 'exit' => -1, 'output' => 'proc_open ist auf diesem Server abgeschaltet.'];
|
||||||
|
}
|
||||||
|
$pipes = [];
|
||||||
|
$process = @proc_open(
|
||||||
|
$command,
|
||||||
|
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
||||||
|
$pipes,
|
||||||
|
$cwd,
|
||||||
|
// A minimal, predictable environment: no inherited APP_ENV that would make a console
|
||||||
|
// command run against the wrong environment.
|
||||||
|
['PATH' => getenv('PATH') ?: '', 'SystemRoot' => getenv('SystemRoot') ?: '', 'TEMP' => sys_get_temp_dir()],
|
||||||
|
);
|
||||||
|
if (!is_resource($process)) {
|
||||||
|
return ['ok' => false, 'exit' => -1, 'output' => sprintf('%s konnte nicht gestartet werden.', $command[0])];
|
||||||
|
}
|
||||||
|
foreach ($pipes as $pipe) {
|
||||||
|
stream_set_blocking($pipe, false);
|
||||||
|
}
|
||||||
|
$output = '';
|
||||||
|
$deadline = microtime(true) + $timeout;
|
||||||
|
while (true) {
|
||||||
|
$status = proc_get_status($process);
|
||||||
|
foreach ($pipes as $pipe) {
|
||||||
|
$output .= stream_get_contents($pipe) ?: '';
|
||||||
|
}
|
||||||
|
if (!$status['running']) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (microtime(true) > $deadline) {
|
||||||
|
proc_terminate($process);
|
||||||
|
foreach ($pipes as $pipe) {
|
||||||
|
fclose($pipe);
|
||||||
|
}
|
||||||
|
proc_close($process);
|
||||||
|
|
||||||
|
return ['ok' => false, 'exit' => -1, 'output' => trim($output)."\n".sprintf('Abgebrochen nach %d Sekunden.', $timeout)];
|
||||||
|
}
|
||||||
|
usleep(50000);
|
||||||
|
}
|
||||||
|
foreach ($pipes as $pipe) {
|
||||||
|
$output .= stream_get_contents($pipe) ?: '';
|
||||||
|
fclose($pipe);
|
||||||
|
}
|
||||||
|
proc_close($process);
|
||||||
|
$exit = $status['exitcode'];
|
||||||
|
|
||||||
|
return ['ok' => 0 === $exit, 'exit' => $exit, 'output' => trim($output)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an executable can be found at all, so a missing one is reported, not guessed at. */
|
||||||
|
public function has(string $executable, string $cwd): bool
|
||||||
|
{
|
||||||
|
return $this->run([$executable, '--version'], $cwd, 30)['ok'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Ops;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulling a new version from the Git repository, in the order laid down in docs/UPDATE.md.
|
||||||
|
*
|
||||||
|
* Three things shape this class:
|
||||||
|
*
|
||||||
|
* 1. **Every step has a condition it checks rather than assumes.** An update that fails halfway is
|
||||||
|
* normal; an update that fails halfway and leaves the website broken is not.
|
||||||
|
* 2. **The database, the media folder and .env are never touched.** They are outside Git, and the
|
||||||
|
* preflight verifies that instead of trusting it.
|
||||||
|
* 3. **A failure rolls back to the previous commit** and, only if a migration had already started,
|
||||||
|
* restores the database backup. Migrations are deliberately irreversible - the way back is the
|
||||||
|
* backup, not a `down()`.
|
||||||
|
*/
|
||||||
|
final class Updater
|
||||||
|
{
|
||||||
|
public const MAINTENANCE_FILE = 'var/maintenance';
|
||||||
|
public const LOG_FILE = 'var/updates.log';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private string $root,
|
||||||
|
private string $databaseUrl,
|
||||||
|
private CommandRunner $runner,
|
||||||
|
private Connection $db,
|
||||||
|
private string $php = PHP_BINARY,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The version that is installed, from the file that ships inside the tag. */
|
||||||
|
public function version(): string
|
||||||
|
{
|
||||||
|
$file = $this->root.'/VERSION';
|
||||||
|
|
||||||
|
return is_readable($file) ? trim((string) file_get_contents($file)) : 'unbekannt';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{version: string, commit: string, describe: string, maintenance: bool,
|
||||||
|
* git: bool, clean: ?bool, log: list<string>}
|
||||||
|
*/
|
||||||
|
public function state(): array
|
||||||
|
{
|
||||||
|
$git = $this->runner->available() && $this->runner->has('git', $this->root);
|
||||||
|
$status = $git ? $this->git(['status', '--porcelain']) : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'version' => $this->version(),
|
||||||
|
'commit' => $git ? substr($this->git(['rev-parse', 'HEAD'])['output'], 0, 8) : 'unbekannt',
|
||||||
|
'describe' => $git ? $this->git(['describe', '--tags', '--always'])['output'] : 'unbekannt',
|
||||||
|
'maintenance' => is_file($this->root.'/'.self::MAINTENANCE_FILE),
|
||||||
|
'git' => $git,
|
||||||
|
'clean' => null === $status ? null : ('' === $status['output'] && $status['ok']),
|
||||||
|
'log' => $this->log(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preflight. Every entry is a sentence about what is wrong and what to do about it.
|
||||||
|
*
|
||||||
|
* @return list<array{name: string, ok: bool, detail: string}>
|
||||||
|
*/
|
||||||
|
public function check(): array
|
||||||
|
{
|
||||||
|
$checks = [];
|
||||||
|
$add = static function (array &$checks, string $name, bool $ok, string $detail) {
|
||||||
|
$checks[] = ['name' => $name, 'ok' => $ok, 'detail' => $detail];
|
||||||
|
};
|
||||||
|
|
||||||
|
$processes = $this->runner->available();
|
||||||
|
$add($checks, 'Externe Befehle', $processes, $processes
|
||||||
|
? 'proc_open ist verfügbar.'
|
||||||
|
: 'proc_open ist auf diesem Server abgeschaltet. Ein Update über Git ist damit nicht möglich; es bleibt der Weg über die Konsole oder ein Deployment von außen.');
|
||||||
|
if (!$processes) {
|
||||||
|
return $checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
$git = $this->runner->has('git', $this->root);
|
||||||
|
$add($checks, 'Git', $git, $git
|
||||||
|
? trim(str_replace('git version', 'Version', $this->git(['--version'])['output']))
|
||||||
|
: 'git ist nicht aufrufbar. Ohne Git gibt es keinen Weg, eine neue Fassung zu holen.');
|
||||||
|
if (!$git) {
|
||||||
|
return $checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inside = $this->git(['rev-parse', '--is-inside-work-tree']);
|
||||||
|
$add($checks, 'Arbeitsverzeichnis', $inside['ok'], $inside['ok']
|
||||||
|
? 'Das Projekt ist eine Git-Arbeitskopie.'
|
||||||
|
: 'Dieses Verzeichnis ist keine Git-Arbeitskopie. Ein Update über Git braucht einen Klon des Repositorys.');
|
||||||
|
if (!$inside['ok']) {
|
||||||
|
return $checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $this->git(['status', '--porcelain']);
|
||||||
|
$dirty = array_values(array_filter(explode("\n", $status['output'])));
|
||||||
|
$add($checks, 'Keine lokalen Änderungen', [] === $dirty, [] === $dirty
|
||||||
|
? 'Die Arbeitskopie ist unverändert.'
|
||||||
|
: sprintf('%d geänderte Datei(en), zum Beispiel %s. Ein Checkout würde sie verwerfen. Erst sichern oder zurücknehmen.', count($dirty), trim(explode("\n", $status['output'])[0])));
|
||||||
|
|
||||||
|
$remote = $this->git(['remote', 'get-url', 'origin']);
|
||||||
|
$add($checks, 'Herkunft', $remote['ok'], $remote['ok']
|
||||||
|
? $remote['output']
|
||||||
|
: 'Es ist keine Herkunft „origin“ eingetragen. Ohne sie weiß der Updater nicht, woher eine neue Fassung kommt.');
|
||||||
|
|
||||||
|
// What must stay outside Git, or an update would overwrite content.
|
||||||
|
foreach ([
|
||||||
|
'Datenbank' => $this->databaseFile(),
|
||||||
|
'Medien' => $this->root.'/var/media',
|
||||||
|
] as $name => $path) {
|
||||||
|
if (null === $path || '' === $path) {
|
||||||
|
$add($checks, $name.' außerhalb von Git', true, 'Nicht als Datei in diesem Projekt (etwa MySQL) – vom Checkout nicht betroffen.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$relative = str_replace('\\', '/', substr($path, strlen($this->root) + 1));
|
||||||
|
$tracked = $this->git(['check-ignore', '-q', $relative]);
|
||||||
|
$add($checks, $name.' außerhalb von Git', $tracked['ok'], $tracked['ok']
|
||||||
|
? sprintf('%s ist von Git ausgenommen.', $relative)
|
||||||
|
: sprintf('%s ist nicht von Git ausgenommen. Ein Update würde Inhalte überschreiben. In .gitignore aufnehmen.', $relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['Projektstamm' => $this->root, 'var/' => $this->root.'/var'] as $name => $path) {
|
||||||
|
$writable = is_dir($path) && is_writable($path);
|
||||||
|
$add($checks, 'Schreibrecht '.$name, $writable, $writable
|
||||||
|
? sprintf('%s ist beschreibbar.', $path)
|
||||||
|
: sprintf('%s ist nicht beschreibbar. Der Updater braucht das für Sicherung, Wartungsschalter und Cache.', $path));
|
||||||
|
}
|
||||||
|
|
||||||
|
$space = @disk_free_space($this->root);
|
||||||
|
$needed = $this->databaseSize() + 104857600;
|
||||||
|
$add($checks, 'Platz', false === $space || $space > $needed, false === $space
|
||||||
|
? 'Der freie Platz ließ sich nicht ermitteln.'
|
||||||
|
: sprintf('%s frei, gebraucht werden etwa %s.', $this->bytes((int) $space), $this->bytes($needed)));
|
||||||
|
|
||||||
|
$composer = $this->runner->has('composer', $this->root);
|
||||||
|
$vendor = is_dir($this->root.'/vendor');
|
||||||
|
$add($checks, 'Abhängigkeiten', $composer || $vendor, $composer
|
||||||
|
? 'Composer ist vorhanden und wird nach dem Checkout ausgeführt.'
|
||||||
|
: ($vendor
|
||||||
|
? 'Composer ist nicht vorhanden. vendor/ muss deshalb Teil des Tags sein, sonst fehlen nach dem Update Abhängigkeiten. Siehe docs/UPDATE.md.'
|
||||||
|
: 'Weder Composer noch ein vendor/-Verzeichnis. So kann die Anwendung nach dem Update nicht starten.'));
|
||||||
|
|
||||||
|
return $checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The newest tag of the form v1.2.3 that the remote offers, or null. */
|
||||||
|
public function availableVersion(): ?string
|
||||||
|
{
|
||||||
|
if (!$this->runner->available() || !$this->runner->has('git', $this->root)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$this->git(['fetch', '--tags', '--quiet', 'origin'], 120);
|
||||||
|
$tags = array_values(array_filter(
|
||||||
|
array_map('trim', explode("\n", $this->git(['tag', '--list', 'v*'])['output'])),
|
||||||
|
static fn (string $tag): bool => 1 === preg_match('/^v\d+\.\d+\.\d+$/D', $tag),
|
||||||
|
));
|
||||||
|
if ([] === $tags) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
usort($tags, static fn (string $a, string $b): int => version_compare(ltrim($a, 'v'), ltrim($b, 'v')));
|
||||||
|
|
||||||
|
return end($tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The update itself.
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>, target: ?string}
|
||||||
|
*/
|
||||||
|
public function update(?string $target = null, bool $dryRun = false): array
|
||||||
|
{
|
||||||
|
$steps = [];
|
||||||
|
$step = static function (array &$steps, string $name, bool $ok, string $detail): bool {
|
||||||
|
$steps[] = ['step' => $name, 'ok' => $ok, 'detail' => $detail];
|
||||||
|
|
||||||
|
return $ok;
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($this->check() as $check) {
|
||||||
|
if (!$check['ok']) {
|
||||||
|
$step($steps, 'Vorprüfung', false, $check['name'].': '.$check['detail']);
|
||||||
|
|
||||||
|
return ['ok' => false, 'steps' => $steps, 'target' => null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$step($steps, 'Vorprüfung', true, 'Alle Prüfungen bestanden.');
|
||||||
|
|
||||||
|
$target ??= $this->availableVersion();
|
||||||
|
if (null === $target) {
|
||||||
|
$step($steps, 'Ziel bestimmen', false, 'Die Herkunft bietet keinen Tag der Form v1.2.3 an.');
|
||||||
|
|
||||||
|
return ['ok' => false, 'steps' => $steps, 'target' => null];
|
||||||
|
}
|
||||||
|
$from = $this->git(['rev-parse', 'HEAD'])['output'];
|
||||||
|
$step($steps, 'Ziel bestimmen', true, sprintf('%s (von %s, Fassung %s)', $target, substr($from, 0, 8), $this->version()));
|
||||||
|
|
||||||
|
if ($target === 'v'.$this->version()) {
|
||||||
|
$step($steps, 'Nichts zu tun', true, sprintf('Fassung %s ist bereits installiert.', $this->version()));
|
||||||
|
|
||||||
|
return ['ok' => true, 'steps' => $steps, 'target' => $target];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$step($steps, 'Probelauf', true, 'Hier würde der Updater sichern, den Wartungszustand einschalten, auschecken, migrieren und prüfen. Es wurde nichts verändert.');
|
||||||
|
|
||||||
|
return ['ok' => true, 'steps' => $steps, 'target' => $target];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- from here on the installation is being changed ----
|
||||||
|
$backup = $this->backup();
|
||||||
|
if (!$step($steps, 'Sicherung', null !== $backup, $backup ?? 'Die Datenbank ließ sich nicht sichern. Ohne Sicherung wird nicht aktualisiert.')) {
|
||||||
|
return ['ok' => false, 'steps' => $steps, 'target' => $target];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->maintenance(true);
|
||||||
|
$step($steps, 'Wartungszustand', true, 'Die Website antwortet mit 503, die Verwaltung bleibt erreichbar.');
|
||||||
|
|
||||||
|
$migrated = false;
|
||||||
|
try {
|
||||||
|
$checkout = $this->git(['checkout', '--force', $target]);
|
||||||
|
if (!$step($steps, 'Code wechseln', $checkout['ok'], $checkout['ok'] ? 'Auf '.$target.' gewechselt.' : $checkout['output'])) {
|
||||||
|
throw new \RuntimeException('checkout');
|
||||||
|
}
|
||||||
|
$version = $this->version();
|
||||||
|
if (!$step($steps, 'Fassung prüfen', 'v'.$version === $target, sprintf('VERSION enthält „%s“, erwartet war „%s“.', $version, ltrim($target, 'v')))) {
|
||||||
|
throw new \RuntimeException('version');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->runner->has('composer', $this->root)) {
|
||||||
|
$install = $this->runner->run(['composer', 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
|
||||||
|
if (!$step($steps, 'Abhängigkeiten', $install['ok'], $install['ok'] ? 'composer install ausgeführt.' : $install['output'])) {
|
||||||
|
throw new \RuntimeException('composer');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$step($steps, 'Abhängigkeiten', true, 'Composer ist nicht vorhanden; vendor/ kommt aus dem Tag.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$migrated = true;
|
||||||
|
$migrate = $this->console(['vendor/bin/doctrine-migrations', 'migrate', '--no-interaction'], 600);
|
||||||
|
if (!$step($steps, 'Migration', $migrate['ok'], $migrate['ok'] ? $this->lastLine($migrate['output']) : $migrate['output'])) {
|
||||||
|
throw new \RuntimeException('migrate');
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache = $this->console(['bin/console', 'cache:clear', '--env=prod'], 300);
|
||||||
|
if (!$step($steps, 'Cache', $cache['ok'], $cache['ok'] ? 'Cache geräumt und aufgebaut.' : $cache['output'])) {
|
||||||
|
throw new \RuntimeException('cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
$health = $this->console(['bin/console', 'cms:health', '--env=prod'], 120);
|
||||||
|
if (!$step($steps, 'Rauchtest', $health['ok'], $this->lastLine($health['output']))) {
|
||||||
|
throw new \RuntimeException('health');
|
||||||
|
}
|
||||||
|
} catch (\RuntimeException) {
|
||||||
|
$this->rollback($steps, $from, $migrated ? $backup : null, $step);
|
||||||
|
$this->write($steps, $target, false);
|
||||||
|
|
||||||
|
return ['ok' => false, 'steps' => $steps, 'target' => $target];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->maintenance(false);
|
||||||
|
$step($steps, 'Fertig', true, sprintf('Fassung %s ist aktiv, die Website ist wieder erreichbar.', $this->version()));
|
||||||
|
$this->write($steps, $target, true);
|
||||||
|
|
||||||
|
return ['ok' => true, 'steps' => $steps, 'target' => $target];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function log(int $lines = 20): array
|
||||||
|
{
|
||||||
|
$file = $this->root.'/'.self::LOG_FILE;
|
||||||
|
if (!is_readable($file)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$entries = array_values(array_filter(explode("\n", (string) file_get_contents($file))));
|
||||||
|
|
||||||
|
return array_slice(array_reverse($entries), 0, $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maintenance(bool $on): void
|
||||||
|
{
|
||||||
|
$file = $this->root.'/'.self::MAINTENANCE_FILE;
|
||||||
|
if ($on) {
|
||||||
|
@file_put_contents($file, gmdate('c')."\n");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (is_file($file)) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back to the previous commit, and to the previous database if a migration had begun.
|
||||||
|
*
|
||||||
|
* Whether the maintenance state stays on is decided by the smoke test: if the old version
|
||||||
|
* answers, the website goes back online on the old version. That is the point of a rollback.
|
||||||
|
*/
|
||||||
|
private function rollback(array &$steps, string $commit, ?string $backup, callable $step): void
|
||||||
|
{
|
||||||
|
$back = $this->git(['checkout', '--force', $commit]);
|
||||||
|
$step($steps, 'Zurück auf den alten Stand', $back['ok'], $back['ok'] ? 'Auf '.substr($commit, 0, 8).' zurückgesetzt.' : $back['output']);
|
||||||
|
|
||||||
|
if (null !== $backup && null !== $this->databaseFile()) {
|
||||||
|
$file = $this->databaseFile();
|
||||||
|
$copy = dirname($backup).'/'.basename((string) $file);
|
||||||
|
$restored = is_readable($copy) && @copy($copy, (string) $file);
|
||||||
|
$step($steps, 'Datenbank zurückschreiben', $restored, $restored
|
||||||
|
? 'Die Sicherung von vor der Migration ist wieder eingespielt.'
|
||||||
|
: 'Die Sicherung ließ sich nicht zurückschreiben. Die Datenbank liegt unter '.dirname((string) $backup).'.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->runner->has('composer', $this->root)) {
|
||||||
|
$this->runner->run(['composer', 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
|
||||||
|
}
|
||||||
|
$this->console(['bin/console', 'cache:clear', '--env=prod'], 300);
|
||||||
|
$health = $this->console(['bin/console', 'cms:health', '--env=prod'], 120);
|
||||||
|
if ($health['ok']) {
|
||||||
|
$this->maintenance(false);
|
||||||
|
$step($steps, 'Wieder online', true, 'Der alte Stand antwortet; der Wartungszustand ist aufgehoben.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$step($steps, 'Wartungszustand bleibt', false, 'Auch der alte Stand antwortet nicht. Die Website bleibt im Wartungszustand, damit niemand eine kaputte Seite sieht. Ausgabe: '.$this->lastLine($health['output']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies the database and the environment files, and returns a sentence about it. */
|
||||||
|
private function backup(): ?string
|
||||||
|
{
|
||||||
|
$directory = $this->root.'/var/backups/update-'.gmdate('Ymd-His');
|
||||||
|
if (!is_dir($directory) && !@mkdir($directory, 0o775, true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$file = $this->databaseFile();
|
||||||
|
if (null !== $file) {
|
||||||
|
if (!is_readable($file) || !@copy($file, $directory.'/'.basename($file))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['.env', '.env.local'] as $environment) {
|
||||||
|
if (is_readable($this->root.'/'.$environment)) {
|
||||||
|
@copy($this->root.'/'.$environment, $directory.'/'.$environment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'%s (%s). Der Medienordner ist nicht dabei – er ist zu groß, und kein Update schreibt darin.',
|
||||||
|
str_replace('\\', '/', substr($directory, strlen($this->root) + 1)),
|
||||||
|
null === $file ? 'nur Umgebungsdateien, die Datenbank liegt nicht als Datei hier' : $this->bytes($this->databaseSize()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{ok: bool, exit: int, output: string} */
|
||||||
|
private function git(array $arguments, int $timeout = 60): array
|
||||||
|
{
|
||||||
|
return $this->runner->run(['git', ...$arguments], $this->root, $timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{ok: bool, exit: int, output: string} */
|
||||||
|
private function console(array $arguments, int $timeout): array
|
||||||
|
{
|
||||||
|
return $this->runner->run([$this->php, ...$arguments], $this->root, $timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The SQLite file behind DATABASE_URL, or null for a server database. */
|
||||||
|
private function databaseFile(): ?string
|
||||||
|
{
|
||||||
|
if (!str_starts_with($this->databaseUrl, 'sqlite:')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$path = preg_replace('#^sqlite:(///)?#', '', $this->databaseUrl);
|
||||||
|
|
||||||
|
return '' === $path ? null : str_replace('\\', '/', (string) $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function databaseSize(): int
|
||||||
|
{
|
||||||
|
$file = $this->databaseFile();
|
||||||
|
if (null !== $file && is_readable($file)) {
|
||||||
|
return (int) filesize($file);
|
||||||
|
}
|
||||||
|
// A rough number for a server database, only used for the free space check.
|
||||||
|
try {
|
||||||
|
return 50 * (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision') * 1024;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return 10485760;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function write(array $steps, string $target, bool $ok): void
|
||||||
|
{
|
||||||
|
$failed = array_values(array_filter($steps, static fn (array $step): bool => !$step['ok']));
|
||||||
|
@file_put_contents($this->root.'/'.self::LOG_FILE, sprintf(
|
||||||
|
"%s %s %s (%d Schritte)%s\n",
|
||||||
|
gmdate('Y-m-d H:i:s'),
|
||||||
|
$ok ? 'OK' : 'FEHLER',
|
||||||
|
$target,
|
||||||
|
count($steps),
|
||||||
|
[] === $failed ? '' : ' – gescheitert: '.$failed[0]['step'].': '.$this->lastLine($failed[0]['detail']),
|
||||||
|
), FILE_APPEND);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lastLine(string $output): string
|
||||||
|
{
|
||||||
|
$lines = array_values(array_filter(array_map('trim', explode("\n", $output))));
|
||||||
|
|
||||||
|
return [] === $lines ? '(keine Ausgabe)' : end($lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bytes(int $bytes): string
|
||||||
|
{
|
||||||
|
return $bytes > 1048576 ? round($bytes / 1048576).' MB' : max(1, (int) round($bytes / 1024)).' KB';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
<ul class="nav-list" aria-labelledby="nav-site">
|
<ul class="nav-list" aria-labelledby="nav-site">
|
||||||
<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">◐</span>Design</a></li>
|
<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">◐</span>Design</a></li>
|
||||||
<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">⚙</span>Einstellungen</a></li>
|
<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">⚙</span>Einstellungen</a></li>
|
||||||
|
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">⌬</span>System</a></li>{% endif %}
|
||||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">☺</span>Benutzer</a></li>{% endif %}
|
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">☺</span>Benutzer</a></li>{% endif %}
|
||||||
<li><a class="nav-item nav-out" href="/">Website ansehen</a></li>
|
<li><a class="nav-item nav-out" href="/">Website ansehen</a></li>
|
||||||
<li><a class="nav-item nav-out" href="/beitraege">Beitragsübersicht</a></li>
|
<li><a class="nav-item nav-out" href="/beitraege">Beitragsübersicht</a></li>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
{% extends 'admin/shell.html.twig' %}
|
||||||
|
{% set nav = 'system' %}
|
||||||
|
{% block title %}System{% endblock %}
|
||||||
|
{% block heading %}System und Update{% endblock %}
|
||||||
|
{% block lead %}Welche Fassung läuft, ob ein Update möglich ist, und der Weg dorthin: sichern, Wartungszustand, Code wechseln, migrieren, Rauchtest.{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Diese Installation</h2>
|
||||||
|
<span class="muted">{{ state.maintenance ? 'Wartungszustand eingeschaltet' : 'im Betrieb' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="facts">
|
||||||
|
<dt>Fassung</dt><dd><strong>{{ state.version }}</strong></dd>
|
||||||
|
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
|
||||||
|
<dt>Verfügbar</dt>
|
||||||
|
<dd>
|
||||||
|
{% if available %}
|
||||||
|
<strong>{{ available }}</strong>{% if available == 'v' ~ state.version %} – das ist die installierte Fassung.{% endif %}
|
||||||
|
{% elseif asked %}
|
||||||
|
Kein Tag der Form <code>v1.2.3</code> in der Herkunft gefunden.
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ path('admin_system', {pruefen: 1}) }}">Beim Repository nachfragen</a>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{% if state.maintenance %}
|
||||||
|
<p class="notice error" role="alert">Die Website antwortet Besuchern gerade mit 503. Wenn das nicht beabsichtigt ist, hebe den Wartungszustand auf – ein gescheitertes Update kann ihn eingeschaltet gelassen haben.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="sheet-actions">
|
||||||
|
<form action="{{ path('admin_system_maintenance') }}" method="post">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||||
|
<input type="hidden" name="state" value="{{ state.maintenance ? 'aus' : 'an' }}">
|
||||||
|
<button class="secondary" type="submit">Wartungszustand {{ state.maintenance ? 'aufheben' : 'einschalten' }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><h2>Vorprüfung</h2><span class="muted">Jede Bedingung wird geprüft, nicht angenommen</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="check-list">
|
||||||
|
{% for check in checks %}
|
||||||
|
<li class="{{ check.ok ? 'check-ok' : 'check-bad' }}">
|
||||||
|
<span class="check-mark" aria-hidden="true">{{ check.ok ? '✓' : '!' }}</span>
|
||||||
|
<span><strong>{{ check.name }}</strong> <span class="visually-hidden">{{ check.ok ? 'erfüllt' : 'nicht erfüllt' }}:</span> {{ check.detail }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><h2>Update einspielen</h2><span class="muted">{{ ready ? 'Vorprüfung bestanden' : 'erst die Vorprüfung erfüllen' }}</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="muted">Der Updater sichert zuerst Datenbank und Umgebungsdateien, schaltet den Wartungszustand ein, wechselt auf den Tag, installiert Abhängigkeiten, migriert, räumt den Cache und prüft, ob die Installation antwortet. Scheitert ein Schritt, wird auf den vorherigen Stand zurückgesetzt – und nur wenn die Migration schon begonnen hatte, auch die Datenbank zurückgeschrieben.</p>
|
||||||
|
<p class="field-help"><strong>Der Medienordner wird nicht gesichert.</strong> Er ist zu groß, und kein Update schreibt darin. Ein Update ist keine Datensicherung.</p>
|
||||||
|
<p class="field-help">Auf manchen Hostings bricht ein Browseraufruf nach kurzer Zeit ab. Dann ist <code>php bin/console cms:update</code> der verlässliche Weg – dort gibt es dieselben Schritte mit <code>--check</code>, <code>--dry-run</code> und <code>--to=v1.2.0</code>.</p>
|
||||||
|
|
||||||
|
<form action="{{ path('admin_system_update') }}" method="post">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||||
|
<div class="fields">
|
||||||
|
<div>
|
||||||
|
<label for="target">Ziel</label>
|
||||||
|
<input id="target" name="target" maxlength="40" placeholder="{{ available ?: 'v1.2.0' }}" pattern="v[0-9]+\.[0-9]+\.[0-9]+">
|
||||||
|
<p class="field-help">Leer lassen für den höchsten verfügbaren Tag.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="check-label" for="dryRun"><input id="dryRun" name="dryRun" type="checkbox" value="1" checked> Nur Probelauf</label>
|
||||||
|
<p class="field-help">Prüft alles und verändert nichts. Nimm das Häkchen weg, wenn es wirklich laufen soll.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="primary" type="submit"{{ ready ? '' : ' disabled' }}>Update ausführen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if steps is not empty %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><h2>Letzter Durchlauf</h2><span class="muted">Schritt für Schritt</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ol class="check-list">
|
||||||
|
{% for step in steps %}
|
||||||
|
<li class="{{ step.ok ? 'check-ok' : 'check-bad' }}">
|
||||||
|
<span class="check-mark" aria-hidden="true">{{ step.ok ? '✓' : '!' }}</span>
|
||||||
|
<span><strong>{{ step.step }}</strong> {{ step.detail }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><h2>Verlauf</h2><span class="muted">var/updates.log</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if state.log is empty %}
|
||||||
|
<p class="muted">Noch kein Update über diesen Weg eingespielt.</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="log-list">
|
||||||
|
{% for line in state.log %}<li><code>{{ line }}</code></li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>Kurz nicht erreichbar</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/assets/site.css">
|
||||||
|
</head>
|
||||||
|
<body class="kant">
|
||||||
|
<main class="shell error-page" id="main" tabindex="-1">
|
||||||
|
<p>Wartung</p>
|
||||||
|
<h1>Einen Moment, wir spielen ein Update ein.</h1>
|
||||||
|
<p>Die Website ist in wenigen Minuten wieder da. Bitte später erneut laden – gespeicherte Inhalte sind davon nicht betroffen.</p>
|
||||||
|
{% if since %}<p class="muted">Begonnen am <time datetime="{{ since }}">{{ since|date('d.m.Y H:i') }}</time> UTC.</p>{% endif %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests;
|
||||||
|
|
||||||
|
use App\Infrastructure\ConnectionFactory;
|
||||||
|
use App\Ops\CommandRunner;
|
||||||
|
use App\Ops\Updater;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The updater against real Git repositories in a temporary directory.
|
||||||
|
*
|
||||||
|
* Not against this project: an update checks out a different commit, and a test that does that to
|
||||||
|
* the working copy it runs in would be a way to lose work. The repositories here are throwaway,
|
||||||
|
* and the two commands the updater calls - the console and the migration tool - are tiny stub
|
||||||
|
* scripts inside them. That keeps the test about the updater's order and its rollback, while
|
||||||
|
* still exercising real git, real file copies and real failures.
|
||||||
|
*/
|
||||||
|
final class UpdaterTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $temp;
|
||||||
|
private string $work;
|
||||||
|
private string $author;
|
||||||
|
private CommandRunner $runner;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->runner = new CommandRunner();
|
||||||
|
if (!$this->runner->available() || !$this->runner->has('git', sys_get_temp_dir())) {
|
||||||
|
self::markTestSkipped('Ohne proc_open und git ist der Updater nicht prüfbar.');
|
||||||
|
}
|
||||||
|
$this->temp = sys_get_temp_dir().'/kant-update-'.bin2hex(random_bytes(6));
|
||||||
|
mkdir($this->temp, 0o775, true);
|
||||||
|
$origin = $this->temp.'/origin.git';
|
||||||
|
$this->work = $this->temp.'/work';
|
||||||
|
$this->author = $this->temp.'/author';
|
||||||
|
|
||||||
|
$this->git(['init', '--bare', '--initial-branch=main', $origin], $this->temp);
|
||||||
|
$this->git(['clone', $origin, $this->author], $this->temp);
|
||||||
|
$this->configure($this->author);
|
||||||
|
$this->release('1.0.0', true);
|
||||||
|
$this->git(['clone', $origin, $this->work], $this->temp);
|
||||||
|
$this->configure($this->work);
|
||||||
|
|
||||||
|
// The installation: a database and a media folder, both outside Git.
|
||||||
|
mkdir($this->work.'/var/media', 0o775, true);
|
||||||
|
$db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->work.'/var/cms.db'));
|
||||||
|
$db->executeStatement('CREATE TABLE revision (id INTEGER PRIMARY KEY, body TEXT)');
|
||||||
|
$db->executeStatement("INSERT INTO revision (body) VALUES ('Inhalt von vor dem Update')");
|
||||||
|
$db->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->remove($this->temp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThePreflightNamesEveryConditionAndWhatIsWrongWithIt(): void
|
||||||
|
{
|
||||||
|
$checks = $this->updater()->check();
|
||||||
|
$names = array_column($checks, 'name');
|
||||||
|
self::assertContains('Keine lokalen Änderungen', $names);
|
||||||
|
self::assertContains('Datenbank außerhalb von Git', $names);
|
||||||
|
self::assertContains('Medien außerhalb von Git', $names);
|
||||||
|
self::assertSame([], array_column(array_filter($checks, static fn (array $check): bool => !$check['ok']), 'name'));
|
||||||
|
|
||||||
|
// A changed file would be lost by a checkout, so the preflight refuses.
|
||||||
|
file_put_contents($this->work.'/README.md', 'lokal geändert');
|
||||||
|
$dirty = $this->named($this->updater()->check(), 'Keine lokalen Änderungen');
|
||||||
|
self::assertFalse($dirty['ok']);
|
||||||
|
self::assertStringContainsString('verwerfen', $dirty['detail']);
|
||||||
|
|
||||||
|
// And a database inside Git would be overwritten by one.
|
||||||
|
$this->git(['checkout', '--', 'README.md'], $this->work);
|
||||||
|
file_put_contents($this->work.'/.gitignore', '/nichts-besonderes/
|
||||||
|
');
|
||||||
|
$this->commit($this->work, 'Datenbank nicht mehr ausgenommen');
|
||||||
|
$exposed = $this->named($this->updater()->check(), 'Datenbank außerhalb von Git');
|
||||||
|
self::assertFalse($exposed['ok']);
|
||||||
|
self::assertStringContainsString('überschreiben', $exposed['detail']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUpdateMovesToTheNewTagAndLeavesTheSiteOnline(): void
|
||||||
|
{
|
||||||
|
$this->release('1.1.0', true);
|
||||||
|
$updater = $this->updater();
|
||||||
|
self::assertSame('1.0.0', $updater->version());
|
||||||
|
self::assertSame('v1.1.0', $updater->availableVersion());
|
||||||
|
|
||||||
|
$result = $updater->update();
|
||||||
|
self::assertTrue($result['ok'], $this->explain($result));
|
||||||
|
self::assertSame('v1.1.0', $result['target']);
|
||||||
|
self::assertSame('1.1.0', $updater->version(), 'The checked out tag is what is installed.');
|
||||||
|
self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE), 'A finished update ends the maintenance state.');
|
||||||
|
|
||||||
|
// The order matters, so it is asserted.
|
||||||
|
self::assertSame(
|
||||||
|
['Vorprüfung', 'Ziel bestimmen', 'Sicherung', 'Wartungszustand', 'Code wechseln', 'Fassung prüfen', 'Abhängigkeiten', 'Migration', 'Cache', 'Rauchtest', 'Fertig'],
|
||||||
|
array_column($result['steps'], 'step'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The backup was taken before anything changed, and the media folder is not in it.
|
||||||
|
$backups = glob($this->work.'/var/backups/update-*');
|
||||||
|
self::assertCount(1, $backups);
|
||||||
|
self::assertFileExists($backups[0].'/cms.db');
|
||||||
|
self::assertFileDoesNotExist($backups[0].'/media');
|
||||||
|
self::assertStringContainsString('Inhalt von vor dem Update', (string) file_get_contents($backups[0].'/cms.db'));
|
||||||
|
|
||||||
|
// The database and the media folder of the installation are untouched.
|
||||||
|
self::assertFileExists($this->work.'/var/cms.db');
|
||||||
|
self::assertDirectoryExists($this->work.'/var/media');
|
||||||
|
|
||||||
|
$log = $updater->log();
|
||||||
|
self::assertCount(1, $log);
|
||||||
|
self::assertStringContainsString('OK v1.1.0', $log[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFailingSmokeTestRollsBackTheCodeAndTheDatabase(): void
|
||||||
|
{
|
||||||
|
// The new version ships a console that fails - as a broken release would.
|
||||||
|
$this->release('1.2.0', false);
|
||||||
|
$updater = $this->updater();
|
||||||
|
|
||||||
|
$result = $updater->update();
|
||||||
|
self::assertFalse($result['ok']);
|
||||||
|
$steps = array_column($result['steps'], 'step');
|
||||||
|
self::assertContains('Zurück auf den alten Stand', $steps);
|
||||||
|
self::assertContains('Datenbank zurückschreiben', $steps);
|
||||||
|
// The migration is where it broke, so the database had to go back too.
|
||||||
|
self::assertSame('Migration', $this->firstFailed($result)['step']);
|
||||||
|
self::assertSame('1.0.0', $updater->version(), 'A failed update leaves the previous version installed.');
|
||||||
|
self::assertStringContainsString('Inhalt von vor dem Update', (string) file_get_contents($this->work.'/var/cms.db'));
|
||||||
|
// The old version answers, so visitors get the website back rather than a maintenance page.
|
||||||
|
self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE));
|
||||||
|
self::assertTrue($this->named($result['steps'], 'Wieder online', 'step')['ok']);
|
||||||
|
self::assertStringContainsString('FEHLER v1.2.0', $updater->log()[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testADryRunChangesNothing(): void
|
||||||
|
{
|
||||||
|
$this->release('1.1.0', true);
|
||||||
|
$updater = $this->updater();
|
||||||
|
fwrite(STDERR, '
|
||||||
|
');
|
||||||
|
$result = $updater->update(null, true);
|
||||||
|
|
||||||
|
self::assertTrue($result['ok'], $this->explain($result));
|
||||||
|
self::assertSame('1.0.0', $updater->version());
|
||||||
|
self::assertSame([], glob($this->work.'/var/backups/update-*') ?: []);
|
||||||
|
self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE));
|
||||||
|
self::assertSame([], $updater->log());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheNewestVersionAlreadyInstalledIsNotAFailure(): void
|
||||||
|
{
|
||||||
|
$updater = $this->updater();
|
||||||
|
$result = $updater->update();
|
||||||
|
|
||||||
|
self::assertTrue($result['ok'], $this->explain($result));
|
||||||
|
self::assertSame('Nichts zu tun', end($result['steps'])['step']);
|
||||||
|
self::assertSame([], glob($this->work.'/var/backups/update-*') ?: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUnknownTagIsRefusedBeforeAnythingIsTouched(): void
|
||||||
|
{
|
||||||
|
$updater = $this->updater();
|
||||||
|
$result = $updater->update('v9.9.9');
|
||||||
|
|
||||||
|
self::assertFalse($result['ok']);
|
||||||
|
self::assertSame('Code wechseln', $this->firstFailed($result)['step']);
|
||||||
|
self::assertSame('1.0.0', $updater->version());
|
||||||
|
// The maintenance state was switched on before the checkout, and the rollback ends it.
|
||||||
|
self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updater(): Updater
|
||||||
|
{
|
||||||
|
$db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->work.'/var/cms.db'));
|
||||||
|
|
||||||
|
return new Updater(
|
||||||
|
str_replace('\\', '/', $this->work),
|
||||||
|
'sqlite:///'.str_replace('\\', '/', $this->work.'/var/cms.db'),
|
||||||
|
$this->runner,
|
||||||
|
$db,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes a release: VERSION, a .gitignore that keeps the installation out of Git, and the
|
||||||
|
* two stub commands the updater calls. `$healthy` decides whether they succeed.
|
||||||
|
*/
|
||||||
|
private function release(string $version, bool $healthy): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->author.'/VERSION', $version."\n");
|
||||||
|
file_put_contents($this->author.'/README.md', 'KantSites '.$version."\n");
|
||||||
|
// Only the installation is kept out of Git here: the stub commands live under vendor/
|
||||||
|
// and travel with the tag, the way vendor/ does on a host without Composer.
|
||||||
|
file_put_contents($this->author.'/.gitignore', '/var/
|
||||||
|
');
|
||||||
|
foreach (['bin', 'vendor/bin'] as $directory) {
|
||||||
|
if (!is_dir($this->author.'/'.$directory)) {
|
||||||
|
mkdir($this->author.'/'.$directory, 0o775, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stubs, not the real console: this test is about the updater's order and its rollback.
|
||||||
|
$stub = $healthy
|
||||||
|
? "<?php\nfwrite(STDOUT, 'OK '.implode(' ', array_slice(\$argv, 1)).PHP_EOL);\nexit(0);\n"
|
||||||
|
: "<?php\nfwrite(STDOUT, 'Migration gescheitert'.PHP_EOL);\nexit(1);\n";
|
||||||
|
file_put_contents($this->author.'/bin/console', $healthy ? $stub : "<?php\nfwrite(STDOUT, 'OK'.PHP_EOL);\nexit(0);\n");
|
||||||
|
file_put_contents($this->author.'/vendor/bin/doctrine-migrations', $stub);
|
||||||
|
$this->commit($this->author, 'Fassung '.$version);
|
||||||
|
$this->git(['tag', 'v'.$version], $this->author);
|
||||||
|
$this->git(['push', '--quiet', 'origin', 'main', '--tags'], $this->author);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function configure(string $directory): void
|
||||||
|
{
|
||||||
|
$this->git(['config', 'user.email', 'test@example.invalid'], $directory);
|
||||||
|
$this->git(['config', 'user.name', 'Updater Test'], $directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function commit(string $directory, string $message): void
|
||||||
|
{
|
||||||
|
$this->git(['add', '-A'], $directory);
|
||||||
|
$this->git(['commit', '--quiet', '-m', $message], $directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function git(array $arguments, string $cwd): void
|
||||||
|
{
|
||||||
|
$result = $this->runner->run(['git', ...$arguments], $cwd, 60);
|
||||||
|
self::assertTrue($result['ok'], 'git '.implode(' ', $arguments).': '.$result['output']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<array<string, mixed>> $entries */
|
||||||
|
private function named(array $entries, string $needle, string $key = 'name'): array
|
||||||
|
{
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($needle === $entry[$key]) {
|
||||||
|
return $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::fail(sprintf('Kein Eintrag „%s“ vorhanden: %s', $needle, implode(', ', array_column($entries, $key))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function firstFailed(array $result): array
|
||||||
|
{
|
||||||
|
foreach ($result['steps'] as $step) {
|
||||||
|
if (!$step['ok']) {
|
||||||
|
return $step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::fail('Kein gescheiterter Schritt: '.$this->explain($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function explain(array $result): string
|
||||||
|
{
|
||||||
|
return implode("\n", array_map(
|
||||||
|
static fn (array $step): string => sprintf('%s %s: %s', $step['ok'] ? 'ok' : 'FEHLER', $step['step'], $step['detail']),
|
||||||
|
$result['steps'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function remove(string $path): void
|
||||||
|
{
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (scandir($path) ?: [] as $entry) {
|
||||||
|
if ('.' !== $entry && '..' !== $entry) {
|
||||||
|
@chmod($path.'/'.$entry, 0o777);
|
||||||
|
$this->remove($path.'/'.$entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@rmdir($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user