494 lines
22 KiB
PHP
494 lines
22 KiB
PHP
<?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';
|
||
|
||
public function __construct(
|
||
private string $root,
|
||
private string $databaseUrl,
|
||
private CommandRunner $runner,
|
||
private Connection $db,
|
||
private string $php = PHP_BINARY,
|
||
) {
|
||
}
|
||
|
||
/** 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 : ('' === $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 = 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<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();
|
||
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;
|
||
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.');
|
||
}
|
||
}
|
||
$checkout = $this->git(['checkout', '--force', $target]);
|
||
$this->restoreEnvironment();
|
||
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<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->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. */
|
||
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;
|
||
$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, ...$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';
|
||
}
|
||
}
|