root.'/VERSION'; return is_readable($file) ? trim((string) file_get_contents($file)) : 'unbekannt'; } /** * @return array{version: string, commit: string, describe: string, maintenance: bool, * git: bool, clean: ?bool, log: list} */ public function state(): array { $git = $this->runner->available() && $this->runner->has('git', $this->root); $status = $git ? $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', 'maintenance' => is_file($this->root.'/'.self::MAINTENANCE_FILE), 'git' => $git, 'clean' => null === $status ? null : ('' === $status['output'] && $status['ok']), 'log' => $this->log(), ]; } /** * The preflight. Every entry is a sentence about what is wrong and what to do about it. * * @return list */ public function check(): array { $checks = []; $add = static function (array &$checks, string $name, bool $ok, string $detail) { $checks[] = ['name' => $name, 'ok' => $ok, 'detail' => $detail]; }; $processes = $this->runner->available(); $add($checks, 'Externe Befehle', $processes, $processes ? 'proc_open ist verfügbar.' : 'proc_open ist auf diesem Server abgeschaltet. Ein Update über Git ist damit nicht möglich; es bleibt der Weg über die Konsole oder ein Deployment von außen.'); if (!$processes) { return $checks; } $git = $this->runner->has('git', $this->root); $add($checks, 'Git', $git, $git ? trim(str_replace('git version', 'Version', $this->git(['--version'])['output'])) : 'git ist nicht aufrufbar. Ohne Git gibt es keinen Weg, eine neue Fassung zu holen.'); if (!$git) { return $checks; } $inside = $this->git(['rev-parse', '--is-inside-work-tree']); $add($checks, 'Arbeitsverzeichnis', $inside['ok'], $inside['ok'] ? 'Das Projekt ist eine Git-Arbeitskopie.' : 'Dieses Verzeichnis ist keine Git-Arbeitskopie. Ein Update über Git braucht einen Klon des Repositorys.'); if (!$inside['ok']) { return $checks; } $status = $this->git(['status', '--porcelain']); $dirty = array_values(array_filter(explode("\n", $status['output']))); $add($checks, 'Keine lokalen Änderungen', [] === $dirty, [] === $dirty ? 'Die Arbeitskopie ist unverändert.' : sprintf('%d geänderte Datei(en), zum Beispiel %s. Ein Checkout würde sie verwerfen. Erst sichern oder zurücknehmen.', count($dirty), trim(explode("\n", $status['output'])[0]))); $remote = $this->git(['remote', 'get-url', 'origin']); $add($checks, 'Herkunft', $remote['ok'], $remote['ok'] ? $remote['output'] : 'Es ist keine Herkunft „origin“ eingetragen. Ohne sie weiß der Updater nicht, woher eine neue Fassung kommt.'); // What must stay outside Git, or an update would overwrite content. foreach ([ 'Datenbank' => $this->databaseFile(), 'Medien' => $this->root.'/var/media', ] as $name => $path) { if (null === $path || '' === $path) { $add($checks, $name.' außerhalb von Git', true, 'Nicht als Datei in diesem Projekt (etwa MySQL) – vom Checkout nicht betroffen.'); continue; } $relative = str_replace('\\', '/', substr($path, strlen($this->root) + 1)); $tracked = $this->git(['check-ignore', '-q', $relative]); $add($checks, $name.' außerhalb von Git', $tracked['ok'], $tracked['ok'] ? sprintf('%s ist von Git ausgenommen.', $relative) : sprintf('%s ist nicht von Git ausgenommen. Ein Update würde Inhalte überschreiben. In .gitignore aufnehmen.', $relative)); } foreach (['Projektstamm' => $this->root, 'var/' => $this->root.'/var'] as $name => $path) { $writable = is_dir($path) && is_writable($path); $add($checks, 'Schreibrecht '.$name, $writable, $writable ? sprintf('%s ist beschreibbar.', $path) : sprintf('%s ist nicht beschreibbar. Der Updater braucht das für Sicherung, Wartungsschalter und Cache.', $path)); } $space = @disk_free_space($this->root); $needed = $this->databaseSize() + 104857600; $add($checks, 'Platz', false === $space || $space > $needed, false === $space ? 'Der freie Platz ließ sich nicht ermitteln.' : sprintf('%s frei, gebraucht werden etwa %s.', $this->bytes((int) $space), $this->bytes($needed))); $composer = $this->runner->has('composer', $this->root); $vendor = is_dir($this->root.'/vendor'); $add($checks, 'Abhängigkeiten', $composer || $vendor, $composer ? 'Composer ist vorhanden und wird nach dem Checkout ausgeführt.' : ($vendor ? 'Composer ist nicht vorhanden. vendor/ muss deshalb Teil des Tags sein, sonst fehlen nach dem Update Abhängigkeiten. Siehe docs/UPDATE.md.' : 'Weder Composer noch ein vendor/-Verzeichnis. So kann die Anwendung nach dem Update nicht starten.')); return $checks; } /** The newest tag of the form v1.2.3 that the remote offers, or null. */ public function availableVersion(): ?string { if (!$this->runner->available() || !$this->runner->has('git', $this->root)) { return null; } $this->git(['fetch', '--tags', '--quiet', 'origin'], 120); $tags = array_values(array_filter( array_map('trim', explode("\n", $this->git(['tag', '--list', 'v*'])['output'])), static fn (string $tag): bool => 1 === preg_match('/^v\d+\.\d+\.\d+$/D', $tag), )); if ([] === $tags) { return null; } usort($tags, static fn (string $a, string $b): int => version_compare(ltrim($a, 'v'), ltrim($b, 'v'))); return end($tags); } /** * The update itself. * * @return array{ok: bool, steps: list, target: ?string} */ public function update(?string $target = null, bool $dryRun = false): array { $steps = []; $step = static function (array &$steps, string $name, bool $ok, string $detail): bool { $steps[] = ['step' => $name, 'ok' => $ok, 'detail' => $detail]; return $ok; }; foreach ($this->check() as $check) { if (!$check['ok']) { $step($steps, 'Vorprüfung', false, $check['name'].': '.$check['detail']); return ['ok' => false, 'steps' => $steps, 'target' => null]; } } $step($steps, 'Vorprüfung', true, 'Alle Prüfungen bestanden.'); $target ??= $this->availableVersion(); if (null === $target) { $step($steps, 'Ziel bestimmen', false, 'Die Herkunft bietet keinen Tag der Form v1.2.3 an.'); return ['ok' => false, 'steps' => $steps, 'target' => null]; } $from = $this->git(['rev-parse', 'HEAD'])['output']; // Remembered before the checkout: if the dependencies change and Composer is missing, // this is what tells us - vendor/ is outside Git and would silently stay old. $lockBefore = $this->lockHash(); $step($steps, 'Ziel bestimmen', true, sprintf('%s (von %s, Fassung %s)', $target, substr($from, 0, 8), $this->version())); if ($target === 'v'.$this->version()) { $step($steps, 'Nichts zu tun', true, sprintf('Fassung %s ist bereits installiert.', $this->version())); return ['ok' => true, 'steps' => $steps, 'target' => $target]; } if ($dryRun) { $step($steps, 'Probelauf', true, 'Hier würde der Updater sichern, den Wartungszustand einschalten, auschecken, migrieren und prüfen. Es wurde nichts verändert.'); return ['ok' => true, 'steps' => $steps, 'target' => $target]; } // ---- from here on the installation is being changed ---- $backup = $this->backup(); if (!$step($steps, 'Sicherung', null !== $backup, $backup ?? 'Die Datenbank ließ sich nicht sichern. Ohne Sicherung wird nicht aktualisiert.')) { return ['ok' => false, 'steps' => $steps, 'target' => $target]; } $this->maintenance(true); $step($steps, 'Wartungszustand', true, 'Die Website antwortet mit 503, die Verwaltung bleibt erreichbar.'); $migrated = false; try { $checkout = $this->git(['checkout', '--force', $target]); if (!$step($steps, 'Code wechseln', $checkout['ok'], $checkout['ok'] ? 'Auf '.$target.' gewechselt.' : $checkout['output'])) { throw new \RuntimeException('checkout'); } $version = $this->version(); if (!$step($steps, 'Fassung prüfen', 'v'.$version === $target, sprintf('VERSION enthält „%s“, erwartet war „%s“.', $version, ltrim($target, 'v')))) { throw new \RuntimeException('version'); } $composer = $this->runner->resolve('composer', $this->root); if (null !== $composer) { $install = $this->runner->run([...$composer, 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600); if (!$step($steps, 'Abhängigkeiten', $install['ok'], $install['ok'] ? 'composer install ausgeführt.' : $install['output'])) { throw new \RuntimeException('composer'); } } elseif ($lockBefore !== $this->lockHash()) { // Without Composer the dependencies cannot be brought up to date, and this update // needs different ones. Going on would leave an installation whose code and // vendor/ do not match - which fails somewhere else, later, for no visible reason. $step($steps, 'Abhängigkeiten', false, 'Dieses Update ändert die Abhängigkeiten (composer.lock), und auf diesem Server ist Composer nicht aufrufbar. Spiele stattdessen das Releasearchiv ein, das vendor/ mitbringt.'); throw new \RuntimeException('lock'); } else { $step($steps, 'Abhängigkeiten', true, 'Composer ist nicht vorhanden; composer.lock ist unverändert, vendor/ bleibt gültig.'); } $migrated = true; $migrate = $this->console(['vendor/bin/doctrine-migrations', 'migrate', '--no-interaction'], 600); if (!$step($steps, 'Migration', $migrate['ok'], $migrate['ok'] ? $this->lastLine($migrate['output']) : $migrate['output'])) { throw new \RuntimeException('migrate'); } $cache = $this->console(['bin/console', 'cache:clear', '--env=prod'], 300); if (!$step($steps, 'Cache', $cache['ok'], $cache['ok'] ? 'Cache geräumt und aufgebaut.' : $cache['output'])) { throw new \RuntimeException('cache'); } $health = $this->console(['bin/console', 'cms:health', '--env=prod'], 120); if (!$step($steps, 'Rauchtest', $health['ok'], $this->lastLine($health['output']))) { throw new \RuntimeException('health'); } } catch (\RuntimeException) { $this->rollback($steps, $from, $migrated ? $backup : null, $step); $this->write($steps, $target, false); return ['ok' => false, 'steps' => $steps, 'target' => $target]; } $this->maintenance(false); $step($steps, 'Fertig', true, sprintf('Fassung %s ist aktiv, die Website ist wieder erreichbar.', $this->version())); $this->write($steps, $target, true); return ['ok' => true, 'steps' => $steps, 'target' => $target]; } /** @return list */ public function log(int $lines = 20): array { $file = $this->root.'/'.self::LOG_FILE; if (!is_readable($file)) { return []; } $entries = array_values(array_filter(explode("\n", (string) file_get_contents($file)))); return array_slice(array_reverse($entries), 0, $lines); } public function maintenance(bool $on): void { $file = $this->root.'/'.self::MAINTENANCE_FILE; if ($on) { @file_put_contents($file, gmdate('c')."\n"); return; } if (is_file($file)) { @unlink($file); } } /** * Back to the previous commit, and to the previous database if a migration had begun. * * Whether the maintenance state stays on is decided by the smoke test: if the old version * answers, the website goes back online on the old version. That is the point of a rollback. */ private function rollback(array &$steps, string $commit, ?string $backup, callable $step): void { $back = $this->git(['checkout', '--force', $commit]); $step($steps, 'Zurück auf den alten Stand', $back['ok'], $back['ok'] ? 'Auf '.substr($commit, 0, 8).' zurückgesetzt.' : $back['output']); if (null !== $backup && null !== $this->databaseFile()) { $file = $this->databaseFile(); $copy = dirname($backup).'/'.basename((string) $file); $restored = is_readable($copy) && @copy($copy, (string) $file); $step($steps, 'Datenbank zurückschreiben', $restored, $restored ? 'Die Sicherung von vor der Migration ist wieder eingespielt.' : 'Die Sicherung ließ sich nicht zurückschreiben. Die Datenbank liegt unter '.dirname((string) $backup).'.'); } $composer = $this->runner->resolve('composer', $this->root); if (null !== $composer) { $this->runner->run([...$composer, 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600); } $this->console(['bin/console', 'cache:clear', '--env=prod'], 300); $health = $this->console(['bin/console', 'cms:health', '--env=prod'], 120); if ($health['ok']) { $this->maintenance(false); $step($steps, 'Wieder online', true, 'Der alte Stand antwortet; der Wartungszustand ist aufgehoben.'); return; } $step($steps, 'Wartungszustand bleibt', false, 'Auch der alte Stand antwortet nicht. Die Website bleibt im Wartungszustand, damit niemand eine kaputte Seite sieht. Ausgabe: '.$this->lastLine($health['output'])); } /** Copies the database and the environment files, and returns a sentence about it. */ private function backup(): ?string { $directory = $this->root.'/var/backups/update-'.gmdate('Ymd-His'); if (!is_dir($directory) && !@mkdir($directory, 0o775, true)) { return null; } $file = $this->databaseFile(); if (null !== $file) { if (!is_readable($file) || !@copy($file, $directory.'/'.basename($file))) { return null; } } foreach (['.env', '.env.local'] as $environment) { if (is_readable($this->root.'/'.$environment)) { @copy($this->root.'/'.$environment, $directory.'/'.$environment); } } return sprintf( '%s (%s). Der Medienordner ist nicht dabei – er ist zu groß, und kein Update schreibt darin.', str_replace('\\', '/', substr($directory, strlen($this->root) + 1)), null === $file ? 'nur Umgebungsdateien, die Datenbank liegt nicht als Datei hier' : $this->bytes($this->databaseSize()), ); } /** The checksum of composer.lock, or an empty string when there is none. */ private function lockHash(): string { $file = $this->root.'/composer.lock'; return is_readable($file) ? (string) hash_file('sha256', $file) : ''; } /** @return array{ok: bool, exit: int, output: string} */ private function git(array $arguments, int $timeout = 60): array { return $this->runner->run(['git', ...$arguments], $this->root, $timeout); } /** @return array{ok: bool, exit: int, output: string} */ private function console(array $arguments, int $timeout): array { return $this->runner->run([$this->php, ...$arguments], $this->root, $timeout); } /** The SQLite file behind DATABASE_URL, or null for a server database. */ private function databaseFile(): ?string { if (!str_starts_with($this->databaseUrl, 'sqlite:')) { return null; } $path = preg_replace('#^sqlite:(///)?#', '', $this->databaseUrl); return '' === $path ? null : str_replace('\\', '/', (string) $path); } private function databaseSize(): int { $file = $this->databaseFile(); if (null !== $file && is_readable($file)) { return (int) filesize($file); } // A rough number for a server database, only used for the free space check. try { return 50 * (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision') * 1024; } catch (\Throwable) { return 10485760; } } private function write(array $steps, string $target, bool $ok): void { $failed = array_values(array_filter($steps, static fn (array $step): bool => !$step['ok'])); @file_put_contents($this->root.'/'.self::LOG_FILE, sprintf( "%s %s %s (%d Schritte)%s\n", gmdate('Y-m-d H:i:s'), $ok ? 'OK' : 'FEHLER', $target, count($steps), [] === $failed ? '' : ' – gescheitert: '.$failed[0]['step'].': '.$this->lastLine($failed[0]['detail']), ), FILE_APPEND); } private function lastLine(string $output): string { $lines = array_values(array_filter(array_map('trim', explode("\n", $output)))); return [] === $lines ? '(keine Ausgabe)' : end($lines); } private function bytes(int $bytes): string { return $bytes > 1048576 ? round($bytes / 1048576).' MB' : max(1, (int) round($bytes / 1024)).' KB'; } }