diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/config/services.yaml b/config/services.yaml index 20a8670..39b8434 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -22,3 +22,13 @@ services: App\Controller\SeoController: arguments: $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%' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 20f346d..3d1e2c5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. **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. diff --git a/docs/AUSBAUPLAN.md b/docs/AUSBAUPLAN.md index d81db03..bd9b1b1 100644 --- a/docs/AUSBAUPLAN.md +++ b/docs/AUSBAUPLAN.md @@ -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) +**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“.* 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. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index 72fe4a6..45f80c7 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -1,5 +1,15 @@ # 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 - **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. diff --git a/docs/STATUS.md b/docs/STATUS.md index 7ccebd7..968d03f 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # 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. @@ -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. - 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. +- **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`). - **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. @@ -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. - 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. +- 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. - 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. diff --git a/docs/UPDATE.md b/docs/UPDATE.md index e72c978..435b5bb 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -1,6 +1,6 @@ # 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 @@ -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. 2. **Wie kommt der Code auf den Server?** Git-Klon im Projektstamm setzt Shell-Zugriff voraus. Gibt es den nicht, ist der Updater in dieser Form nicht möglich, und der Weg ist ein Deployment von außen (rsync eines Releaseverzeichnisses, Umschalten eines Symlinks). Das ist derselbe Ablauf, nur mit einem anderen Schritt 5. 3. **Ein Repository oder zwei?** Für den Piloten reicht eines. Sobald mehrere Websites dasselbe CMS nutzen, gehört der Code in ein eigenes Repository und die Inhalte bleiben je Installation. + +## Was anders wurde + +- **Kein `symfony/process`**, sondern ein eigener Runner über `proc_open` mit Befehl als Array. Grund: eine Abhängigkeit weniger, kein Shell-Quoting, und „proc_open ist abgeschaltet“ wird die erste Antwort der Vorprüfung statt einer Überraschung im dritten Schritt. +- **Der Rückweg endet online, wenn er kann.** Geplant war, den Wartungszustand nach einem Fehlschlag anzulassen. Umgesetzt ist: nach dem Zurücksetzen läuft derselbe Rauchtest; antwortet der alte Stand, geht die Website wieder an. Nur wenn auch der alte Stand nicht antwortet, bleibt der Wartungszustand – dann ist eine ehrliche Meldung besser als eine kaputte Seite. +- **Die Datenbank wird nur zurückgeschrieben, wenn die Migration schon begonnen hatte.** Sonst würde der Rückweg Inhalte verlieren, die während des Updates entstanden sind. +- **Der Rauchtest ist `cms:health`** und läuft als eigener Prozess, weil nur ein frischer Prozess den neuen Code ausführt. +- **Noch offen:** MySQL wird gesichert, indem `mysqldump` aufgerufen wird – das ist **nicht** umgesetzt, weil noch keine MySQL-Installation existiert (Arbeitspaket J). Bei einer Datenbank, die nicht als Datei vorliegt, sichert der Updater derzeit nur die Umgebungsdateien und sagt das im Schritt „Sicherung“. Vor dem ersten Livegang mit MySQL muss das nachgezogen werden. +- **Ebenfalls offen:** der Installer aus Arbeitspaket N. Die Systemprüfung, die er braucht, existiert jetzt. diff --git a/public/assets/admin.css b/public/assets/admin.css index e53e4d8..925fb9e 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -578,3 +578,25 @@ details.card > summary:hover { background: #fafbf8 } .stats-list li:first-child { border-top: 0 } .stats-list span, .stats-list a { overflow-wrap: anywhere; min-width: 0 } .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 } diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index 3e79417..db2bd12 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -355,6 +355,7 @@ try { ['menus', '/admin/menus'], ['forms', '/admin/forms'], ['statistics', '/admin/statistik'], + ['system and update', '/admin/system'], ['areas', '/admin/partials'], ['design', '/admin/theme'], ['settings', '/admin/settings'], diff --git a/src/Command/HealthCommand.php b/src/Command/HealthCommand.php new file mode 100644 index 0000000..59a29dd --- /dev/null +++ b/src/Command/HealthCommand.php @@ -0,0 +1,71 @@ +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; + } +} diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php new file mode 100644 index 0000000..ffa1102 --- /dev/null +++ b/src/Command/UpdateCommand.php @@ -0,0 +1,89 @@ +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; + } +} diff --git a/src/Controller/SystemController.php b/src/Controller/SystemController.php new file mode 100644 index 0000000..87153e8 --- /dev/null +++ b/src/Controller/SystemController.php @@ -0,0 +1,85 @@ +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'); + } +} diff --git a/src/EventSubscriber/MaintenanceSubscriber.php b/src/EventSubscriber/MaintenanceSubscriber.php new file mode 100644 index 0000000..b2cb9ba --- /dev/null +++ b/src/EventSubscriber/MaintenanceSubscriber.php @@ -0,0 +1,54 @@ + ['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); + } +} diff --git a/src/Ops/CommandRunner.php b/src/Ops/CommandRunner.php new file mode 100644 index 0000000..80f8bb3 --- /dev/null +++ b/src/Ops/CommandRunner.php @@ -0,0 +1,87 @@ + $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']; + } +} diff --git a/src/Ops/Updater.php b/src/Ops/Updater.php new file mode 100644 index 0000000..a01d9eb --- /dev/null +++ b/src/Ops/Updater.php @@ -0,0 +1,416 @@ +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} + */ + 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 + */ + 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, 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 */ + 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'; + } +} diff --git a/templates/admin/shell.html.twig b/templates/admin/shell.html.twig index 23b3e81..06f77c6 100644 --- a/templates/admin/shell.html.twig +++ b/templates/admin/shell.html.twig @@ -24,6 +24,7 @@