Files
KantSites/src/Ops/Updater.php
T
kantarciandClaude Opus 5.5 8b212d5cc9 Force HTTPS from the admin, with a probe before and after
System > HTTPS and cms:https switch the redirect lines in public/.htaccess
only when the site answers over HTTPS, and take them back on a loop. HSTS
is a separate second switch. The redirect block moves before the front
controller rules, where it never reached pages, and .well-known is no
longer denied, so the origin probe works on Apache. The Git updater keeps
switched-on lines like the archive update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:22:56 +02:00

591 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Ops;
use Doctrine\DBAL\Connection;
/**
* Pulling a new version from the Git repository, in the order laid down in docs/UPDATE.md.
*
* Three things shape this class:
*
* 1. **Every step has a condition it checks rather than assumes.** An update that fails halfway is
* normal; an update that fails halfway and leaves the website broken is not.
* 2. **The database, the media folder and .env are never touched.** They are outside Git, and the
* preflight verifies that instead of trusting it.
* 3. **A failure rolls back to the previous commit** and, only if a migration had already started,
* restores the database backup. Migrations are deliberately irreversible - the way back is the
* backup, not a `down()`.
*/
final class Updater
{
private array $environmentBefore = [];
private ?string $backupDirectory = null;
public const MAINTENANCE_FILE = 'var/maintenance';
public const LOG_FILE = 'var/updates.log';
/** Eine Datei, die waehrend eines Updates gesperrt ist - Git-Weg und Archiv-Weg teilen sie sich. */
public const LOCK_FILE = 'var/update.lock';
/** Der Name der Sicherung, die vor dem Update entstand - der Rueckweg spielt sie ein. */
private ?string $backupName = null;
public function __construct(
private string $root,
private string $databaseUrl,
private CommandRunner $runner,
private Connection $db,
private ?string $php = null,
private ?Backup $backups = null,
) {
}
/**
* Sperrt Updates gegeneinander. Zwei Klicks auf "Aktualisieren", ein Cronjob neben der
* Verwaltung, der Archiv-Weg neben dem Git-Weg: jeder zweite Lauf bekommt null und faengt
* nichts an. Die Sperre faellt mit dem Handle, also spaetestens mit dem Prozess.
*
* @return resource|null
*/
public function lock(): mixed
{
$file = $this->root.'/'.self::LOCK_FILE;
@mkdir(dirname($file), 0o775, true);
$handle = @fopen($file, 'c');
if (!is_resource($handle)) {
return null;
}
if (!flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
return null;
}
return $handle;
}
/** The version that is installed, from the file that ships inside the tag. */
public function version(): string
{
$file = $this->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<string>}
*/
public function state(): array
{
$git = $this->runner->available() && $this->runner->has('git', $this->root);
// Zwei verschiedene Fragen, und die Verwechslung war ein echter Fehler: Ob der Befehl
// `git` existiert, sagt nichts darueber, ob dieses Verzeichnis eine Arbeitskopie ist. Ein
// Hosting hat oft beides - git installiert, aber die Installation kam aus dem
// Releasearchiv. Dann lief jeder Aufruf ins Leere, und die Verwaltung zeigte dem
// Betreiber „fatal: Not a git repository“ an einer Stelle, an der eine Fassungsnummer
// stehen sollte.
$arbeitskopie = $git && is_dir($this->root.'/.git');
$status = $arbeitskopie ? $this->git(['status', '--porcelain']) : null;
return [
'version' => $this->version(),
'commit' => $arbeitskopie ? substr($this->git(['rev-parse', 'HEAD'])['output'], 0, 8) : 'unbekannt',
'describe' => $arbeitskopie ? $this->git(['describe', '--tags', '--always'])['output'] : 'unbekannt',
'maintenance' => is_file($this->root.'/'.self::MAINTENANCE_FILE),
'git' => $git,
// Der Weg ueber Git steht nur einer Arbeitskopie offen.
'worktree' => $arbeitskopie,
'clean' => null === $status ? null : ([] === self::dirty($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<array{name: string, ok: bool, detail: string}>
*/
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 = self::dirty($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($dirty[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<array{step: string, ok: bool, detail: string}>, 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();
// Ein Tag der Form v1.2.3 und sonst nichts: der Wert geht an git checkout (ADR 116).
if (null !== $target && 1 !== preg_match('/^v\d{1,4}\.\d{1,5}\.\d{1,6}$/D', $target)) {
$step($steps, 'Ziel bestimmen', false, 'Das Ziel hat nicht die Form v1.2.3.');
return ['ok' => false, 'steps' => $steps, 'target' => $target];
}
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 ----
$lock = $this->lock();
if (!$step($steps, 'Sperre', null !== $lock, null !== $lock ? 'Kein anderes Update läuft.' : 'Ein anderes Update läuft gerade (var/update.lock). Bitte warten.')) {
return ['ok' => false, 'steps' => $steps, 'target' => $target];
}
try {
return $this->apply($steps, $step, $target, $from, $lockBefore);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** Der Teil des Updates, der die Installation veraendert - unter der Sperre. */
private function apply(array &$steps, callable $step, string $target, string $from, string $lockBefore): array
{
$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;
foreach (['.env', '.env.local'] as $file) {
if (is_file($this->root.'/'.$file)) {
$this->environmentBefore[$file] = (string) file_get_contents($this->root.'/'.$file);
}
}
try {
// Older repositories tracked this file. Snapshot above, clear legacy index flags,
// then let Git replace its own copy; restore the installation before booting PHP.
if ($this->git(['ls-files', '--error-unmatch', '.env'])['ok']) {
$this->git(['update-index', '--no-assume-unchanged', '--no-skip-worktree', '.env']);
if (!$this->git(['checkout', '--', '.env'])['ok']) {
throw new \RuntimeException('Die alte Git-Konfiguration konnte nicht für den Updatewechsel vorbereitet werden.');
}
}
$htaccess = $this->root.'/public/.htaccess';
$htaccessBefore = is_file($htaccess) ? (string) file_get_contents($htaccess) : null;
$checkout = $this->git(['checkout', '--force', $target, '--']);
$this->restoreEnvironment();
if (null !== $htaccessBefore && is_file($htaccess)) {
@file_put_contents($htaccess, HttpsRedirect::keepEnabled((string) file_get_contents($htaccess), $htaccessBefore), LOCK_EX);
}
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 (\Throwable $error) {
// Nicht nur die eigenen Abbrueche: jeder Fehler auf dem Weg nimmt den Rueckweg, statt
// den Wartungszustand ohne ein Wort stehen zu lassen.
if (!$error instanceof \RuntimeException) {
$step($steps, 'Unerwarteter Fehler', false, $error->getMessage());
}
$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<string> */
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]);
try {
$this->restoreEnvironment();
} catch (\RuntimeException $e) {
$step($steps, 'Konfiguration wiederherstellen', false, $e->getMessage());
return;
}
$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->backupName && null !== $this->backups) {
// Ueber die Sicherung, nicht ueber eine rohe Kopie: derselbe konsistente Weg fuer
// SQLite (VACUUM INTO) und MySQL (Dump und Client).
$restored = $this->backups->restore($this->backupName, true)['ok'];
$step($steps, 'Datenbank zurückschreiben', $restored, $restored
? 'Die Sicherung von vor der Migration ist wieder eingespielt.'
: 'Die Sicherung ließ sich nicht zurückschreiben. Sie liegt unter var/backups/'.$this->backupName.'.');
if (!$restored) {
$step($steps, 'Wartungszustand bleibt', false, 'Die Datenbank konnte nicht wiederhergestellt werden. Der Betrieb bleibt angehalten.');
return;
}
} elseif (null !== $backup && null !== $this->databaseFile()) {
$file = $this->databaseFile();
$copy = $this->backupDirectory.'/'.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 '.$this->backupDirectory.'.');
if (!$restored) {
$step($steps, 'Wartungszustand bleibt', false, 'Die Datenbank konnte nicht wiederhergestellt werden. Der Betrieb bleibt angehalten.');
return;
}
}
$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']));
}
/** Legacy tags tracked .env; preserve the installation even when checking out those tags. */
/**
* Die geänderten Dateien laut `git status --porcelain` - ohne public/.htaccess: Eine dort
* eingeschaltete HTTPS-Umleitung nimmt apply() in die neue Fassung mit (ADR 120), sie ist
* kein Grund, das Update zu verweigern.
*
* @return list<string>
*/
private static function dirty(string $porcelain): array
{
return array_values(array_filter(explode("\n", $porcelain), static fn (string $line): bool => '' !== trim($line) && 1 !== preg_match('#^\s*M{1,2}\s+public/\.htaccess$#', rtrim($line))));
}
private function restoreEnvironment(): void
{
foreach ($this->environmentBefore as $file => $content) {
$path = $this->root.'/'.$file;
if (is_file($path) && file_get_contents($path) === $content) {
continue;
}
if (false === @file_put_contents($path, $content, LOCK_EX)) {
throw new \RuntimeException('Die Installationskonfiguration konnte nicht wiederhergestellt werden. Sie liegt in der Updatesicherung unter var/backups.');
}
@chmod($path, 0o600);
}
}
/** Copies the database and the environment files, and returns a sentence about it. */
private function backup(): ?string
{
$this->backupDirectory = null;
$this->backupName = null;
if (null !== $this->backups) {
try {
$sicherung = $this->backups->create('vor dem Update');
$this->backupName = $sicherung['name'];
return sprintf('var/backups/%s (Datenbank %s, Medien %d Datei(en)).', $sicherung['name'], $this->bytes((int) ($sicherung['manifest']['datenbank']['bytes'] ?? 0)), (int) ($sicherung['manifest']['medien']['dateien'] ?? 0));
} catch (\Throwable) {
return null;
}
}
$directory = $this->root.'/var/backups/update-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(4));
if (!@mkdir($directory, 0o700, 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)) {
if (!@copy($this->root.'/'.$environment, $directory.'/'.$environment)) {
return null;
}
@chmod($directory.'/'.$environment, 0o600);
}
}
$this->backupDirectory = $directory;
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 ??= $this->runner->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';
}
}