Updater: neue Fassung über Git, mit Vorprüfung und Rückweg
Aus dem Plan in docs/UPDATE.md ist Code geworden: cms:update, cms:health, der Bildschirm "System" und der Wartungszustand. Die Vorprüfung ist die halbe Arbeit. Sie fragt nach proc_open, Git, sauberer Arbeitskopie, Herkunft, Schreibrechten und Platz - und danach, ob Datenbank und Medienordner wirklich von Git ausgenommen sind. Das ist der Punkt, an dem ein Update Inhalte zerstören könnte, also wird er geprüft und nicht angenommen. Jede Prüfung sagt, was zu tun ist, nicht bloß "fehlgeschlagen". Der Rückweg ist Teil des Wegs: scheitert ein Schritt, wird auf den vorherigen Commit zurückgesetzt. Die Datenbank nur dann zurückgeschrieben, wenn die Migration schon begonnen hatte - sonst verlöre der Rückweg Inhalte, die während des Updates entstanden sind. Antwortet der alte Stand im Rauchtest, geht die Website wieder online; antwortet er nicht, bleibt der Wartungszustand, weil eine kaputte Seite schlechter ist als eine ehrliche Meldung. Der Rauchtest läuft als eigener Prozess, denn nur ein frischer Prozess führt den neuen Code aus. Ein Test im laufenden Prozess hätte den alten geprüft und "alles in Ordnung" gemeldet. Kein symfony/process: der Runner sind dreißig Zeilen über proc_open mit dem Befehl als Array - kein Shell, nichts zu maskieren, und "proc_open ist abgeschaltet" wird die erste Antwort statt der Überraschung im dritten Schritt. 285 PHP-Tests / 1244 Assertions. Sechs davon fahren den Updater gegen echte Git-Repositorys in einem Temp-Verzeichnis, mit Platzhalterskripten für die zwei Befehle - inklusive des Fehlschlags, nach dem Code und Datenbank zurückgesetzt sein müssen. Nicht gegen dieses Projekt: ein Test, der die Arbeitskopie auscheckt, in der er läuft, ist ein Weg, Arbeit zu verlieren. Offen bleibt die Sicherung einer MySQL-Datenbank per mysqldump - dafür fehlt die Installation; der Updater sagt das im Schritt "Sicherung". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99bad61179
commit
52f0ab3807
19 files changed
+1289
-2
No files matched your search
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Runs an external command, or says plainly that it cannot.
|
||||
*
|
||||
* `proc_open` is disabled on a lot of shared hosting, and that is not an edge case to discover
|
||||
* halfway through an update - it is the first thing the updater has to ask. The command is passed
|
||||
* as an array, so there is no shell and nothing to quote: an argument can contain a space, a
|
||||
* quote or a semicolon without becoming a second command.
|
||||
*/
|
||||
final class CommandRunner
|
||||
{
|
||||
public function available(): bool
|
||||
{
|
||||
if (!function_exists('proc_open')) {
|
||||
return false;
|
||||
}
|
||||
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
|
||||
|
||||
return !in_array('proc_open', $disabled, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $command
|
||||
*
|
||||
* @return array{ok: bool, exit: int, output: string}
|
||||
*/
|
||||
public function run(array $command, string $cwd, int $timeout = 300): array
|
||||
{
|
||||
if (!$this->available()) {
|
||||
return ['ok' => false, 'exit' => -1, 'output' => 'proc_open ist auf diesem Server abgeschaltet.'];
|
||||
}
|
||||
$pipes = [];
|
||||
$process = @proc_open(
|
||||
$command,
|
||||
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
||||
$pipes,
|
||||
$cwd,
|
||||
// A minimal, predictable environment: no inherited APP_ENV that would make a console
|
||||
// command run against the wrong environment.
|
||||
['PATH' => getenv('PATH') ?: '', 'SystemRoot' => getenv('SystemRoot') ?: '', 'TEMP' => sys_get_temp_dir()],
|
||||
);
|
||||
if (!is_resource($process)) {
|
||||
return ['ok' => false, 'exit' => -1, 'output' => sprintf('%s konnte nicht gestartet werden.', $command[0])];
|
||||
}
|
||||
foreach ($pipes as $pipe) {
|
||||
stream_set_blocking($pipe, false);
|
||||
}
|
||||
$output = '';
|
||||
$deadline = microtime(true) + $timeout;
|
||||
while (true) {
|
||||
$status = proc_get_status($process);
|
||||
foreach ($pipes as $pipe) {
|
||||
$output .= stream_get_contents($pipe) ?: '';
|
||||
}
|
||||
if (!$status['running']) {
|
||||
break;
|
||||
}
|
||||
if (microtime(true) > $deadline) {
|
||||
proc_terminate($process);
|
||||
foreach ($pipes as $pipe) {
|
||||
fclose($pipe);
|
||||
}
|
||||
proc_close($process);
|
||||
|
||||
return ['ok' => false, 'exit' => -1, 'output' => trim($output)."\n".sprintf('Abgebrochen nach %d Sekunden.', $timeout)];
|
||||
}
|
||||
usleep(50000);
|
||||
}
|
||||
foreach ($pipes as $pipe) {
|
||||
$output .= stream_get_contents($pipe) ?: '';
|
||||
fclose($pipe);
|
||||
}
|
||||
proc_close($process);
|
||||
$exit = $status['exitcode'];
|
||||
|
||||
return ['ok' => 0 === $exit, 'exit' => $exit, 'output' => trim($output)];
|
||||
}
|
||||
|
||||
/** Whether an executable can be found at all, so a missing one is reported, not guessed at. */
|
||||
public function has(string $executable, string $cwd): bool
|
||||
{
|
||||
return $this->run([$executable, '--version'], $cwd, 30)['ok'];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user