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,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Is this installation answering?
|
||||
*
|
||||
* Run as a separate process after an update, so it exercises the new code rather than the code
|
||||
* that is already in memory. It touches exactly the things that break when an update goes wrong:
|
||||
* the database schema, the settings, and whether a published page can still be read.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:health', description: 'Checks database, schema and one published page. Used as the smoke test after an update.')]
|
||||
final class HealthCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $db,
|
||||
private PageRepository $pages,
|
||||
private SiteSettings $settings,
|
||||
private string $projectDir,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$problems = [];
|
||||
$facts = [];
|
||||
try {
|
||||
$this->db->fetchOne('SELECT 1');
|
||||
$facts[] = 'Datenbank erreichbar';
|
||||
} catch (\Throwable $error) {
|
||||
$problems[] = 'Datenbank: '.$error->getMessage();
|
||||
}
|
||||
if ([] === $problems) {
|
||||
try {
|
||||
$version = $this->db->fetchOne('SELECT MAX(version) FROM migration_versions');
|
||||
$facts[] = 'Migration '.(is_string($version) ? substr($version, -14) : 'unbekannt');
|
||||
} catch (\Throwable $error) {
|
||||
$problems[] = 'Migrationstabelle: '.$error->getMessage();
|
||||
}
|
||||
try {
|
||||
$home = $this->settings->homePath();
|
||||
$page = $this->pages->findPublished($home);
|
||||
null === $page
|
||||
? $problems[] = sprintf('Die Startseite /%s ist nicht veröffentlicht abrufbar.', $home)
|
||||
: $facts[] = sprintf('Startseite /%s lesbar', $home);
|
||||
} catch (\Throwable $error) {
|
||||
$problems[] = 'Startseite: '.$error->getMessage();
|
||||
}
|
||||
}
|
||||
$file = $this->projectDir.'/VERSION';
|
||||
$facts[] = 'Fassung '.(is_readable($file) ? trim((string) file_get_contents($file)) : 'unbekannt');
|
||||
|
||||
if ([] !== $problems) {
|
||||
$output->writeln('FEHLER: '.implode(' | ', $problems));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$output->writeln('OK: '.implode(', ', $facts));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* The way that always works, also without a web interface - and the one to reach for when the
|
||||
* administration itself is what is broken.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:update', description: 'Pulls a new version from the Git repository: preflight, backup, maintenance, checkout, migration, smoke test.')]
|
||||
final class UpdateCommand extends Command
|
||||
{
|
||||
public function __construct(private Updater $updater)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('check', null, InputOption::VALUE_NONE, 'Nur die Vorprüfung und die verfügbare Fassung zeigen.')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Alles prüfen, aber nichts verändern.')
|
||||
->addOption('to', null, InputOption::VALUE_REQUIRED, 'Ein bestimmter Tag, zum Beispiel v1.2.0.')
|
||||
->addOption('maintenance', null, InputOption::VALUE_REQUIRED, 'Wartungszustand von Hand setzen: an oder aus.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$switch = $input->getOption('maintenance');
|
||||
if (null !== $switch) {
|
||||
if (!in_array($switch, ['an', 'aus'], true)) {
|
||||
$io->error('--maintenance erwartet „an“ oder „aus“.');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
$this->updater->maintenance('an' === $switch);
|
||||
$io->success(sprintf('Wartungszustand ist %s.', 'an' === $switch ? 'eingeschaltet – die Website antwortet mit 503' : 'aufgehoben'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$state = $this->updater->state();
|
||||
$io->definitionList(
|
||||
['Fassung' => $state['version']],
|
||||
['Stand' => $state['describe'].' ('.$state['commit'].')'],
|
||||
['Wartung' => $state['maintenance'] ? 'eingeschaltet' : 'aus'],
|
||||
);
|
||||
|
||||
$checks = $this->updater->check();
|
||||
$io->table(['Prüfung', '', 'Befund'], array_map(
|
||||
static fn (array $check): array => [$check['name'], $check['ok'] ? 'ok' : 'FEHLT', $check['detail']],
|
||||
$checks,
|
||||
));
|
||||
$failed = array_values(array_filter($checks, static fn (array $check): bool => !$check['ok']));
|
||||
|
||||
if ($input->getOption('check')) {
|
||||
$available = $this->updater->availableVersion();
|
||||
$io->writeln(sprintf('Verfügbar: %s', $available ?? 'kein Tag der Form v1.2.3 gefunden'));
|
||||
|
||||
return [] === $failed ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
if ([] !== $failed) {
|
||||
$io->error('Die Vorprüfung ist nicht bestanden; es wird nichts verändert.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$target = $input->getOption('to');
|
||||
$result = $this->updater->update(is_string($target) ? $target : null, (bool) $input->getOption('dry-run'));
|
||||
foreach ($result['steps'] as $step) {
|
||||
$io->writeln(sprintf('%s %s: %s', $step['ok'] ? ' ok ' : ' FEHLER', $step['step'], $step['detail']));
|
||||
}
|
||||
if ($result['ok']) {
|
||||
$io->success(sprintf('Update auf %s abgeschlossen.', $result['target'] ?? 'die aktuelle Fassung'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
$io->error('Das Update ist gescheitert. Die Schritte oben sagen, wo.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Version, preflight and the update itself.
|
||||
*
|
||||
* Administrators only, and every write with a token. The console stays the more reliable way: an
|
||||
* update in a browser request has to finish within whatever the hosting allows, and that is not
|
||||
* something this controller can promise - so it says so instead.
|
||||
*/
|
||||
final class SystemController extends AbstractController
|
||||
{
|
||||
public function __construct(private Updater $updater)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/system', name: 'admin_system', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$checks = $this->updater->check();
|
||||
|
||||
return $this->render('admin/system.html.twig', [
|
||||
'state' => $this->updater->state(),
|
||||
'checks' => $checks,
|
||||
'ready' => [] === array_filter($checks, static fn (array $check): bool => !$check['ok']),
|
||||
// Asking the remote costs a network round trip, so only when it is wanted.
|
||||
'available' => $request->query->getBoolean('pruefen') ? $this->updater->availableVersion() : null,
|
||||
'asked' => $request->query->getBoolean('pruefen'),
|
||||
'steps' => $request->getSession()->remove('update_steps') ?: [],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/system/update', name: 'admin_system_update', methods: ['POST'])]
|
||||
public function update(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
// Checkout, composer and migration take longer than a page usually may. Where the hosting
|
||||
// allows it, the limit is lifted; where it does not, the console is the way.
|
||||
@set_time_limit(0);
|
||||
$target = $request->request->getString('target');
|
||||
$result = $this->updater->update('' === $target ? null : $target, $request->request->getBoolean('dryRun'));
|
||||
$request->getSession()->set('update_steps', $result['steps']);
|
||||
$this->addFlash(
|
||||
$result['ok'] ? 'success' : 'error',
|
||||
$result['ok']
|
||||
? sprintf('Update auf %s abgeschlossen.', $result['target'] ?? 'die aktuelle Fassung')
|
||||
: 'Das Update ist gescheitert. Die Schritte unten sagen, wo – und ob die Website wieder online ist.',
|
||||
);
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
|
||||
#[Route('/admin/system/wartung', name: 'admin_system_maintenance', methods: ['POST'])]
|
||||
public function maintenance(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$on = 'an' === $request->request->getString('state');
|
||||
$this->updater->maintenance($on);
|
||||
|
||||
return $this->back($on
|
||||
? 'Der Wartungszustand ist eingeschaltet: die Website antwortet mit 503, die Verwaltung bleibt erreichbar.'
|
||||
: 'Der Wartungszustand ist aufgehoben.', 'success');
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* While an update runs, the website answers with 503 and the administration stays open.
|
||||
*
|
||||
* 503 and not 200 with a notice: a search engine reads 503 with Retry-After as "come back later"
|
||||
* and keeps the page in its index, while a 200 would teach it that the page now contains an
|
||||
* excuse. The administration has to stay reachable, because otherwise nobody could finish or
|
||||
* undo the update.
|
||||
*/
|
||||
final class MaintenanceSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private Environment $twig, private string $projectDir)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
// Before the router: during maintenance no controller should run at all.
|
||||
return [KernelEvents::REQUEST => ['onRequest', 40]];
|
||||
}
|
||||
|
||||
public function onRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest() || !is_file($this->projectDir.'/'.Updater::MAINTENANCE_FILE)) {
|
||||
return;
|
||||
}
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (preg_match('#^/(admin|assets/|media/|favicon)#', $path)) {
|
||||
return;
|
||||
}
|
||||
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
|
||||
$response = new Response(
|
||||
$this->twig->render('maintenance.html.twig', ['since' => trim((string) $since)]),
|
||||
Response::HTTP_SERVICE_UNAVAILABLE,
|
||||
[
|
||||
'Retry-After' => '600',
|
||||
'Cache-Control' => 'no-store',
|
||||
'Content-Type' => 'text/html; charset=UTF-8',
|
||||
'Content-Security-Policy' => "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||
],
|
||||
);
|
||||
$event->setResponse($response);
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
<?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
|
||||
{
|
||||
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);
|
||||
$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<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'];
|
||||
$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');
|
||||
}
|
||||
|
||||
if ($this->runner->has('composer', $this->root)) {
|
||||
$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');
|
||||
}
|
||||
} else {
|
||||
$step($steps, 'Abhängigkeiten', true, 'Composer ist nicht vorhanden; vendor/ kommt aus dem Tag.');
|
||||
}
|
||||
|
||||
$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]);
|
||||
$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).'.');
|
||||
}
|
||||
|
||||
if ($this->runner->has('composer', $this->root)) {
|
||||
$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()),
|
||||
);
|
||||
}
|
||||
|
||||
/** @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';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user