Commit Graph

713 Commits (7e215b9c50aa56e68fc1fff3db83faf08f5230ad)

Author SHA1 Message Date
nexxo 7e215b9c50 Die Website hatte gar kein Alpine: elf x-show taten nichts, darunter der Preis-Umschalter 2026-08-01 18:13:55 +02:00
nexxo b95cae1669 Test für den Normalfall der Statusanzeige: "Im Verkauf"
Drei der vier Zustände waren belegt, der häufigste nicht. Eine reine
assertSee() wäre nicht falsifizierbar gewesen: die Spaltenüberschrift
"plans.live_version" übersetzt zufällig auf denselben Satz wie die Plakette
"plans.on_sale" und stünde selbst dann auf der Seite, wenn keine Zeile die
Plakette zeigte — deshalb über die Häufigkeit geprüft (Überschrift kommt genau
einmal vor, mehr als das beweist eine echte Zeile). Mit einer Mutation
(sellable-Prüfung stumpf auf false) probeweise als rot bestätigt, dann
zurückgesetzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:04:28 +02:00
nexxo 6314bb60fb Schalter je Paket, ehrliche Statusanzeige, Reste der alten Leiter
Die Umschaltmigration verglich beim Erkennen eines bereits umgeschalteten
Bestands nur Kontingent und Platte. Bei Intern waren beide schon vorher
richtig (5/20 GB), also hielt sie das Paket für erledigt und ließ RAM, Kerne
und Plätze auf ihren alten Werten stehen (1024 MB/1 Kern/5 statt 4096 MB/2/3).
Die Prüfung vergleicht jetzt alle neun Vorgaben; eine zweite, kleine Migration
hebt einen Bestand nach, auf dem die erste schon lief, und tut nichts auf einer
Neuinstallation.

Die Statusanzeige unterschied bisher nicht zwischen "kein Angebot" und "läuft,
aber nicht im Laden" — ein internes Paket zeigte "Nichts verfügbar" in der
Liste und "Im Verkauf" auf der Versionsseite darunter. Vier Zustände statt
zwei, mit fester Reihenfolge: der Notausschalter (sales_enabled) schlägt die
Konsolen-Kennzeichnung (internal).

Ein zweiter Schalter je Paketfamilie nimmt sie aus dem Preisblatt, ohne sie
unverkäuflich zu machen — nach dem Vorbild des vorhandenen Verkaufsschalters,
ohne Bestätigungsmodal, weil reversibel. Enterprise wechselt von
sales_enabled=false (weder käuflich noch verschenkbar) auf internal=true, wie
das Testpaket.

Dazu die liegengebliebenen Zahlen der alten Leiter in Produktattrappe,
Mail-Vorschau, Fabrik-Vorgaben und Seedern, sowie eine Testzusicherung, die auf
eine wandernde ID statt auf den berechneten Wert hätte treffen können.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:53:06 +02:00
nexxo f961205512 Die neue Leiter: Start 39, Team 79, Business 139
Zugeschnitten auf die Maschine, die dasteht. 388 GB vergebbar geteilt durch
40 GB Platte sind neun Startkunden statt drei — Speicher und RAM gehen damit
gleichzeitig aus, wo vorher sechs RAM-Plaetze verfielen. 351 Euro Umsatz je
Server statt 147.

Umgeschaltet wird durch Beenden und Nachfolgen, nicht durch Aendern: eine
veroeffentlichte Version ist unveraenderlich, und laufende Vertraege tragen
ihren eigenen Schnappschuss. Als Migration und nicht als Befehl, weil der
Katalog von einer Migration gesetzt wird — sonst blieben Neuinstallation und
Testsuite auf einer Leiter, die wir nicht mehr verkaufen.

Enterprise verlaesst den Verkauf (2000 GB finden auf 388 GB keinen Host), das
Testpaket heisst Intern und wird intern — es steht ausserdem zum ersten Mal im
Katalog einer frischen Installation, statt von Hand angelegt werden zu muessen.

Dreissig Testdateien nennen die neuen Zahlen. Drei Faelle waren keine Zahlen:
Vertraege auf Enterprise entstehen nur noch als Bestandsvertraege (Helfer
asGrandfathered() in tests/Pest.php), die Preisblatt-Stufe "Premium" haengt am
Paket, das den Laden verlassen hat, und ein Katalogleser, der die einzige
Preiszeile einer Familie suchte, findet seit der Handreichung zwei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:05:58 +02:00
nexxo f8d0c2c353 Merge branch 'claude/angry-golick-187172' into feat/neue-pakete 2026-08-01 16:27:13 +02:00
nexxo b4eadbfba8 Merge branch 'main' into feat/neue-pakete 2026-08-01 16:27:13 +02:00
nexxo ce7d0426cb Fix-Runde: preferredDatacenter() blind fuer Reservierung, Loeschen hob sie auf
preferredDatacenter() waehlte das Rechenzentrum ueber Host::availableGb() ohne
->unreserved() - ein Rechenzentrum mit einem grossen, aber komplett
reservierten Host sah geraeumiger aus als eines mit echtem allgemeinem
Bestand. Der Checkout haette die Bestellung dorthin gelegt, placeableIn()
haette dort niemanden gefunden, und sie waere geparkt, obwohl anderswo Platz
war. Jetzt ->unreserved(), derselbe Bestand wie largestPlaceableGb().

Die Migration nutzte nullOnDelete() und tat damit das Gegenteil der eigenen
Vorgabe: die Reservierung sollte einen Kundenaustritt nicht stillschweigend
ueberleben, loeste sich mit nullOnDelete() aber genau so auf, sobald der
Kunde verschwindet. restrictOnDelete() macht das Loeschen eines Kunden mit
eigener Maschine zum Fehler, bis ein Operator die Reservierung von Hand
gelöst hat - Migration und Modellkommentar sagen jetzt dasselbe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:21:46 +02:00
nexxo 3df7434f7a Fix-Runde 1/1: Enterprise-Schwelle folgt dem Katalog statt einer festen Zahl
Zwei Betreiber-Entscheidungen aus der Durchsicht:

1. Keine zweite, feste Untergrenze mehr im Fliesstext ("ab 500 GB") -- die
   eigene Maschine beginnt dort, wo das groesste Paket endet, ihre Groesse ist
   Hardware-Frage einer Angebotsanfrage, kein zweiter Wert auf dem Preisblatt.
2. Die Zahl in der Ueberschrift ("Mehr als :quota?") kommt jetzt aus $plans --
   derselben Liste, die baseline() und comparison() schon lesen, statt fest im
   Sprachtext zu stehen. Eine spaetere Umschaltung der Paketleiter (Task 9)
   aendert sonst, was "am groessten" ist, und der Satz haette es nicht gemerkt.

max() auf einer leeren Plan-Liste (Katalog nicht lesbar) waere ein Fatal
gewesen -- enterprise() gibt in diesem Fall jetzt null zurueck, mit Test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:15:59 +02:00
nexxo 4ad1828f27 Ein reservierter Host gehoert seinem Kunden
placeableIn() nahm jeden aktiven Host im Rechenzentrum, und hasRoomFor() sowie
largestPlaceableGb() zaehlten eine exklusiv verkaufte Maschine obendrein mit —
der Shop versprach damit Platz, der bereits vergeben war. Ohne diese Markierung
ist 'eigener Server' ein Satz im Angebot und nirgends eine Tatsache.

