clupilot:bind-hosts traegt die Hostnamen in eine bestehende .env nach
parent
0d7950c464
commit
d90676632e
|
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Env\EnvFileEditor;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Trägt die Hostnamen in eine bestehende .env nach.
|
||||
*
|
||||
* `install.sh` schreibt die .env nur bei einer NEUEN Installation („Keeping the
|
||||
* existing .env"). Jede Maschine, die es schon gibt, bekommt die vier Schlüssel
|
||||
* also nie — und ohne sie laufen die Route::domain()-Gruppen host-unabhängig
|
||||
* und antworten überall. Das ist kein Schönheitsfehler: der Betreiber erreicht
|
||||
* dann die Website unter app. und die Anmeldung unter www.
|
||||
*
|
||||
* Über EnvFileEditor und nicht mit einem eigenen `sed`: die Datei hält jedes
|
||||
* Geheimnis dieser Installation, und der Editor prüft den neuen Inhalt Zeile
|
||||
* für Zeile und legt vorher eine Kopie mit Zeitstempel daneben.
|
||||
*
|
||||
* Was gesetzt ist, bleibt. Was leer ist, gilt als fehlend — .env.example
|
||||
* liefert die vier Schlüssel leer aus, und ein Befehl, der leere Zeilen als
|
||||
* „steht ja schon da" behandelt, hilft genau auf den Installationen nicht, für
|
||||
* die es ihn gibt.
|
||||
*/
|
||||
class BindHosts extends Command
|
||||
{
|
||||
protected $signature = 'clupilot:bind-hosts
|
||||
{--app= : Hostname des Kundenportals, z. B. app.clupilot.com}
|
||||
{--site= : Hostnamen der Website, kommagetrennt, der erste ist kanonisch}
|
||||
{--status= : Hostname der Statusseite}
|
||||
{--files= : Hostname für Downloads}
|
||||
{--dry-run : Nur zeigen, was geschähe}
|
||||
{--force : Ohne Rückfrage schreiben}';
|
||||
|
||||
protected $description = 'Trägt APP_HOST, SITE_HOST, STATUS_HOST und FILES_HOST in eine bestehende .env nach';
|
||||
|
||||
public function handle(EnvFileEditor $env): int
|
||||
{
|
||||
$content = $env->read();
|
||||
|
||||
if (trim($content) === '') {
|
||||
$this->error("Keine .env unter {$env->path()}.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$wanted = [
|
||||
'APP_HOST' => (string) ($this->option('app') ?: $this->hostOf($content)),
|
||||
'SITE_HOST' => (string) $this->option('site'),
|
||||
'STATUS_HOST' => (string) $this->option('status'),
|
||||
'FILES_HOST' => (string) $this->option('files'),
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($wanted as $key => $value) {
|
||||
if ($value === '' || $this->valueOf($content, $key) !== '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missing[$key] = $value;
|
||||
}
|
||||
|
||||
if ($missing === []) {
|
||||
$this->info('Nichts nachzutragen — jeder angegebene Hostname steht bereits in der Datei.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->line('In '.$env->path().':');
|
||||
|
||||
foreach ($missing as $key => $value) {
|
||||
$this->line(" {$key}={$value}");
|
||||
}
|
||||
|
||||
// Der Satz, der einen Ausfall verhindert. Ein Hostname wird durch das
|
||||
// Binden zur EINZIGEN Adresse, unter der diese Routen noch antworten —
|
||||
// steht dafür kein DNS-Eintrag und kein Block im Reverse Proxy, ist der
|
||||
// Bereich danach schlicht nicht mehr erreichbar.
|
||||
$this->newLine();
|
||||
$this->warn('Jeder dieser Namen braucht einen DNS-Eintrag und einen Block im Reverse Proxy.');
|
||||
$this->warn('Ohne den ist der jeweilige Bereich nach dem Neuladen der Konfiguration nicht mehr erreichbar.');
|
||||
$this->newLine();
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('--dry-run: nichts geschrieben.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm('Diese Namen jetzt binden?')) {
|
||||
$this->line('Nichts geschrieben.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$backup = $env->write($this->apply($content, $missing));
|
||||
|
||||
$this->info('Geschrieben. Die vorherige Fassung liegt unter '.$backup);
|
||||
$this->line('Danach: php artisan config:cache && php artisan route:cache');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Wert eines Schlüssels, oder '' wenn er fehlt ODER leer ist.
|
||||
*
|
||||
* Beides zusammen, absichtlich: siehe Klassenkommentar.
|
||||
*/
|
||||
private function valueOf(string $content, string $key): string
|
||||
{
|
||||
return preg_match('/^'.preg_quote($key, '/').'=(.*)$/m', $content, $matches) === 1
|
||||
? trim($matches[1])
|
||||
: '';
|
||||
}
|
||||
|
||||
/** Der Hostname aus APP_URL — die Antwort für APP_HOST steht schon in der Datei. */
|
||||
private function hostOf(string $content): string
|
||||
{
|
||||
return (string) parse_url($this->valueOf($content, 'APP_URL'), PHP_URL_HOST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorhandene Zeile ersetzen, sonst anhängen.
|
||||
*
|
||||
* Ersetzen und nicht nur anhängen, weil ein leerer Schlüssel als fehlend
|
||||
* gilt: `APP_HOST=` steht dann schon da, und ein zweites `APP_HOST=…`
|
||||
* darunter wäre eine Datei mit zwei Antworten auf dieselbe Frage.
|
||||
*
|
||||
* @param array<string, string> $values
|
||||
*/
|
||||
private function apply(string $content, array $values): string
|
||||
{
|
||||
foreach ($values as $key => $value) {
|
||||
$line = $key.'='.$value;
|
||||
$pattern = '/^'.preg_quote($key, '/').'=.*$/m';
|
||||
|
||||
$content = preg_match($pattern, $content) === 1
|
||||
? preg_replace($pattern, $line, $content, 1)
|
||||
: rtrim($content, "\n")."\n".$line."\n";
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Env\EnvFileEditor;
|
||||
|
||||
beforeEach(function () {
|
||||
// Eine eigene Datei je Test. Der Befehl schreibt sonst die .env dieser
|
||||
// Entwicklungsmaschine, und ein Test, der das kann, wird irgendwann
|
||||
// versehentlich scharf ausgeführt.
|
||||
$this->envPath = sys_get_temp_dir().'/clupilot-env-'.bin2hex(random_bytes(6));
|
||||
$this->app->instance(EnvFileEditor::class, new EnvFileEditor($this->envPath));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
foreach (glob($this->envPath.'*') ?: [] as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
});
|
||||
|
||||
it('trägt die fehlenden Hostnamen nach', function () {
|
||||
file_put_contents($this->envPath, "APP_URL=https://app.example.test\nADMIN_HOSTS=admin.example.test\n");
|
||||
|
||||
$this->artisan('clupilot:bind-hosts', [
|
||||
'--site' => 'www.example.test,example.test',
|
||||
'--status' => 'status.example.test',
|
||||
'--files' => 'files.example.test',
|
||||
'--force' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
$written = file_get_contents($this->envPath);
|
||||
|
||||
// APP_HOST kommt aus dem Host von APP_URL — danach muss niemand gefragt
|
||||
// werden, die Antwort steht schon in der Datei.
|
||||
expect($written)->toContain('APP_HOST=app.example.test')
|
||||
->and($written)->toContain('SITE_HOST=www.example.test,example.test')
|
||||
->and($written)->toContain('STATUS_HOST=status.example.test')
|
||||
->and($written)->toContain('FILES_HOST=files.example.test')
|
||||
// Und nichts Vorhandenes geht verloren.
|
||||
->and($written)->toContain('APP_URL=https://app.example.test')
|
||||
->and($written)->toContain('ADMIN_HOSTS=admin.example.test');
|
||||
});
|
||||
|
||||
it('überschreibt niemals einen Wert, den jemand von Hand gesetzt hat', function () {
|
||||
file_put_contents(
|
||||
$this->envPath,
|
||||
"APP_URL=https://app.example.test\nAPP_HOST=eigener.example.test\nSITE_HOST=\n",
|
||||
);
|
||||
|
||||
$this->artisan('clupilot:bind-hosts', [
|
||||
'--site' => 'www.example.test',
|
||||
'--force' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
$written = file_get_contents($this->envPath);
|
||||
|
||||
// Gesetzt bleibt gesetzt …
|
||||
expect($written)->toContain('APP_HOST=eigener.example.test')
|
||||
->and($written)->not->toContain('APP_HOST=app.example.test')
|
||||
// … und leer gilt als fehlend, denn genau so liefert .env.example aus.
|
||||
->and($written)->toContain('SITE_HOST=www.example.test');
|
||||
});
|
||||
|
||||
it('sichert die alte Datei, bevor es schreibt', function () {
|
||||
file_put_contents($this->envPath, "APP_URL=https://app.example.test\n");
|
||||
|
||||
$this->artisan('clupilot:bind-hosts', ['--force' => true])->assertSuccessful();
|
||||
|
||||
// EnvFileEditor legt eine Kopie mit Zeitstempel daneben. Diese Datei hält
|
||||
// jedes Geheimnis der Installation; ein Schreibfehler darf sie nicht
|
||||
// ersatzlos ersetzen.
|
||||
expect(glob($this->envPath.'.bak-*'))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('tut ohne Bestätigung nichts', function () {
|
||||
file_put_contents($this->envPath, "APP_URL=https://app.example.test\n");
|
||||
|
||||
$this->artisan('clupilot:bind-hosts', ['--site' => 'www.example.test'])
|
||||
->expectsConfirmation(
|
||||
'Diese Namen jetzt binden?',
|
||||
'no',
|
||||
)
|
||||
->assertFailed();
|
||||
|
||||
expect(file_get_contents($this->envPath))->not->toContain('SITE_HOST');
|
||||
});
|
||||
|
||||
it('schreibt bei --dry-run nichts, sagt aber was geschähe', function () {
|
||||
file_put_contents($this->envPath, "APP_URL=https://app.example.test\n");
|
||||
|
||||
$this->artisan('clupilot:bind-hosts', ['--site' => 'www.example.test', '--dry-run' => true])
|
||||
->expectsOutputToContain('SITE_HOST=www.example.test')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(file_get_contents($this->envPath))->not->toContain('SITE_HOST')
|
||||
->and(glob($this->envPath.'.bak-*'))->toBeEmpty();
|
||||
});
|
||||
|
||||
it('sagt es, wenn schon alles steht, statt eine Sicherung anzulegen', function () {
|
||||
file_put_contents($this->envPath, implode("\n", [
|
||||
'APP_URL=https://app.example.test',
|
||||
'APP_HOST=app.example.test',
|
||||
'SITE_HOST=www.example.test',
|
||||
'STATUS_HOST=status.example.test',
|
||||
'FILES_HOST=files.example.test',
|
||||
'',
|
||||
]));
|
||||
|
||||
$this->artisan('clupilot:bind-hosts', ['--force' => true])
|
||||
->expectsOutputToContain('Nichts nachzutragen')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(glob($this->envPath.'.bak-*'))->toBeEmpty();
|
||||
});
|
||||
Loading…
Reference in New Issue