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>
438 lines
19 KiB
PHP
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];
|
|
}
|
|
}
|