hosts.reserved_for_customer_id (nullable, ueberlebt den Kunden) markiert die
Maschine; Host::placeableIn() und HostCapacity zaehlen sie nur noch fuer den
eigenen Mieter oder gar nicht mehr zum allgemeinen Bestand. Auf der
Host-Detailseite kann ein Operator reservieren und wieder loesen — Loesen
laeuft ueber ein eigenes Bestaetigungsmodal (R23), das selbst nichts aendert,
sondern nur an HostDetail::releaseReservation() zurueckmeldet. Host-Liste und
Kapazitaetsseite weisen eine reservierte Maschine als solche aus, statt sie
kommentarlos verschwinden zu lassen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:06:14 +02:00
nexxo 1692fd3977 Enterprise wird angefragt, nicht ausgepreist
Ab 500 GB ist es eine eigene Maschine — im Shop faende placeableIn() auf 388 GB
vergebbarem Platz keinen Host, und der Kunde erfuehre das nach der Zahlung.
Der Anfrage-Block erscheint als vierter Bestandteil neben der
Vergleichstabelle, sobald PlanCatalogue::sellable() Enterprise nicht mehr
liefert; die Familie selbst bleibt bestehen, nur sales_enabled entscheidet
(gesetzt wird das erst in Task 9).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:48:41 +02:00
nexxo 3b5dee8f0d Ein Ausweg wird nur angeboten, wenn er auch buchbar ist
DowngradeCheck baute seine Empfehlung aus packsToCover(), und die rechnet
bloss. BookAddon lehnt aus ZWEI Gruenden ab - der Deckel bei drei Bloecken
und der Ausschluss von Enterprise -, und die Empfehlung kannte keinen davon.
Solange ein Block 100 GB brachte, war das unerreichbar; seit er 20 GB bringt,
landet jede Ueberschreitung ueber 60 GB dort.

Business -> Team mit 600 GB belegt bot "5 x Zusatzspeicher buchen (+100 GB)"
an. Das Modal klemmte still auf drei, den vierten haette BookAddon abgelehnt,
und der Kunde stand nach 45 Euro im Monat genau dort, wo er vorher stand - auf
der Karte, die sein Abo billiger machen sollte. Enterprise -> Business empfahl
Bloecke, die es fuer dieses Paket ueberhaupt nicht gibt.

AddonCatalogue::bookableQuantity() antwortet jetzt auf beide Gruende. Es gab
einem Enterprise-Vertrag "3" auf die Frage, die sein eigener Docblock stellt.

DowngradeCheck stellt diese eine Frage und klemmt daran: `packs` ist die
gebrauchte Zahl nur, wenn der Vertrag sie auch buchen darf, sonst null - und
dann traegt `short`, was nach allen buchbaren Bloecken uebrig bliebe, gemessen
an DEREN Restmenge statt am Deckel, damit ein Kunde mit einem Block nicht mehr
zu loeschen bekommt als noetig. Kein halbes Angebot: eine Dauerbuchung, die
den Wechsel trotzdem nicht freigibt, ist kein Ausweg, sondern der ausgegraute
Knopf mit Preisschild.

packsToCover() bleibt reine Arithmetik, mit einem Kommentar, der sagt warum:
die kaufmaennische Grenze steht in AddonCatalogue, und ein Klemmen an dieser
Stelle zoege eine Vertragsabfrage in jeden Kontingent-Schritt und ins Portal,
die beide keine Verkaufsfrage stellen.

Der Knopf haengt schon an `packs > 0` und verschwindet von selbst; der Satz
wechselt auf downgrade_escape.capped, der die verbleibende Luecke nennt und
nicht den Grund - "hoechstens drei Bloecke" waere im Enterprise-Fall falsch,
wo es gar keine gibt.

Zwei bestehende Tests hingen an 600 und 800 GB aus der 100-GB-Zeit, beide
inzwischen nicht mehr deckbar; einer haette das Modal gesucht, das die Karte
zu Recht nicht mehr oeffnet. Auf 550 und 560 GB umgestellt, wo sie das pruefen,
wofuer sie geschrieben wurden.

Dazu ein Test, der belegt statt annimmt, warum das max(1, min(...)) in
ConfirmBookStorage stehen bleiben darf: ein Modal ist per openModal direkt
erreichbar, aber bookStoragePacks() legt weder am Deckel noch bei Enterprise
eine Bestellzeile an. Die bestehenden Tests deckten purchase() ab, nicht diese
Weiterleitung.

Voller Testlauf: 2405 bestanden. Pint sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:40:26 +02:00
nexxo e643769be2 Fix-Runde 3: Wiederholbarkeit ehrlich gemacht, Seeder-Zaehler darf nicht sinken
Zwei Befunde, die die letzte Fix-Runde selbst eingefuehrt hat:

- Der Kommentar ueber dem dns_name-Block behauptete, ein Fehlschlag am Riegel
  liesse next_host_number schon da, dns_name aber noch stehen - verkehrt
  herum, dropColumn laeuft VOR dem Riegel. Scheitert also ausgerechnet
  unique('name'), ist dns_name schon weg, und ein zweiter Anlauf starb an der
  Vorabpruefung mit "Unknown column 'dns_name'" statt an der Stelle, die der
  Kommentar nannte. $hadDnsName wird jetzt einmal ermittelt und bindet
  Vorabpruefung, Uebertragung und den Spalten-Block an dasselbe Urteil -
  faellt dns_name schon in einem frueheren Anlauf, prueft die Vorabpruefung
  direkt auf name statt auf den nicht mehr vorhandenen CASE-Ausdruck, und
  meldet Doppelte weiterhin sauber statt sie stillschweigend durchzulassen.
  Kommentar korrigiert; die verbleibende (sehr schmale) Grenze - gelingt
  unique('name'), scheitert nur noch die DELETE-Zeile danach - ehrlich als
  offen benannt statt verschwiegen oder ungeprueft behauptet zu sein.

- DatabaseSeeder schrieb next_host_number=2 durch den Update-Teil von
  updateOrCreate und drehte damit den Zaehler bei jedem Re-Seed einer
  Installation zurueck, die laengst weiterzaehlte - genau die Wieder-
  verwendung, gegen die dieser Umbau gebaut wurde. Jetzt max(vorhanden, 2):
  frisch angelegt hebt es auf 2, bestand die Zeile schon und zaehlte hoeher,
  bleibt sie stehen.

Beide Fixe gegen echtes MariaDB auf eigenen Wegwerf-Datenbanken geprueft
(drei Migrationslaeufe fuer den ersten Befund, zwei Saatlaeufe fuer den
zweiten), nicht auf der geteilten Entwicklungsdatenbank. Gezielte Testlaeufe
(173 + 21 bestanden) statt der vollen Suite, wie vorgegeben. Bericht
angehaengt an .superpowers/sdd/2026-08-01-hostname-vergabe/final-fix-report.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:33:31 +02:00
nexxo c3c05ff9f8 Interne Pakete verschwinden aus dem Laden und bleiben in der Konsole
sales_enabled waere der falsche Hebel gewesen: es schaltet auch das
Verschenken ab. Das Testpaket ist fuer Abnahmelaeufe da und muss verschenkbar
bleiben, also zwei Felder fuer zwei Fragen. Der Checkout lehnt einen internen
Schluessel ausdruecklich ab, ueber denselben Fang wie einen unbekannten — eine
URL ist keine Liste, und die Antwort darf keinen Unterschied verraten.

GrantPlan las bislang dieselbe sellable()-Liste wie Preisblatt und Warenkorb,
sowohl fuer sein Dropdown als auch fuer die Validierung des Formularfelds —
ein interner Schluessel waere dort ebenso abgelehnt worden wie im Checkout,
und das Verschenken haette sein einziges Tor verloren. PlanCatalogue bekommt
deshalb grantable() als zweiten Leser derselben Abfrage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:31:46 +02:00
nexxo afa6183903 Zusatzspeicher: 20 GB fuer 15 Euro, hoechstens drei, nicht bei Enterprise
100 GB fuer 10 Euro waren 0,10 Euro/GB — unter Einstandspreis und billiger als
jeder Aufstieg. Wer stapelt, belegt dann den knappsten Rohstoff zum niedrigsten
Preis. 0,75 Euro/GB liegen ueber beiden Aufstiegen (0,73 und 0,67), und der
Deckel bei drei liegt dort, wo Aufsteigen billiger UND besser wird.

BookAddon haelt den Ausschluss durch AddonCatalogue::availabilityRefusal()
(gleiches Muster wie CustomDomainAccess), damit greift er auch beim
Verschenken durch den Betreiber. Billing::purchase() und storageLimitNote
fragen dieselbe Regel VOR der Zahlung, sonst haette ein Enterprise-Kunde einen
zahlbaren Auftrag anlegen koennen, den BookAddon erst danach abgelehnt haette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:09:02 +02:00
nexxo 4cd848eead Fix-Runde 2: Warnung fuer nicht reparierte Hosts, case-sichere Vorabpruefung, wiederholbare Migration
Vier Befunde aus dem Abschluss-Review ueber d62a2c8/c815aee/11ba7ee:

