83 Commits (133b2cf5521c9404268f61844d4ced451ab86fca)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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> |
|
|
|
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> |
|
|
|
a7c7f81131 |
Entwurf: geprueft, dass ein laufender Vertrag seine Version einfriert
Die Frage des Betreibers war, ob die Umschaltung einem Startkunden mit 100 GB ein Kontingent von 30 GB auf eine volle Cloud schreibt. Nachgesehen statt vermutet: snapshotFrom() kopiert die Groessen beim Kauf auf die Vertragszeile, CustomerStep::plan() liest die Vertragsspalten, StorageAllowance::for() nimmt instances.quota_gb — den Katalog fragt keiner von ihnen. Die Pakete sind also sicher, die Module nicht: dort steht die Packungsgroesse live in der Konfiguration. Die Reihenfolge gilt fuer die Bloecke, und fuer die gilt sie zwingend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
e4d4fd0cfb |
Entwurf: die neuen Pakete
Die Leiter wird auf die Maschine zugeschnitten, die dasteht. Speicher bindet die Kundenzahl je Host, nicht Rechenleistung: die alte Leiter band bei drei Kunden je Server und ließ sechs RAM-Plaetze verfallen. Neu sind es neun Kunden a 40 GB Platte, 351 EUR statt 147 EUR je Server. Kopfraum waechst mit: max(10 GB, 12 %). Eine 175-GB-Nextcloud mit 50 Nutzern hat eine groessere Datenbank und mehr Versionsstaende als eine mit drei Leuten, und eine volle Platte legt nicht den Upload still, sondern die Instanz. Der Zusatzblock geht auf 20 GB fuer 15 EUR und ist bei drei gedeckelt. Bei 12 EUR waere er mit 0,60 EUR/GB billiger gewesen als jeder Aufstieg (0,73 und 0,67) — dann stapeln Kunden, statt umzusteigen, und belegen den knappsten Rohstoff zum niedrigsten Preis. Ein Block belegt 22 GB Platte, damit die Kopfraum-Regel auch fuer ein gestapeltes Paket gilt. Drei Reparaturen haengen daran, und eine davon steht seit laengerem im Code angeschrieben: StorageAllowance liest die Packungsgroesse live aus der Konfiguration, mit dem Vermerk, dass sie an dem Tag auf die Buchung gehoert, an dem sie sich bewegt. Dieser Tag ist heute — ohne die Aenderung schrumpft ein gekaufter 100-GB-Block in der Sekunde der Umstellung auf 20 GB. Das Testpaket heisst Intern und verschwindet aus dem Verkauf, bleibt aber verschenkbar. Enterprise verlaesst den Shop, weil 500 GB auf 388 GB keinen Platz finden und der Kunde das erst nach der Zahlung erfuehre. Und damit "eigener Server" mehr ist als ein Wort: Hosts bekommen eine Reservierung — heute nimmt placeableIn() jeden aktiven Host, und die Kapazitaetszahlen zaehlen eine exklusiv verkaufte Maschine mit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
5cae870dd9 |
Entwurf: vmbr0 automatisch bauen
Der letzte Handgriff der Host-Uebernahme. Derselbe Mechanismus wie BuildVmTemplate — bridge.sh + bridge-run.sh hochladen und abgekoppelt fahren, nicht in PHP nachbauen. Die tragenden Punkte: - Abgekoppelt ist Bedingung, nicht Optimierung: ifreload -a nimmt die Leitung, ueber die der Befehl laeuft. - Der Zeitgeber steht vor jeder Aenderung, und nur er stellt zurueck. Kein zweiter Ruecknahmeweg. - Abbestellt wird erst, wenn BEIDE Richtungen stimmen: Internet erreichbar UND frischer WireGuard-Handshake. Nur die erste zu pruefen laesst den Fall zu, in dem der Host oeffentlich lebt, CluPilot ausgesperrt ist und die Rueckfahrkarte gerade weggeworfen wurde. - Kein ping — Hetzners Debian-Basis hat keins, und network.sh:190 haette deshalb immer 'nicht erreichbar' gesagt. - Bruecke schon da heisst nichts anfassen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
dc35e5310f |
Release v1.3.93 — die Seite konnte den Host gar nicht fragen
tests / pest (push) Failing after 9m23s
Details
tests / assets (push) Successful in 28s
Details
tests / release (push) Has been skipped
Details
Auf echter Hardware blieb jede Messkachel leer, während der Host sichtbar online war und vor zwei Minuten geantwortet hatte. Kein Zufall und kein Aussetzer: ein Entwurfsfehler. Nur der queue-provisioning-Container hängt im WireGuard-Netz — NET_ADMIN, /dev/net/tun, das wireguard-Volume. Der app-Container, der die Konsole rendert, hat überhaupt keine Route zu 10.66.0.x. Ich hatte den Proxmox-Aufruf in render() gelegt, also in den einen Container, der die Management-Adresse nicht erreichen kann. Ein Aufruf von dort konnte nie etwas anderes sein als eine Zeitüberschreitung. PingHosts schreibt dieselbe Regel seit Langem in seinen Kopf: "runs on the provisioning queue, which is where the Proxmox credentials are usable." Ich habe sie gelesen und nicht angewendet. Verschlimmert hat es mein eigenes catch (Throwable): der Grund wurde verschluckt, und die Kachel sagte "keine Messwerte" — ununterscheidbar davon, dass der Host schweigt. Auf dem Testhost fiel nichts auf, weil TEST-NET ohnehin nie antwortet. Jetzt zwei Hälften: - HostLoadSeries::collect() holt und legt ab, aus Jobs\CollectHostLoad auf der provisioning-Warteschlange, minütlich — der Takt, in dem Proxmox einen frischen Messwert schreibt. Ein Fehlschlag wird protokolliert, mit Host, Node und Grund. - HostLoadSeries::forHost() liest nur aus dem Zwischenspeicher und öffnet nie eine Verbindung. Ein Test hält das mit Http::assertNothingSent() fest. Der Eintrag lebt fünf Minuten bei minütlichem Sammeln: länger als der Takt, damit ein ausgefallener Lauf keine Seite leert, die eine Sekunde vorher in Ordnung war — und kurz genug, dass ein stehengebliebener Sammler die Zahlen mitnimmt, statt eine alte Stunde als aktuell stehenzulassen. Der Sammler ist ShouldBeUnique (Codex-Befund, P1). Die provisioning-Warteschlange ist DIESELBE, auf der Kunden-Bereitstellung läuft; ein stiller Host kostet den vollen HTTP-Zeitablauf, und ohne diese Sperre stauten sich minütlich neue Läufe hinter dem alten und verzögerten bezahlte Arbeit. Dasselbe Mittel, das CollectInstanceTraffic nebenan schon benutzt. Dazu: der Zustands-Punkt war mit 62 px so groß wie der Speicher-Ring nebenan. Eine gefüllte Scheibe wiegt optisch weit mehr als ein dünner Ring und erschlug die Kachel — jetzt 32 px. Und ein Test, der aus Versehen recht behielt: die Kachel-Prüfung verglich mit "50", was auch die 500 GB in der Instanzenliste darunter trifft. Sie prüft jetzt Zahlen, die sonst nirgends auf der Seite vorkommen. Noch offen, nicht hier angefasst: VmTemplateCheck fragt die Proxmox-API ebenfalls aus dem app-Container heraus, von der Bereitschaftsseite aus. Selber Fehler, Bestand, eigener Punkt. Geprüft: 2299 Tests grün, Pint sauber, Codex ohne Befund. Im Browser mit eingespielten Messwerten: sechs gefüllte Kacheln, Zustands-Scheibe in Proportion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
665a0c4e08 |
Release v1.3.91 — die Host-Seite zeigt Last statt Ausstattung
tests / pest (push) Failing after 9m29s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Die Karte "Rechenleistung" zeigte keine Leistung. 12 Kerne und 63 GB sind die
Ausstattung des Blechs und ändern sich nie — sie standen aber im selben
Kartenraster wie "Zustand" und "Speicher", die beide leben. Wer die Seite
öffnete, um zu sehen, wie es dem Host geht, las dort eine Zahl, die das nie
sagen konnte.
An ihrer Stelle steht jetzt die Last: CPU und RAM als Stundenkurve, beide in
Prozent auf EINER Achse, dazu die aktuellen Werte als beschriftete Zahlen.
Die Geschichte kommt aus Proxmox' eigener Aufzeichnung
(/nodes/{node}/rrddata), nicht aus einem eigenen Sampler. Ein Sampler hieße
neue Tabelle, minütlicher Job, Aufräum-Job und ~1440 Zeilen je Host und Tag —
um weniger genau nachzubauen, was ohnehin auf der Platte liegt. Die RRD ist ab
der ersten Sekunde gefüllt, auch für die Stunde vor dem ersten Hinsehen, und
kann nicht von dem abweichen, was Proxmox' eigene Oberfläche zeigt.
Eine Lücke bleibt eine Lücke: ein Punkt ohne Messwert wird null, nie 0, und
spanGaps steht auf false. Dieselbe Regel wie in instance_metrics. Antwortet der
Host gar nicht, sagt die Tafel das in einem Satz, statt eine ruhige Stunde zu
zeichnen — auf dem Testhost live bestätigt.
Der Fehler, den das ans Licht gebracht hat
------------------------------------------
x-ui.chart steht überall unter wire:ignore, sonst zerstört Livewire das Canvas.
Ein Poll erreicht den Chart also nie. Dafür bekam das Bauteil ein optionales
update-on: es hört auf ein Fenster-Ereignis und tauscht die Daten IM
bestehenden Chart.js-Objekt.
Das lief nicht. Die Zahlen neben der Kurve wanderten, die Kurve nicht, und
chart.update() starb still im Legenden-Plugin:
TypeError: Cannot set properties of undefined (setting 'fullSize')
Grund: die Chart.js-Instanz lag als Eigenschaft im Alpine-Objekt und wurde
damit reaktiv umhüllt. Chart.js' Plugin-Innenleben überlebt das Proxy nicht.
Gemessen statt geschlossen: dieselbe Instanz wirft über das Proxy und läuft
über Alpine.raw(). Sie liegt jetzt in der Closure.
Aufgefallen ist es nie, weil bis zum ersten Live-Chart kein einziger Chart in
diesem Repo je update() gerufen hat — konstruieren und Erstzeichnen gehen durch
die Hülle noch. tests/Feature/ChartLiveUpdateTest.php hält die Regel fest,
damit der nächste Live-Chart nicht denselben Nachmittag kostet.
Der Rest
--------
- Version lesbar: "Proxmox VE 9.2.6" statt pve-manager/9.2.6/7f8d…, mitten in
der Bau-Kennung abgeschnitten. Die Kennung steht klein darunter. Eine
unerwartete Form wird unverändert durchgereicht statt verschluckt.
- Vier Kleinkarten (Mgmt-IP, Node, Version, Instanzen) sind eine
Ausstattungs-Tafel geworden. Die Instanzen-Anzahl steht in der Überschrift
der Liste, die sie ohnehin zeigt.
- Der Übernahme-Fortschritt klappt zu, sobald sie durch ist. Fünfzehn
abgehakte Schritte sind auf einem laufenden Host kein Dauerinhalt —
aufklappbar über <details>, ohne JavaScript.
- PlanVersion::requiredTemplateVmids() ersetzt die dritte Kopie derselben
Fensterlogik.
- BuildVmTemplate sagt nicht mehr "this takes 10–20 minutes". Das war eine
Schätzung vor dem ersten Lauf; gemessen waren es unter zwei. Eine Konsole,
die falsch vorhersagt, erzieht dazu, sie zu ignorieren.
Geprüft: 2281 Tests grün, Pint sauber, Codex ohne Befund. Die Farbwahl gegen
den Validator gerechnet (ΔE 28,3 protan / 39,2 normal; der Akzent liegt unter
3:1 gegen die Fläche, deshalb tragen beide Reihen sichtbare Beschriftung). Im
Browser: null Konsolenfehler über einen vollen Poll-Zyklus, und die Kurve
wandert auf dem echten Poll ohne Neuladen — mit eingespielten Messwerten
belegt, weil die Testhosts in TEST-NET liegen und nie antworten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
db125781ed |
Release v1.3.88 — die Vorlage baut sich selbst
tests / pest (push) Failing after 9m17s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Der letzte Handgriff in der Host-Übernahme fällt weg. VerifyVmTemplate meldete
bisher nur, dass eine Vorlage fehlt, weil niemand entschieden hatte, was in die
goldene Vorlage gehört. Entschieden ist es längst und steht in
deploy/bootstrap/lib/template.sh — der neue Schritt BuildVmTemplate lädt genau
diese Datei auf den Host und führt sie dort aus, statt ihre Prüfungen ein
zweites Mal in PHP zu haben.
Er läuft abgekoppelt und wird abgefragt: Abbild laden und drei
virt-customize-Läufe brauchen zehn bis zwanzig Minuten, ein einzelner
SSH-Aufruf liefe gegen den Befehlszeitablauf von 2000 s. "Läuft noch" heißt
dabei, dass der Prozess lebt (kill -0 gegen die hinterlegte PID) — in der
Statusdatei steht "running" auch dann noch, wenn niemand mehr da ist, der sie
ändert.
Fünf Fehler, die dabei aufgefallen sind und Geld gekostet hätten:
- qm importdisk hängte die Platte unter ${storage}:vm-9000-disk-0 ein. Der Name
gilt nur bei Block-Ablagen; auf einer Verzeichnis-Ablage heißt sie
local:9000/vm-9000-disk-0.qcow2 — also genau auf dem per Debian aufgesetzten
Proxmox, um das es hier geht. Jetzt qm set --import-from, und Proxmox
benennt selbst.
- growpart war nie installiert. GrowGuestFilesystem ruft es auf, und es lief
bisher, weil Debians Cloud-Abbild es zufällig mitbringt. Fiele es heraus,
läge jedes gekaufte Kontingent über einem Dateisystem, das nie gewachsen ist.
Jetzt ausdrücklich eingebaut und als vierte Falle nachgewiesen.
- local nimmt ab Werk keine Platten an. Ohne das stirbt nicht nur der Bau,
RegisterCapacity meldet danach Kapazität 0: ein Host, der fertig aussieht und
nie einen Kunden tragen kann. ensure_image_storage greift nur ein, wenn keine
Ablage Platten annimmt, hängt images an die vorhandene Liste an statt sie zu
ersetzen, und schreibt über pvesm set statt in die pmxcfs-Datei.
- Ein abgebrochener Download blieb unter dem Zielnamen liegen und wäre beim
nächsten Lauf ungeprüft weiterbenutzt worden. Jetzt .part, umbenannt erst
nach geprüfter Summe.
- VerifyVmTemplate und VmTemplateCheck fragten nur, ob VMID 9000 existiert. Ein
abgebrochener Bau hinterlässt eine gewöhnliche VM mit dieser Nummer, und
beide sagten dazu "passt" — der Fehler kam beim ersten bezahlten Klon zurück.
Jetzt template: 1.
isTemplate() stellt zwei Anfragen, weil die falsche Antwort hier etwas
zerstört: false heißt "Vorlage fehlt", und der Bau fängt mit qm destroy --purge
an. Proxmox beantwortet die Konfiguration einer nicht vorhandenen VM mit 500 —
demselben Code wie einen Knoten in Not. Die VM-Liste klärt deshalb die
Abwesenheit, alles darunter wirft und landet im Wiederholungs-Zweig.
Aufgeben beendet erst die Prozessgruppe, dann räumt es auf, und gebaut wird nur
die Fehlliste: create_proxmox_template räumt eine VMID weg, bevor es sie
anlegt, also hätte "alles Verlangte" eine gesunde zweite Vorlage auf dem Weg
zerstört.
Geprüft: 2267 Tests grün, Pint sauber, sh -n über alle drei Shell-Dateien, die
storage.cfg-Auswertung gegen eine echte Beispieldatei durchgespielt, und jeder
Befehl, den der Schritt absetzt, geht durch sh -n — keine andere Prüfung führt
diese Shell je aus. Drei Codex-Runden (R15), alle Befunde behoben.
Nicht geprüft: nichts davon lief je gegen echte Hardware. Die erste Übernahme
auf einem Proxmox-Host ist die Abnahme.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
62e5e0c346 | Spezifikation und Plan: Zahlungsmittel, Nachzahlen, Mahnlauf | |
|
|
200df30b9d |
Hetzner-Cloud-DNS, Bereitschaftsseite aus sich heraus behebbar
Die alte Hetzner-DNS-API ist abgeschaltet (301 auf die Weboberflaeche).
Jede Bereitstellung starb in ConfigureDnsAndTls, nachdem der Kunde bezahlt
hatte. HttpHetznerDnsClient und DnsTokenCheck sprechen jetzt die Cloud-API:
Bearer statt Auth-API-Token, RRSets ueber {name}/{typ} statt Record-IDs, der
Zonen-Lookup entfaellt. Gegen das Live-Konto gemessen, nicht geraten.
Drei Fallen, die am Konto gemessen wurden: TXT-Werte muessen in
Anfuehrungszeichen (sonst 422, was als read_only gemeldet worden waere), ein
Name mit Zonensuffix wird STILL angenommen (201), und der Fake gab andere IDs
aus als der echte Client -- zwoelf Tests waren gruen ueber einem Abbau, der im
Betrieb geworfen haette.
Bereitschaftspunkte, die nur eine Shell beheben konnte, sind jetzt bedienbar:
SSH-Schluesselpaar erzeugen (Ed25519 ueber phpseclib, privater Teil direkt in
den Tresor), Stripe-Katalog abgleichen (Warteschlange, Trockenlauf, Ausgabe
wortgleich), Mailzustellung als Schalter statt MAIL_MAILER, Neustart der
Arbeiterprozesse. Stripe hat jetzt alle drei Werte in der Konsole: Secret Key,
Signatur-Secret (Tresor, je Modus getrennt) und Publishable Key (Klartext).
Codex-Review (R15), zwei P1 behoben: der Signaturschluessel faellt nicht mehr
vom Live- in den Testplatz, und eine Record-ID aus der alten API macht einen
Host nicht mehr unloeschbar -- was adressierbar ist, wandelt eine Migration um,
der Rest wird beim Loeschen laut uebergangen statt geworfen.
2109 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
4c8840788e |
Hand over the three open threads, and what they have in common
Written so the next session can start without rediscovering anything: where the code stands, the three open pieces in the order they matter, and the two release traps that cost two version numbers today. The second piece is the owner's own point, and it is the right one: a readiness page that flags things only a shell can fix has moved the work rather than done it. Three places do that today — generating the host keypair, syncing the Stripe catalogue, and nothing else. The rest already links where it should. The Stripe sync gets a note about not running it inside a Livewire call: it talks to Stripe and creates products, so it belongs in a queue with a visible result, and the dry run belongs in the interface beside it rather than only in a terminal. Last section names the pattern the whole session kept turning up: a display more confident than what it rests on. A green badge over a red measurement, a claim about somebody's Hetzner account derived from an HTML page, a comment promising a file the installer never created. Worth reading the code with that in mind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a91e8525d2 |
Write down the Hetzner DNS migration before starting it
The root cause of the zone_not_found hunt: the old DNS API is gone. It answers 301 to the web console, and the owner's token already returns 200 against api.hetzner.cloud — so this is code work, not a credential problem. The note holds what the next session needs and would otherwise have to rediscover: the endpoints, taken from cloud.spec.json rather than guessed, the fact that records are now RRSets addressed by name and type, that a zone can be addressed by NAME directly, and two traps in the name field — it must not end with the zone name, which is exactly what the current client sends, and PUT on an rrset sets labels rather than records. Recorded rather than started, deliberately. HttpHetznerDnsClient writes the A record for every customer instance; rewriting it is not something to begin without room left to test it properly, and a half-migrated DNS client is worse than a documented broken one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
447d6fe0f3 |
Merge main into the host-takeover branch
One real conflict, in SyncStripeCatalogue::handle(), and both sides were right. Main's parallel work resolves the adoption singletons and takes "before" counts so a run can tell created from adopted. The operating-mode work refuses a catalogue whose stored objects belong to the other mode, and records which mode the catalogue now belongs to. Kept both, with the mode guard first. It REFUSES, so it must not run behind anything that has already built state; the counters only need to be in place before the create loop, and they still are. Reversing that order would have the command resolve singletons and take counts on a catalogue it is about to reject. 2017 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ba4996f316 |
Keep the platform on .com and the customers on .cloud
The owner caught me putting the archive on clupilot.cloud. That is the customer
zone, and looking into it turned up the same confusion already sitting in the
code — in the one place that matters most.
RegisterHostDns says "fsn-01.node.clupilot.com" in its own docblock. The
validation comment in Datacenters says it. ServicesTest writes it out verbatim.
The step itself built the name from config('provisioning.dns.zone') — the
CUSTOMER zone — so on this installation a host was actually called
fsn-01.node.clupilot.cloud. Three places asserting one thing and the code doing
another.
OfficialDomains explains why that matters and is worth not weakening: two
registrable domains by design, the company's for site, portal and console, the
instance zone for customer workloads. A Nextcloud is third-party software that
strangers sign into, and on the same registrable domain as the portal it shares
cookie scope with it. A host name in that zone does not break the separation,
but it puts it in question, and the next slip is more expensive.
So there is now a platform_zone, derived from APP_URL when unset, and
RegisterHostDns uses it.
My own archiveUrl was broken for a second reason. It fell back to url() when
APP_HOST is empty — and APP_HOST is empty on most installations, because empty
means "the portal answers on any hostname" and that is the default. Called from
the console, url() would have produced the CONSOLE hostname, and the line would
have 404'd on a machine that is not allowed to reach the admin area at all. It
takes the host from APP_URL now.
The script no longer guesses its own name. It used reverse DNS, then the tunnel
address, then a hard-coded clupilot.net — a third domain that appears nowhere
else in this project and was simply invented. PrepareBaseSystem has the same
invention. A guessed name does not stay guessed: it ends up in /etc/hostname, in
/etc/hosts, in every log line and in every certificate request the machine ever
makes. CluPilot knows the name because it just assigned it, so it passes --fqdn
and the script refuses without it. The value now also survives the reboot in the
arguments file, which it would not have.
The ACME contact moved to .com for the same reason it was wrong: the operator
does not live in the customer zone.
Open, and NOT decided here: the owner also wants the host to get a public DNS
record and a certificate on the .com name. RegisterHostDns deliberately writes
host names only into the tunnel's dnsmasq, and says why — publishing them hands
every scanner the internal subnet and roughly how many hosts sit behind it.
Nothing in the current design needs a public certificate for a host's own name;
Traefik serves customer domains, not this one. Reversing that is a security
decision and belongs to the owner, not to this commit.
1992 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
668ed67de4 |
Write down what to do when the bootstrap stops
One entry per section: how you notice, where to look, what it usually is. Above all of them the rule the plan already states — a half installed machine is reinstalled, not repaired — with the reason, which is not convenience. The script is built so a second run on a freshly imaged machine always comes out the same; a run on a machine somebody straightened out by hand is no longer that. Repairing trades a repeatable result for a one-off, and the difference only shows up on the next host. rescue_checked is the stated exception, because nothing has been written yet. It is the only section where fixing in place is right, and the runbook says so rather than leaving the reader to infer it. Two things that are easy to get wrong are answered up front. Whether the reboot has happened is one `findmnt -no FSTYPE /` — tmpfs or overlay means still in the rescue system, zfs means the first-boot hook already resumed. And when a host is unreachable, the first question is whether the firewall had even run: it is applied last, so if `registered` is not done, the cause is the network and not the rules. The installer can be watched while it runs. If QEMU is still up, forwarding 5900 over SSH shows what the ISO is doing, and an input mask there means the answer file was not accepted and the installer has dropped into interactive mode where it will sit until the hour expires. That failure otherwise arrives as a timeout saying nothing. The last heading is the honest one: everything here is written from the script and the findings behind it, not from incidents. What actually stops on the first real run belongs in this file afterwards, quoted as it appeared in the console. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f45f290f00 |
Hand over the token and let the console take it from here
The last section carries three things, and none of them could sit anywhere else: the registration and key swap, Traefik's token plus the proof that a route table actually arrives, and the nftables lockdown that ran last in the old pipeline for a reason. `|| true` is gone from the pveum calls, and not by deleting it. `role add || true` applied the privilege list only on the run that first created the role, so a privilege added later reached new hosts and no existing one — which is exactly how Sys.Modify was missing everywhere, surfacing much later and somewhere else as a 403 on POST /cluster/backup that failed a paying customer at register_backup. So: add, and on failure modify, as two separate commands so a failure can be attributed. For the user and the ACL the tolerated `|| true` is replaced by looking: try, and if it fails, check whether the desired state holds anyway. A `|| true` hides "already there" and "went wrong" equally well. The privilege list is copied verbatim from config/provisioning.php and checked against it — 18 privileges, byte-identical, Sys.Modify included. It is granted on / because both endpoints that need it check / and nothing narrower satisfies them. The key swap follows the four steps this plan was amended to require. Register first and hold the answer, switch wg0 to the new private key, PROVE a fresh handshake, and only then let the old key go. If the handshake does not come, it puts the old key back and fails loudly. Ordering alone says when to discard, not whether the new key carries — and a host that locks itself out on the final step is the one failure nobody fixes remotely. Traefik's token is written here because here is where it exists, and the route table is then fetched directly with that token and that URL. Section 7 could not give this proof; it is not skipped, it is given where it can be. The lockdown comes dead last, with the full ruleset from SecureHostFirewall including the ICMP correction — ICMPv6 neighbour discovery and packet-too-big, IPv4 fragmentation-needed — because a bare policy drop takes IPv6 down within minutes and black-holes large transfers instead of failing them. The ruleset is validated with `nft -c` before it is applied, since a partially loaded ruleset means policy drop is in place and the exceptions are not. There is no automatic reopening: a firewall that reopens itself under failure is not a firewall, so the emergency release is a script for the provider's console. Verified without hardware: all seven files dash-clean; json_field reads full-tokenid, value and host_token without jq; the privilege list is identical to the config's; and the rendered ruleset carries policy drop, both ICMP families, 80/443, the DHCP rebind rule and the tunnel-only 22/8006. Step 2 unticked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
5d25018cc6 |
Build the golden template so nobody has to remember how
All three Block A traps are CHECKED here rather than assumed, because each one
has already cost a paid order and none of them shows a symptom until it is too
late.
Trap 2 is checked before anything is built: virt-filesystems is asked whether
the image uses LVM and whether root is the last partition, and the section
refuses if either is wrong. Debian's cloud image satisfies both by construction,
which is exactly why it is the base — but "satisfies it by construction" is a
claim about an artefact somebody else builds, and it gets verified rather than
trusted.
Traps 1 and 3 are checked AFTER the image is customised, because virt-customize
reports success even when apt quietly did not install something. qemu-guest-agent
has to be in /usr/bin or every provisioning run hangs in WaitForGuestAgent until
it times out, and the compose file inside the image has to carry `user:
www-data` or every occ call fails — including the acceptance check that decides
whether a customer's instance is usable.
The template is verified by its ATTRIBUTE, not its existence. VerifyVmTemplate
checks only that 9000 is there, which a VM that merely happens to be numbered
9000 also passes; `template: 1` is passed only by a template.
Docker comes from Docker's own repository, not Debian's. docker.io ships no
compose plugin and Debian's docker-compose is the old Python one, which does not
read this file at all.
The compose file is deliberately customer-independent: no password, no name, no
domain. Everything variable arrives in /opt/nextcloud/.env, written into the
guest by cloud-init at clone time. Baking a password into an image copies it
onto every host and into every instance, and leaves it there long after the
customer has changed it.
OVERWRITEPROTOCOL, OVERWRITEHOST and TRUSTED_PROXIES are set because TLS ends at
Traefik on the host. Without them Nextcloud builds its own URLs with http://,
the login loops, and WebDAV clients get handed an address that does not exist.
Storage is discovered rather than named: local-zfs is what the PVE installer
creates on ZFS, local-lvm on ext4, and `local` frequently cannot hold disks at
all — an importdisk there fails only after the copy.
Verified without hardware: dash-clean; the compose file parses, carries `user:
www-data` on the app service, publishes 80, and every credential is a ${...}
reference rather than a literal. Step 2 unticked, and it is the one the plan is
most insistent about: the template must actually be cloned, started, and asked
`occ status` as www-data. Without that clone it is not proven.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
a25e9c2fe6 |
Run Traefik as a service that fetches its own routes
Binary plus systemd unit, no Docker on the hypervisor: nftables stays the only owner of the host's firewall rules and a Docker daemon would bring its own chain. The names were read out of SshTraefikWriter::render() rather than guessed, which is what the handoff asks for in as many words. The route endpoint emits entryPoints ["websecure"] and certResolver "letsencrypt", so the static config declares exactly those. Name them differently here and the routers point at nothing while Traefik still reports a clean start — a failure with no symptom at the place it happens. An ordering problem this plan had not resolved: the http provider needs the durable host token, and that token only exists after POST /host/register, two sections later. Rather than reorder the section keys — they are the contract with the platform plan — the static config is written twice. Here with an empty token, so the service stands and holds 80 and 443, and again in Task 9 with the real one. The proof does not disappear, it moves to where it can be given. That proof asks the endpoint directly with the same token and URL the config carries, instead of counting Traefik's routers. A fresh host has no customers, so its table is legitimately empty, and "zero routers" would mean both "fine" and "never fetched". Ports are checked separately from the service. "Running" and "listening" are two claims, and Traefik starts cleanly even when a typo means an entryPoint was never created. The binary is checksummed against the release's own checksums file, for the same reason the ISO is: what listens on 80 and 443 and holds every customer's certificate does not get taken off the network unverified. The version is discovered at runtime, because a pinned number becomes a 404 mid-takeover. acme.json is created at 600 before Traefik ever runs. It holds the private keys of every customer certificate, and Traefik refuses wider permissions — rightly. The unit runs with CAP_NET_BIND_SERVICE and nothing else, ProtectSystem=strict, NoNewPrivileges: this is the one process on the box reachable from the open internet. Verified without hardware: dash-clean, the generated config parses as YAML, and it carries web/websecure/traefik as entryPoints, letsencrypt as the resolver, the Bearer header on the http provider, web redirecting to websecure, and 640 on the config with 600 on acme.json and the token file. Step 2 unticked — it wants a fetched route set, and that is Task 9's to show. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c345f21e7d |
Join the tunnel, then tell the console everything so far
The three interface states come over from ConfigureWireguard.php exactly as that file works them out, because the one line they replace got two of them wrong. `systemctl enable --now wg-quick@wg0 || wg-quick up wg0` brought the interface up via its fallback WITHOUT the systemd enablement, so the tunnel did not come back after a reboot; and on the next attempt both halves failed with "wg0 already exists", so it retried forever against a tunnel that was working. Enable before start, because enable is the half that survives a reboot, and it is repaired even when the interface is already up by other means. The configuration is compared before it is written. The file used to be rewritten unconditionally with nothing reloading it, so correcting a wrong hub key showed a new file and identical behaviour — the running interface still held the old peer. Knowing whether it CHANGED is what lets an unchanged, working tunnel be left alone and a corrected one actually be applied. A change means a restart rather than `wg syncconf`, because syncconf applies peers only and a corrected Address or AllowedIPs would silently do nothing. AllowedIPs is computed, not copied. Writing the host's own address there would let the tunnel handshake and leave every other participant unreachable — a fault that looks like a routing problem somewhere else entirely. The handshake target is derived from --api rather than taking its own argument. CluPilot's tunnel address is already in there, and a second value meaning the same thing is a second value that eventually disagrees with the first. The tunnel counts only when it has been PROVEN, never when the file is on disk. A stored tunnel address without proof made every later connection attempt useless and the recovery was hand-editing the database. Once it is proven, flush_reports runs and the whole history from the rescue system onwards reaches the console carrying its original timestamps. Verified without hardware: dash-clean; the subnet arithmetic is right on octet boundaries and off them (192.168.5.130/25 gives 192.168.5.128/25, 172.16.4.9/12 gives 172.16.0.0/12); the API host parser handles a bare address, a scheme, a port and a path; and wg0.conf renders with AllowedIPs on the network rather than the host. Step 2 unticked — it wants all five earlier sections showing up in the console with real timestamps, and that needs a hub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
9e485efe87 |
Write the firewall's running order into the plan, not just the commit
The previous commit moved the nftables lockdown out of Task 5 and explained why in its message. A commit message is not where the next person looks. Task 5 now carries it: the bridge is normally already there because the ISO installer writes it, the lockdown belongs in Task 9 because SecureHostFirewall ran last for a reason, and "check from outside" cannot mean what it says before the tunnel exists — so the script checks inside-out and says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f214713113 |
Point Proxmox at the sources this Debian actually has
PVE arrives with the image now, so this section accepts it rather than installs it — but it needs every line of knowledge from InstallProxmoxVe.php to do that, just pointed the other way. The kernel is the claim, not the package. An installed proxmox-ve running on a Debian kernel is exactly the half state RebootIntoPveKernel exists because of, so uname is what gets checked. The codename table now verifies a pairing instead of creating one. An image whose PVE major does not match its Debian base was built wrong, and no amount of fixing package sources heals a PVE compiled against a different libc — that machine gets reinstalled with the right image. The other half of the table, codename to PVE major, is written down here because the ISO route needs it and the original file only had the suite side. Removing the subscription repositories is the part that everything after this depends on. A Proxmox image ships pve-enterprise, and without a subscription it fails every apt-get update — after which no package install in any later section succeeds. Both spellings are handled, because PVE 8 used one-line .list files and PVE 9 moved to deb822, and ceph is in the list because the same trap is set there a second time. The proof is a clean apt-get update at the end, which is the only thing that tells removal apart from overwriting. One thing changed after the first draft: when pveversion cannot be parsed, the pairing check used to be skipped in silence. That is the fake that R19 records as worse than no check at all, because it stops the next person from looking. It now says "Paarung UNGEPRÜFT" in the report and in the log. Verified without hardware: dash-clean; bookworm maps to PVE 8, trixie to PVE 9, forky is refused; the pveversion parser reads 9 and 8 out of both real formats and yields nothing for garbage; and against a copy of /etc/apt/sources.list.d holding all five subscription files plus an unrelated one, exactly the five go and the unrelated one stays. Step 2 unticked — there is no Proxmox here to accept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0f563545fd |
Write the image and survive the first reboot
The official Proxmox ISO, an answer file baked into it, and QEMU running it against the physical disks from the rescue system. Options were looked up rather than recalled: prepare-iso takes --fetch-from iso, --answer-file and --on-first-boot, and it names its own output, so the finished ISO is found by glob afterwards instead of being handed an --output flag I would have invented. validate-answer runs BEFORE the ISO is baked. An answer file with a typo drops the installer into interactive mode, where it then hangs invisibly inside QEMU until the hour runs out — the failure would arrive as a timeout and say nothing about the cause. The codename table comes over verbatim from InstallProxmoxVe.php, which the platform plan deletes. It is used here to install the assistant into the RESCUE system, so it keys off the rescue system's own codename. An unknown one aborts; not "trixie is newest, so trixie", because the next Debian will be unknown too and by then nobody is watching. The ISO is checksummed against the mirror's SHA256SUMS. Booting an operating system that could have become anything in transit is not a risk worth taking, and a truncated download is enough to cause it — malice is not required. The reboot carry-across is the gap this plan was amended for, and [first-boot] takes exactly one executable. So that one file carries everything: the script, its lib/, the progress file and the arguments, as a base64 archive in its own body. That also avoids importing the fresh ZFS pool from the rescue system to copy files into it. Two bugs found by running it rather than reading it. The staging copy put the work directory inside the state directory, so cp refused to copy a directory into itself — and had it not refused, the gigabyte ISO would have been base64'd into the hook that gets baked into that same ISO. The fix is not a better exclude: the two directories have opposite lifetimes, so they now live in different places, and a guard refuses loudly if anyone points them at each other again. Structure beats a rule someone has to remember. Verified without hardware: both files pass sh and dash. The generated answer file parses as TOML with the expected disk-list, zfs.raid, zfs.arc-max, first-boot and network.filter, and a 64-character root password. The codename table accepts bookworm and trixie and refuses bullseye and forky. The shim comes out at 26 KB with a 3 MB dummy ISO sitting in the work directory, proving it stayed out. Run in a fresh debian:13-slim container it unpacks to /opt/clupilot/bootstrap and /var/lib/clupilot, restores the progress file with its original timestamp, has 700 on the directory and 600 on the arguments that hold the WireGuard key, skips rescue_checked as already done, and fails cleanly on the missing qemu. Step 2 stays unticked: a container is not a rescue system, and nothing here has yet written to a disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0b26e45147 |
Install Proxmox the way Proxmox says to, not the way I guessed
The previous commit put installimage with a Proxmox image into Task 3. That
image does not exist. Hetzner's installimage carries no Proxmox and lists it as
unsupported, so anyone following that instruction finds only Debian and ends up
back at the intermediate base system the same commit had just removed. Looked
up rather than remembered, which is what should have happened before writing it
down.
The mechanism the owner described does exist and is the official one since PVE
8.2: build the real Proxmox ISO with an embedded answer file via
proxmox-auto-install-assistant, run it under QEMU against the physical disks
from the rescue system, reboot into a finished node. Hetzner publishes a
tutorial for exactly this. No Debian, no VNC, no click — which is what the
owner said in the first place.
Two things fall out of the answer file that improve the plan.
The hand-written systemd resume service is gone. [first-boot] with
source = "from-iso" is the supported way to have the script pick itself up
after the reboot, and it is the only variant that can work before the tunnel
exists — a from-url hook would be pointing at a CluPilot it cannot reach yet.
The carry-across of the progress file stays necessary regardless: the rescue
system's copy still lives in RAM.
Trap 2 was being applied to the wrong machine. "Root partition last, not on
LVM" comes from the template traps, and the reason it exists is
GrowGuestFilesystem, which grows a GUEST filesystem. The template is built in
Task 8 from a Debian cloud image, not from the host's own layout, so the
justification written into Task 3 ("it applies to the host too, because the
template is built from here") does not hold. It remains binding for the
template, where it belongs. The host gets ZFS, and that it happens to involve
no LVM is a side effect rather than the reason — with arc-max pinned, because
the default takes half the RAM a host needs for its guests.
Also recorded: the answer file needs an fqdn and a root password that the
command line does not carry. The password is generated and thrown away, because
the platform no longer logs in over SSH and the plan already says a half
installed machine is reinstalled rather than repaired — keeping a secret for an
emergency the plan does not have only creates another place a secret lives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
8f943f3d1b |
Take the Debian step out of a plan that never needed one
The owner contradicted the plan, and the plan was wrong. It had the script write Debian 13 and then had Task 4 turn that into a Proxmox host. In reality you boot the provider's rescue system and install Proxmox directly — there is no intermediate Debian for anyone to convert. The section keys do NOT change. They are the only agreement with the platform plan, and they are not worth touching for a rename — especially since they still fit: Proxmox VE is Debian with the PVE kernel and packages on top. What changes is what they mean, written down so the console labels them honestly: debian_installed is "base system written", proxmox_installed is "PVE proven to run". Task 3 now uses installimage with a Proxmox image, and says the image name gets pinned during acceptance instead of guessed here — the list belongs to the provider and moves. No debootstrap and no hand-rolled partitioner: the provider knows its own hardware, and a self-built boot path on someone else's metal only comes back through their console. Hetzner dedicated only; a netcup path gets written when there is a netcup machine to accept it on, because an unproven fallback is exactly the assertion this plan avoids everywhere else (R22). Task 4 keeps every line of knowledge from InstallProxmoxVe.php and needs all of it — it just applies it to an image instead of a bare base. The codename table now VERIFIES the pairing rather than creating it, and the enterprise repo that ships with a Proxmox image has to go, because without a subscription it fails every apt-get update and therefore every package install in the sections that follow. Its acceptance now includes a clean apt-get update, which is the only way to tell removal from overwriting. Delivery is settled too, and was written down in neither document: a tar.gz unpacked to /opt/clupilot/bootstrap. A curl | sh cannot carry a lib/, and the separate files are load-bearing. A static archive is not an endpoint that answers questions, so spec §5 stands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
25bcab345d |
Refuse a machine that cannot do the job
Four checks, all of them before a single byte is written, because this is the
only moment the script still has the option of not overwriting a disk.
The expensive one is "is this actually a rescue system". It demands POSITIVE
evidence rather than the absence of a counter-argument: a rescue system runs
from RAM, so its root is tmpfs, an overlay or a ramdisk, and an installed
system has a real partition there. A running pveversion is refused outright —
that is a hypervisor with customers on it. Mounted partitions of real disks are
refused too, because at that point the script cannot tell an empty machine from
someone's data.
There is deliberately no flag to skip the check. Such a flag gets used exactly
once, on the evening it should not have been. A rescue system this does not
recognise belongs in the runbook, not in a bypass.
The disk floor is written as arithmetic rather than a number, because the repo
has no threshold to borrow — plans live in the database, not in
config/provisioning.php. Template ~20 GB, Proxmox and its swap and backups
~20 GB, one smallest customer ~50 GB, rounded up to 100. It is explicitly not
capacity planning; HostCapacity and reserve_pct do that after registration.
This only turns away the machine the whole thing cannot fit on.
Network and clock come from one plain-HTTP HEAD, deliberately without TLS —
otherwise a wrong clock would be checking itself and would report a certificate
error instead of the time. Where `date -d` is missing (busybox), the clock is
NOT checked and the report says so: a check that silently waves things through
is worse than none, because it stops the next person from looking. That is the
same lesson R19 records about ->timezone(config('app.timezone')).
Verified here, in both directions where a direction existed. On this VM (root
on /dev/sda1, 80 GB, /dev/kvm present): refuses with three findings at once,
exits 1, writes nothing. In a debian:13-slim container (root on overlay, no
/dev/kvm): the overlay root is accepted as rescue-like and the missing
/dev/kvm is caught, which is the abort case the plan names. Against a receiver
serving a Date header two years off: 63074141 seconds of drift, refused, with
the exact `date -u -s` line to fix it. Step 2 stays unticked — none of these is
a rescue system on a dedicated server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
af284d7d26 |
Give the bootstrap a spine and a way to report
Argument parsing, the state directory, and the half of the script that knows the way back to CluPilot. No sections are hooked up yet; main lists the nine it will get, in the order from spec §7, so what is missing stays visible. report() writes locally first and only then tries to send, because before wireguard_joined there is no path at all — the lines queue up and flush_reports delivers them later WITH THEIR OWN TIMESTAMPS. That last part is the whole point: a twenty-minute install that arrives in one batch looks like one second in the console, and nobody can see which section was slow. Every network operation is explicitly caught. The script runs under `set -e`, and a failing send is the normal case for the first five sections, not an error — a report that aborts the run would be the diagnosis that kills the patient. What is sent had no agreed shape. The section keys were the only agreement between the two plans; the envelope around them was not written down anywhere, so it is written down here, at the top of report.sh, for the platform side to read once. There is deliberately no "running" state: the console derives "open" from the absence of a report (spec §7), and a third state would be a second truth about the same thing. The sent-marker is a line count in its own file rather than a flag rewritten into progress.jsonl. That keeps the log append-only, so a power cut mid-write costs at most a partial last line instead of a rewritten file — and after the reboot in Task 3 that file is the only thing that remembers anything. Verified here, without hardware: both syntax checks pass; two reports against an unreachable CluPilot queue up without aborting and leave progress.sent at 0; a receiver brought up afterwards gets both in one batch, carrying 17:28:55 and 17:28:57 rather than the 17:29:22 they arrived at; a message containing quotes, a backslash, a tab and a newline survives as valid JSON; and section_done counts a `done` as done and a `failed` as not. Step 2 of this task is NOT ticked — none of that is a rescue system, and the plan is right to insist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
79654333a8 |
Name the recurring filter, and stop the spec contradicting itself
Three sentences left over from the wave before, all of the kind this branch exists to end. - "Every active Price" was one word short. activePricesFor() sends `type: recurring` as well as `active: true`, so a one-time Price is never listed — which made the hand-built-payment-link sentence false for a one-time link: neither listed nor archived. Both places say `recurring` now, and the docblock says why the filter is not a gap: createPrice() always sends `recurring[interval]` and is the only call that mints a Price at one of our Products, so the filter can only ever hide somebody else's one-time Price. A filter left out of a sentence is a gap the next reader goes looking for. - The sweep test file still opened "The orphans nobody can adopt" — the exact narrowing the command's own docblock had to be corrected away from. It happens to describe the fixtures, which are all metadata-less, so it now says that instead of claiming it of the command. - The design doc called --archive "harmlos" seven lines above the correction establishing that it is not, for an adoptable orphan. A document that contradicts itself seven lines apart teaches nobody anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
61c2501136 |
Close three gaps before they become three bugs in the script
Read on review, before a line of shell was written. All three would have been found on hardware instead — two of them late, and one of them only by noticing something that was silently absent. The progress file did not survive the reboot. Task 1 writes it in the rescue system, whose root lives in RAM; Task 3 installs Debian and reboots. The plan said the resuming service uses "the same progress file" without saying how it gets there. Because flush_reports first runs in Task 6, AFTER the reboot, rescue_checked and debian_installed would simply never have reached the console — and nothing would have pointed at the reboot as the reason. Task 3 now carries the file, the script, and the arguments across, the last one root-readable only because the WireGuard key is in it. RebootIntoPveKernel.php was on the platform plan's delete list but not on this plan's read list. Six files were named; seven are deleted. This is the one that proves the boot path BEFORE issuing the one irreversible command in the whole run: a -pve kernel image present, its initramfs present (a full /boot leaves exactly the half-written one), and update-grub checked on its exit status, which its predecessor discarded. Losing that would have cost a machine that does not come back, and on a dedicated server that means the provider's console. Reading it also corrected a rule this plan states. Task 9 rightly refuses `pveum ... || true`, but that is not a rule against `|| true` — RebootIntoPveKernel tolerates one deliberately, with the reason written next to it, and a script that applies the ban literally breaks the kernel removal in Task 3. The rule is now stated as what it is: no `|| true` over a command whose failure means something. The key swap in Task 9 had no proven handshake. Task 6 insists a tunnel counts only with one, then Task 9 re-keys that same tunnel and discards the old key on ordering alone. Ordering says when to discard, not whether the new key carries. Four numbered steps now, with the proof third and the discard fourth, and a fallback to the old key if the handshake does not come — the one failure nobody fixes remotely, at the end of a run that did everything else right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
fe07fa2e77 |
Break the host takeover into two plans that can run side by side
Platform: ten tasks, all provable by the suite. Enrolment code and key pair, the tunnel-only restriction (its test is the reason the design looks the way it does), register, progress, routes, retiring SshTraefikWriter, the console, the customer's own subdomain, shrinking the pipeline to six steps that only look, and pulling the readiness checks along. Script: ten tasks, none provable by the suite — a script that installs an operating system cannot be run in SQLite. Each one ends on real hardware instead, and two of them insist on it: the bridge section must have its self-revert triggered on purpose, and the template must actually be cloned and booted before it counts. The one real coupling is written into both: platform task 9 DELETES the seven step files the script plan reads its knowledge from. Do not run it before that knowledge has been lifted — git log keeps them, but nobody reads history they do not know to look for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a43be4186a |
Say what the sweep did not check, before telling anyone to archive
stripe:sweep-orphan-prices described itself as listing "what AdoptStripePrice will not take over". It computes nothing of the sort: it lists every active Price at one of our Products that no register row knows, adoptable or not — the only question it asks is whether a row holds the id. And then it invited the destructive path, with no word anywhere about running stripe:sync-catalogue first. Following that advice can make a plan unsellable. Archive an orphan the next sync would have adopted and the sync can no longer see it: activePricesFor() asks Stripe for active prices only, so adoption returns null and createPrice() fires under the same key the crashed run used. For twenty-four hours Stripe answers a repeated key by replaying the stored response — the archived Price's id, in the body it had while it was live. The register writes it down as live, and the checkout is refused. This is the same class of defect the branch exists against: a comment that was eloquent and wrong. - The docblock now says what the command lists, names the twenty-four-hour replay so nobody has to rediscover it, and the ordering requirement is in $description and printed on every path that found something, --archive included. The reassurance that archiving is harmless is now scoped to what THIS codebase sells; a payment link an operator built by hand against one of our Products is withdrawn all the same, and says so. - stripe:sync-catalogue --dry-run says "created or adopted" again. A dry run counts intents without calling ensure(), so it cannot know which Stripe already holds — and a dry run is the first thing an operator runs after an interrupted one. The live line keeps both figures, where they are knowable. - The sweep's plan side gets a test: an orphan at a family Product is reported and a known plan Price never is. Deleting either half of the lookup turns it red; the five tests it joins all stayed green for the products() half. - The throttle test pinned "once" and neither "per price" nor "per day". A key mutated to a constant would have silenced every orphan after the first and kept it green — the silent no-op the branch is built against. It now plants a second orphan, and travels a day to hear the first one again. - Two of my own documents corrected. The spec sentence claiming an adoptable orphan never appears in this list is what the implementation faithfully built; it holds only if a sync has run since the orphan appeared, which nothing enforced. And the two adoption classes do not carry the same run log: $adoptions on the Price side, $duplicates on the Product side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
32016d9b80 |
Write down the network boundary, the subdomain, and two decided follow-ups
The route pull stays inside the tunnel. The operator's criterion was not availability but "publicly reachable, yes or no" — and an endpoint that survives a tunnel outage must by definition be reachable outside it. So nothing new opens from outside, on the host or here. The subdomain becomes the customer's choice at checkout, with suggestions from their own name and a sentence saying the address is permanently public: every Let's Encrypt certificate lands in the Certificate Transparency logs, which are public, searchable and never deleted. Also recorded, both decided and both getting their own spec: resetting a Nextcloud to factory state with a 14-day safety backup, and an outage mail to the operator after two consecutive failed checks — measured today that neither an incident nor a mail exists, so a customer VM can fail unnoticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
277a6bab83 |
Give the operator something to do about an orphan nobody can adopt
The recognition step refuses a price that proves nothing about itself, and that is right — adopting a stranger's price is worse than minting a second one. It left an operator with a log line and no way to act on it. Reports by default and touches nothing. With --archive the orphans stop being sold, which is harmless for the reason it always is here: Stripe goes on billing every subscription already on an archived price, and nothing is sold on a price no row knows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c0cd367a0e |
Drop the public endpoint: the command line already carries what the host needs
The operator was right to challenge POST /enrol. The three values a script needs before the tunnel exists — hub key, hub address, assigned tunnel IP — are all known to the console when the host is created, so they travel in the copied command instead of being fetched. The one thing that forced an endpoint was the hub needing the host's public key before the tunnel is up. So the console generates the pair and hands it over, and the host swaps in a freshly generated key through the tunnel afterwards. The key that passed through a clipboard lives for minutes. Net effect: the network boundary is unchanged. No new way in from outside. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0f1f397823 |
Design a host that installs itself and a platform that only takes it over
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
7d66a01a40 |
Break the follow-ups into six testable steps
Six tasks, each ending on a green billing folder and its own commit: move the money gate into the class that promises it, four fixes that make the fake behave like Stripe, ask Stripe for its products, recognise an orphaned one, count adopted apart from created, and hand the operator a sweep for the orphans nobody can adopt. Self-review dropped one item the spec had carried over from the last review: the zero guard in PlanPrices::ensure(). Its only purpose was to keep a tiered price unreachable, and the gate move rejects those directly — so it would be a behaviour change with no remaining benefit, and it would make a free plan unsellable. Recorded as deliberately not built, with the reason, rather than quietly dropped. Also corrected: a test that counted every product where the run mints one per family, and a step that named the test it replaces only by description. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3ceec8fe5e |
Design the nine things the reviews found and nobody built
The one that matters is orphan PRODUCTS. A price orphan is recognised now; a product orphan is not, and it is worse — a second product makes every activePricesFor() ask about the wrong one, so price recognition goes blind for that whole family and the guard disables itself through the gap beside it. Duplicated products are reported, not deactivated. That is the owner's call and it is the careful one: an archived PRICE is provably harmless, which is what the grandfathering rule has always rested on, but deactivating a PRODUCT makes its prices unsellable and contracts can be running on those. What was proven for one is not proven for the other. The money gate moves into the class whose docblock promises it. Today AdoptStripePrice can compare three properties and the other four are filtered in one client, which the fake cannot express — so no test can reach the promise the class makes. Also: a sweep command for orphans nobody can adopt, a day's throttle on the warning that currently fires on every customer booking, and six mechanical fixes. Self-review caught one of my own: the plan to tell "created" from "adopted" by comparing the price id before and after the call cannot work — there is no id before, in either case. AdoptStripePrice counts its own adoptions instead, which needs it bound as a singleton, and the spec now says why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
865fd16f58 |
Put the price recognition back, all 3526 lines of it
|
|
|
|
2321854967 |
Discard an unconfirmed registration, and sweep the abandoned ones
tests / pest (push) Failing after 8m17s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
"Abmelden und neu registrieren" was advice that could not work. Signing out frees nothing: the address is still held by the unique index, so registering again with the address somebody meant fails — and every abandoned attempt stayed in `users` for ever. Reported exactly that way, and it was right. The button discards the account instead. Only an UNCONFIRMED one, and only one with nothing behind it: the webhook creates a customer's login from a paid checkout, so a user can sit on this page unconfirmed while already having a contract, and deleting that is data loss dressed up as a convenience. It refuses and says where to go. A confirmed account is closed, not discarded — that is ConfirmCloseAccount, with an invoice history behind it. R23: confirmed in a modal. The modal mutates nothing; it dispatches the event the page listens for, so the checks stay in the one place they already are rather than being duplicated where they could drift. And nothing has to be discarded by hand. clupilot:prune-unverified removes registrations nobody confirmed after five days — long enough for somebody who signed up on a Friday and found the mail in a spam folder on Monday, short enough that a typo does not hold the correct address for a month. It skips a confirmed account whatever its age, and any account with a customer record behind it, matched on user_id AND on the address, because either link means somebody is a customer. Every removal is logged with the address: a line that disappears is what somebody asks about later, and a count answers nothing. The deadline is said on the page as well as in the mail. Somebody who never received the mail is looking at the page, and the deadline is the reason they do not have to do anything about an attempt they abandon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
43d1d55909 | Merge branch 'main' into claude/cool-sammet-368fee | |
|
|
4eb90c834a |
Write down what the reviews found and nobody fixed
Ten follow-ups, none of them blocking and none of them done. The one that matters: activePricesFor() recognises an orphaned Price and nothing recognises an orphaned PRODUCT — and a second Product is worse than a duplicate Price, because every activePricesFor() then asks about the wrong Product and recognition goes blind for that whole family. The branch disables its own guard through the gap it did not close. Second: AdoptStripePrice promises in its own docblock that no adoption can move money, but can only compare amount, currency and interval. The four properties that also decide what a Price charges are filtered in HttpStripeClient, which the fake cannot express — so no test of the class can reach the promise it makes. Also recorded: two pre-existing test failures this branch surfaced without causing, with the diagnosis, so the next person does not derive them again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3cf16ecd63 |
Name every property that decides what a price charges
The money gate compared amount, currency and interval and left the rest to a filter that excluded two fields. A recurring, licensed, monthly price with transform_quantity divide_by 10, at our exact figure, on our product, carrying our addon key passed all five conditions. Modules bill BY quantity — a pack is one item at quantity n — so a customer holding three would have been charged one. A tiered price was excluded only by accident: Stripe reports unit_amount null for it and we read that as 0, which fails the amount match unless the caller's own figure is 0, and PlanPrices::ensure() has no zero guard. activePricesFor() now also skips a non-empty transform_quantity and any billing_scheme other than per_unit. Absent keys stay Stripe's defaults, which is the direction that matters: read the other way round the filter would refuse every legitimate price and turn recognition into a permanent no-op. The contract docblock names the four properties instead of claiming to cover all of them, and says the list is not everything Stripe can put on a price — only what is known to change what one charges. And a comment that says a gap is covered is worse than the gap. The sync said a crash between Stripe creating a product and us storing its id gives back the same product next run: true for twenty-four hours, false afterwards, and the exact claim this branch was written to refute. There is no recognition step for products at all, so an interrupted run leaves an orphan and the next run past the expiry mints a second one — worse than a duplicate price, because activePricesFor() then looks at the wrong product and recognition goes blind for the whole family. Written down as a known gap at both createProduct call sites, with the trade this branch chose: folding name and metadata into the key means a renamed family now mints a second product where it used to answer 400, and a blockade reaches a paying customer while a duplicate product does not. The sweep no longer reports "created" for what it adopted — the count is taken before ensure() runs, and telling those two apart is the whole point. The test named after 2026-07-29 could not reproduce it: the fake's key ledger was empty, so createPrice() could not have thrown and the test could not tell "adoption prevented the 400" from "no 400 was possible". Seeded now with the key today's call sends and a fingerprint over the metadata the pre-9da1358 call sent. Remove the adopt() call from AddonPrices::ensure() and the test dies with Stripe's own sentence instead of an assertion. Also: the plan document still instructed migrate:fresh --env=testing. There is no .env.testing here, so it targets the live development database; struck through with the reason, not deleted, and a Korrekturen section records the five defects the plan's own reviews found in it. Four comments corrected — "below" that meant above, a counter claimed unable to tie that ties with a planted price, the unique index's second meaning for remember()'s catch, and that MariaDB DDL is not transactional so the dedupe commits before the index. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
dba5df5d1e |
One row per Stripe price on the module side too
The plan register has had this since 2026_07_30_110000. The module register never did, so two rows could claim one Price and archiving the one would have withdrawn the Price the other was still selling — Block D of the real-run handoff. It is also the net under the adoption step: at a VAT rate of nought both treatments charge the same amount, and the check that refuses an already-claimed Price is then the only thing keeping the two apart. Duplicates are deleted, not archived. An archived row goes on claiming the id and a unique index knows nothing about archived_at; the row rebuilds itself on the next ensure(), and subscription_addons holds the Stripe id as text rather than a foreign key, so no booking loses its price. AddonPrices::remember()'s catch comment described the guard as the only thing standing between two rows and one Price; now that the index exists, rewritten to describe it as the net under that guard instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
fe4a74773e |
Break the mode switch and the readiness page into twelve steps
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0ec5d93da8 |
Break the price-adoption spec into six testable steps
Six tasks, each ending on a green billing folder and its own commit: the key fingerprint (and a fake that finally reproduces Stripe's refusal), the two listing calls, the adoption step itself, then each price service in turn, then one row per Stripe price. The spec now names both test files. What goes on the wire is checked through Http::fake against the real client — asserting against the fake would only prove the fake agrees with itself, which is the mistake that let the incident through. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3768433792 |
Design a test/live switch and a page that says what is missing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
af9d60469e |
Write down why a half-finished sync blocked itself for a day
A run on 2026-07-29 23:11 created price_1TygdEC7u8NpJ8pOt3nsoyYw at Stripe and
died before the row was written.
|
|
|
|
36cb684d8b |
Write down what the audits found and what a real run needs
tests / pest (push) Failing after 8m0s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
e44f6658db | Hand off with the process failure at the top, not the feature list | |
|
|
9fc74eedc3 |
Hand off with the effort rule at the top, not the feature list
tests / pest (pull_request) Failing after 7m21s
Details
tests / assets (pull_request) Successful in 23s
Details
tests / release (pull_request) Has been skipped
Details
tests / pest (push) Failing after 7m18s
Details
tests / assets (push) Successful in 27s
Details
tests / release (push) Has been skipped
Details
|
|
|
|
e6793c4dae |
Plan the operator identity work as nine testable steps
Re-measuring before writing found the spec's numbers had moved: seventeen permissions now, not sixteen, because the mailbox work added mail.manage. It also turned up something the spec did not anticipate — ConfirmsPassword resolves the password through auth()->user(), the default guard, so all five components using it break the moment a second guard exists. That is now its own task. Self-review caught two references to things that do not exist: users has no uuid column, so the impersonation link addresses the customer instead, and there is no layouts.guest — the auth pages use layouts.portal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |