Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d295757ce0 |
No files matched your search
@@ -440,6 +440,37 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
|
||||
|
||||
**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 072 – Drei Fehler einer Familie: dieses Hosting ist nicht mein Rechner (13.09.2026)
|
||||
|
||||
Umut, während er 1.4.4 in Betrieb nahm: „Das buggt doch immer noch" und „Bei Sicherung anlegen kommt auch Fehler 500 – wieso ist das alles verbuggt?" Drei Befunde an einem Abend, und sie gehören zusammen: **Jeder einzelne war auf meinem Rechner unsichtbar.**
|
||||
|
||||
**1. `git` installiert ist nicht dasselbe wie „Arbeitskopie".** `Updater::state()` gab unter dem Schlüssel `git` zurück, ob der *Befehl* `git` aufrufbar ist. Auf Umuts Hosting ist er das (Version 1.8.3.1), nur ist das Verzeichnis eine Installation aus dem Releasearchiv. Also lief jeder Git-Aufruf ins Leere, und in der Verwaltung stand dort, wo eine Fassungsnummer hingehört: `fatal: Not a git repository`. Dazu eine Vorprüfung voller roter Ausrufezeichen für Bedingungen, die für diese Installation gar nicht gelten. Die Software war in Ordnung und sah kaputt aus – für den Betreiber ist das dasselbe. Der Zustand heißt jetzt `worktree` und wird getrennt von `git` erhoben; ohne Arbeitskopie verschwinden die Git-Karten vollständig, und an ihrer Stelle steht ein Satz: „Eingerichtet über das Releasearchiv."
|
||||
|
||||
**2. `VACUUM INTO` gibt es erst seit SQLite 3.27.** Die Sicherung benutzt es, weil eine Dateikopie während eines Schreibvorgangs unbrauchbar sein kann (ADR 038). Auf Umuts Hosting läuft eine ältere Fassung: alt genug für die Installation (die braucht 3.16, ADR aus 1.4.2), zu alt für die Sicherung. Ergebnis: 500 beim Knopf „Sicherung jetzt anlegen". Jetzt wird bei älteren Fassungen unter einer Schreibsperre kopiert – `PRAGMA wal_checkpoint`, `BEGIN IMMEDIATE`, kopieren, `COMMIT` –, und der Test setzt die Schwelle künstlich hoch, damit dieser Weg wirklich läuft und nicht nur auf fremden Servern.
|
||||
|
||||
**3. Ein unerwarteter Fehler darf keine 500-Seite sein.** Der Sicherungsknopf fing `InvalidPage` ab, also die *erwarteten* Fälle. Alles andere – alte Datenbankfassung, fehlendes Schreibrecht, voller Datenträger – fiel durch. Genau das sind aber die Fälle, die nur auf fremden Servern auftreten. Der Knopf fängt jetzt jeden Fehler und sagt, woran es lag.
|
||||
|
||||
**Was ich daraus mitnehme:** Jeder dieser Fehler war eine *Umgebungsdifferenz*, und keiner davon war durch mehr Tests auf meinem Rechner zu finden. Was hilft, ist zweierlei – eine nachgestellte Installation aus dem echten Releasearchiv (so wurde ADR 071 gefunden) und die Regel, dass ein Knopf in der Verwaltung **niemals** mit einer 500-Seite enden darf, sondern mit einem Satz. Das Zweite ist billiger und fängt auch die Fälle, an die ich nicht gedacht habe.
|
||||
|
||||
## ADR 071 – Ein Buchstabe, der die ganze Funktion unbrauchbar machte (13.09.2026)
|
||||
|
||||
Umut hat 1.4.3 installiert, den Updateknopf gedrückt und eine **500-Seite** bekommen. Sein Satz dazu: „Das gefällt mir alles ehrlich gesagt nicht. Ich möchte es so einfach wie WordPress." Beides berechtigt.
|
||||
|
||||
Nachgestellt wurde es mit einer echten 1.4.3-Installation aus dem Releasearchiv, eigener Datenbank, eigenem Port. Der Fehler stand dann im Protokoll:
|
||||
|
||||
```
|
||||
Twig\Error\RuntimeError: Key "step" for sequence/mapping with keys
|
||||
"label, ok, detail" does not exist in "admin/system.html.twig" at line 137
|
||||
```
|
||||
|
||||
Der Updater über Git nennt einen Schritt `step`, der neue über das Archiv nannte ihn `label`, und beide legen ihr Ergebnis unter demselben Sitzungsschlüssel ab. Die Vorlage liest `step`. **Das Update lief vollständig durch** – alle neun Schritte in Ordnung, Dateien getauscht, `var/` und `.env` unberührt – und dann starb die Seite, die das berichten sollte. Damit war auch der zweite Knopf unerreichbar, der die Migrationen ausführt, und die Website blieb im Wartungszustand.
|
||||
|
||||
**Warum kein Test das gefunden hat.** Zehn Tests prüfen den Archiv-Updater, und alle prüfen sein Rückgabe-Array. Keiner hat die Seite gerendert, die dieses Array anzeigt. Ein Ergebnis, das stimmt, und eine Anzeige, die es nicht lesen kann – das ist dieselbe Familie wie das `og:image`, das gespeichert wurde und nie ankam (ADR 056), und wie das Stylesheet, das nichts mehr tat (ADR 064). **Die Naht zwischen zwei richtigen Hälften ist die Stelle, an der es bricht.** Der Test dazu rendert jetzt `/admin/system` mit den Schritten eines echten Laufs in der Sitzung; gegengeprüft, indem der Fehler noch einmal eingebaut wurde – der Test wird rot.
|
||||
|
||||
Der Schlüssel heißt jetzt in beiden Updatern `step`. Nicht die Vorlage wurde nachsichtig gemacht: Zwei Dinge derselben Art beschreiben sich gleich, sonst wiederholt sich das beim nächsten Anzeigeort.
|
||||
|
||||
**Und der zweite Teil von Umuts Satz.** Nach dem Tausch stand die Verwaltung mit zwei Karten da, die beide dasselbe wollten – „Migrationen ausführen und freigeben" und „Migrationen stehen aus". Solange das Archiv-Update auf seinen zweiten Schritt wartet, gehört der Knopf an eine Stelle. Zwei Knöpfe, die dasselbe tun, sind eine Frage zu viel.
|
||||
|
||||
## ADR 070 – Die zwei Lücken, die erst das Veröffentlichen zeigte (13.09.2026)
|
||||
|
||||
Umut, kurz nach dem Release: „Wie spiele ich das Update manuell ein?" Die Frage war berechtigt, und die Antwort hatte zwei Lücken.
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: 13.09.2026, Fassung **1.4.4**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. 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.
|
||||
Stand: 13.09.2026, Fassung **1.4.5**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. 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.
|
||||
|
||||
@@ -39,6 +39,9 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
|
||||
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
|
||||
- **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen.
|
||||
- **Behoben in 1.4.5: die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git** (ADR 072). `git` installiert und „Arbeitskopie" waren derselbe Schalter; auf Umuts Hosting ist Git vorhanden, die Installation kam aber aus dem Releasearchiv. Dort stand `fatal: Not a git repository` an der Stelle der Fassungsnummer und eine rote Vorprüfung für Bedingungen, die gar nicht gelten. Ohne Arbeitskopie verschwinden die Git-Karten jetzt.
|
||||
- **Behoben in 1.4.5: „Sicherung anlegen" endete mit 500** (ADR 072). `VACUUM INTO` gibt es erst seit SQLite 3.27; Umuts Hosting hat eine ältere Fassung. Bei älteren wird unter einer Schreibsperre kopiert, und der Sicherungsknopf fängt jeden Fehler ab, statt mit einer 500-Seite zu enden.
|
||||
- **Behoben in 1.4.5: der Updateknopf endete mit 500** (ADR 071). Auf Umuts Hosting gefunden, mit einer echten 1.4.3-Installation nachgestellt: Das Update lief vollständig durch – alle neun Schritte, Dateien getauscht, `var/` und `.env` unberührt –, aber die **Ergebnisseite** starb daran. Der Updater über Git nennt einen Schritt `step`, der über das Archiv nannte ihn `label`, beide legen ihn unter demselben Sitzungsschlüssel ab, und die Vorlage liest `step`. Damit war auch der zweite Knopf unerreichbar und die Website blieb im Wartungszustand. Zehn Tests prüften das Rückgabe-Array, keiner die Seite, die es anzeigt; der neue Test rendert sie und wurde gegengeprüft, indem der Fehler noch einmal eingebaut wurde.
|
||||
- **Migrationen im Browser ausführen** (1.4.4, ADR 070): Wer ein Update von Hand über FTP einspielt, hat danach neuen Code und eine alte Datenbank. Der Bildschirm „System" zeigt jetzt eine Karte, sobald etwas aussteht, nennt die Namen und führt sie auf Knopfdruck aus – vorher ging das nur über eine Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Weg von Hand steht Schritt für Schritt in `docs/UPDATE.md`.
|
||||
- **Ein Fehlschlag beim Laden sagt, woran es lag** (1.4.4, ADR 070): „Die Releaseseite antwortet nicht" war drei Ursachen in einem Satz. Beim Gegenprüfen des Releases fiel auf, dass diesem PHP der **Zertifikatsspeicher** fehlt – auf einem Webhosting der häufigste Grund und der einzige, den man in einer Minute behebt. Die Meldung nennt ihn jetzt, und bei genau diesem Fehler wird der zweite Weg über den Dateizeiger versucht. Abgeschaltet wird die Prüfung nicht.
|
||||
- **Das Hintergrundbild des ersten Abschnitts wird vorab geholt** (1.4.3, ADR 069): Auf der Startseite des Piloten ist das größte Element über der Falz kein `<img>`, sondern der Hintergrund des Heros – und der steht nur im Stylesheet, das der Browser erst nach dem HTML holt. Drei Vorabverweise mit denselben Gerätegrenzen wie der Compiler holen **genau die Variante, die die Regel auch nimmt**; ein `imagesrcset` hätte bei manchen Fensterbreiten ein zweites Bild geladen. Gemessen: LCP 1,3 s → 1,1 s, Leistung Desktop 96 → 97, Mobil 98 → 99 (auf dem Entwicklungsserver ohne Kompression). Dabei gefunden: dasselbe Bild wurde je Geräteregel neu abgefragt – die Mediathek merkt sich jetzt, wonach sie in dieser Anfrage schon gefragt wurde, **Startseite 19 → 15 Abfragen**.
|
||||
|
||||
@@ -167,6 +167,12 @@ final class SystemController extends AbstractController
|
||||
$result = $this->backup->create('Verwaltung');
|
||||
} catch (InvalidPage $error) {
|
||||
return $this->back($error->getMessage());
|
||||
} catch (\Throwable $error) {
|
||||
// Alles andere ist kein erwarteter Fall, sondern einer, den dieses Hosting mitbringt -
|
||||
// eine alte SQLite-Fassung, ein fehlendes Schreibrecht, ein voller Datentraeger. Eine
|
||||
// 500-Seite laesst den Betreiber damit allein; der Satz hier sagt wenigstens, woran es
|
||||
// lag.
|
||||
return $this->back('Die Sicherung ist fehlgeschlagen: '.$error->getMessage());
|
||||
}
|
||||
$removed = $this->backup->rotate();
|
||||
|
||||
|
||||
@@ -91,13 +91,13 @@ final class ArchiveUpdate
|
||||
/**
|
||||
* Spielt die angegebene (oder die neueste) Fassung ein.
|
||||
*
|
||||
* @return array{ok: bool, steps: list<array{label: string, ok: bool, detail: string}>}
|
||||
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
|
||||
*/
|
||||
public function run(?string $version = null): array
|
||||
{
|
||||
$schritte = [];
|
||||
$melden = static function (array &$schritte, string $label, bool $ok, string $detail = ''): bool {
|
||||
$schritte[] = ['label' => $label, 'ok' => $ok, 'detail' => $detail];
|
||||
$melden = static function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool {
|
||||
$schritte[] = ['step' => $schritt, 'ok' => $ok, 'detail' => $detail];
|
||||
|
||||
return $ok;
|
||||
};
|
||||
@@ -254,25 +254,25 @@ final class ArchiveUpdate
|
||||
*
|
||||
* Läuft in einer eigenen Anfrage, also mit dem Code der neuen Fassung.
|
||||
*
|
||||
* @return array{ok: bool, steps: list<array{label: string, ok: bool, detail: string}>}
|
||||
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
|
||||
*/
|
||||
public function finish(Installation $installation): array
|
||||
{
|
||||
$schritte = [];
|
||||
try {
|
||||
$namen = $installation->migrate();
|
||||
$schritte[] = ['label' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
|
||||
$schritte[] = ['step' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
|
||||
? 'Es stand keine an - die Datenbank war bereits auf dem Stand der neuen Fassung.'
|
||||
: sprintf('%d ausgeführt: %s.', count($namen), implode(', ', $namen))];
|
||||
} catch (\Throwable $fehler) {
|
||||
$schritte[] = ['label' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
|
||||
$schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
@unlink($this->root.'/'.self::MARKE);
|
||||
$this->updater->maintenance(false);
|
||||
$schritte[] = ['label' => 'Fertig', 'ok' => true, 'detail' => sprintf(
|
||||
$schritte[] = ['step' => 'Fertig', 'ok' => true, 'detail' => sprintf(
|
||||
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/, bis du ihn löschst.',
|
||||
$this->updater->version(),
|
||||
)];
|
||||
|
||||
+67
-3
@@ -35,6 +35,14 @@ final class Backup
|
||||
private string $mediaDirectory,
|
||||
private Connection $db,
|
||||
private CommandRunner $runner,
|
||||
/**
|
||||
* Ab welcher SQLite-Fassung `VACUUM INTO` benutzt wird.
|
||||
*
|
||||
* Als Parameter und nicht als Konstante, damit der Weg fuer aeltere Fassungen im Test
|
||||
* wirklich laeuft. Ein Ersatzweg, der nur auf fremden Servern ausgefuehrt wird, ist ein
|
||||
* ungeprueftes Versprechen.
|
||||
*/
|
||||
private string $vacuumSince = '3.27.0',
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -214,9 +222,19 @@ final class Backup
|
||||
throw new InvalidPage('Sicherung über mysqldump ist noch nicht umgesetzt (Arbeitspaket J).');
|
||||
}
|
||||
$target = $directory.'/'.basename($file);
|
||||
// VACUUM INTO instead of copy(): a copy taken while something writes can be unusable, and
|
||||
// it would not say so until the day it is needed. The path is ours, not user input.
|
||||
$this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $target))));
|
||||
// VACUUM INTO statt copy(): Eine Kopie, die waehrend eines Schreibvorgangs entsteht, kann
|
||||
// unbrauchbar sein - und sie sagt das nicht, sondern erst an dem Tag, an dem sie gebraucht
|
||||
// wird. Der Pfad kommt von uns, nicht aus einer Eingabe.
|
||||
//
|
||||
// **Es gibt VACUUM INTO aber erst seit SQLite 3.27.** Auf Umuts Hosting ist die Fassung
|
||||
// aelter: Die Installation lief (dafuer reicht 3.16), die Sicherung endete mit 500. Auf
|
||||
// einer solchen Installation wird stattdessen unter einer Schreibsperre kopiert - das ist
|
||||
// der Weg, den SQLite selbst fuer diesen Fall nennt, und er ist ebenso konsistent.
|
||||
if (version_compare($this->sqliteVersion(), $this->vacuumSince, '>=')) {
|
||||
$this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $target))));
|
||||
} else {
|
||||
$this->copyUnderLock($file, $target);
|
||||
}
|
||||
if (!is_readable($target)) {
|
||||
throw new InvalidPage('Die Datenbank ließ sich nicht sichern.');
|
||||
}
|
||||
@@ -224,6 +242,52 @@ final class Backup
|
||||
return ['datei' => basename($file), 'bytes' => (int) filesize($target), 'sha256' => (string) hash_file('sha256', $target)];
|
||||
}
|
||||
|
||||
/** Die Fassung von SQLite, oder '0' wenn das keine SQLite-Verbindung ist. */
|
||||
private function sqliteVersion(): string
|
||||
{
|
||||
try {
|
||||
return (string) $this->db->fetchOne('SELECT sqlite_version()');
|
||||
} catch (\Throwable) {
|
||||
return '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine konsistente Kopie ohne VACUUM INTO.
|
||||
*
|
||||
* `BEGIN IMMEDIATE` haelt jeden anderen Schreibvorgang an, solange kopiert wird; der
|
||||
* Checkpoint davor schreibt zurueck, was noch im Write-Ahead-Log steht, damit die Kopie
|
||||
* vollstaendig ist und nicht die Haelfte der letzten Aenderungen fehlt.
|
||||
*
|
||||
* @throws InvalidPage
|
||||
*/
|
||||
private function copyUnderLock(string $file, string $target): void
|
||||
{
|
||||
try {
|
||||
$this->db->executeStatement('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
} catch (\Throwable) {
|
||||
// Ohne WAL gibt es nichts zurueckzuschreiben - das ist kein Fehler.
|
||||
}
|
||||
|
||||
try {
|
||||
$this->db->executeStatement('BEGIN IMMEDIATE');
|
||||
} catch (\Throwable $fehler) {
|
||||
throw new InvalidPage('Die Datenbank ließ sich fuer die Sicherung nicht sperren: '.$fehler->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
if (!@copy($file, $target)) {
|
||||
throw new InvalidPage('Die Datenbankdatei ließ sich nicht kopieren. Schreibrechte auf var/backups pruefen.');
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
$this->db->executeStatement('COMMIT');
|
||||
} catch (\Throwable) {
|
||||
// Die Sperre faellt spaetestens mit der Verbindung.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{count: int, bytes: int} */
|
||||
private function copyMedia(string $target): array
|
||||
{
|
||||
|
||||
+12
-3
@@ -46,14 +46,23 @@ final class Updater
|
||||
public function state(): array
|
||||
{
|
||||
$git = $this->runner->available() && $this->runner->has('git', $this->root);
|
||||
$status = $git ? $this->git(['status', '--porcelain']) : null;
|
||||
// Zwei verschiedene Fragen, und die Verwechslung war ein echter Fehler: Ob der Befehl
|
||||
// `git` existiert, sagt nichts darueber, ob dieses Verzeichnis eine Arbeitskopie ist. Ein
|
||||
// Hosting hat oft beides - git installiert, aber die Installation kam aus dem
|
||||
// Releasearchiv. Dann lief jeder Aufruf ins Leere, und die Verwaltung zeigte dem
|
||||
// Betreiber „fatal: Not a git repository“ an einer Stelle, an der eine Fassungsnummer
|
||||
// stehen sollte.
|
||||
$arbeitskopie = $git && is_dir($this->root.'/.git');
|
||||
$status = $arbeitskopie ? $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',
|
||||
'commit' => $arbeitskopie ? substr($this->git(['rev-parse', 'HEAD'])['output'], 0, 8) : 'unbekannt',
|
||||
'describe' => $arbeitskopie ? $this->git(['describe', '--tags', '--always'])['output'] : 'unbekannt',
|
||||
'maintenance' => is_file($this->root.'/'.self::MAINTENANCE_FILE),
|
||||
'git' => $git,
|
||||
// Der Weg ueber Git steht nur einer Arbeitskopie offen.
|
||||
'worktree' => $arbeitskopie,
|
||||
'clean' => null === $status ? null : ('' === $status['output'] && $status['ok']),
|
||||
'log' => $this->log(),
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% 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 lead %}Welche Fassung läuft, ob ein Update möglich ist, und der Weg dorthin: sichern, Wartungszustand, Dateien tauschen, migrieren.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="card">
|
||||
@@ -22,7 +22,13 @@
|
||||
<span class="muted">– geändert. Sie wird in der <code>robots.txt</code> nicht genannt; notiere sie dir an einem sicheren Ort. Zurück mit <code>php bin/console cms:admin:path admin</code>.</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% if state.worktree %}
|
||||
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
|
||||
{% else %}
|
||||
<dt>Eingerichtet über</dt>
|
||||
<dd>das <strong>Releasearchiv</strong> <span class="muted">– kein Git in diesem Verzeichnis, und dafür ist es auch nicht gedacht. Updates laufen über die Karte weiter unten.</span></dd>
|
||||
{% endif %}
|
||||
{% if state.worktree %}
|
||||
<dt>Verfügbar</dt>
|
||||
<dd>
|
||||
{% if available %}
|
||||
@@ -33,6 +39,7 @@
|
||||
<a href="{{ path('admin_system', {pruefen: 1}) }}">Beim Repository nachfragen</a>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
{% if state.maintenance %}
|
||||
@@ -49,6 +56,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Die Vorprüfung gehört zum Updateweg über Git: Arbeitsverzeichnis sauber, Datenbank
|
||||
außerhalb von Git, Tag erreichbar. Auf einer Installation aus dem Releasearchiv prüft sie
|
||||
Bedingungen, die dort keine sind - und zeigt lauter rote Ausrufezeichen für etwas, das
|
||||
vollkommen in Ordnung ist. #}
|
||||
{% if state.worktree %}
|
||||
<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">
|
||||
@@ -62,8 +74,11 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if pendingMigrations|default([]) %}
|
||||
{# Waehrend das Archiv-Update auf seinen zweiten Schritt wartet, gehoert der Knopf dorthin -
|
||||
zwei Knoepfe, die dasselbe tun, sind eine Frage zu viel. #}
|
||||
{% if pendingMigrations|default([]) and not archivePending %}
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Migrationen stehen aus</h2>
|
||||
@@ -83,8 +98,8 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Update aus dem Releasearchiv</h2>
|
||||
<span class="muted">{{ state.git ? 'Alternative zum Weg über Git' : 'der Weg ohne Git' }}</span>
|
||||
<h2>{{ state.worktree ? 'Update aus dem Releasearchiv' : 'Update einspielen' }}</h2>
|
||||
<span class="muted">{{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung und tauscht die Dateien' }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if archivePending %}
|
||||
@@ -119,6 +134,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if state.worktree %}
|
||||
<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">
|
||||
@@ -143,6 +159,7 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if steps is not empty %}
|
||||
<div class="card">
|
||||
|
||||
@@ -77,7 +77,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
self::assertTrue($ergebnis['migrate'] ?? false, 'Nach dem Ersetzen fehlen noch die Migrationen.');
|
||||
self::assertSame(
|
||||
['Fassung ermitteln', 'Sicherung', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
|
||||
array_column($ergebnis['steps'], 'label'),
|
||||
array_column($ergebnis['steps'], 'step'),
|
||||
'Die Reihenfolge ist der Punkt: gesichert wird vor dem ersten Handgriff, geprüft vor dem Entpacken.',
|
||||
);
|
||||
|
||||
@@ -127,7 +127,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
$ergebnis = $this->updater()->run();
|
||||
|
||||
self::assertFalse($ergebnis['ok']);
|
||||
self::assertSame('Prüfsumme', $this->firstFailed($ergebnis)['label']);
|
||||
self::assertSame('Prüfsumme', $this->firstFailed($ergebnis)['step']);
|
||||
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')), 'Nichts wurde ersetzt.');
|
||||
self::assertFileExists($this->root.'/src/Alt.php');
|
||||
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Die Website darf davon nichts merken.');
|
||||
@@ -142,7 +142,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
$ergebnis = $this->updater()->run();
|
||||
|
||||
self::assertFalse($ergebnis['ok']);
|
||||
self::assertSame('Entpacken', $this->firstFailed($ergebnis)['label']);
|
||||
self::assertSame('Entpacken', $this->firstFailed($ergebnis)['step']);
|
||||
self::assertStringContainsString('vendor', $this->firstFailed($ergebnis)['detail']);
|
||||
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
|
||||
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
|
||||
@@ -172,7 +172,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
|
||||
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
|
||||
self::assertFalse($ergebnis['migrate'] ?? false);
|
||||
self::assertSame('Nichts zu tun', end($ergebnis['steps'])['label']);
|
||||
self::assertSame('Nichts zu tun', end($ergebnis['steps'])['step']);
|
||||
self::assertSame([], glob($this->root.'/var/backups/*') ?: []);
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
|
||||
self::assertFalse($ergebnis['ok']);
|
||||
$gescheitert = $this->firstFailed($ergebnis);
|
||||
self::assertSame('Archiv laden', $gescheitert['label']);
|
||||
self::assertSame('Archiv laden', $gescheitert['step']);
|
||||
self::assertStringContainsString('herunterladen', $gescheitert['detail']);
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
file_put_contents($this->root.'/'.$relativ, $inhalt);
|
||||
}
|
||||
|
||||
/** @param array{steps: list<array{label: string, ok: bool, detail: string}>} $ergebnis */
|
||||
/** @param array{steps: list<array{step: string, ok: bool, detail: string}>} $ergebnis */
|
||||
private function firstFailed(array $ergebnis): array
|
||||
{
|
||||
foreach ($ergebnis['steps'] as $schritt) {
|
||||
@@ -323,7 +323,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
private function explain(array $ergebnis): string
|
||||
{
|
||||
return implode("\n", array_map(
|
||||
static fn (array $schritt): string => sprintf('%s %s: %s', $schritt['ok'] ? 'ok' : 'FEHLER', $schritt['label'], $schritt['detail']),
|
||||
static fn (array $schritt): string => sprintf('%s %s: %s', $schritt['ok'] ? 'ok' : 'FEHLER', $schritt['step'], $schritt['detail']),
|
||||
$ergebnis['steps'],
|
||||
));
|
||||
}
|
||||
|
||||
@@ -40,6 +40,36 @@ final class BackupTest extends TestCase
|
||||
$this->backup = new Backup($this->root, $url, $this->root.'/var/media', $this->db, new CommandRunner());
|
||||
}
|
||||
|
||||
public function testABackupWorksOnAnOlderSqliteToo(): void
|
||||
{
|
||||
// Auf Umuts Hosting endete „Sicherung anlegen" mit 500. Der Grund: `VACUUM INTO` gibt es
|
||||
// erst seit SQLite 3.27, und dort läuft eine ältere Fassung - alt genug für die
|
||||
// Installation (die braucht 3.16), zu alt für die Sicherung. Statt VACUUM wird dann unter
|
||||
// einer Schreibsperre kopiert. Geprüft wird das hier wirklich und nicht nur versprochen:
|
||||
// die Schwelle wird so hoch gesetzt, dass der Ersatzweg läuft.
|
||||
$url = 'sqlite:///'.str_replace('\\', '/', $this->root).'/var/cms.db';
|
||||
$alt = new Backup($this->root, $url, $this->root.'/var/media', $this->db, new CommandRunner(), '99.0.0');
|
||||
|
||||
$sicherung = $alt->create('auf altem SQLite');
|
||||
|
||||
$datei = $sicherung['path'].'/cms.db';
|
||||
self::assertFileExists($datei);
|
||||
self::assertGreaterThan(0, (int) filesize($datei));
|
||||
|
||||
// Und es ist eine vollständige, lesbare Datenbank - nicht ein halb geschriebener Torso.
|
||||
$kopie = new \PDO('sqlite:'.$datei);
|
||||
self::assertSame(
|
||||
(int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'),
|
||||
(int) $kopie->query('SELECT COUNT(*) FROM revision')->fetchColumn(),
|
||||
'Die Kopie muss denselben Inhalt haben wie das Original.',
|
||||
);
|
||||
self::assertSame(
|
||||
$sicherung['manifest']['datenbank']['sha256'],
|
||||
hash_file('sha256', $datei),
|
||||
'Die Prüfsumme im Manifest gehört zu genau dieser Datei.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->db->close();
|
||||
|
||||
@@ -10,6 +10,11 @@ use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use App\Ops\Download;
|
||||
use App\Ops\Updater;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
@@ -99,6 +104,42 @@ final class SystemScreenTest extends WebTestCase
|
||||
self::assertStringContainsString('Sicherung', $html);
|
||||
}
|
||||
|
||||
public function testTheScreenShowsWhatTheArchiveUpdaterDidInsteadOfDyingOnIt(): void
|
||||
{
|
||||
// Der Fehler, den Umut auf seinem Hosting gesehen hat: Das Update lief, legte seine
|
||||
// Schritte ab – und die Seite, die sie anzeigen soll, starb daran. Der Updater über Git
|
||||
// nennt einen Schritt `step`, der über das Archiv nannte ihn `label`, und die Vorlage
|
||||
// liest `step`. Ergebnis: 500 statt Ergebnis, und der zweite Knopf unerreichbar. Ein
|
||||
// Test, der nur das Ergebnis-Array prüft, findet so etwas nie – dieser rendert die Seite.
|
||||
$archiv = new ArchiveUpdate(
|
||||
dirname(__DIR__),
|
||||
new Updater(dirname(__DIR__), (string) $_ENV['DATABASE_URL'], new CommandRunner(), $this->db),
|
||||
new Backup(dirname(__DIR__), (string) $_ENV['DATABASE_URL'], $this->path.'-media', $this->db, new CommandRunner()),
|
||||
new Download(connectTimeout: 1, timeout: 2),
|
||||
// Eine Adresse, die es nicht gibt: Der Lauf endet nach dem ersten Schritt, ohne
|
||||
// irgendetwas anzufassen – und genau dieser eine Schritt muss auf der Seite stehen.
|
||||
'https://kein-server.invalid/download',
|
||||
'https://kein-server.invalid/api',
|
||||
);
|
||||
$ergebnis = $archiv->run();
|
||||
self::assertFalse($ergebnis['ok']);
|
||||
self::assertSame(
|
||||
['step', 'ok', 'detail'],
|
||||
array_keys($ergebnis['steps'][0]),
|
||||
'Beide Updater beschreiben ihre Schritte gleich - die Vorlage kennt nur eine Form.',
|
||||
);
|
||||
|
||||
$client = $this->login();
|
||||
$client->request('GET', '/admin/system');
|
||||
$sitzung = $client->getRequest()->getSession();
|
||||
$sitzung->set('update_steps', $ergebnis['steps']);
|
||||
$sitzung->save();
|
||||
|
||||
$client->request('GET', '/admin/system');
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode(), 'Die Ergebnisseite darf an den eigenen Schritten nicht sterben.');
|
||||
self::assertStringContainsString('Fassung ermitteln', (string) $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testNobodyButAnAdministratorCanStartAMigration(): void
|
||||
{
|
||||
// Ein Knopf, der die Datenbank umbaut, ist das lohnendste Ziel auf diesem Bildschirm.
|
||||
|
||||
Reference in new issue
Block a user