- Die Migration renamt nur Hosts mit dns_name; pve-fsn-1/pve-hel-1 blieben
  unrepariert und stumm. Sie werden jetzt gesammelt und gemeldet (Log +
  Konsole), ohne die Migration abzubrechen - diese Hosts laufen weiter.
- Die Vorabpruefung verglich in PHP byteweise, die Spalte liegt auf
  utf8mb4_unicode_ci. Umgestellt auf GROUP BY/HAVING in SQL, damit dieselbe
  Kollation entscheidet, die spaeter den Unique-Index baut.
- Ein Fehlschlag nach der Vorabpruefung liess einen zweiten Anlauf sofort an
  "Duplicate column name" sterben. Die beiden betroffenen Schema-Schritte
  stehen jetzt hinter Schema::hasColumn(), macht den Kommentar darueber wahr.
- Seeder (DatabaseSeeder, DemoCustomerSeeder) sind auf pve-*-Namen sitzen
  geblieben, weil sie dns_name nie benutzt hatten. Auf fsn-01/hel-01
  umgestellt, next_host_number entsprechend vorbelegt.

Dazu vier Kleinigkeiten: ein Test nagelte den falschen Config-Schluessel fest
(dns.zone statt platform_zone), HostName::claim() erzwingt jetzt wirklich
eine Transaktion statt es nur zu verlangen, down() vergisst nicht mehr den
Settings-Cache, und der Kommentar ueber HostName::free() nennt jetzt ehrlich
die Einschraenkung auf einen einzelnen Thread.

Alle vier Migrationslaeufe (Vorabpruefung-Kollision, Meldung fuer
unreparierte Hosts, Fehlschlag-und-erneuter-Anlauf, Rueckbau mit
Cache-Invalidierung) gegen echtes MariaDB auf einer Scratch-Datenbank
geprueft, um die parallele Billing-Session nicht zu beruehren. Voller
Testlauf: 2395 bestanden. Bericht mit allen Befehlen und Ausgaben unter
.superpowers/sdd/2026-08-01-hostname-vergabe/final-fix-report.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:52:15 +02:00
nexxo d6f2aa1c9e Fix-Runde 1: Formel-Test raus, eigene Testhilfe, Docblock nachgezogen
Der Formel-Test rechnete dieselbe Formel nach und wich dabei vom echten Code
ab (kein max(0, ...) um den neuen Summanden) — ein Test, der die Implementierung
nachrechnet und dabei abweicht, ist schlechter als keiner. Der Verhaltenstest
deckt die Anforderung bereits ab und bleibt als einziger Test der Datei.

StoragePackHeadroomTest.php hing außerdem an reservedRun() aus
CustomerStepsTest.php und brach einzeln gefahren mit einem PHP-Fatal ab, statt
mit einem ehrlichen Fehlschlag. Eigene, in sich geschlossene Hilfsfunktion
(packHeadroomRun()) nach dem Muster der Nachbardateien (hostRun(),
restartableInstance()) — die Datei läuft jetzt auch allein grün.

