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
parent
295444f432
commit
5a2e6564db
|
|
@ -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 218–239 — `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 300–312; Schreibblock ~Zeile 289–295)
|
||||
- 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
|
||||
303–312).
|
||||
|
||||
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 218–239 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 3–5 |
|
||||
| 4. `clupilot:bind-hosts` | Task 3 |
|
||||
| 5. `.env.example` | Task 2, Step 7 |
|
||||
| Abnahme 1–3 (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.
|
||||
Loading…
Reference in New Issue