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} */ 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} */ 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]; } }