Fassung 1.7.0: verlässliche Installation, übersichtliche Verwaltung und bedienbare Bewegung
This commit is contained in:
1 parent
b164c4edfe
commit
495b26896e
55 files changed
+1632
-423
No files matched your search
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
use Symfony\Component\Dotenv\Dotenv;
|
||||
|
||||
/** One installation file, with an explicit, recoverable transition from older releases. */
|
||||
final class Environment
|
||||
{
|
||||
public static function prepare(string $root): void
|
||||
{
|
||||
if (is_file($root.'/.env') && !is_file($root.'/.env.local')) {
|
||||
return;
|
||||
}
|
||||
self::locked($root, static function () use ($root): void {
|
||||
$file = $root.'/.env';
|
||||
if (!is_file($file)) {
|
||||
$template = is_file($root.'/.env.example') ? (string) file_get_contents($root.'/.env.example') : '';
|
||||
self::replace($file, $template);
|
||||
}
|
||||
$legacy = $root.'/.env.local';
|
||||
if (is_file($legacy)) {
|
||||
// Preserve the previously effective configuration, not a release's default DB.
|
||||
$base = (string) file_get_contents($file);
|
||||
$old = (string) file_get_contents($legacy);
|
||||
$values = (new Dotenv())->parse($base."\n".$old, $legacy);
|
||||
if (preg_match('/^DATABASE_URL=/m', $old)) {
|
||||
$values['KANT_DATABASE_CONFIGURED'] = '1';
|
||||
}
|
||||
$directory = $root.'/var/backups/environment-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(4));
|
||||
if (!@mkdir($directory, 0o700, true)) {
|
||||
throw new \RuntimeException('Die bisherige Konfiguration konnte nicht gesichert werden. Bitte die Schreibrechte auf var/backups prüfen.');
|
||||
}
|
||||
self::replace($directory.'/.env', $base);
|
||||
self::replace($directory.'/.env.local', $old);
|
||||
self::replace($file, self::encode($values));
|
||||
if (!@unlink($legacy)) {
|
||||
throw new \RuntimeException('Die Einstellungen sind in .env gesichert, aber .env.local konnte nicht entfernt werden. Bitte deren Schreibrechte prüfen.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static function write(string $root, array $values): void
|
||||
{
|
||||
self::locked($root, static function () use ($root, $values): void {
|
||||
$file = $root.'/.env';
|
||||
$existing = is_file($file) ? (new Dotenv())->parse((string) file_get_contents($file), $file) : [];
|
||||
self::replace($file, self::encode(array_replace($existing, $values)));
|
||||
});
|
||||
}
|
||||
|
||||
/** A file lock serializes concurrent installer requests and secret generation. */
|
||||
private static function locked(string $root, callable $operation): void
|
||||
{
|
||||
$lock = @fopen($root.'/.env.lock', 'c');
|
||||
if (false === $lock || !flock($lock, LOCK_EX)) {
|
||||
if (is_resource($lock)) {
|
||||
fclose($lock);
|
||||
}
|
||||
throw new \RuntimeException('.env ist nicht beschreibbar. Bitte die Schreibrechte im Projektstamm prüfen.');
|
||||
}
|
||||
try {
|
||||
$operation();
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
public static function ensureSecret(string $root): string
|
||||
{
|
||||
$secret = '';
|
||||
self::locked($root, static function () use ($root, &$secret): void {
|
||||
$file = $root.'/.env';
|
||||
$values = (new Dotenv())->parse((string) file_get_contents($file), $file);
|
||||
$secret = $values['APP_SECRET'] ?? '';
|
||||
if ('' === $secret) {
|
||||
$secret = bin2hex(random_bytes(32));
|
||||
self::replace($file, self::encode([...$values, 'APP_SECRET' => $secret]));
|
||||
}
|
||||
});
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
private static function encode(array $values): string
|
||||
{
|
||||
$lines = ['# Konfiguration dieser Installation. Updates erhalten diese Datei.'];
|
||||
foreach ($values as $key => $value) {
|
||||
if (1 !== preg_match('/^[A-Z_][A-Z0-9_]*$/D', $key)) {
|
||||
throw new \InvalidArgumentException('Ungültiger Name einer Konfigurationseinstellung.');
|
||||
}
|
||||
$lines[] = $key.'="'.str_replace(['\\', '"', '$', "\n", "\r"], ['\\\\', '\\"', '\\$', '\\n', '\\r'], (string) $value).'"';
|
||||
}
|
||||
|
||||
return implode("\n", $lines)."\n";
|
||||
}
|
||||
|
||||
private static function replace(string $file, string $content): void
|
||||
{
|
||||
$temporary = $file.'.'.bin2hex(random_bytes(6)).'.tmp';
|
||||
if (false === @file_put_contents($temporary, $content)) {
|
||||
throw new \RuntimeException('.env ließ sich nicht schreiben. Bitte die Schreibrechte im Projektstamm prüfen.');
|
||||
}
|
||||
@chmod($temporary, 0o600);
|
||||
if (!@rename($temporary, $file)) {
|
||||
@unlink($temporary);
|
||||
throw new \RuntimeException('.env ließ sich nicht ersetzen. Die bisherige Konfiguration bleibt erhalten.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
* Everything the browser installer does, without a browser.
|
||||
*
|
||||
* The order is the order of the screens, and every step writes its result immediately instead of
|
||||
* collecting answers in a session: after the database step there is a `.env.local`, after the
|
||||
* collecting answers in a session: after the database step there is a `.env`, after the
|
||||
* migration step there are tables, after the website step there is content. That way a reload, a
|
||||
* timeout or a closed tab never loses a half filled form - the installer simply sees what is
|
||||
* already there and continues at the next missing thing.
|
||||
@@ -123,7 +123,7 @@ final class Installation
|
||||
$checks[] = $this->check('ZIP-Erweiterung', class_exists(\ZipArchive::class), 'Wird für Sicherungen als Archiv gebraucht. Ohne sie bleibt die Sicherung ein Verzeichnis.', false);
|
||||
$checks[] = $this->check('proc_open', \function_exists('proc_open') && !\in_array('proc_open', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true), 'Ohne proc_open kann das Update im Browser nicht laufen. Ein Update über das Releasearchiv geht trotzdem.', false);
|
||||
|
||||
foreach ([$this->root => 'Projektstamm (für .env.local)', $this->root.'/var' => 'var/ (Datenbank, Cache, Sicherungen)', $this->mediaDirectory => 'Medienordner'] as $path => $label) {
|
||||
foreach ([$this->root => 'Projektstamm (für .env)', $this->root.'/var' => 'var/ (Datenbank, Cache, Sicherungen)', $this->mediaDirectory => 'Medienordner'] as $path => $label) {
|
||||
$checks[] = $this->check('Schreibrecht: '.$label, $this->writable($path), sprintf('%s ist nicht beschreibbar. Im FTP-Programm die Rechte auf 0775 setzen (oder 0755, wenn PHP als Eigentümer läuft).', $path), true, $path);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ final class Installation
|
||||
false,
|
||||
$documentRoot ?: null,
|
||||
);
|
||||
$checks[] = $this->check('Geheimnis (APP_SECRET)', '' !== $this->secret && !str_contains($this->secret, 'nicht-gespeichert'), 'Das Geheimnis konnte nicht in .env.local geschrieben werden. Ohne Schreibrecht im Projektstamm bleibt jede Anmeldung nach einem Neustart ungültig.');
|
||||
$checks[] = $this->check('Geheimnis (APP_SECRET)', '' !== $this->secret && !str_contains($this->secret, 'nicht-gespeichert'), 'Das Geheimnis konnte nicht in .env geschrieben werden. Ohne Schreibrecht im Projektstamm bleibt jede Anmeldung nach einem Neustart ungültig.');
|
||||
|
||||
return $checks;
|
||||
}
|
||||
@@ -151,7 +151,7 @@ final class Installation
|
||||
|
||||
public function environmentWritten(): bool
|
||||
{
|
||||
return is_file($this->root.'/.env.local') && str_contains((string) file_get_contents($this->root.'/.env.local'), 'DATABASE_URL=');
|
||||
return is_file($this->root.'/.env') && str_contains((string) file_get_contents($this->root.'/.env'), 'KANT_DATABASE_CONFIGURED="1"');
|
||||
}
|
||||
|
||||
public function schemaReady(): bool
|
||||
@@ -206,32 +206,13 @@ final class Installation
|
||||
return ['url' => $url, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes `.env.local`, never `.env`.
|
||||
*
|
||||
* `.env` belongs to the release and is replaced by the next update; `.env.local` belongs to
|
||||
* this installation and survives. Values that are already there stay unless they are given
|
||||
* again, so the website step can add the address without losing the database.
|
||||
*/
|
||||
/** Save installation settings in the single persistent configuration file. */
|
||||
public function writeEnvironment(array $values): void
|
||||
{
|
||||
$file = $this->root.'/.env.local';
|
||||
$lines = [];
|
||||
foreach (is_file($file) ? (preg_split('/\R/', (string) file_get_contents($file)) ?: []) : [] as $line) {
|
||||
if ('' !== trim($line) && 1 === preg_match('/^([A-Z_]+)=/', $line, $match) && !\array_key_exists($match[1], $values)) {
|
||||
$lines[$match[1]] = $line;
|
||||
}
|
||||
if (isset($values['DATABASE_URL'])) {
|
||||
$values['KANT_DATABASE_CONFIGURED'] = '1';
|
||||
}
|
||||
foreach ($values as $key => $value) {
|
||||
$lines[$key] = $key.'="'.str_replace(['\\', '"', "\n"], ['\\\\', '\"', ''], (string) $value).'"';
|
||||
}
|
||||
$content = "# Diese Datei gehört zu dieser Installation und wird von keinem Update überschrieben.\n".implode("\n", $lines)."\n";
|
||||
$temporary = $file.'.'.bin2hex(random_bytes(4));
|
||||
if (false === file_put_contents($temporary, $content) || !rename($temporary, $file)) {
|
||||
@unlink($temporary);
|
||||
throw new \RuntimeException('.env.local ließ sich nicht schreiben. Bitte das Schreibrecht im Projektstamm prüfen.');
|
||||
}
|
||||
@chmod($file, 0o600);
|
||||
Environment::write($this->root, $values);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+54
-5
@@ -19,6 +19,8 @@ use Doctrine\DBAL\Connection;
|
||||
*/
|
||||
final class Updater
|
||||
{
|
||||
private array $environmentBefore = [];
|
||||
private ?string $backupDirectory = null;
|
||||
public const MAINTENANCE_FILE = 'var/maintenance';
|
||||
public const LOG_FILE = 'var/updates.log';
|
||||
|
||||
@@ -231,8 +233,22 @@ final class Updater
|
||||
$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');
|
||||
}
|
||||
@@ -320,15 +336,27 @@ final class Updater
|
||||
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 = dirname($backup).'/'.basename((string) $file);
|
||||
$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 '.dirname((string) $backup).'.');
|
||||
: '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);
|
||||
@@ -346,11 +374,27 @@ final class Updater
|
||||
$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
|
||||
{
|
||||
$directory = $this->root.'/var/backups/update-'.gmdate('Ymd-His');
|
||||
if (!is_dir($directory) && !@mkdir($directory, 0o775, true)) {
|
||||
$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();
|
||||
@@ -361,10 +405,15 @@ final class Updater
|
||||
}
|
||||
foreach (['.env', '.env.local'] as $environment) {
|
||||
if (is_readable($this->root.'/'.$environment)) {
|
||||
@copy($this->root.'/'.$environment, $directory.'/'.$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)),
|
||||
|
||||
Reference in new issue
Block a user