Wiederkehrende Aufgaben ohne Cronjob (ADR 073): Sicherung, Statistik kuerzen, geplante Veroeffentlichung, Updatepruefung und Aufraeumen laufen als Plan in der Tabelle scheduled_task - eine Zeile je Aufgabe mit Faelligkeit, Sperre und letztem Ergebnis. Auf geteiltem Hosting gibt es oft keinen Cronjob; der Plan haengt stattdessen an Anfragen und an einem Takt aus dem geoeffneten Backend. Diese Arbeit entstand in einer parallel laufenden Sitzung und lag bis hierher unveroeffentlicht im Arbeitsverzeichnis: die Releases 1.5.0 bis 1.5.5 wurden bewusst aus dem jeweiligen Tag gebaut und enthielten sie deshalb nicht. Vor der Veroeffentlichung geprueft, nicht bloss eingepackt: - phpunit 452/452, npm test 28/28, npm run test:editor vollstaendig. - Die Migration 20260914000100 legt nur die Tabelle scheduled_task an und nimmt sie beim Zurueckrollen wieder weg. Sie fasst keine bestehenden Daten an. - Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr. Dabei zwei Fehler behoben: - config/services.yaml verwendet !tagged_iterator. Das ist gueltiges Symfony, aber "lint:yaml config" kennt eigene Tags nur mit --parse-tags und gab 1 zurueck - der CI-Lauf waere daran gescheitert. Flag ergaenzt. - AdminHeadersSubscriber.php (aus 1.5.3) entsprach nicht dem Stil von php-cs-fixer; composer lint gab 8 zurueck. Behoben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
360 lines
15 KiB
PHP
360 lines
15 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'];
|
|
|
|
/**
|
|
* 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];
|
|
}
|
|
|
|
$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';
|
|
$inhalt = $this->download->get(sprintf('%s/v%s/kantsites-%s.zip', $this->releaseBase, $ziel, $ziel));
|
|
if (null === $inhalt || '' === $inhalt) {
|
|
$melden($schritte, 'Archiv laden', false, trim('Das Archiv ließ sich nicht herunterladen. '.$this->download->reason()));
|
|
|
|
return ['ok' => false, 'steps' => $schritte];
|
|
}
|
|
file_put_contents($archiv, $inhalt);
|
|
$melden($schritte, 'Archiv laden', true, sprintf('%d KB geladen.', (int) round(strlen($inhalt) / 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');
|
|
} 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));
|
|
|
|
// Der Zwischenspeicher gehört zum alten Stand.
|
|
$this->removeDirectory($this->root.'/var/cache');
|
|
$melden($schritte, 'Zwischenspeicher', true, 'Geleert - sonst liefe die neue Fassung mit den Wegweisern der alten.');
|
|
|
|
// 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']);
|
|
$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(),
|
|
)];
|
|
|
|
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;
|
|
}
|
|
@copy($quelle, $ziel);
|
|
}
|
|
}
|
|
|
|
/** 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];
|
|
}
|
|
|
|
private function removeDirectory(string $pfad): void
|
|
{
|
|
if (!is_dir($pfad)) {
|
|
return;
|
|
}
|
|
foreach (scandir($pfad) ?: [] as $eintrag) {
|
|
if ('.' === $eintrag || '..' === $eintrag) {
|
|
continue;
|
|
}
|
|
$voll = $pfad.'/'.$eintrag;
|
|
is_dir($voll) ? $this->removeDirectory($voll) : @unlink($voll);
|
|
}
|
|
@rmdir($pfad);
|
|
}
|
|
}
|