Files
KantSites/src/Ops/ArchiveUpdate.php
T
kantarciandClaude Opus 5 d8c8cbe7bd Remove the old favicon.svg in the second update step, which runs the new code
The file copy still runs the code of the installed version, which does not
know the list; the archive check from 1.21.6 to 1.21.7 left the file behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 07:41:15 +02:00

438 lines
19 KiB
PHP

<?php
namespace App\Ops;
/**
* Ein Update für Installationen ohne Git.
*
* Der Updater über Git setzt eine Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht:
* dort wird über das Releasearchiv installiert, und danach steht in der Verwaltung „Dieses
* Verzeichnis ist keine Git-Arbeitskopie" - also kein Update, außer von Hand über FTP. Das ist
* genau der Fall, für den dieses CMS gedacht ist.
*
* Hier läuft derselbe Weg wie bei der Erstinstallation: Archiv laden, Prüfsumme vergleichen,
* entpacken, Dateien ersetzen, Migrationen ausführen. Mit drei Unterschieden, die ein Update von
* einer Installation trennen:
*
* 1. **Was dem Betreiber gehört, wird nicht angefasst.** `var/`, `.env`, `.env.local`, die
* hochgeladenen Schriften und das erzeugte Stylesheet-Bündel bleiben, wie sie sind.
* 2. **Der alte Stand wird beiseitegelegt, nicht gelöscht.** Geht etwas schief, steht er unter
* `var/update/` und lässt sich zurückschieben.
* 3. **Die Website ist währenddessen im Wartungszustand** - eine halb ersetzte Anwendung darf
* niemand zu sehen bekommen.
*/
final class ArchiveUpdate
{
/** Was aus dem Archiv übernommen wird. Alles andere im Projektstamm bleibt unberührt. */
private const ERSETZEN = ['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION'];
/** Was innerhalb von public/ dem Betreiber gehört und deshalb stehen bleibt. */
private const EIGENES_IN_PUBLIC = ['assets/fonts', 'assets/build'];
/**
* Was frühere Fassungen in public/ mitbrachten und jetzt die Anwendung beantwortet.
*
* Das Zusammenführen kopiert nur hinzu. Bliebe favicon.svg liegen, lieferte der Webserver
* sie weiter selbst aus - das mitgelieferte Zeichen, auch wenn ein eigenes Symbol gesetzt ist.
* Gelöscht wird in finish(): Das Kopieren erledigt noch der Code der alten Fassung, der
* diese Liste nicht kennt.
*/
private const ENTFALLEN_IN_PUBLIC = ['favicon.svg'];
/**
* Die Marke zwischen den beiden Schritten.
*
* Sie liegt auf der Platte und nicht in der Sitzung: Fällt die Sitzung zwischen „Dateien
* ersetzt" und „Migrationen" aus - Zeitüberschreitung, anderer Browser, anderer Rechner -,
* bliebe die Website sonst im Wartungszustand und der Knopf, der sie herausholt, wäre weg.
*/
public const MARKE = 'var/update/offen.json';
/** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */
private const PROTOKOLL = 'var/updates.log';
public function __construct(
private string $root,
private Updater $updater,
private Backup $backup,
private Download $download,
private string $releaseBase = 'https://git.kant-it.de/kantarci/KantSites/releases/download',
private string $apiBase = 'https://git.kant-it.de/api/v1/repos/kantarci/KantSites',
) {
}
/**
* Die neueste veröffentlichte Fassung, oder null wenn sie sich nicht ermitteln lässt.
*
* @return array{version: string, url: string, checksum: ?string}|null
*/
public function latest(): ?array
{
$antwort = $this->download->get($this->apiBase.'/releases/latest');
if (null === $antwort) {
return null;
}
try {
$daten = json_decode($antwort, true, 16, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
$tag = (string) ($daten['tag_name'] ?? '');
if (1 !== preg_match('/^v(\d+\.\d+\.\d+)$/D', $tag, $treffer)) {
return null;
}
$version = $treffer[1];
return [
'version' => $version,
'url' => sprintf('%s/%s/kantsites-%s.zip', $this->releaseBase, $tag, $version),
'checksum' => $this->checksumOf(sprintf('%s/%s/kantsites-%s.zip.sha256', $this->releaseBase, $tag, $version)),
];
}
/** Gibt es etwas Neueres als das, was hier läuft? */
public function updateAvailable(): ?string
{
$neueste = $this->latest();
return null !== $neueste && version_compare($neueste['version'], $this->updater->version(), '>')
? $neueste['version']
: null;
}
/**
* Spielt die angegebene (oder die neueste) Fassung ein.
*
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
*/
public function run(?string $version = null): array
{
$schritte = [];
// Jeder Schritt geht sofort ins Protokoll, nicht erst am Ende.
//
// Der Grund steht in ADR 071: Wenn die Seite, die das Ergebnis anzeigt, nicht erscheint -
// aus welchem Grund auch immer -, weiß niemand mehr, was passiert ist. Eine Zeile in
// einer Datei überlebt das, und auf einem Hosting ohne Konsole ist sie manchmal das
// Einzige, was von einem gescheiterten Update übrig bleibt.
$melden = function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool {
$schritte[] = ['step' => $schritt, 'ok' => $ok, 'detail' => $detail];
$this->protokoll(sprintf('%s %s: %s', $ok ? 'ok ' : 'FEHLER', $schritt, $detail));
return $ok;
};
$this->protokoll(sprintf('--- Update aus dem Archiv, %s UTC', gmdate('Y-m-d H:i:s')));
$neueste = $this->latest();
if (null === $neueste) {
$melden($schritte, 'Fassung ermitteln', false, trim('Die Releaseseite antwortet nicht. Ohne sie ist nicht feststellbar, was aktuell ist. '.$this->download->reason()));
return ['ok' => false, 'steps' => $schritte];
}
$ziel = $version ?? $neueste['version'];
$melden($schritte, 'Fassung ermitteln', true, sprintf('Installiert: %s, verfügbar: %s.', $this->updater->version(), $neueste['version']));
if (version_compare($ziel, $this->updater->version(), '<=')) {
$melden($schritte, 'Nichts zu tun', true, 'Diese Installation ist bereits auf dem neuesten Stand.');
return ['ok' => true, 'steps' => $schritte];
}
$lock = $this->updater->lock();
if (null === $lock) {
$melden($schritte, 'Sperre', false, 'Ein anderes Update läuft gerade (var/update.lock). Bitte warten.');
return ['ok' => false, 'steps' => $schritte];
}
try {
return $this->apply($schritte, $melden, $ziel, $neueste);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** Der Teil des Updates, der die Installation veraendert - unter der Sperre. */
private function apply(array &$schritte, callable $melden, string $ziel, array $neueste): array
{
$arbeit = $this->root.'/var/update/'.gmdate('Ymd-His');
if (!@mkdir($arbeit, 0o775, true) && !is_dir($arbeit)) {
$melden($schritte, 'Arbeitsordner', false, $arbeit.' ließ sich nicht anlegen. Fehlt das Schreibrecht auf var/?');
return ['ok' => false, 'steps' => $schritte];
}
// Erst sichern, dann anfassen. Eine Sicherung nach dem Ersetzen wäre keine.
try {
$sicherung = $this->backup->create('vor dem Update');
$melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung')));
} catch (\Throwable $fehler) {
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
return ['ok' => false, 'steps' => $schritte];
}
$archiv = $arbeit.'/kantsites-'.$ziel.'.zip';
// Direkt in die Datei, nicht erst als Zeichenkette in den Speicher: ein Archiv von einigen
// Megabyte passte in memory_limit, ein groesseres nicht mehr.
if (!$this->download->toFile(sprintf('%s/v%s/kantsites-%s.zip', $this->releaseBase, $ziel, $ziel), $archiv)) {
$melden($schritte, 'Archiv laden', false, trim('Das Archiv ließ sich nicht herunterladen. '.$this->download->reason()));
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Archiv laden', true, sprintf('%d KB geladen.', (int) round((int) filesize($archiv) / 1024)));
$erwartet = $ziel === $neueste['version'] ? $neueste['checksum'] : $this->checksumOf(sprintf('%s/v%s/kantsites-%s.zip.sha256', $this->releaseBase, $ziel, $ziel));
if (null === $erwartet) {
$melden($schritte, 'Prüfsumme', false, 'Zu diesem Archiv gibt es keine Prüfsumme. Ohne sie wird nichts entpackt.');
return ['ok' => false, 'steps' => $schritte];
}
$tatsaechlich = hash_file('sha256', $archiv);
if (!hash_equals($erwartet, (string) $tatsaechlich)) {
$melden($schritte, 'Prüfsumme', false, 'Die Prüfsumme stimmt nicht. Das Archiv wird nicht verwendet.');
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Prüfsumme', true, 'Stimmt mit der Angabe auf der Releaseseite überein.');
$entpackt = $arbeit.'/neu';
$zip = new \ZipArchive();
if (true !== $zip->open($archiv) || !$zip->extractTo($entpackt)) {
$melden($schritte, 'Entpacken', false, 'Das Archiv ließ sich nicht entpacken.');
return ['ok' => false, 'steps' => $schritte];
}
$zip->close();
$quelle = $entpackt.'/kantsites-'.$ziel;
if (!is_file($quelle.'/VERSION') || !is_file($quelle.'/vendor/autoload.php')) {
$melden($schritte, 'Entpacken', false, 'Im Archiv fehlen VERSION oder vendor/ - das ist kein vollständiges Release.');
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Entpacken', true, 'Vollständig: VERSION und vendor/ sind vorhanden.');
$this->updater->maintenance(true);
$melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.');
$alt = $arbeit.'/alt';
@mkdir($alt, 0o775, true);
$ersetzt = 0;
try {
foreach (self::ERSETZEN as $eintrag) {
$ziant = $this->root.'/'.$eintrag;
if (file_exists($ziant) && !@rename($ziant, $alt.'/'.$eintrag)) {
throw new \RuntimeException($eintrag.' ließ sich nicht beiseitelegen.');
}
if (file_exists($quelle.'/'.$eintrag) && !@rename($quelle.'/'.$eintrag, $ziant)) {
throw new \RuntimeException($eintrag.' ließ sich nicht ersetzen.');
}
++$ersetzt;
}
// public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem
// Betreiber und wären sonst weg.
$this->mergePublic($quelle.'/public', $this->root.'/public');
// Ebenso plugins/: die mitgelieferten Plugins werden aktualisiert, ein vom Betreiber
// installiertes Plugin bleibt, weil sein Ordner im Archiv nicht vorkommt.
$this->mergePublic($quelle.'/plugins', $this->root.'/plugins');
} catch (\Throwable $fehler) {
foreach (self::ERSETZEN as $eintrag) {
if (is_dir($alt.'/'.$eintrag) || is_file($alt.'/'.$eintrag)) {
@rename($this->root.'/'.$eintrag, $quelle.'/'.$eintrag);
@rename($alt.'/'.$eintrag, $this->root.'/'.$eintrag);
}
}
$melden($schritte, 'Dateien ersetzen', false, $fehler->getMessage().' Der alte Stand wurde zurückgeschoben.');
$this->updater->maintenance(false);
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Dateien ersetzen', true, sprintf('%d Einträge ersetzt, var/ und .env unberührt.', $ersetzt));
// Never delete compiled services underneath the running kernel. The new kernel uses
// a release-specific cache directory; old requests can finish with their own files.
$melden($schritte, 'Zwischenspeicher', true, 'Die neue Fassung verwendet einen eigenen Cache; laufende Anfragen behalten ihren bisherigen.');
// Hier endet dieser Durchgang mit Absicht. Der laufende Prozess hat den alten Code im
// Speicher - Klassen, die PHP schon geladen hat, laedt es nicht neu. Die Migrationen
// gehoeren deshalb in eine eigene Anfrage, die mit der neuen Fassung startet. Der
// Wartungszustand bleibt so lange an.
@file_put_contents($this->root.'/'.self::MARKE, json_encode([
'fassung' => $ziel,
'seit' => gmdate('c'),
'alt' => basename($arbeit),
], JSON_UNESCAPED_SLASHES));
$melden($schritte, 'Dateien sind ersetzt', true, sprintf(
'Fassung %s liegt bereit. Jetzt fehlen noch die Migrationen - sie laufen im nächsten Schritt mit dem neuen Code.',
$ziel,
));
return ['ok' => true, 'steps' => $schritte, 'migrate' => true];
}
/**
* Steht ein zweiter Schritt aus? Gibt Fassung und Zeitpunkt zurück, sonst null.
*
* @return array{fassung: string, seit: string, alt: string}|null
*/
public function pending(): ?array
{
$inhalt = @file_get_contents($this->root.'/'.self::MARKE);
if (!is_string($inhalt)) {
return null;
}
try {
$daten = json_decode($inhalt, true, 8, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($daten) && isset($daten['fassung']) ? $daten : null;
}
/**
* Der zweite Schritt: Migrationen ausführen und die Website wieder freigeben.
*
* Läuft in einer eigenen Anfrage, also mit dem Code der neuen Fassung.
*
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
*/
public function finish(Installation $installation): array
{
$schritte = [];
$this->protokoll(sprintf('--- Migrationen nach dem Update, %s UTC', gmdate('Y-m-d H:i:s')));
try {
$namen = $installation->migrate();
$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[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
$this->protokoll('FEHLER Migrationen: '.$fehler->getMessage());
return ['ok' => false, 'steps' => $schritte];
}
@unlink($this->root.'/'.self::MARKE);
$this->updater->maintenance(false);
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
foreach (self::ENTFALLEN_IN_PUBLIC as $eintrag) {
@unlink($this->root.'/public/'.$eintrag);
}
$schritte[] = ['step' => 'Aufräumen', 'ok' => true, 'detail' => $this->aufraeumen()];
$schritte[] = ['step' => 'Fertig', 'ok' => true, 'detail' => sprintf(
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/ (die zwei jüngsten bleiben).',
$this->updater->version(),
)];
return ['ok' => true, 'steps' => $schritte];
}
/**
* Eine Zeile ins Protokoll, und zwar sofort.
*
* Ohne Ausnahme bei Fehlschlag: Ein Update, das daran scheitert, dass sein Protokoll nicht
* schreibbar ist, wäre absurd.
*/
private function protokoll(string $zeile): void
{
@file_put_contents($this->root.'/'.self::PROTOKOLL, $zeile.PHP_EOL, FILE_APPEND);
}
/** Kopiert das neue public/ über das alte, ohne das zu berühren, was dort entstanden ist. */
private function mergePublic(string $von, string $nach): void
{
if (!is_dir($von)) {
return;
}
foreach (scandir($von) ?: [] as $eintrag) {
if ('.' === $eintrag || '..' === $eintrag) {
continue;
}
$quelle = $von.'/'.$eintrag;
$ziel = $nach.'/'.$eintrag;
$relativ = ltrim(str_replace($this->root.'/public', '', $ziel), '/');
if (in_array($relativ, self::EIGENES_IN_PUBLIC, true)) {
continue;
}
if (is_dir($quelle)) {
@mkdir($ziel, 0o775, true);
$this->mergePublic($quelle, $ziel);
continue;
}
if ('.htaccess' === $eintrag && is_file($ziel)) {
$this->mergeHtaccess($quelle, $ziel);
continue;
}
@copy($quelle, $ziel);
}
}
/**
* Die neue .htaccess uebernehmen, aber die Zeilen, die der Betreiber eingeschaltet hat,
* eingeschaltet lassen: HTTPS-Zwang und HSTS. Vorher kam jedes Update mit den auskommentierten
* Zeilen des Releases zurueck, und die Website lief nach dem Update wieder ohne Umleitung.
*/
private function mergeHtaccess(string $neu, string $alt): void
{
$vorher = (string) file_get_contents($alt);
$inhalt = (string) file_get_contents($neu);
foreach (['RewriteCond %{HTTPS} !=on', 'RewriteCond %{HTTP:X-Forwarded-Proto} !=https', 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]', 'Header always set Strict-Transport-Security'] as $zeile) {
$aktiv = 1 === preg_match('/^\s*'.preg_quote($zeile, '/').'/m', $vorher);
if ($aktiv) {
$inhalt = (string) preg_replace('/^(\s*)#\s*('.preg_quote($zeile, '/').')/m', '$1$2', $inhalt);
}
}
file_put_contents($alt, $inhalt);
}
/**
* Raeumt nach einem gelungenen Update auf: die Arbeitsordner unter var/update/ bis auf die
* zwei juengsten, und die Zwischenspeicher frueherer Fassungen unter var/cache/. Beides wuchs
* vorher mit jedem Update, bis der Speicherplatz des Hostings voll war.
*/
private function aufraeumen(): string
{
$entfernt = 0;
$ordner = glob($this->root.'/var/update/*', GLOB_ONLYDIR) ?: [];
rsort($ordner);
foreach (array_slice($ordner, 2) as $pfad) {
$this->loeschen($pfad);
++$entfernt;
}
$aktuell = 'release-'.substr(hash_file('sha256', $this->root.'/VERSION') ?: '', 0, 16);
foreach (glob($this->root.'/var/cache/*/release-*', GLOB_ONLYDIR) ?: [] as $pfad) {
if (basename($pfad) !== $aktuell) {
$this->loeschen($pfad);
++$entfernt;
}
}
return 0 === $entfernt ? 'Nichts aufzuräumen.' : sprintf('%d alte Ordner unter var/update und var/cache entfernt.', $entfernt);
}
private function loeschen(string $pfad): void
{
foreach (scandir($pfad) ?: [] as $eintrag) {
if ('.' === $eintrag || '..' === $eintrag) {
continue;
}
is_dir($pfad.'/'.$eintrag) && !is_link($pfad.'/'.$eintrag) ? $this->loeschen($pfad.'/'.$eintrag) : @unlink($pfad.'/'.$eintrag);
}
@rmdir($pfad);
}
/** Liest die Prüfsumme aus der Datei neben dem Archiv. */
private function checksumOf(string $url): ?string
{
$inhalt = $this->download->get($url);
if (null === $inhalt || 1 !== preg_match('/^([a-f0-9]{64})/', trim($inhalt), $treffer)) {
return null;
}
return $treffer[1];
}
}