Umsetzungsplan Hostnamen-Trennung: vier Aufgaben, Testcode im Container gelaufen

Der Testcode im Plan ist keine Skizze. Er lief: 123 Routen, zwoelf ohne Domain,
und die Original-Anwendung danach lesend UND schreibend intakt. Letzteres nicht
selbstverstaendlich — die zweite Anwendungsinstanz zeigt Eloquents statischen
Connection-Resolver auf ihre eigene, leere :memory:-Datenbank, und der halbe
Testlauf faende danach keine Tabelle mehr. Genau so ist es beim ersten Versuch
passiert; das Zurueckstellen steht deshalb mit Begruendung im Plan.

Ebenfalls vorher geprueft statt geraten: dass Pest-Expectations eine eigene
Fehlermeldung als zweites Argument nehmen, und dass dynamische Properties in
beforeEach hier Hausstil sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 14:16:10 +02:00
parent 295444f432
commit 5a2e6564db
1 changed files with 942 additions and 0 deletions

View File

@ -0,0 +1,942 @@
# Hostnamen-Trennung — Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die vorhandene Hostnamen-Trennung wird eingeschaltet, per Test
festgehalten, und das eine unbegründete host-unabhängige Route-Paar geschlossen.
**Architecture:** Am Routing wird nichts geändert — `routes/web.php` trennt
bereits korrekt, sobald `APP_HOST`/`SITE_HOST`/`STATUS_HOST`/`FILES_HOST`
gesetzt sind. Gebaut werden: ein Test, der die **vollständige** Routentabelle
misst (indem er eine zweite Anwendungsinstanz mit gesetzten Hostnamen bootet),
das Schließen von `storage/{path}`, der Konfigurationsweg für Neuinstallationen
(`install.sh`, `.env.example`) und ein Artisan-Befehl, der den Bestand nachrüstet.
**Tech Stack:** Laravel 12, Pest 4, Bash (deploy/install.sh), Docker Compose.
## Global Constraints
- **Entwurf:** `docs/superpowers/specs/2026-08-04-hostnamen-trennung-design.md`.
Bei Abweichung gilt der Entwurf; bei Konflikt mit `CLAUDE.md` → STOP & fragen.
- **Testbefehl:** `docker exec clupilot-app-1 php artisan test`. Einzelne Datei:
`docker exec clupilot-app-1 php artisan test --filter=<Name>`.
Der Worktree hat **kein** `vendor/`; Tests laufen im Container, der
`/home/nexxo/clupilot` unter `/var/www/html` einhängt.
- **`routes/web.php`, `app/Http/Middleware/RestrictAdminHost.php`,
`config/fortify.php`, `app/Http/Middleware/PublicSiteGate.php` werden NICHT
geändert.** Gemessen korrekt.
- **Sprache:** Kommentare und Ausgaben auf Deutsch, wie in den letzten Commits.
Pfade und Routennamen bleiben englisch (R13).
- **Kommentare sagen WARUM, nicht WAS.** Hausstil: siehe `install-agent.sh` und
`RestrictAdminHost`.
- **R22:** Eine Prüfrunde, eine Fix-Runde. Keine flächendeckenden
Mutationstests.
---
## File Structure
| Datei | Zuständigkeit |
|---|---|
| `tests/Feature/HostSeparationTest.php` | **neu** — misst die vollständige Routentabelle mit gesetzten Hostnamen; hält Kriterium 4 und 5 fest |
| `config/filesystems.php` | **ändern**, Zeile 36 — `'serve' => false` |
| `deploy/install.sh` | **ändern** — Reihenfolge-Panne; schreibt die vier Hostnamen |
| `.env.example` | **ändern**, Zeilen 218239 — `STATUS_HOST` ergänzen, Block sortieren |
| `tests/Feature/InstallerWritesHostsTest.php` | **neu** — hält beides am Installer fest |
| `app/Console/Commands/BindHosts.php` | **neu**`clupilot:bind-hosts` für den Bestand |
| `tests/Feature/BindHostsCommandTest.php` | **neu** |
| `VERSION` | **ändern** — Release |
---
## Task 1: Der Test, und das Leck, das er findet
**Files:**
- Create: `tests/Feature/HostSeparationTest.php`
- Modify: `config/filesystems.php:36`
**Interfaces:**
- Produces: `hostSeparationTable(): array` — globale Testfunktion, gibt je Route
`['uri' => string, 'domain' => ?string, 'name' => ?string]` zurück. Der Name
ist bewusst eigen, weil `PortalHostTest` bereits `routerWithHosts()`,
`dispatchOn()` und `answers()` global definiert.
**Hintergrund für den Umsetzenden:** Die Routen werden einmal beim Booten
angemeldet. `config()->set()` in einem Test ändert daran nichts. `PortalHostTest`
löst das, indem es einen frischen `Router` baut und `routes/web.php` neu lädt —
das reicht hier **nicht**, weil Livewire, `storage/{path}`, `up` und
`broadcasting/auth` nicht aus `routes/web.php` kommen, sondern von
Service-Providern. Genau die fehlten dann in der Messung. Deshalb wird eine
**zweite Anwendungsinstanz** gebootet.
Verifiziert: das reproduziert `artisan route:list --json` exakt (123 Routen,
12 ohne Domain).
- [ ] **Step 1: Den Test schreiben**
Datei `tests/Feature/HostSeparationTest.php`:
```php
<?php
use Illuminate\Container\Container;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Facade;
/**
* Jede Route gehört zu genau einem Hostnamen.
*
* Gemessen wird die VOLLSTÄNDIGE Routentabelle, nicht nur routes/web.php. Der
* Unterschied ist der ganze Punkt: `storage/{path}`, `livewire/*`, `up` und
* `broadcasting/auth` meldet nicht diese Datei an, sondern ein Service-Provider
* — und `storage/{path}` war genau die Route, die niemandem aufgefallen ist.
* Ein Test, der die halbe Tabelle misst, prüft die Trennung nicht.
*
* Routen entstehen beim Booten, also ändert `config()->set()` daran nichts.
* PortalHostTest baut dafür einen frischen Router und lädt routes/web.php neu;
* das sieht die Provider-Routen nicht. Hier wird deshalb eine zweite
* Anwendungsinstanz gebootet — dasselbe, was `artisan route:list` tut.
*
* Was danach wieder zurückgestellt werden MUSS, und warum: Eloquent hält seinen
* Connection-Resolver STATISCH. Die zweite Anwendung zeigt ihn auf ihre eigene,
* leere :memory:-Datenbank, und der restliche Testlauf fände danach keine
* einzige Tabelle mehr. Das ist kein hypothetisches Risiko — genau so ist es
* beim Bau dieses Tests passiert.
*/
function hostSeparationTable(): array
{
$env = [
'APP_HOST' => 'app.clupilot.test',
'SITE_HOST' => 'www.clupilot.test,clupilot.test',
'STATUS_HOST' => 'status.clupilot.test',
'FILES_HOST' => 'files.clupilot.test',
'ADMIN_HOSTS' => 'admin.clupilot.test',
'ADMIN_HOST_EXCLUSIVE' => 'true',
];
// Gemerkt und zurückgestellt, nicht bloß gelöscht: phpunit.xml setzt
// FILES_HOST und ADMIN_HOSTS für den ganzen Lauf, und ein Test, der sie
// hinterher entfernt, kippt die Tests nach ihm.
$previous = [];
foreach ($env as $key => $value) {
$previous[$key] = $_SERVER[$key] ?? null;
putenv("{$key}={$value}");
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
$original = Container::getInstance();
try {
$app = require base_path('bootstrap/app.php');
$app->make(Kernel::class)->bootstrap();
$table = [];
foreach ($app['router']->getRoutes()->getRoutes() as $route) {
$table[] = [
'uri' => $route->uri(),
'domain' => $route->getDomain(),
'name' => $route->getName(),
];
}
} finally {
Container::setInstance($original);
Facade::clearResolvedInstances();
Facade::setFacadeApplication($original);
Model::setConnectionResolver($original['db']);
Model::setEventDispatcher($original['events']);
foreach ($previous as $key => $value) {
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
} else {
putenv("{$key}={$value}");
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
}
return $table;
}
/** Die Tabelle nach Routennamen, für die gezielten Prüfungen unten. */
function hostSeparationByName(): array
{
$byName = [];
foreach (hostSeparationTable() as $route) {
if ($route['name'] !== null && $route['name'] !== '') {
$byName[$route['name']] = $route;
}
}
return $byName;
}
it('bindet jede Route an einen Hostnamen, außer den hier benannten', function () {
// Jeder Eintrag mit seinem Grund. Wer hier etwas hinzufügt, schreibt den
// Grund dazu — sonst ist die Liste in einem Jahr eine Liste von Ausnahmen,
// die niemand mehr prüfen kann.
$shared = [
// Konsole und Portal posten Komponenten-Aktionen an denselben
// Endpunkt. RestrictAdminHost ist die Schutzschicht dafür; eine
// Host-Bindung hier zerlegt die Konsole.
'livewire/update',
'livewire/upload-file',
'livewire/preview-file/{filename}',
'livewire/livewire.js',
'livewire/livewire.min.js.map',
// Gesundheitsprüfung, ebenfalls RestrictAdminHost::SHARED.
'up',
// Der Live-Feed der Konsole meldet sich hier an (routes/channels.php,
// admin.runs) — ebenfalls RestrictAdminHost::SHARED.
'broadcasting/auth',
// Stripe postet an die URL, die es einmal bekommen hat. Ein
// Hostname-Fehler an dieser Stelle verliert Zahlungen.
'webhooks/stripe',
// "/" auf einem Namen, der weder Website noch Portal noch Status ist:
// eine Weiterleitung ins Portal, kein 404. Wer "/" aus dem Gedächtnis
// tippt, soll nicht auf einer Fehlerseite über Hostnamen belehrt werden.
'/',
// Weiterleitung auf den Statushost. Eine Statusseite ist die Adresse,
// die in einem Lesezeichen steht, wenn ohnehin schon etwas kaputt ist.
'status',
];
$unbound = [];
foreach (hostSeparationTable() as $route) {
if ($route['domain'] === null) {
$unbound[] = $route['uri'];
}
}
$unbound = array_values(array_unique($unbound));
sort($unbound);
sort($shared);
expect($unbound)->toBe($shared);
});
it('legt jeden Weg in ein Konto hinein und wieder heraus auf den Portal-Hostnamen', function () {
// Kriterium 4, und die teuerste Falle des ganzen Umbaus: Fortify meldet
// diese Routen SELBST an, nicht routes/web.php. Gebunden werden sie über
// config/fortify.php ('domain' => env('APP_HOST')). Verschwindet diese eine
// Zeile beim Aufräumen, kann sich auf app. niemand mehr anmelden — und
// routes/web.php sieht dabei völlig unverdächtig aus.
$byName = hostSeparationByName();
$mustBeOnThePortal = [
'login', 'login.store', 'logout',
'password.request', 'password.email',
'password.reset', 'password.update',
'two-factor.login', 'two-factor.login.store',
'password.confirm.store',
'verification.notice', 'verification.verify',
];
foreach ($mustBeOnThePortal as $name) {
expect($byName[$name]['domain'] ?? null)
->toBe('app.clupilot.test', "Route [{$name}] liegt nicht auf dem Portal-Hostnamen.");
}
});
it('behält die Namen, an denen PublicSiteGate die Downloads erkennt', function () {
// PublicSiteGate::isDownload() prüft auf ROUTENNAMEN. Zieht eine Route um
// und ändert dabei ihren Namen, fällt die Ausnahme still weg — und ein
// Server im Rettungssystem schiebt die 503-Platzhalterseite in `tar`.
$byName = hostSeparationByName();
expect($byName['bootstrap.archive']['domain'] ?? null)->toBe('files.clupilot.test')
->and($byName['files.public']['domain'] ?? null)->toBe('files.clupilot.test');
$gate = file_get_contents(base_path('app/Http/Middleware/PublicSiteGate.php'));
expect($gate)->toContain("'bootstrap.archive'")
->and($gate)->toContain("'files.public'");
});
```
- [ ] **Step 2: Den Test laufen lassen und den Fehlschlag ansehen**
Run: `docker exec clupilot-app-1 php artisan test --filter=HostSeparation`
Expected: Der erste Test **schlägt fehl**. Die Differenz zeigt `storage/{path}`
als überzähligen Eintrag in `$unbound`. Die beiden anderen Tests sind grün —
sie halten fest, was heute schon stimmt.
Steht in der Differenz noch etwas anderes als `storage/{path}`: **STOP**. Dann
ist seit der Messung eine Route dazugekommen, und sie gehört erst begründet,
bevor sie auf die Liste kommt.
- [ ] **Step 3: `storage/{path}` schließen**
In `config/filesystems.php`, Zeile 36, `'serve' => true` ersetzen durch:
```php
// Aus, und das ist der Unterschied zwischen zwei Routen und keiner:
// `true` meldet `storage/{path}` als GET und PUT an — ohne
// Hostnamen, also auf JEDEM Namen dieser Installation. Benutzt hat
// sie nie jemand. Jede Datei, die dieses Produkt ausliefert, geht
// durch einen eigenen Controller (PublicFileController,
// BootstrapArchiveController, invoices.pdf, dpa.file), und die
// beiden `temporaryUrl()`-Aufrufe im Repo sind Livewire-Uploads,
// die `livewire/preview-file/…` erzeugen und nicht diese Route.
'serve' => false,
```
- [ ] **Step 4: Den Test noch einmal laufen lassen**
Run: `docker exec clupilot-app-1 php artisan test --filter=HostSeparation`
Expected: PASS, drei Tests.
- [ ] **Step 5: Die Nachbartests laufen lassen**
Run: `docker exec clupilot-app-1 php artisan test --filter="PortalHost|FilesHost|Welcome|OfficialDomains"`
Expected: PASS. Das prüft, dass das Zurückstellen der Anwendungsinstanz sauber
ist und dass `serve => false` nichts anderes trifft.
- [ ] **Step 6: Commit**
```bash
git add tests/Feature/HostSeparationTest.php config/filesystems.php
git commit -m "Jede Route an einen Hostnamen, und storage/{path} zugemacht"
```
---
## Task 2: Der Konfigurationsweg für Neuinstallationen
**Files:**
- Modify: `deploy/install.sh` (Frageblock ~Zeile 118; `optional_env`-Definition
Zeilen 300312; Schreibblock ~Zeile 289295)
- Modify: `.env.example:218-239`
- Create: `tests/Feature/InstallerWritesHostsTest.php`
**Interfaces:**
- Consumes: nichts aus Task 1.
- Produces: nichts, was Task 3 braucht.
**Hintergrund:** `install.sh` fragt nach `APP_DOMAIN`, `WWW_DOMAIN`,
`STATUS_DOMAIN` und wirft die Antworten weg. Und es ruft `optional_env` in
Zeile 295 auf, obwohl die Funktion erst in Zeile 308 definiert wird — bei
`set -euo pipefail` ist das Exit 127, **jede Neuinstallation bricht dort ab**.
- [ ] **Step 1: Den Test schreiben**
Datei `tests/Feature/InstallerWritesHostsTest.php`:
```php
<?php
use Symfony\Component\Process\Process;
/**
* Der Installer fragte nach den Hostnamen und schrieb sie nicht.
*
* Und er rief `optional_env` dreizehn Zeilen vor ihrer Definition auf. In bash
* ist eine Funktion erst ab ihrer Definition bekannt; unter `set -euo pipefail`
* ist der Aufruf davor ein Exit 127, der die Installation beendet. Das ist der
* Fehler, der hier festgehalten wird — nicht die Schreibweise.
*/
function installerSource(): string
{
return file_get_contents(base_path('deploy/install.sh'));
}
it('ist syntaktisch gültiges bash', function () {
$process = new Process(['bash', '-n', base_path('deploy/install.sh')]);
$process->run();
expect($process->getExitCode())->toBe(0, $process->getErrorOutput());
});
it('definiert jede Hilfsfunktion, bevor sie gebraucht wird', function () {
$source = installerSource();
foreach (['set_env', 'optional_env'] as $function) {
$definedAt = strpos($source, " {$function}() {");
expect($definedAt)->not->toBeFalse("{$function}() wird nicht mehr definiert.");
// Der erste Aufruf ist der erste Treffer, der KEINE Definition ist.
$calledAt = preg_match(
'/^\s+'.preg_quote($function, '/').' [A-Z]/m',
$source,
$matches,
PREG_OFFSET_CAPTURE,
) ? $matches[0][1] : false;
expect($calledAt)->not->toBeFalse("{$function} wird nirgends aufgerufen.");
expect($definedAt)->toBeLessThan(
$calledAt,
"{$function}() wird vor seiner Definition aufgerufen — unter set -e ist das Exit 127.",
);
}
});
it('schreibt jeden Hostnamen, nach dem es fragt', function () {
$source = installerSource();
// Gefragt wird nach allen fünf …
foreach (['APP_DOMAIN', 'WWW_DOMAIN', 'STATUS_DOMAIN', 'FILES_DOMAIN', 'ADMIN_DOMAIN'] as $asked) {
expect($source)->toMatch('/^ask\s+'.$asked.'\s/m', "Es wird nicht mehr nach {$asked} gefragt.");
}
// … und jede Antwort landet auch in der .env. Genau das fehlte: die Fragen
// standen da, die Werte wurden nie geschrieben, und die Trennung war
// deshalb auf keiner Installation eingeschaltet.
foreach (['APP_HOST', 'SITE_HOST', 'STATUS_HOST', 'FILES_HOST'] as $written) {
expect($source)->toMatch(
'/^\s+(set_env|optional_env)\s+'.$written.'\s/m',
"{$written} wird nicht in die .env geschrieben.",
);
}
});
```
- [ ] **Step 2: Den Test laufen lassen und den Fehlschlag ansehen**
Run: `docker exec clupilot-app-1 php artisan test --filter=InstallerWritesHosts`
Expected: „ist syntaktisch gültiges bash" grün; die beiden anderen **rot**
`optional_env` wird vor der Definition aufgerufen, und `APP_HOST`/`SITE_HOST`/
`FILES_HOST` werden nicht geschrieben.
- [ ] **Step 3: `optional_env` nach oben ziehen**
In `deploy/install.sh` **nach Inhalt** arbeiten, nicht nach Zeilennummer — die
verschiebt sich beim Bearbeiten. Zu entfernen ist der zusammenhängende Block aus
den **fünf** Kommentarzeilen ab „`# An \`if\`, not \`[[ … ]] && …\`:`" bis
einschließlich der schließenden Klammer von `optional_env()` (heute Zeilen
303312).
Achtung, was **stehen bleibt**: die beiden Zeilen unmittelbar davor
(„`# Supplied through --env-file; …`" / „`# install can happen before the Stripe
account exists.`") beschreiben die *Aufrufe* darunter, nicht die Definition. Die
beiden Zeilen davor („`# The container user is mapped …`") gehören zu
`HOST_UID`/`HOST_GID` weiter unten. Beide Paare bleiben, wo sie sind.
Den entfernten Block direkt **hinter** die schließende Klammer von `set_env()`
setzen (dort endet die Funktion mit ` }` gefolgt von einer Leerzeile):
```bash
# An `if`, not `[[ … ]] && …`: the && form makes the function return 1 for
# every value that is absent, and under `set -e` that ends the install.
# Absent is the NORMAL case here — these are the optional ones — so the
# script killed itself on the first installation that did not happen to
# supply a Hetzner token.
#
# Und die Definition steht VOR dem ersten Aufruf. Sie stand einmal dreizehn
# Zeilen dahinter: in bash ist eine Funktion erst ab ihrer Definition
# bekannt, der Aufruf davor ist ein Exit 127, und `set -euo pipefail` macht
# daraus das Ende der Installation. Jede Neuinstallation starb an
# `optional_env STATUS_HOST`.
optional_env() {
if [[ -n "${2:-}" ]]; then
set_env "$1" "$2"
fi
}
```
- [ ] **Step 4: Nach dem Dateinamen fragen**
Direkt hinter die `ask STATUS_DOMAIN`-Zeile (Zeile 118):
```bash
ask FILES_DOMAIN "Domain for downloads: terms, DPA, bootstrap archive (blank to keep them on the portal)" "files.clupilot.com"
```
- [ ] **Step 5: Die Hostnamen schreiben**
Die Zeile `optional_env STATUS_HOST "$STATUS_DOMAIN"` ersetzen durch:
```bash
# Die Antworten von oben landen HIER, und das ist der ganze Unterschied
# zwischen einer getrennten Installation und einer, auf der Website und
# Portal einander bedienen. Ohne diese vier Zeilen laufen beide
# Route::domain()-Gruppen host-unabhängig und antworten überall — der
# Betreiber erreicht dann die Website unter app. und die Anmeldung unter
# www., und nichts an der Konfiguration deutet darauf hin, warum.
set_env APP_HOST "$APP_DOMAIN"
set_env SITE_HOST "$WWW_DOMAIN"
optional_env STATUS_HOST "$STATUS_DOMAIN"
optional_env FILES_HOST "$FILES_DOMAIN"
```
- [ ] **Step 6: Test laufen lassen**
Run: `docker exec clupilot-app-1 php artisan test --filter=InstallerWritesHosts`
Expected: PASS, drei Tests.
- [ ] **Step 7: `.env.example` sortieren**
Die Zeilen 218239 vollständig ersetzen. Heute steht der `FILES_HOST`-Absatz
mitten im `APP_HOST`-Text, der `SITE_HOST`-Absatz bricht mitten im Satz ab
(„Leer heisst: Startseite ueberall,"), und `STATUS_HOST` fehlt ganz — obwohl
`install.sh` danach fragt und `config/admin_access.php` es liest.
```
# APP_HOST: Hostname des Kundenportals, z. B. app.clupilot.com. Gesetzt, ist
# JEDE Portal-Route an diesen Host gebunden und existiert nirgends sonst —
# www.clupilot.com/dashboard ist dann ein 404, kein funktionierender Aufruf.
# Das schliesst die Anmeldung ein: Fortify bindet seine eigenen Routen ueber
# denselben Wert (config/fortify.php).
# Leer = Portal antwortet ueberall (Vorgabe, und was jede dev-Maschine ueber
# eine blanke IP braucht).
APP_HOST=
# SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die
# Startseite NUR dort — jeder andere Host (das Portal, eine blanke IP) zeigt
# unter "/" die Anmeldung bzw. das Dashboard. Leer heisst: Startseite ueberall,
# auch auf dem Portal-Hostnamen.
# Mehrere Namen kommagetrennt, der ERSTE ist der kanonische — er liefert aus,
# alle weiteren leiten dauerhaft dorthin um (Pfad und Query bleiben erhalten):
# SITE_HOST=www.clupilot.com,clupilot.com
# Gebunden sind Startseite, robots.txt, Kontakt und die Legal-Seiten — dadurch
# erzeugt route('legal.impressum') auch aus einer Mail heraus eine www-Adresse,
# wo es keine Anfrage gibt, aus der man einen Hostnamen nehmen koennte.
SITE_HOST=
# STATUS_HOST: Hostname der Statusseite, z. B. status.clupilot.com. Gesetzt,
# liegt sie an der WURZEL dieses Namens, und /status leitet von jedem anderen
# Host dorthin um statt zu 404en — eine Statusseite ist die Adresse, die in
# einem Lesezeichen steht, wenn ohnehin schon etwas kaputt ist.
# Leer = /status antwortet auf jedem Host.
STATUS_HOST=
# FILES_HOST: Hostname fuer Dateien zum Herunterladen — AGB, AV, TOM
# (oeffentlich, dauerhaft zitierbar, weil diese Adressen in Vertraegen stehen)
# und das Bootstrap-Archiv (nur mit gueltigem Einmal-Code).
# Leer = das Archiv bleibt auf dem Portal-Hostnamen, damit keine bereits
# ausgegebene Befehlszeile bricht, solange der DNS-Eintrag noch nicht steht.
FILES_HOST=
```
Wichtig: **keine** `set -e`-Falle bauen — die Werte bleiben leer, weil eine
Entwicklungsmaschine über eine blanke IP erreicht wird und dort nichts gebunden
sein darf.
- [ ] **Step 8: Den vollen Testlauf anstoßen und commiten**
Run: `docker exec clupilot-app-1 php artisan test --filter="InstallerWritesHosts|HostSeparation"`
Expected: PASS.
```bash
git add deploy/install.sh .env.example tests/Feature/InstallerWritesHostsTest.php
git commit -m "Der Installer schreibt die Hostnamen, nach denen er fragt"
```
---
## Task 3: `clupilot:bind-hosts` für den Bestand
**Files:**
- Create: `app/Console/Commands/BindHosts.php`
- Create: `tests/Feature/BindHostsCommandTest.php`
**Interfaces:**
- Consumes: `App\Services\Env\EnvFileEditor``path(): string`,
`read(): string`, `isValid(string): bool`,
`write(string $content): string` (gibt den Sicherungspfad zurück, validiert
und sichert vor dem Schreiben, wirft `InvalidEnvContentException`).
- Produces: Befehl `clupilot:bind-hosts`.
**Warum es diesen Befehl gibt:** `install.sh` schreibt `.env` **nur** bei einer
neuen Installation („Keeping the existing .env"). Die laufende Maschine bekommt
die vier Zeilen also nie.
**Die eine Regel, die festzulegen ist:** Ein Schlüssel mit **leerem** Wert gilt
als fehlend und wird gefüllt (`.env.example` liefert `APP_HOST=` leer aus). Ein
Schlüssel mit nicht-leerem Wert gilt als gesetzt und wird **nie** überschrieben.
- [ ] **Step 1: Den Test schreiben**
Datei `tests/Feature/BindHostsCommandTest.php`:
```php
<?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();
});
```
- [ ] **Step 2: Den Test laufen lassen und den Fehlschlag ansehen**
Run: `docker exec clupilot-app-1 php artisan test --filter=BindHostsCommand`
Expected: FAIL — „The command 'clupilot:bind-hosts' does not exist."
- [ ] **Step 3: Den Befehl schreiben**
Datei `app/Console/Commands/BindHosts.php`:
```php
<?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;
}
}
```
- [ ] **Step 4: Test laufen lassen**
Run: `docker exec clupilot-app-1 php artisan test --filter=BindHostsCommand`
Expected: PASS, sechs Tests.
- [ ] **Step 5: Commit**
```bash
git add app/Console/Commands/BindHosts.php tests/Feature/BindHostsCommandTest.php
git commit -m "clupilot:bind-hosts traegt die Hostnamen in eine bestehende .env nach"
```
---
## Task 4: Voller Testlauf und Release
**Files:**
- Modify: `VERSION`
- [ ] **Step 1: Voller Testlauf**
Run: `docker exec clupilot-app-1 php artisan test`
Expected: PASS, alles.
Bei Fehlschlägen außerhalb der neuen Dateien: **STOP** und melden, nicht
nebenbei reparieren.
- [ ] **Step 2: Die Messung gegen das Abnahmekriterium**
Run:
```bash
docker exec -e APP_HOST=app.dev.clupilot.com -e SITE_HOST=www.dev.clupilot.com -e STATUS_HOST=status.dev.clupilot.com -e FILES_HOST=files.dev.clupilot.com clupilot-app-1 php artisan route:list --json | python3 -c "import json,sys; r=json.load(sys.stdin); u=[x for x in r if not x.get('domain')]; print(len(r),'Routen,',len(u),'ohne Domain'); [print(' ',x['uri']) for x in u]"
```
Expected: `10 ohne Domain`, und die zehn sind genau die Liste aus
`HostSeparationTest`. Das ist Abnahmekriterium 5, mit dem Werkzeug gemessen,
das das Kriterium nennt.
- [ ] **Step 3: Den höchsten Tag prüfen — VOR dem Versionssprung**
Run:
```bash
git fetch --tags && git tag -l 'v*' --sort=-v:refname | head -3 && pwd && git branch --show-current
```
Es laufen mehrere Sitzungen an diesem Repo. Liegt der höchste Tag über `v1.8.0`,
wird die neue Version entsprechend höher gewählt — ein Release unter dem
höchsten Tag liefert der Update-Agent nie aus.
- [ ] **Step 4: VERSION setzen und commiten**
`VERSION` auf `1.9.0` (bzw. eine Minor über dem höchsten gefundenen Tag).
```bash
git add VERSION
git commit -m "Version 1.9.0 — jede Route gehoert zu einem Hostnamen"
```
- [ ] **Step 5: Nach main bringen, taggen, pushen**
`deploy/update.sh` folgt in Zweigbetrieb `BRANCH=main` (Zeile 55), und `v1.8.0`
liegt auf `main`. Der Tag gehört also auf `main`, nicht auf den Arbeitszweig.
```bash
git switch main && git merge --no-ff claude/eager-elion-c581f4 -m "Hostnamen-Trennung eingeschaltet und festgehalten"
```
Dann prüfen, dass der Testlauf auf `main` grün ist, danach:
```bash
git tag -a v1.9.0 -m "Jede Route gehoert zu genau einem Hostnamen" && git push origin main && git push origin v1.9.0
```
- [ ] **Step 6: Dem Betreiber sagen, was außerhalb des Repos zu tun ist**
Zusammenfassen, nicht ausführen:
1. `php artisan clupilot:bind-hosts` auf der Maschine — trägt die vier
Schlüssel nach und legt vorher eine Sicherung an.
2. **Vorher** DNS und Caddy-Blöcke für die gewählten Namen. Ohne sie ist der
jeweilige Bereich danach nicht erreichbar.
3. `php artisan config:cache && php artisan route:cache`.
---
## Self-Review
**Spec-Abdeckung**
| Entwurfsabschnitt | Task |
|---|---|
| 1. `HostSeparationTest` | Task 1 |
| 2. `storage/{path}` schließen | Task 1, Step 3 |
| 3. `deploy/install.sh` | Task 2, Steps 35 |
| 4. `clupilot:bind-hosts` | Task 3 |
| 5. `.env.example` | Task 2, Step 7 |
| Abnahme 13 (vorhandene Tests) | Task 4, Step 1 |
| Abnahme 4 (Anmeldung auf `app.`) | Task 1, Test 2 |
| Abnahme 5 (`route:list`) | Task 1, Test 1 + Task 4, Step 2 |
| Abnahme 6 (voller Lauf) | Task 4, Step 1 |
| Nicht anfassen | Global Constraints |
**Platzhalter:** keine. Jeder Codeblock ist vollständig; der Testcode aus Task 1
ist im Container gelaufen (123 Routen, 12 ungebunden, Original-App danach
lesend und schreibend intakt).
**Typkonsistenz:** `hostSeparationTable()` liefert
`['uri' => string, 'domain' => ?string, 'name' => ?string]`; `hostSeparationByName()`
schlüsselt dieselbe Struktur nach Namen. `EnvFileEditor::write()` gibt den
Sicherungspfad als `string` zurück — so in Task 3 verwendet. Der Konstruktor
nimmt den Pfad als erstes Argument, wie im Test gebunden.