Der Docblock von growDisk() sprach noch vom Kopfraum, der "unchanged"
mitfährt, und widersprach damit dem neuen Summanden direkt darunter. Nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:41:39 +02:00
nexxo 3ed80a05e0 Ein Block bringt seinen eigenen Kopfraum mit
Ein Block gibt 20 GB und belegt 22. Ohne das haette ein Start mit drei Bloecken
90 GB auf 100 GB Platte gehabt — zehn Gigabyte Kopfraum, wo die Regel bei dieser
Plattengroesse zwoelf verlangt, und der gestapelte Tarif waere genau der Fall
geworden, den die Regel verhindern soll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:31:52 +02:00
nexxo af05ed6694 Kapazitaetspruefung hinter den Idempotenz-Kurzschluss verschoben
Durchsicht (R22): die Pruefung stand in __invoke(), vor book()s Kurzschluss
fuer einen wiederholt zugestellten Webhook ("Idempotent against a retried
webhook"). Damit fragte jede Wiederholung erneut "passt NOCH ein Block
drauf" - obwohl keiner hinzukommt - und ein laengst bezahlter, laengst
gebuchter Vorgang quittierte die Wiederholung mit einem Fehler, sobald der
Host zwischenzeitlich eng geworden war. Genau das Szenario, fuer das diese
Aufgabe gebaut wurde, nur gegen den eigenen Kunden gerichtet.

Jetzt sitzt die Pruefung in book(), hinter dem order_id+addon_key-Kurzschluss:
eine Wiederholung bekommt ihre bestehende Buchung zurueck, ohne die Frage
erneut zu stellen. Eine echte neue Buchung durchlaeuft die Pruefung wie
zuvor. Deckel (quantityRefusal) und Domain-Ausschluss bleiben unangetastet -
sie haben dasselbe Muster im Kleinen, sind aber nicht Gegenstand dieses
Befundes.

Neuer Test: derselbe Auftrag wird zweimal gebucht, der Host wird zwischen
den beiden Aufrufen eng - der zweite Aufruf gibt die vorhandene Buchung
zurueck statt zu werfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:18:01 +02:00
nexxo 11ba7ee34f Hostdetailseite: die Adresse steht neben den IPs und ist ein Klick zur Proxmox-Oberflaeche 2026-08-01 14:12:03 +02:00
nexxo c815aee0b5 Fix-Runde 1: Vorabpruefung vor dem Unique-Index, zwei Praezisierungen, ein fehlender Test
Migration: die Reihenfolge in up() stellte den unwiderruflichsten Schritt
(app_settings loeschen, dns_name-Spalte weg) VOR den einzigen Schritt, der an
vorhandenen Daten scheitern kann (unique('name') - hosts.name trug noch nie
einen eindeutigen Index). Schlug der auf MariaDB fehl, war der Schaden nicht
mehr rueckgaengig zu machen: DDL committet dort implizit, die Migration gilt
aber mangels Eintrag in der Migrationstabelle als nicht gelaufen, und ein
zweiter up()-Versuch stirbt an der bereits fehlenden dns_name-Spalte. Jetzt
steht eine reine Vorabpruefung ganz am Anfang, die simuliert, was die
Uebertragung schreiben wuerde, und mit einer RuntimeException abbricht, bevor
irgendetwas angefasst ist; das Loeschen der app_settings-Zeilen steht jetzt
hinter dem Unique-Index, nicht davor. Gegen echtes MariaDB geprueft,
einschliesslich eines Laufs mit zwei absichtlich kollidierenden Hostnamen.

HostStepsTest: Titel und ein Kommentar praezisiert - der Schritt vergibt den
Namen nicht mehr, er veroeffentlicht ihn nur noch.

HostNamingTest: ungenutzten Import entfernt (Pint), Rueckfall-Test fuer
HostName::label() bei einem Code ohne gueltige Zeichen ergaenzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:03:35 +02:00
nexxo 01bcc29d13 Ein Block wird nur gebucht, wenn der Host ihn tragen kann
HostCapacity wurde im Checkout, in der Bestellung und im Konsolenbereich
gefragt — beim Aufstocken nicht. Weil qcow2 duenn belegt ist, gelingt die
Ueberbuchung sofort und faellt erst auf, wenn die Gaeste wirklich schreiben.
Gefragt wird der Host der Instanz, denn eine laufende Instanz zieht nicht um.

StorageAllowanceTest: der Host in storageFixture() war mit 1000 GB (active()
Standard) schon fuer eine reine Business-Instanz (1050 GB disk_gb) zu klein —
placeableIn() haette sie nie dort platziert. Ohne Kapazitaetspruefung fiel das
nie auf; jetzt schon, deshalb auf 2000 GB angehoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:03:07 +02:00
nexxo c6ead1afc9 Deckel haelt auch am Rand: Warenkorb und Bestaetigungsdialog
Zwei Luecken aus der Durchsicht von Task 2, beide am selben Rand: genau am
erreichten Deckel.

purchase() klemmte $lines mit max(1, min(..., bookableQuantity, ...)) — bei
bookableQuantity=0 floort das auf eine Bestellzeile statt auf keine, und der
Speicher-Knopf war bedingungslos gerendert. Jetzt bricht purchase() fuer
type=storage frueh ab, wenn AddonCatalogue::quantityRefusal() ablehnt, und der
Knopf verschwindet zugunsten des Ablehnungssatzes.

ConfirmBookStorage klemmte an der absoluten Grenze (3), nicht an der
Restmenge — ein Vertrag mit einem gebuchten Block haette drei weitere
versprochen bekommen, obwohl nur zwei noch buchbar sind. Das Modal loest jetzt
seinen eigenen Vertrag auf (wie ConfirmCancelAddon und ConfirmRevokeSeat) und
klemmt an bookableQuantity().

Beide Faelle waren ungetestet; zwei neue Tests in StoragePackLimitTest.php
belegen sie und wurden vor dem Fix gegen den alten Stand als rot verifiziert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:50:07 +02:00
nexxo d62a2c8ff8 Hostnamen vergibt CluPilot: ein Name statt zweier, und der Zaehler ueberlebt das Loeschen
HostName::claim() haengt den Namen jetzt an die Rechenzentrums-Zeile
(next_host_number), nicht an MAX(hosts.name)+1: der Zaehler ueberlebt so
das Loeschen des zuletzt angelegten Hosts. hosts.dns_name faellt weg -
name ist ab jetzt der einzige Name, den Konsole, DNS, /etc/hosts und
Proxmox-Node teilen. RegisterHostDns veroeffentlicht nur noch, was
StartHostOnboarding beim Anlegen vergeben hat, statt selbst zu
nummerieren; PrepareBaseSystem baut den FQDN ueber HostName::fqdn()
statt ueber die nirgends konfigurierte clupilot.net.

Zwei Testdateien ausserhalb der Aufgabenliste (DatacenterTest,
HostTakeoverPageTest) setzten ->set('name', ...) auf HostCreate, das
Feld jetzt aber nicht mehr hat - im vollen Testlauf nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:45:43 +02:00
nexxo 133b2cf552 Codex-Runde 2: der Gateway-Fix erreichte den Produktivpfad nicht
tests / pest (push) Failing after 9m29s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Zwei Befunde, beide echt, und der erste ist ein Patzer:
detect_network_style wurde auf default_gateway() umgestellt,
bridge-run.sh blieb auf awk '{ print  }'. Der Treiber reichte also
weiter den KARTENNAMEN als Gateway an build_bridge, und in der Strophe
stand 'gateway ens3' — der Fix half genau der Stelle nicht, fuer die er
gedacht war.

Meine Sandkiste konnte das nicht sehen: sie hatte immer ein via. Jetzt
ist sie parametrisiert, und ein Test faehrt bridge-run.sh mit einer
via-losen Standardroute durch und liest die geschriebene Strophe. Der
Beweis laeuft ueber den Produktivpfad, nicht ueber eine Einzelfunktion.

Zweitens: default_gateway suchte das erste via IRGENDWO in der Ausgabe,
detect_primary_interface das dev der ERSTEN Zeile. Bei zwei
Standardrouten baute das eine Bruecke ueber die Karte der einen mit dem
Gateway der anderen. Beide haengen jetzt an primary_default_route mit
head -1 — eine Route, eine Quelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:44:47 +02:00
nexxo 0bb61d39bd Codex-Runde 1: drei P1 an der Rueckfahrkarte, plus ein Gateway ohne via
Die Ruecknahme konnte Erfolg melden, ohne einen zu haben. Drei Wege
dorthin, alle behoben:

- Sie schaltete den entmachteten Netzverwalter nicht wieder ein. Die
  Sicherung umfasst nur /etc/network/interfaces*; kam die Verbindung von
  cloud-init, networkd oder NetworkManager, spielte die Ruecknahme eine
  Datei zurueck, die die Maschine nie getragen hat, und liess den
  Verwalter abgeschaltet. Genau der tote Host, den der Zeitgeber
  verhindern soll. disown_network_manager hinterlaesst jetzt eine Notiz
  (WAS entmachtet, WELCHE Strophen verdraengt), die das Ruecknahme-Skript
  beim Feuern liest — aufgeschrieben statt eingebacken, weil der
  Zeitgeber vor dem Entmachten gestellt wird.
- Sie setzte 'rolled-back' auch, wenn tar oder ifreload scheiterten. Das
  urspruengliche network.sh hatte dafuer set -e; beim Umbau ist es
  verlorengegangen. Jetzt bricht jeder Fehlschlag ab, bevor die Marke
  entsteht — CluPilot pollt dann bis zur Frist statt 'ist zurueck' zu
  glauben.
- Eine verdraengte interfaces.d-Strophe wurde nur umbenannt. Der Stern in
  'source interfaces.d/*' fasst sie weiter; das versteckte die Kollision
  vor dem Leser, statt sie zu loesen. Jetzt wandert sie aus dem
  Verzeichnis heraus, und die Ruecknahme holt sie zurueck.

Dazu ein eigener Fund: das Gateway wurde mit awk '{print }' gelesen.
Bei 'default dev ens3 scope link' ist das der KARTENNAME, woraus
'gateway ens3' in der Strophe wuerde. default_gateway() liest jetzt
hinter dem via, und eine Standardroute ohne via wandert als eigene
up-Zeile mit, statt verlorenzugehen.

Zurueckgewiesen: der P2 zu extra_routes. 'ip route show dev X' laesst das
dev-Feld WEG (im Container nachgemessen), das angehaengte 'dev vmbr0' ist
also richtig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:44:47 +02:00
nexxo 669045021a EnsureNetworkBridge in die Pipeline: nach dem Neustart, vor der Nachpruefung
Der Schritt haengt jetzt zwischen RebootIntoPveKernel und
ConfigureProxmox. Nach dem Neustart, weil dort ifupdown2 steht und der
Tunnel gerade bewiesen hat, dass er einen Neustart ueberlebt. Vor
ConfigureProxmox, dessen refuseWithoutBridge() unveraendert stehen bleibt
und damit zur Nachpruefung wird — bauen UND pruefen, dasselbe Paar wie
BuildVmTemplate -> VerifyVmTemplate.

Beschriftung in beiden Sprachen, und ein Test verlangt das kuenftig von
JEDEM Pipeline-Schritt statt nur vom neuen: wer einen einhaengt und die
Sprachdateien vergisst, faellt im Test auf statt in der Konsole.

bridge.sh und bridge-run.sh gehoeren ins Bootstrap-Archiv — es ist der
einzige Weg, auf dem das Skript auf eine nackte Maschine kommt.

Der End-to-End-Test lief rot, und zu Recht: sein Attrappen-Host sagte
nichts ueber sein Netz, also lehnte der neue Schritt ab. Das war der
Beweis, dass er wirklich haengt. Er bekommt jetzt einen Host, der seine
Bruecke schon hat — den Bau-Pfad kann eine Attrappe nicht nachstellen,
er lebt davon, dass die Verbindung abreisst und wiederkommt. Dafuer gibt
es die Schritt-Tests und die drei Sandkasten-Laeufe.

scriptBridgeState/scriptBridgeStatus liegen in tests/Pest.php, nicht in
einer einzelnen Testdatei — zwei Dateien brauchen sie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:44:47 +02:00
nexxo ba7e25da54 EnsureNetworkBridge: abbestellen nach der Nachpruefung, aufgeben ohne die Rueckfahrkarte wegzuwerfen
Drei Enden, und jedes hat eine Reihenfolge:

- Abbestellt wird erst, NACHDEM die Bruecke nachgeprueft ist. Dass der
  Zweig erreicht wird, ist der Beweis: SSH kam ueber den Tunnel an und
  vmbr0 traegt Standardroute und Adresse — bridge_proven, von aussen
  gefragt. Und nur, wenn DIESER Lauf den Zeitgeber auch gestellt hat;
  ohne Termin im Kontext gehoeren die Unit-Dateien jemand anderem.
- awaitRollback pollt, bis 'rolled-back' liegt, statt sofort zu
  scheitern. Solange der Zeitgeber aussteht, steckt die Maschine mitten
  in einer Umstellung, und ein fail() liesse sie dort liegen. Gedeckelt
  durch die Schritt-Frist, die das Dreifache der Zeitgeber-Frist ist.
- giveUp loescht den Termin (sonst wird jedes Retry nach einem
  Fehlschlag zum sofortigen zweiten Fehlschlag), behaelt bridge_attempts
  (sonst ist der Deckel von zwei Versuchen keiner) und bestellt den
  Zeitgeber NICHT ab — er ist die Rueckfahrkarte, und steht er noch, hat
  er seinen Grund.

Der sh-n-Test ist gegengeprobt: mit absichtlich kaputtem Quoting faellt
er.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:44:47 +02:00
nexxo 712422c161 EnsureNetworkBridge: Start abgekoppelt, und der Verbindungsabriss wird gepollt statt gezaehlt
Die tragende Zeile ist das try/catch um keyLogin. RunRunner:102
verwandelt jede geworfene Ausnahme in ein retry(), und retry verbraucht
das Versuchskonto — ungefangen brennt der erwartete Verbindungsabriss die
fuenf Versuche in wenigen Minuten durch und laesst den Lauf scheitern,
BEVOR der Host wieder da ist. Genau der Unterschied zwischen
'Wiederholung' und 'toter Server'.

Unterschieden wird am Termin im Run-Kontext: ohne ihn hat dieser Lauf
nichts angefasst, dann ist ein Verbindungsfehler ein gewoehnlicher und
darf einen Versuch kosten. Mit ihm laeuft gerade eine Umstellung, dann
wird gepollt.

bridge.sh und bridge-run.sh gehen wortgleich hoch (Test vergleicht Byte
fuer Byte gegen die Repo-Datei), env traegt den Hub-Schluessel mit, damit
der Treiber den Handshake gegen den RICHTIGEN Peer prueft. Start per
nohup setsid, PID-Datei abgewartet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:44:47 +02:00
nexxo 786dd54257 EnsureNetworkBridge: schon da heisst nichts anfassen, und keine Bruecke auf einer Bruecke
Der Schritt, aber erst sein billigster Teil. Zwei Zusicherungen:

- Traegt vmbr0 schon Standardroute UND Adresse, dann advance() ohne einen
  einzigen veraendernden Befehl. pve-fns-1 hat die Bruecke von Hand; ein
  Wiederanlauf, der sie umbaut, baut ein funktionierendes Netz um.
- Ist die Karte mit der Standardroute keine physische (Bond, Bridge,
  VLAN), dann fail() mit Klartext statt bauen. bridge_ports darauf waere
  falsch, und was dabei herauskommt, ist aus der Ferne nicht mehr zu
  reparieren.

readBridgeState fragt beides in einem Rundlauf und leitet aus dem
LAUFENDEN Zustand ab (ip, /sys/class/net), nie aus der Datei des
Anbieters. 'up' sind absichtlich alle drei Fakten: eine vmbr0 ohne
Adresse und ohne Standardroute ist eine Bruecke im Sinne von 'ip link'
und sonst nichts.

Start, Poll und Abbestellen folgen. Bis dahin steht dort ein fail() —
gefahrlos, weil der Schritt noch nicht in der Pipeline haengt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:44:47 +02:00
nexxo b0761de1eb bridge-run.sh: der Treiber, und der Zeitgeber steht vor jeder Aenderung
Verdrahtet bridge.sh in der einen Reihenfolge, die stimmen muss:
sichern -> Zeitgeber -> uebernehmen -> umstellen -> nachsehen. Alles
davor stellt nur fest und veraendert nichts; ab 'sichern' gibt es einen
Weg zurueck, und erst ab dann darf ueberhaupt etwas angefasst werden.

Abgekoppelt ist hier Bedingung, nicht Optimierung: ifreload -a nimmt die
Leitung, ueber die der Befehl laeuft. PID als allererstes, damit ein
frueher Poll nicht 'running aber nicht lebendig' liest und einen gesunden
Lauf fuer tot erklaert.

Der Treiber bestellt den Zeitgeber NIE ab — das tut CluPilot nach dem
Wiederverbinden. Ein Test haelt das fest.

Dazu die Fremdverwalter-Erkennung in bridge.sh: cloud-init, networkd,
NetworkManager werden benannt und entmachtet, Unbekanntes fuehrt zum
Abbruch statt zu einem Versuch ins Blaue. Der Zeitgeber faengt diesen
Fall NICHT ab — zu seiner Zeit war alles in Ordnung, und die Bruecke
verschwaende erst beim naechsten Neustart, mit Kunden darauf.

Drei Tests fahren den Treiber in einer Sandkiste WIRKLICH durch, statt
nur seinen Text zu lesen: die ip-Attrappe antwortet vor dem ifreload
anders als danach. Sie belegen ok, failed-ohne-Bruecke und
failed-mit-schalem-Tunnel — und in allen drei Faellen, dass die
Rueckfahrkarte stehen bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:44:47 +02:00
nexxo e963821eb4 bridge.sh: nachsehen in beide Richtungen, und kein ping
Nur 'komme ich raus' zu pruefen ist notwendig und NICHT hinreichend.
Der Fehlerfall: ifreload bringt vmbr0 sauber hoch, die Maschine erreicht
das Internet, der Treiber waere zufrieden — aber wg0 kommt nicht zurueck.
Dann lebt der Host oeffentlich, CluPilot ist ausgesperrt, und wer hier
abbestellt, hat die Rueckfahrkarte weggeworfen.

wg0.conf enthaelt keine Geraetebindung; der Tunnel haengt an der
Quelladresse, die die Routing-Tabelle hergibt — und genau das ist die
Groesse, die der Umbau anfasst. Das macht den Fall nicht
unwahrscheinlicher, nur unauffaelliger: kein Fehler im Protokoll, nur
ein Handshake, der ausbleibt.

Ist der Handshake schal, wird wg-quick@wg0 EINMAL neu gestartet und
nochmal nachgesehen. Gefahrlos, weil ifreload die SSH-Sitzung ohnehin
schon mitgenommen hat — und es verwandelt einen haengenden Tunnel in
einen laufenden statt in eine Ruecknahme.

Kein ping: Hetzners Debian-Basis hat keins, PrepareBaseSystem
installiert es nicht, und network.sh:190 haette damit immer 'nicht
erreichbar' gesagt. Ein Test haelt bridge.sh ping-frei.

Nebenbei ein Fehler in den Tests selbst behoben: 'if gibtsnicht; then
… else echo NEIN; fi' ist in sh unwahr, also war jeder Test, der NEIN
erwartete, gruen SOLANGE die Funktion fehlte. verdictBody() meldet jetzt
FEHLT und trennt 'falsch' von 'gibt es nicht'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:44:47 +02:00
nexxo 0005d321a3 bridge.sh: die Rueckfahrkarte, und der Grund steht vor dem Zurueckspielen
Der Zeitgeber ist eine systemd-Einheit und kein 'sleep &': ein
Hintergrundlauf stirbt mit seiner Sitzung, und die Sitzung ist genau
das, was abreisst, wenn die Umstellung schiefgeht.

Zwei Dinge gegenueber der Fassung, die in network.sh stand:

- render_rollback_script ist vom Stellen getrennt, damit die Reihenfolge
  darin ohne systemd pruefbar ist. Und die Reihenfolge ist der Punkt:
  'failed' samt Grund wird geschrieben, BEVOR zurueckgespielt wird —
  ein halb gegluecktes Zurueckspielen soll das Urteil trotzdem
  hinterlassen. 'rolled-back' kommt zuletzt und ist das Signal, auf das
  CluPilot wartet.
- Der Zeitgeber raeumt seine Unit-Dateien nach dem Feuern selbst weg.
  Sonst sieht eine abgeschlossene Ruecknahme beim naechsten Hinsehen aus
  wie eine ausstehende.

Der Treiber bestellt NICHT ab. Das tut CluPilot, nachdem es sich ueber
den Tunnel neu verbunden hat — ein Skript auf dem Host kann ueber seine
eigene Erreichbarkeit von aussen nur raten.

Geprueft: der Zeitgeber steht vor dem ersten veraendernden Aufruf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:44:47 +02:00
nexxo b630c079ca bridge.sh: die Strophe, gegen vier Anbieterfaelle geprueft
write_bridge_stanza ist von build_bridge getrennt: das Schreiben ist das,
was eine Maschine umbringt, und so ist es pruefbar, ohne dafuer ein Netz
neu laden zu muessen. Beide Pfade sind ueberschreibbar
(CLUPILOT_INTERFACES_FILE, CLUPILOT_IFRELOAD), dieselbe Technik wie
CLUPILOT_STORAGE_CFG beim Vorlagenbau.

Drei Dinge kann die Fassung mehr als die alte in network.sh:

- hwaddress festgenagelt. Eine Bruecke waehlt sonst die kleinste MAC
  ihrer Ports; bei einem Port ist das dieselbe, aber 'ist dieselbe' und
  'bleibt dieselbe' sind zweierlei.
- Zusatzrouten des Anbieters wandern mit. Ausgelassen bleiben die
  Kernel-Route zum eigenen Subnetz und die Link-Route zum Gateway —
  beide entstehen von selbst, und ein gescheitertes 'up' nimmt bei
  ifreload die ganze Strophe mit.
- IPv6, aber nur statisch und global. SLAAC/DHCPv6 werden bewusst nicht
  nachgebaut (forwarding=1 laesst den Kernel RAs ohne accept_ra=2
  verwerfen) — dafuer gibt es eine Zeile ins Protokoll statt eines
  stillen Verlusts.

Geprueft gegen echte sh: Hetzner /32 routed mit pointopoint, netcup
Subnetz ohne, DHCP ohne Adresse, statisches IPv6 mit fe80::1, SLAAC
faellt weg, Zusatzrouten kommen mit.

network.sh hat sein eigenes build_bridge abgegeben; ein Test haelt jetzt
jede der sieben Brueckenfunktionen auf genau einer Stelle im Repo fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:44:47 +02:00
nexxo 81df08aaa6 bridge.sh: die Erkennung herausgeloest und allein lauffaehig gemacht
Die Bruecken-Erkennung stand in network.sh, geschrieben fuer den
stillgelegten Rettungssystem-Weg, und borgte sich vier Dinge von
woanders: detect_primary_interface aus proxmox.sh, log, http_get und
CLUPILOT_PROBE_URL aus clupilot-bootstrap.sh.

Der Debian-Weg laedt die Bibliothek EINZELN auf den Host und faehrt sie
dort — geborgte Helfer waeren dann nicht da. Also allein lauffaehig, mit
log und http_get unter einem command-v-Schutz, damit der Bootstrap seine
eigenen behaelt.

Neu und aus dem laufenden Zustand abgeleitet:
- interface_is_physical — bridge_ports auf einem Bond oder einer
  bestehenden Bridge ist falsch, und aus der Ferne nicht reparierbar.
- address_is_dynamic — der Kernel markiert eine geleaste Adresse, das
  steht bei jedem Anbieter gleich da. Die Datei des Anbieters ist nur
  noch das Zweitsignal.

Geprueft gegen eine echte sh mit aufgezeichneten ip-Ausgaben, plus die
Zusicherung, dass es detect_primary_interface im Repo genau einmal gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:44:47 +02:00
nexxo ad79ce44be Codex-Runde 2: der Gateway-Fix erreichte den Produktivpfad nicht
Zwei Befunde, beide echt, und der erste ist ein Patzer:
detect_network_style wurde auf default_gateway() umgestellt,
bridge-run.sh blieb auf awk '{ print  }'. Der Treiber reichte also
weiter den KARTENNAMEN als Gateway an build_bridge, und in der Strophe
stand 'gateway ens3' — der Fix half genau der Stelle nicht, fuer die er
gedacht war.

Meine Sandkiste konnte das nicht sehen: sie hatte immer ein via. Jetzt
ist sie parametrisiert, und ein Test faehrt bridge-run.sh mit einer
via-losen Standardroute durch und liest die geschriebene Strophe. Der
Beweis laeuft ueber den Produktivpfad, nicht ueber eine Einzelfunktion.

Zweitens: default_gateway suchte das erste via IRGENDWO in der Ausgabe,
detect_primary_interface das dev der ERSTEN Zeile. Bei zwei
Standardrouten baute das eine Bruecke ueber die Karte der einen mit dem
Gateway der anderen. Beide haengen jetzt an primary_default_route mit
head -1 — eine Route, eine Quelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:34:12 +02:00
nexxo 1f32a2d5b3 Hoechstens drei Bloecke, geprueft in der Buchung
Die Ansicht durfte 50 in den Warenkorb legen, die Aktion pruefte nichts. Der
Deckel liegt kaufmaennisch dort, wo Aufsteigen billiger wird als Stapeln, und
gehoert deshalb dorthin, wo gebucht wird — gezaehlt ueber alle laufenden
Buchungen, denn drei Bestellungen a einem Block sind drei Bloecke.

StorageAllowanceTest schrieb die alte Notbremse (50) als erwartete Zahl fest;
angepasst auf die jetzt engere kaufmaennische Grenze (3), wie im Aufgabenblatt
fuer DowngradeTest vorgezeichnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:33:36 +02:00
nexxo 98354716d0 Codex-Runde 1: drei P1 an der Rueckfahrkarte, plus ein Gateway ohne via
Die Ruecknahme konnte Erfolg melden, ohne einen zu haben. Drei Wege
dorthin, alle behoben:

- Sie schaltete den entmachteten Netzverwalter nicht wieder ein. Die
  Sicherung umfasst nur /etc/network/interfaces*; kam die Verbindung von
  cloud-init, networkd oder NetworkManager, spielte die Ruecknahme eine
  Datei zurueck, die die Maschine nie getragen hat, und liess den
  Verwalter abgeschaltet. Genau der tote Host, den der Zeitgeber
  verhindern soll. disown_network_manager hinterlaesst jetzt eine Notiz
  (WAS entmachtet, WELCHE Strophen verdraengt), die das Ruecknahme-Skript
  beim Feuern liest — aufgeschrieben statt eingebacken, weil der
  Zeitgeber vor dem Entmachten gestellt wird.
- Sie setzte 'rolled-back' auch, wenn tar oder ifreload scheiterten. Das
  urspruengliche network.sh hatte dafuer set -e; beim Umbau ist es
  verlorengegangen. Jetzt bricht jeder Fehlschlag ab, bevor die Marke
  entsteht — CluPilot pollt dann bis zur Frist statt 'ist zurueck' zu
  glauben.
- Eine verdraengte interfaces.d-Strophe wurde nur umbenannt. Der Stern in
  'source interfaces.d/*' fasst sie weiter; das versteckte die Kollision
  vor dem Leser, statt sie zu loesen. Jetzt wandert sie aus dem
  Verzeichnis heraus, und die Ruecknahme holt sie zurueck.

Dazu ein eigener Fund: das Gateway wurde mit awk '{print }' gelesen.
Bei 'default dev ens3 scope link' ist das der KARTENNAME, woraus
'gateway ens3' in der Strophe wuerde. default_gateway() liest jetzt
hinter dem via, und eine Standardroute ohne via wandert als eigene
up-Zeile mit, statt verlorenzugehen.

Zurueckgewiesen: der P2 zu extra_routes. 'ip route show dev X' laesst das
dev-Feld WEG (im Container nachgemessen), das angehaengte 'dev vmbr0' ist
also richtig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:27:49 +02:00
nexxo 66bddd4bce EnsureNetworkBridge in die Pipeline: nach dem Neustart, vor der Nachpruefung
Der Schritt haengt jetzt zwischen RebootIntoPveKernel und
ConfigureProxmox. Nach dem Neustart, weil dort ifupdown2 steht und der
Tunnel gerade bewiesen hat, dass er einen Neustart ueberlebt. Vor
ConfigureProxmox, dessen refuseWithoutBridge() unveraendert stehen bleibt
und damit zur Nachpruefung wird — bauen UND pruefen, dasselbe Paar wie
BuildVmTemplate -> VerifyVmTemplate.

Beschriftung in beiden Sprachen, und ein Test verlangt das kuenftig von
JEDEM Pipeline-Schritt statt nur vom neuen: wer einen einhaengt und die
Sprachdateien vergisst, faellt im Test auf statt in der Konsole.

bridge.sh und bridge-run.sh gehoeren ins Bootstrap-Archiv — es ist der
einzige Weg, auf dem das Skript auf eine nackte Maschine kommt.

Der End-to-End-Test lief rot, und zu Recht: sein Attrappen-Host sagte
nichts ueber sein Netz, also lehnte der neue Schritt ab. Das war der
Beweis, dass er wirklich haengt. Er bekommt jetzt einen Host, der seine
Bruecke schon hat — den Bau-Pfad kann eine Attrappe nicht nachstellen,
er lebt davon, dass die Verbindung abreisst und wiederkommt. Dafuer gibt
es die Schritt-Tests und die drei Sandkasten-Laeufe.

scriptBridgeState/scriptBridgeStatus liegen in tests/Pest.php, nicht in
einer einzelnen Testdatei — zwei Dateien brauchen sie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:14:40 +02:00
nexxo 9888f898b8 EnsureNetworkBridge: abbestellen nach der Nachpruefung, aufgeben ohne die Rueckfahrkarte wegzuwerfen
Drei Enden, und jedes hat eine Reihenfolge:

- Abbestellt wird erst, NACHDEM die Bruecke nachgeprueft ist. Dass der
  Zweig erreicht wird, ist der Beweis: SSH kam ueber den Tunnel an und
  vmbr0 traegt Standardroute und Adresse — bridge_proven, von aussen
  gefragt. Und nur, wenn DIESER Lauf den Zeitgeber auch gestellt hat;
  ohne Termin im Kontext gehoeren die Unit-Dateien jemand anderem.
- awaitRollback pollt, bis 'rolled-back' liegt, statt sofort zu
  scheitern. Solange der Zeitgeber aussteht, steckt die Maschine mitten
  in einer Umstellung, und ein fail() liesse sie dort liegen. Gedeckelt
  durch die Schritt-Frist, die das Dreifache der Zeitgeber-Frist ist.
- giveUp loescht den Termin (sonst wird jedes Retry nach einem
  Fehlschlag zum sofortigen zweiten Fehlschlag), behaelt bridge_attempts
  (sonst ist der Deckel von zwei Versuchen keiner) und bestellt den
  Zeitgeber NICHT ab — er ist die Rueckfahrkarte, und steht er noch, hat
  er seinen Grund.

Der sh-n-Test ist gegengeprobt: mit absichtlich kaputtem Quoting faellt
er.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:05:58 +02:00
nexxo 2e511899f8 EnsureNetworkBridge: Start abgekoppelt, und der Verbindungsabriss wird gepollt statt gezaehlt
Die tragende Zeile ist das try/catch um keyLogin. RunRunner:102
verwandelt jede geworfene Ausnahme in ein retry(), und retry verbraucht
das Versuchskonto — ungefangen brennt der erwartete Verbindungsabriss die
fuenf Versuche in wenigen Minuten durch und laesst den Lauf scheitern,
BEVOR der Host wieder da ist. Genau der Unterschied zwischen
'Wiederholung' und 'toter Server'.

Unterschieden wird am Termin im Run-Kontext: ohne ihn hat dieser Lauf
nichts angefasst, dann ist ein Verbindungsfehler ein gewoehnlicher und
darf einen Versuch kosten. Mit ihm laeuft gerade eine Umstellung, dann
wird gepollt.

bridge.sh und bridge-run.sh gehen wortgleich hoch (Test vergleicht Byte
fuer Byte gegen die Repo-Datei), env traegt den Hub-Schluessel mit, damit
der Treiber den Handshake gegen den RICHTIGEN Peer prueft. Start per
nohup setsid, PID-Datei abgewartet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:04:03 +02:00
nexxo 91ecb382f5 EnsureNetworkBridge: schon da heisst nichts anfassen, und keine Bruecke auf einer Bruecke
Der Schritt, aber erst sein billigster Teil. Zwei Zusicherungen:

- Traegt vmbr0 schon Standardroute UND Adresse, dann advance() ohne einen
  einzigen veraendernden Befehl. pve-fns-1 hat die Bruecke von Hand; ein
  Wiederanlauf, der sie umbaut, baut ein funktionierendes Netz um.
- Ist die Karte mit der Standardroute keine physische (Bond, Bridge,
  VLAN), dann fail() mit Klartext statt bauen. bridge_ports darauf waere
  falsch, und was dabei herauskommt, ist aus der Ferne nicht mehr zu
  reparieren.

readBridgeState fragt beides in einem Rundlauf und leitet aus dem
LAUFENDEN Zustand ab (ip, /sys/class/net), nie aus der Datei des
Anbieters. 'up' sind absichtlich alle drei Fakten: eine vmbr0 ohne
Adresse und ohne Standardroute ist eine Bruecke im Sinne von 'ip link'
und sonst nichts.

Start, Poll und Abbestellen folgen. Bis dahin steht dort ein fail() —
gefahrlos, weil der Schritt noch nicht in der Pipeline haengt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:02:23 +02:00
nexxo 5df2c1240e bridge-run.sh: der Treiber, und der Zeitgeber steht vor jeder Aenderung
Verdrahtet bridge.sh in der einen Reihenfolge, die stimmen muss:
sichern -> Zeitgeber -> uebernehmen -> umstellen -> nachsehen. Alles
davor stellt nur fest und veraendert nichts; ab 'sichern' gibt es einen
Weg zurueck, und erst ab dann darf ueberhaupt etwas angefasst werden.

Abgekoppelt ist hier Bedingung, nicht Optimierung: ifreload -a nimmt die
Leitung, ueber die der Befehl laeuft. PID als allererstes, damit ein
frueher Poll nicht 'running aber nicht lebendig' liest und einen gesunden
Lauf fuer tot erklaert.

Der Treiber bestellt den Zeitgeber NIE ab — das tut CluPilot nach dem
Wiederverbinden. Ein Test haelt das fest.

Dazu die Fremdverwalter-Erkennung in bridge.sh: cloud-init, networkd,
NetworkManager werden benannt und entmachtet, Unbekanntes fuehrt zum
Abbruch statt zu einem Versuch ins Blaue. Der Zeitgeber faengt diesen
Fall NICHT ab — zu seiner Zeit war alles in Ordnung, und die Bruecke
verschwaende erst beim naechsten Neustart, mit Kunden darauf.

Drei Tests fahren den Treiber in einer Sandkiste WIRKLICH durch, statt
nur seinen Text zu lesen: die ip-Attrappe antwortet vor dem ifreload
anders als danach. Sie belegen ok, failed-ohne-Bruecke und
failed-mit-schalem-Tunnel — und in allen drei Faellen, dass die
Rueckfahrkarte stehen bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:56:16 +02:00
nexxo 4ba74e862a bridge.sh: nachsehen in beide Richtungen, und kein ping
Nur 'komme ich raus' zu pruefen ist notwendig und NICHT hinreichend.
Der Fehlerfall: ifreload bringt vmbr0 sauber hoch, die Maschine erreicht
das Internet, der Treiber waere zufrieden — aber wg0 kommt nicht zurueck.
Dann lebt der Host oeffentlich, CluPilot ist ausgesperrt, und wer hier
abbestellt, hat die Rueckfahrkarte weggeworfen.

wg0.conf enthaelt keine Geraetebindung; der Tunnel haengt an der
Quelladresse, die die Routing-Tabelle hergibt — und genau das ist die
Groesse, die der Umbau anfasst. Das macht den Fall nicht
unwahrscheinlicher, nur unauffaelliger: kein Fehler im Protokoll, nur
ein Handshake, der ausbleibt.

Ist der Handshake schal, wird wg-quick@wg0 EINMAL neu gestartet und
nochmal nachgesehen. Gefahrlos, weil ifreload die SSH-Sitzung ohnehin
schon mitgenommen hat — und es verwandelt einen haengenden Tunnel in
einen laufenden statt in eine Ruecknahme.

Kein ping: Hetzners Debian-Basis hat keins, PrepareBaseSystem
installiert es nicht, und network.sh:190 haette damit immer 'nicht
erreichbar' gesagt. Ein Test haelt bridge.sh ping-frei.

Nebenbei ein Fehler in den Tests selbst behoben: 'if gibtsnicht; then
… else echo NEIN; fi' ist in sh unwahr, also war jeder Test, der NEIN
erwartete, gruen SOLANGE die Funktion fehlte. verdictBody() meldet jetzt
FEHLT und trennt 'falsch' von 'gibt es nicht'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:47:02 +02:00
nexxo 8ac4a38e2e bridge.sh: die Rueckfahrkarte, und der Grund steht vor dem Zurueckspielen
Der Zeitgeber ist eine systemd-Einheit und kein 'sleep &': ein
Hintergrundlauf stirbt mit seiner Sitzung, und die Sitzung ist genau
das, was abreisst, wenn die Umstellung schiefgeht.

Zwei Dinge gegenueber der Fassung, die in network.sh stand:

- render_rollback_script ist vom Stellen getrennt, damit die Reihenfolge
  darin ohne systemd pruefbar ist. Und die Reihenfolge ist der Punkt:
  'failed' samt Grund wird geschrieben, BEVOR zurueckgespielt wird —
  ein halb gegluecktes Zurueckspielen soll das Urteil trotzdem
  hinterlassen. 'rolled-back' kommt zuletzt und ist das Signal, auf das
  CluPilot wartet.
- Der Zeitgeber raeumt seine Unit-Dateien nach dem Feuern selbst weg.
  Sonst sieht eine abgeschlossene Ruecknahme beim naechsten Hinsehen aus
  wie eine ausstehende.

Der Treiber bestellt NICHT ab. Das tut CluPilot, nachdem es sich ueber
den Tunnel neu verbunden hat — ein Skript auf dem Host kann ueber seine
eigene Erreichbarkeit von aussen nur raten.

Geprueft: der Zeitgeber steht vor dem ersten veraendernden Aufruf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:39:44 +02:00
nexxo 587d1351d1 bridge.sh: die Strophe, gegen vier Anbieterfaelle geprueft
write_bridge_stanza ist von build_bridge getrennt: das Schreiben ist das,
was eine Maschine umbringt, und so ist es pruefbar, ohne dafuer ein Netz
neu laden zu muessen. Beide Pfade sind ueberschreibbar
(CLUPILOT_INTERFACES_FILE, CLUPILOT_IFRELOAD), dieselbe Technik wie
CLUPILOT_STORAGE_CFG beim Vorlagenbau.

Drei Dinge kann die Fassung mehr als die alte in network.sh:

- hwaddress festgenagelt. Eine Bruecke waehlt sonst die kleinste MAC
  ihrer Ports; bei einem Port ist das dieselbe, aber 'ist dieselbe' und
  'bleibt dieselbe' sind zweierlei.
- Zusatzrouten des Anbieters wandern mit. Ausgelassen bleiben die
  Kernel-Route zum eigenen Subnetz und die Link-Route zum Gateway —
  beide entstehen von selbst, und ein gescheitertes 'up' nimmt bei
  ifreload die ganze Strophe mit.
- IPv6, aber nur statisch und global. SLAAC/DHCPv6 werden bewusst nicht
  nachgebaut (forwarding=1 laesst den Kernel RAs ohne accept_ra=2
  verwerfen) — dafuer gibt es eine Zeile ins Protokoll statt eines
  stillen Verlusts.

Geprueft gegen echte sh: Hetzner /32 routed mit pointopoint, netcup
Subnetz ohne, DHCP ohne Adresse, statisches IPv6 mit fe80::1, SLAAC
faellt weg, Zusatzrouten kommen mit.

network.sh hat sein eigenes build_bridge abgegeben; ein Test haelt jetzt
jede der sieben Brueckenfunktionen auf genau einer Stelle im Repo fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:37:17 +02:00
nexxo 6bda0e1d8a bridge.sh: die Erkennung herausgeloest und allein lauffaehig gemacht
Die Bruecken-Erkennung stand in network.sh, geschrieben fuer den
stillgelegten Rettungssystem-Weg, und borgte sich vier Dinge von
woanders: detect_primary_interface aus proxmox.sh, log, http_get und
CLUPILOT_PROBE_URL aus clupilot-bootstrap.sh.

Der Debian-Weg laedt die Bibliothek EINZELN auf den Host und faehrt sie
dort — geborgte Helfer waeren dann nicht da. Also allein lauffaehig, mit
log und http_get unter einem command-v-Schutz, damit der Bootstrap seine
eigenen behaelt.

Neu und aus dem laufenden Zustand abgeleitet:
- interface_is_physical — bridge_ports auf einem Bond oder einer
  bestehenden Bridge ist falsch, und aus der Ferne nicht reparierbar.
- address_is_dynamic — der Kernel markiert eine geleaste Adresse, das
  steht bei jedem Anbieter gleich da. Die Datei des Anbieters ist nur
  noch das Zweitsignal.

Geprueft gegen eine echte sh mit aufgezeichneten ip-Ausgaben, plus die
Zusicherung, dass es detect_primary_interface im Repo genau einmal gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:34:26 +02:00
nexxo 2ab6a92fd8 Die Packungsgroesse steht auf der Buchung, nicht in der Konfiguration
Bisher las StorageAllowance sie bei jeder Anzeige neu aus config. Solange es
eine Groesse gab, war das folgenlos — beim Zuschnitt von 100 GB auf 20 GB waere
es der stille Verlust von 80 GB je gekauftem Block gewesen, bei einem Kunden,
der bereits Daten darin liegen hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:25:30 +02:00
nexxo 0ac76fb05b Plan: zwei Tests, die nichts bewiesen haben
Der eine rechnete mit festen Zahlen und pruefte damit, ob PHP dividieren kann;
er liest jetzt Katalog und Konfiguration und schlaegt an, wenn ein Preis so
gesetzt wird, dass Stapeln sich wieder lohnt. Der andere legte einen Vertrag an
und pruefte die Werte, die er gerade selbst geschrieben hatte; er geht jetzt
ueber den Weg, ueber den eine Bereitstellung das Kontingent holt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:14:04 +02:00
nexxo 1e570f17fc Plan: die neuen Pakete in zehn Aufgaben
Die Reihenfolge ist der Plan: erst die drei Reparaturen am Zusatzspeicher, dann
die Umschaltung. Wer die Konfiguration zuerst umstellt, verkleinert gekaufte
Bloecke.

Eine Abweichung vom Entwurf, mit Grund: die Umschaltung wird eine Migration und
kein Befehl. Der Katalog wird von einer Migration gesetzt, mit fest
eingetragenen Werten und der Begruendung, sie muesse in fuenf Jahren dieselben
Werte setzen — ein Befehl allein liesse jede Neuinstallation und die gesamte
Testsuite auf der alten Leiter zurueck. Die 28 Testdateien mit alten Zahlen
gehoeren deshalb in dieselbe Aufgabe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:06:53 +02:00
nexxo 02957a038d Plan: vmbr0 automatisch bauen — neun Tasks, TDD
Task 1-4  bridge.sh: Erkennung, Strophe, Rueckfahrkarte, Nachsehen
Task 5    bridge-run.sh, der Treiber
Task 6-8  EnsureNetworkBridge: Guard, Start/Abriss, Abbestellen
Task 9    Pipeline, Sprachdateien, ganze Suite

Der wertvollste Test steht in Task 2: der Strophengenerator wird gegen
eine echte Shell gefahren und gegen vier Anbieterfaelle geprueft
(Hetzner /32 routed, DHCP, Subnetz, statisches IPv6 mit fe80::1). Die
Strophe ist das, was eine Maschine umbringt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:02:54 +02:00