tests / release (push) Blocked by required conditionsDetails
Zwei Nachwirkungen der ersten Fix-Welle (c9fef59).
1 — Die Regression, die sie selbst gesetzt hat. `endedAt()` fragte nur
„laeuft gerade nichts?" und nahm dann die juengste beendete Instanz. „Nichts
in Betrieb" ist aber nicht dasselbe wie „mit uns fertig": eine frisch
bestellte Instanz entsteht als `reserving` (ReserveResources) und bleibt das
den ganzen Bereitstellungslauf lang, `failed` (Order::markFailed) steht, bis
ein Betreiber wiederholt — beide fehlen in der Betriebs-Abfrage. Ein
wiederkehrender Kunde, der eben bezahlt hatte, las deshalb „Ihre Cloud ist
beendet." samt „Neues Paket buchen", direkt ueber dem Streifen, der seinen
laufenden Aufbau zeigte.
Jetzt muss die JUENGSTE Instanz des Kunden die beendete sein. Alle sechs
Instanzzustaende nachgeprueft (reserving, provisioning, active, failed,
cancellation_scheduled, ended; `suspended` ist seit 2026_07_31_160000 keiner
mehr, sondern eine eigene Marke) — jeder ausser `ended` ganz oben heisst: es
geschieht etwas Neueres.
Dazu eine zweite Bedingung, die die erste allein nicht traegt:
ReserveResources parkt eine bezahlte Bestellung ohne Platz und legt dabei GAR
KEINE Instanz an, bis zu vierzehn Tage. Die juengste Instanz ist in diesem
Fenster weiterhin die alte, beendete. Der Kasten bleibt deshalb weg, solange
fuer den Kunden ein Aufbau laeuft — dieselbe Bedingung, unter der
CustomerProvisioning den Fortschrittsstreifen zeigt. Zwei Bauteile auf einer
Seite duerfen einander nicht widersprechen.
2 — Das Versprechen stand noch dort, wo entschieden wird. Die erste Welle hat
drei von vier Stellen zurueckgenommen; die vierte war der Kuendigungsdialog
selbst. Der Kunde las dort einen Liefergegenstand („einen Link zum
Herunterladen", „erhalten Sie ... einen vollstaendigen Datenexport"), klickte
einmal auf Ja — und die Vertragsseite sagte danach nur noch „Ihr Wunsch ist
vermerkt, wir melden uns". Beide Saetze sind jetzt auf dasselbe Mass gebracht:
kein Liefergegenstand, keine Frist, kein Link, solange der Export nicht gebaut
ist. Der wahre Teil bleibt wortgleich stehen — ab dem Laufzeitende ist die
Cloud nicht mehr erreichbar, bis dahin selbst sichern.
lang/{de,en}/order.php bleibt absichtlich unveraendert: dieser Satz steht im
Verkauf und beschreibt, was das Produkt zusagt. Ihn zu streichen aendert das
Angebot und ist eine Entscheidung des Eigentuemers. Begruendung im Bericht.
Zu beiden Befunden wurde der Fix zurueckgedreht und die neue Zusicherung rot
gesehen (3 rot beim Waechter, 2 rot bei den Sprachdateien), dazu die
Gegenprobe mit einem zu breiten Waechter (1 rot). Ganze Suite: 2842 gruen,
0 rot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Am 4. August 2026 hielt ein einziger Aufruf — `timeout 45 docker compose
exec` ohne `--kill-after` — die Sperre stundenlang, und jeder folgende
Lauf wurde übersprungen. Behoben ist die Ursache in v1.6.5; was fehlte,
war der Griff für das nächste Mal. Er lag per SSH auf dem Wirt.
Die Konsole kann ihn nicht selbst führen: sie ist www-data in einem
Behälter, der Sperrenhalter ist ein Prozess auf dem WIRT. Also bittet
sie den Agenten, und der ruft den root-eigenen Helfer — dasselbe Muster
wie `apply-proxy-hosts`, mit eigener sudoers-Zeile.
Die Bitte liegt in einer EIGENEN Datei, und das ist keine
Geschmacksfrage: der Agent nimmt die Sperre in seinen ersten Zeilen,
lange bevor er den Postkasten ansieht. Ein blockierter Lauf steigt
vorher aus — eine Entsperr-Bitte im Postkasten erreichte ihn also genau
dann nie, wenn sie gebraucht wird. Dazu nimmt der Postkasten eine Bitte
zur Zeit an, und die dreißig Minuten, in denen dort eine wartende
Update-Anfrage liegt, sind die, in denen jemand entsperren will.
Der Dienstbenutzer sagt „gib die Sperre frei", nicht „töte 1234": welcher
Prozess das ist, sucht der Helfer selbst. Dürfte der Anrufer die Nummer
liefern, wäre die Freigabe das Recht, jeden beliebigen Prozess als root
zu beenden.
Beendet werden alle, die die Sperrdatei OFFEN halten — nicht nur der,
der das flock genommen hat. Ein flock hängt an der offenen
Dateibeschreibung, und die wird vererbt: stirbt der Agent, sein
hängendes `docker compose exec` aber nicht, bleibt die Sperre gehalten.
Genau das war der Vorfall. Verschont bleiben PID 1 und der Anrufer samt
Vorfahren — im Betrieb hält der blockierte Agent die Datei selbst offen
und ist zugleich der, der anruft.
Erst SIGTERM, fünf Sekunden, dann SIGKILL. Eine Frist ohne Nachdruck ist
keine Frist.
Bestätigt wird im Modal (R23), das VORHER nennt, was es beendet — PID,
Laufzeit und Kommandozeile aus dem Lebenszeichen. Eigene Berechtigung
`deployment.unblock`, nicht `site.manage`: wer aktualisieren darf, darf
damit nicht automatisch in einen laufenden Vorgang hineingreifen.
Der Helfer-Vertrag steigt auf 3, und update.sh verlangt ihn. Ohne das
wäre der Knopf auf einem Wirt, der install-agent.sh seither nicht mehr
gefahren hat, ein Knopf, der still nichts tut — und die Konsole sagt es
jetzt, statt den Betreiber dorthin zurückzuschicken, wo er ohne sie
schon war.
Geprüft wird ausgeführt, nicht begutachtet: der Helfer läuft im Test
gegen eine echte Sperrdatei mit echten Prozessen daran. Das hat gleich
einen Fehler gefunden — der Helfer erbt den Deskriptor und hielt seine
eigenen Kommandosubstitutionen für Halter, sodass die Runde nie zum Ende
gekommen wäre.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eigene Faehigkeit, eigener Schluessel, eigene Tuer. Die Bruecke bleibt
unveraendert: sie kennt keine Hosts, nur Tickets.
Den Weg zum Wirt traegt die root-eigene Haelfte des Updaters — sie holt
sich die oeffentliche Haelfte selbst, prueft sie hart und schreibt die
Optionsliste, die das Dienstkonto nicht bestimmen darf. Kein sudo-Weg
dorthin: einen Root-Schluessel eintraegt ein Mensch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Vier Befunde aus dem Gesamt-Review, und alle vier hatten dieselbe Wurzel:
`export_wish` fuehrt drei Zustaende, und jede Stelle, die den Kunden
ansprach, kannte nur zwei.
K1 — Der Streifen im Dashboard trug einen Schalter, und `(bool) null` ist
`false`. Wer vor dieser Ausrollung gekuendigt hat, las unter dem Streifen
„Kein Export gewuenscht" — als waere das seine eigene Antwort. Jetzt stehen
dort dieselben zwei Auswahlfelder wie im Kuendigungsdialog: gleiche Frage,
gleiche Form, und ein unbeantworteter Zustand markiert schlicht keines von
beiden. Der Satz daneben fragt dann, statt zu behaupten, und sagt, was
passiert, wenn die Frage offen bleibt.
K2 — Die Vertragsseite versprach jedem den Export, auch dem, den der Dialog
eine Sekunde vorher mit einem bewussten „Nein" genau dorthin umgeleitet
hatte. Drei Fassungen statt einer, an `export_wish` gebunden. Die Ja-Fassung
verspricht dabei nicht mehr den Export selbst, sondern dass der Wunsch
vermerkt ist und sich jemand meldet — den Export gibt es nicht, und ein
gebundenes, aber weiterhin unhaltbares Versprechen haette den Fehler nur
verschoben.
Dazu der Zustand danach: eine `ended`-Instanz holt Dashboard::render() nicht
mehr, und der Kunde fiel in denselben Zweig wie jemand, der noch nie etwas
bestellt hat — „Ihre Cloud wird eingerichtet." samt „Paket buchen", am Tag,
an dem ihm die Adresse eingezogen wurde. Der Fall hat jetzt seinen eigenen
Kasten, mit dem Datum, an dem das Paket endete.
W1 — Die Erinnerungsmail behauptete im Praesens, wir bereiteten bereits einen
Export vor. Der Satz sagt jetzt, was stimmt. Und die Antwort hatte in der
ganzen Konsole keinen einzigen Leser: ein „Ja" landete in einer Spalte, die
niemand je zu Gesicht bekam. Ueber der Instanzliste steht deshalb ein
Abschnitt „Datenexport bestellt" — wer, und bis wann. Nicht als Plakette in
der Zeile, weil die Liste geblaettert ist und ein alter Eintrag auf Seite acht
saesse; nicht auf der Uebersicht, weil ein Hinweis, den nichts je wieder
abraeumen kann, Moebel waere.
W3 — Die einzige Pruefung zur Anzeige der Antwort konnte nicht fehlschlagen:
`x-ui.switch` rendert beide Woerter und ueberlaesst dem CSS die Auswahl, also
war `assertSee('Kein Export gewuenscht')` bei true, bei false UND bei null
gruen. Nachgewiesen mit einer Wegwerf-Pruefung gegen den alten Streifen:
dreimal derselbe Satz, dreimal gruen. Jetzt drei Pruefungen, je eine pro
Zustand, am `checked`-Attribut der Auswahlfelder.
Zu jedem der vier Punkte wurde der Fix kurz zurueckgedreht und die neue
Zusicherung rot gesehen; die Ergebnisse stehen im Bericht.
Ganze Suite: 2819 gruen, 2 rot — beide fremd. ReadinessPageTest scheitert an
`server.private_key` aus der parallel laufenden Terminal-Arbeit im selben
Baum; HostStepTest ist auf main vorbestehend rot (install-agent.sh traegt
CONTRACT=3, update.sh HOST_STEP_NEEDS=2, beide unveraendert).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Konsole meldete „kommt seit HH:MM nicht an die Arbeit", sobald der
Agent EINMAL an der Sperre vorbeilief. Gemessen wurde das bei einem
Überholen von neun Sekunden: Zeitgeber und Wächter laufen beide
minütlich, einer nimmt die Sperre, der andere geht weg. Das ist Betrieb,
kein Fehler — und der Besitzer hat daraufhin eine Stunde lang eine
gesunde Anlage auseinandergenommen.
Der Kopfkommentar an der Stelle kannte den Unterschied längst („ließe
sich von einem gesunden Überholen zweier Läufe nicht unterscheiden").
Die Dauer wurde mitgeführt, nur gegen nichts verglichen.
Der Agent zählt die Serie jetzt mit (`skips` im Lebenszeichen), die
Konsole macht ab zwei Läufen eine Meldung daraus. Gezählt wird in
LÄUFEN, nicht in Minuten: wie oft der Zeitgeber wirklich auslöst, steht
in der systemd-Unit auf dem Wirt, die diese Anwendung nicht sehen kann.
Die Schwelle gilt für den ganzen Zustand, nicht nur für den Satz. Nur
die Meldung zu unterdrücken hätte den Fehlalarm gegen einen stilleren
getauscht: `blocked_since` blendet auch „N Aktualisierungen zurück" und
die Zielversion aus, und die wären beim ersten übersprungenen Lauf
verschwunden, ohne dass irgendwo stünde warum.
Geprüft wird der Zähler am echten Skript, nicht an einer Nachbildung
seiner Logik: der Zweig liegt vor allem Teuren, also läuft der Agent im
Test gegen eine gehaltene Sperre und steigt aus, bevor `git fetch` oder
`docker compose` in die Nähe kommen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eine Mail sieben Tage vorher kann im Postfach untergehen; die eigene Uebersicht
oeffnet der Kunde ohnehin. Der Streifen nennt das Datum, die Restzeit und was
danach geschieht — dass der Zugang zur Cloud endet, nicht dass irgendetwas
geloescht wird. Das ist die Frist, die ihn betrifft.
Und er traegt die Antwort zum Export: wer liest, dass die Zeit laeuft, will es
sich im selben Atemzug anders ueberlegen koennen (Aufgabe 4) — bis zum
Laufzeitende, danach nicht mehr, geprueft direkt an der Methode und nicht nur
am Schalter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests / release (push) Blocked by required conditionsDetails
$instance->update(['export_reminded_at' => now()]) lag ausserhalb des
try/catch, das nur den Mailversand absicherte. Ein Fehlschlag dieses einen
Schreibzugriffs haette die Marke leer gelassen (morgen eine zweite Mail fuer
dieselbe Instanz) UND den ganzen Lauf abgebrochen, statt nur diese eine
Instanz zu ueberspringen. Jetzt beides in derselben Absicherung.
Dazu zwei Pruefungen, die der Rand "Ende schon vorbei" bislang nicht hatte:
keine Erinnerung mehr, wenn die Frist schon abgelaufen ist (kein akademischer
Fall - EndInstanceService laesst einen DNS-Fehler bewusst durch, eine
Instanz kann also real tagelang mit abgelaufener Frist stehenbleiben), und
die obere Einschlussgrenze bei genau sieben Tagen, damit beide Raender der
"hoechstens sieben Tage"-Regel durch je eine eigene Pruefung belegt sind.
Ausserdem: die Selbst-Herunterladen-Adresse selbst wird jetzt im Rendertest
geprueft, nicht nur Datum und Sprachschluessel.
Ab dem Laufzeitende zieht EndInstanceService die Adresse ein — der Kunde kommt
ab dem Moment nicht mehr an seine Nextcloud. Wer selbst etwas herunterladen
will, muss es VORHER tun, und das wusste bisher niemand.
Die Auswahl ist bewusst "hoechstens sieben Tage" und nicht "genau sieben Tage":
ein Gleichheitsvergleich verfehlt jede Instanz, die zwischen zwei Laeufen
durchrutscht, und der Preis dafuer waere, dass jemand ausgesperrt wird, ohne es
gewusst zu haben.
Zusaetzlich zum Zettel: lang/{de,en}/mail_pace.php bekommen einen Eintrag fuer
ServiceEndingSoonMail, weil MailPacePageTest fuer jede Klasse in MailLane::all()
einen Anzeigenamen verlangt — ohne ihn waere die Suite rot.
tests / release (push) Blocked by required conditionsDetails
Befund 1: neuer deutscher Text (Kommentare, ein Testname) benutzte ae/oe/ue/ss
statt ä/ö/ü/ß — der Bestand schreibt mit echten Umlauten, das Original wird
hier nachgezogen. Betroffen: die drei Kommentare in ConfirmCancelPackage.php,
der Blade-Kommentar, sowie Kopfkommentare und ein Testname in
CancelAsksAboutExportTest.php. Der Funktionsname kuendbareInstanz() und die
Test-Fixtures (E-Mail/Subdomain "kuendigt") bleiben ASCII — ersterer ist
wörtlich aus dem Zettel übernommen, letztere sind technische Werte wie jede
andere Test-Subdomain im Bestand (acme, berger).
Befund 2 (Entscheidung des Betreibers): cancel_point_export versprach den
Datenexport unbedingt, direkt über einer Frage, die ihn an ein Ja knüpft. "auf
Wunsch" eingefügt, in beiden Sprachdateien — der Satz sagt jetzt, was der
Dialog tatsächlich tut, ohne sonst etwas am Text zu ändern.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Angelpunkt des ganzen Vorhabens: wer keinen Export braucht, loest keine
Arbeit aus und wartet auf nichts. Zwei Auswahlfelder und keine Checkbox — eine
Checkbox kennt keinen dritten Zustand, und "nicht angekreuzt" waere von "nein"
nicht zu unterscheiden.
Die Frage ist nicht ueberspringbar (#[Validate('required|boolean')], vor Stripe
geprueft wie jede andere Vorbedingung hier). Das macht drei bestehende
Erfolgspfad-Pruefungen zu ConfirmCancelPackage neu pflichtig in einem Feld, das
sie vorher nicht kannten — PackageCancellationTest, SettingsTest und
EndInstanceServiceTest setzen deshalb jetzt zusaetzlich exportWish, ohne dass
sich an ihren eigentlichen Zusicherungen etwas aendert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests / release (push) Blocked by required conditionsDetails
Drei Zustaende, nicht zwei: `null` heisst "noch nicht gefragt". Jede Instanz,
die vor diesem Bau gekuendigt wurde, hat die Frage nie gesehen — ihr ein "nein"
zu unterstellen hiesse, still fuer Menschen zu antworten, die niemand gefragt
hat.
Achtundzwanzig Eintraege in sieben Gruppen, und „System" war der Platz fuer
alles, was sonst nirgends hinpasste: Mail-Einrichtung neben einem
Rechtsdokument, neben den persoenlichen Kontoeinstellungen, neben der
Mitarbeiterverwaltung — und ganz unten die Seite, die sagt, was liegt. Der
Betreiber hat es so beschrieben: „offene Punkte ist der letzte Punkt, dann
Rolle drueber und Einstellungen wieder drueber".
Zwei Regeln ordnen es jetzt.
Was man einmal einrichtet, verlaesst die Leiste: neun Seiten liegen als
Kacheln hinter EINEM Eintrag (admin.setup), gruppiert nach dem, was sie
einrichten. Keine dieser Seiten wurde angefasst — sie behalten Route,
Berechtigung und Inhalt, es aendert sich nur der Weg dorthin. Damit ist der
Umbau rueckholbar.
Und die drei Seiten, auf denen etwas WARTET, stehen ganz oben, mit einer Zahl
daneben: das ist die Frage, mit der man eine Konsole oeffnet. Stoerungen sind
aus „Betrieb" nach oben gezogen und Zahlungsprobleme aus „Geld" — umgezogen,
nicht verdoppelt. Bei null faellt die Plakette weg, der Eintrag bleibt: eine
Seite, die verschwindet, sobald nichts offen ist, ist genau dann nicht
erreichbar, wenn man nachsehen will, ob wirklich nichts offen ist.
Zwanzig Eintraege statt achtundzwanzig, jede Seite genau einmal.
Zwei Dinge, die beim Verschieben kaputtgegangen waeren:
* Die neun verschobenen Seiten standen nicht mehr in console(). Damit war auf
ihnen KEIN Eintrag markiert (Codex R15, P2) und currentLabel() lieferte
null — die Brotkrume haette dort nur noch „Konsole" gesagt. Zwei Stellen,
eine Wurzel: die Kachelliste ist nach Navigation::setup() gewandert, wo
beide sie lesen, und isCurrent() haelt die Tuer markiert, solange man
dahinter steht.
* Die Versionszeile im Fuss stand als toter Text da, waehrend die Seite mit
dem Aktualisierungsknopf in die Einrichtung gezogen war. Gemeldet vom
Betreiber: „man sieht es nicht, ohne genau hinzuklicken." Sie ist jetzt der
Weg dorthin — und sagt in der Akzentfarbe, wenn etwas wartet. Wartet
nichts, bleibt sie grau: eine Zeile, die immer ruft, ruft nie.
Die drei Zahlen liegen fuer eine Minute im Zwischenspeicher. Diese Leiste
rendert auf JEDER Konsolenseite; ohne das waeren es vier Abfragen je
Seitenaufruf — eine Abgabe, die man erst sucht, wenn die Konsole zaeh ist.
Achtzehn Zusicherungen, darunter die, auf die es ankommt: keine der
achtundzwanzig Seiten von vorher ist verlorengegangen. Die Liste steht im Test
ausgeschrieben und nicht aus der Repository-Geschichte gelesen — ein Test, der
sich seine Erwartung aus demselben Repository holt, das er prueft, prueft
nichts.
Entwurf: docs/superpowers/specs/2026-08-04-konsolen-seitenleiste-design.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests / release (push) Blocked by required conditionsDetails
Läuft der 72-Stunden-Link ab, gab es keinen Weg zurück ausser der Shell —
und die setzt dabei ungefragt auf Owner. Settings::resendInvitation() zieht
für ein noch nie benutztes Konto (last_login_at ist null) ein neues Token
und verschickt dieselbe Einladungsmail; der Broker macht das alte Token
dabei von selbst ungültig. inviteStaff() läuft jetzt in einer Transaktion,
damit eine Störung zwischen Kontoanlage und Mailversand keine für immer
blockierte Adresse mehr hinterlässt.
Dazu der Nachtrag am Testnachweis: die reflektierende Prüfung auf ein
verstecktes Passwort steigt jetzt in Arrays ab statt sie zu überspringen,
und prüft zusätzlich das gerenderte HTML statt nur die Komponenteneigen-
schaften. Drei Kleinigkeiten: ein falscher Kommentarhinweis auf eine
angeblich fehlende Übersetzungsdatei korrigiert, eine fehlende Zusicherung
gegen einen rohen Statusschlüssel ergänzt, und ein toter throttle-Wert aus
config/auth.php entfernt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bisher erzeugte Settings::inviteStaff() beim Einladen eines
Betreiber-Mitarbeiters ein Passwort und zeigte es dem Inhaber einmal auf dem
Bildschirm, damit er es "sicher weitergibt" -- fuer einen Zugang zur Konsole,
die die ganze Flotte verwaltet. Der Kommentar dort nannte sich selbst eine
Attrappe.
Jetzt geht eine Einladung ohne Passwort hinaus: der Eingeladene bekommt eine
Mail mit einem Link und vergibt sein Passwort selbst, ueber
App\Livewire\Auth\OperatorSetPassword. Niemand -- auch der Inhaber nicht --
kennt je ein fremdes Passwort.
Dafuer brauchte es einen eigenen Weg, den es fuer Betreiber noch nicht gab
(R21: Konsole und Portal teilen keine Identitaet). Ein eigener Broker mit
eigener Tabelle (operator_password_reset_tokens, config/auth.php), eine
eigene Route in der Gast-Gruppe der Konsole (admin.invitation), und
Operator::sendPasswordResetNotification() wirft jetzt, statt Laravels
Vorgabe zu nutzen, die auf die Portalseite verlinkt haette.
Der Einladungslink gilt 72 Stunden -- lang genug fuer ein Wochenende, kurz
genug, dass ein altes Postfach nicht auf Dauer einen Schluessel zur Konsole
haelt. Zwei-Faktor bleibt unberuehrt: die neue Seite meldet niemanden
automatisch an, der Eingeladene durchlaeuft danach die normale Anmeldung
samt ihrer bestehenden Zwei-Faktor-Pruefung. Die Route liegt hinter denselben
Netz- und Hostwaechtern wie der Rest der Konsole (RestrictAdminHost,
RestrictConsoleNetwork), ohne Sonderfall.
OperatorInvitationMail reiht sich in den Versandtakt ein (MailLane::LOCKED,
wie ResetPasswordMail und VerifyEmailMail -- ein Mensch wartet gerade) und
ist damit in der Vorschau- und Versandtakt-Uebersicht der Konsole sichtbar,
statt lautlos in die gedrosselte Spur zu fallen.
Zwei Mutationsproben gegen die tragende Zusicherung durchgefuehrt (Bericht:
.superpowers/sdd/betreiber-einladung-report.md): die erste (durch die
Vorgaengersitzung) hielt fest, dass drei Zusicherungen fallen wuerden, waere
das Konto sofort anmeldbar; die zweite -- das Passwort testweise wieder auf
die Seite gebracht -- faellt exakt an der dafuer gebauten reflektierenden
Pruefung ueber alle oeffentlichen Eigenschaften der Seite.
Suite: 2771 passed (9683 assertions).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gemeldet: „seit 1.5.1 muss ich nach jedem Update install-agent.sh fahren,
sonst kommt kein Update mehr". Die Konsole meldete „Der Update-Dienst auf dem
Server laeuft nicht" und schickte genau dorthin.
Er lief. Das Journal zeigt zwei Tage lang lueckenlos 57-59 Laeufe je Stunde.
Aber ueber zweiundachtzig Minuten hinweg startete und endete jeder Lauf in
DERSELBEN Sekunde, waehrend ein arbeitender Lauf zwei braucht: sie stiegen
alle sofort wieder aus, an der Sperre eines anderen Vorgangs — `flock -n 9 ||
exit 0`. Lautlos. Kein Journal-Eintrag (systemd sieht einen sauberen Lauf),
keine Zeile in der Statusdatei, nichts in der Konsole.
Und die Statusdatei war die einzige Lebendmeldung, die es gab. Sie wird erst
nach rund 190 Zeilen geschrieben — nach dem `git fetch` und nach einem
`docker compose exec`, beide ohne Zeitgrenze und beide unter der Sperre. Ein
Lauf, der davor aussteigt, hinterlaesst nichts, und nach zwanzig Minuten
schliesst die Konsole daraus, der Dienst sei tot. Sie schloss falsch, und die
Handlungsanweisung dazu aendert an einer gehaltenen Sperre nichts.
Drei Aenderungen:
* Ein Lebenszeichen (agent-alive.json) als ERSTES bei jedem Lauf, vor allem,
was blockieren kann. Zwei Zustaende: `running` heisst "ich habe die Sperre
und arbeite", `blocked` heisst "ich bin ausgestiegen" — mit `since` (seit
wann ununterbrochen) und `held_by` (wer, per fuser und ps).
* Zeitgrenzen: 45 Sekunden um den docker-exec, 120 um den git fetch. Ohne
sie wartet ein Abruf gegen eine tote Verbindung, bis das Betriebssystem
ihn nach vielen Minuten aufgibt — und haelt dabei die Sperre.
* Die Konsole liest das Lebenszeichen statt der Statusdatei. Der Unterschied
ist der Punkt: Status heisst "zuletzt ERFOLGREICH nachgesehen",
Lebenszeichen heisst "zuletzt ueberhaupt gelaufen". Ein blockierter Agent
gilt als lebendig, aber seine Zahlen zaehlen nicht mehr als aktuell —
`behind` und `target_release` fallen auf "unbekannt", statt eine Stunde
alte Auskunft als frisch auszugeben.
Statt "laeuft nicht" steht dort jetzt "laeuft, kommt aber seit HH:MM nicht an
die Arbeit", mit dem Prozess darunter.
Ein Agent von VOR dieser Aenderung schreibt die Datei nicht — fuer den gilt
weiter die alte Regel. Ihn dafuer fuer tot zu erklaeren waere derselbe Fehler
mit umgekehrtem Vorzeichen; ein Test haelt das fest.
Widerlegt und damit ausgeschlossen: Besitzrechte (Wirt, .env und Behaelter
fuehren alle 1001), das Ausführbar-Bit (100755 im Repo), systemds
Startdrosselung (seit v1.1.0 abgeschaltet), ein zwischengespeicherter Zustand
im Panel (es liest bei jedem Aufruf frisch) und eine Luecke im Timer (keine).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests / release (push) Blocked by required conditionsDetails
Drei eigene Saetze statt einer geteilten Meldung an den drei
Inhaber-Wachen (sperren, Rolle aendern, entfernen) — jede nennt jetzt
den Grund: der Inhaber-Sitz ist der Zugang, mit dem der Kunde seine
eigene Cloud verwaltet.
Der Kommentar zum Auffangnetz in NextcloudUsers::applyRole() behauptete
einen engeren Schutz, als &&/|| linksassoziativ tatsaechlich bilden —
korrigiert statt geklammert, weil Klammern die gerade erst gebaute
|| true-Erkennung in FakeProxmoxClient gebrochen haetten, fuer einen in
der Praxis folgenlosen Fall.
Beide Fakes (FakeProxmoxClient, FakeRemoteShell) verweisen jetzt im
Kopfkommentar aufeinander: sie behandeln ein angehaengtes || true
unterschiedlich, und wer nur den einen kennt, soll das an der Klasse
lesen koennen.
Die Restnaht beim nachgeschickten Sperren (SyncSeatToNextcloud::invite())
bleibt Code wie er ist — Begruendung als Kommentar an der Stelle: die
Naht ist real, aber folgenlos (kein nc_synced_at, kein Wiederholen-Knopf,
Weg zurueck bleibt die Vordertuer).
Und ein kurzer Satz an der restore-Aktion in retry(), der die schon
vorhandene, aber weit oben stehende Begruendung buendelt: kein Feld fuer
ein faelliges enable, weil das die naechste Behauptung ueber eine Cloud
waere, in die das Portal nicht sehen kann.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Zuordnung zeigte class_basename(): „DormantAccountWarningMail" stand
dort, wo der Betreiber „Konto ohne Paket wird geloescht" sucht. Ein
Klassenname ist eine Auskunft ueber den Bauplan, keine ueber die Mail.
Die Namen liegen in lang/{de,en}/mail_pace.php und NICHT in MailCatalogue,
obwohl der sehr aehnlich klingt: der Katalog zaehlt Mail*arten* — vier
Mahnstufen, vier Eintraege, vier Absenderwege —, diese Seite verteilt
Mail*klassen* auf Spuren, und alle vier Mahnstufen sind EINE Klasse, die als
eine Zeile eine Spur bekommt. Zwei Listen, weil es zwei verschiedene Dinge
sind. Fehlt doch ein Name, steht der Klassenname da wie bisher statt einer
leeren Zeile — und ein Test haelt fest, dass keiner fehlt, in beiden Sprachen.
Der Warteschlangenname (mail-direkt und Geschwister) stand in der Wertspalte
zwischen Beschriftung und Eingabefeldern und las sich wie ein weiterer Wert.
Er steht jetzt als Unterzeile bei der Spur. Weg gehoert er nicht: es ist die
Zeichenkette, die der Arbeiter in docker-compose.yml abhoert.
Suite gruen, Codex ohne Befund. Der Namenstest faellt gegen die alte Fassung
— nachgemessen, nicht angenommen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Befunde aus dem Re-Review.
Die Mailwache fehlte an retry(). sendInvite() weist ab, wenn der Versand nicht
eingerichtet ist — der Zweig `nc_synced_at === null => 'invite'` in retry()
schickt genau dasselbe `occ user:add --generate-password --email` und tat es
nicht. Der Weg dorthin ist am ersten Tag rein durch Klicken erreichbar: /users
steht offen, solange die Instanz noch in Bereitstellung ist (customer.active
prueft den Kunden, nicht die Instanz), und ohne AKTIVE Instanz sagt
mailversandBereit() `true`. Also kein Hinweis, keine Wache, Einladung hinaus,
Auftrag scheitert an `no_instance`, Zeile rot, Wiederholen-Knopf da. Wird die
Bereitstellung fertig, ohne dass der Versand steht, legte ein Klick das Konto
an, occ beendete mit 0, und die Plakette sagte "Eingeladen". Zweiter Weg zum
selben Ende: ein einmal eingerichtetes Versandkonto wird abgeschaltet, und jede
rote Zeile ohne nc_synced_at fuehrt beim Wiederholen dorthin.
Die Wache gilt nur fuer `invite`. `disable` und `restore` verschicken nichts —
sie duerfen auch ohne Mailversand laufen, und sie sind die Rueckfahrkarte aus
einem Fehlschlag. Sie zu sperren hiesse, einen offenen Zugang offen zu lassen,
weil eine Mail nicht ginge. Die Pruefung faehrt den ganzen Weg ab, mit echtem
Auftragslauf in der Mitte.
Und der W1-Fix hatte eine Zusicherung aufgeweicht: an `status === 'invited'` war
die Adresse des INHABER-Sitzes nie aenderbar, denn er steht immer auf 'active'.
An nc_username allein wurde sie es, bis linkToInstanceAdmin() greift. Die Folge
ist kein Umbenennen — laeuft die Adresse des Inhaber-Sitzes von der
Kundenadresse weg, legt "Anlegen" mit der echten Adresse eine ZWEITE Zeile fuer
dieselbe Person an, die gegen die Platzgrenze zaehlt. Der Inhaber-Sitz ist der
eine Sitz, dessen Adresse nicht ihm gehoert, sondern dem Kundenkonto.
Die Bedingung steht jetzt einmal als adresseAenderbar() statt dreimal
abgeschrieben; auseinanderlaufen muss sie nur einmal, um eine Luecke zu sein.
Der bestehende Umbenennen-Test prueft addressEditable nicht und waere gruen
geblieben — der neue faehrt am Formular vorbei und haelt zugleich fest, dass
Umbenennen am Inhaber-Sitz erlaubt bleibt.
Beide gegen den zurueckgedrehten Fix rot gesehen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die zweite Haelfte von K4. Die Wanderung legt das Versandkonto an; solange es
niemand ausgefuellt hat, bestand die Attrappe unveraendert fort — und sie ist
nicht der Randfall, sondern der Normalfall: der Mailserver dieses Produkts
entsteht gerade erst, das Postfach ist am ersten Tag garantiert leer.
Die Einladung verschickt NEXTCLOUD, nicht CluPilot — nur so entsteht das
Passwort dort, wo niemand sonst es zu sehen bekommt. Ohne eingerichteten
Versand kommt dort aber nichts heraus, und `occ user:add --generate-password
--email` gelingt trotzdem: Nextcloud legt das Konto an, versucht die
Willkommensmail, protokolliert intern einen Fehler und beendet mit 0. Der
Auftrag meldete Erfolg, die Zeile sprang auf "Eingeladen", die Meldung versprach
einen Link, an dem der Mitarbeiter sein Passwort selbst setzt — und niemand
bekam etwas.
sendInvite() fragt jetzt GuestMailConfig::for($instance)->available(): ein
reiner Blick in die Datenbank, kein Tunnel, keine Warteschlange, deshalb darf er
auf der Seite stehen. Steht der Versand nicht, geht kein Auftrag hinaus, der
Sitz bleibt unveraendert — insbesondere ohne Anmeldenamen — und die Meldung sagt,
dass nichts verschickt wurde und woran es liegt.
Drei Entscheidungen dabei:
Der Knopf bleibt stehen, der Hinweis steht ueber der Tabelle. Ihn an jeder Zeile
verschwinden zu lassen liest sich nicht als "geht hier gerade nicht", sondern als
"das kann dieses Produkt nicht" — genau die Beschwerde, die schon einmal dazu
gefuehrt hat, dass die Aktionsspalte immer gezeichnet wird. Und der Zustand ist
voruebergehend: er endet, sobald der Betrieb das Konto ausfuellt.
Die Wache steht VOR dem Ratelimit. Sonst haette ein Inhaber seine Versuche
aufgebraucht, bevor ueberhaupt einer hinausgehen konnte.
Ohne laufende Instanz greift sie gar nicht: dann scheitert der Auftrag ohnehin an
`no_instance` und die Zeile sagt das im Klartext. Diese Wache gilt dem anderen
Fall — die Cloud laeuft, nur der Versand fehlt.
Das Anlegen bleibt offen: es verspricht ausdruecklich keine Mail, und der
Hinweistext sagt das auch. Der Bereitstellungsschritt ist unangetastet und bleibt
bei "nicht eingerichtet ist etwas anderes als kaputt".
Fuenf Zusicherungen, drei davon gegen den zurueckgedrehten Fix rot gesehen; die
uebrigen zwei sind Grenzpruefungen und muessen in beide Richtungen gruen sein.
Acht Bestandspruefungen richten den Versand jetzt ueber eine eigene
Hilfsfunktion ein — zwei davon haetten sonst aus dem falschen Grund bestanden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
K3. `mitarbeiter` und `nur-lesen` legt niemand an — sie entstehen erst, wenn
`user:add --group=…` sie zum ersten Mal braucht. `occ group:removeuser` legt
nichts an und beendet mit Fehlercode, wenn die Gruppe fehlt, und run() verundet
alle Exitcodes. Auf einer frischen Instanz scheiterte damit die erste
Rollenaenderung dauerhaft: "fehlgeschlagen — Die Cloud hat die Aenderung nicht
angenommen", und Wiederholen waehlte wieder role, also wieder denselben
Fehlschlag.
`2>/dev/null || true` hinter dem Entfernen, mit derselben Begruendung wie in
HostFirewall::releaseMany(): nicht in der Gruppe zu sein IST der gewuenschte
Endzustand. Nur fuers Entfernen — ein gescheitertes group:adduser bleibt ein
Fehlschlag, denn wer in keiner Gruppe landet, sieht in seiner neuen Cloud
nichts.
Warum die Suite das nie sah: FakeProxmoxClient liess jeden nicht verskripteten
Befehl gelingen, und die Pruefungen belegten die erzeugte Befehlsmenge, nie die
Antwort des Gasts. Der Fake beachtet jetzt ein abschliessendes `|| true` — das
ist eine Aussage der Shell, nicht des Aufrufers, und ein Fake, der trotzdem
einen Fehlercode zurueckgaebe, liesse einen Test beweisen, dass ein Befehl
scheitert, den keine echte Shell je scheitern laesst. Damit haelt die Pruefung
den Fake ausdruecklich auf Fehlercode und sieht applyRole() trotzdem true
liefern.
Dazu ein Testkommentar, der den falschen Schutz benannte: bei owner/admin auf
denselben Gruppennamen traegt `if ($gruppe !== $ziel)`, nicht array_unique.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
W1. `addressEditable` hing an status === 'invited' — und nichts im Bestand
setzt seats.status je von 'invited' auf 'active'. Ein eingeladener Sitz blieb
damit fuer immer 'invited' und seine Adresse dauerhaft aenderbar, entgegen dem
eigenen Kopfkommentar dieser Klasse.
Der Ablauf: einladen fuer a@x, spaeter im Modal auf b@y aendern. seats.email
wird b@y, nc_username bleibt a@x (richtig, Nextcloud kann nicht umbenennen),
und kein Befehl traegt die neue Adresse in den Gast. Danach schickt "Erneut
senden" ein user:welcome --reset-password 'a@x': das Portal fuehrt b@y als
Inhaberin des Sitzes, tatsaechlich haelt a@x den Zugang und bekommt jederzeit
einen neuen Zuruecksetzen-Link.
Massgeblich ist jetzt blank($seat->nc_username) — genau die Bedingung, die der
Kommentar ohnehin meinte: einmal gesetzt, danach unveraenderlich. Der Kommentar
ist berichtigt. Beide Stellen, das Anzeigen und die erneute Pruefung in save(),
lesen dieselbe Angabe; die hydrierte Eigenschaft bleibt ungeglaubt.
Die Adresse nachtraeglich in den Gast zu fahren ist ausdruecklich nicht Teil
dieses Durchgangs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drei Befunde aus dem Gesamt-Review, alle dieselbe Klasse: die Zeile im Portal
behauptet etwas, das in der Cloud des Kunden nicht gilt.
K1 — Entziehen, waehrend die Einladung noch in der Warteschlange steht. Der
Arbeiter teilt sich die Warteschlange mit der bezahlten Bereitstellung; das
Fenster ist Minuten lang. queueSync() stieg bei nc_synced_at === null aus und
kannte damit den dritten Waechter-Fall nicht: bei `pending` entsteht dort
gerade etwas, das gesperrt werden muss. Und der invite-Auftrag liest den Status
jetzt am Ende frisch aus der Datenbank nach und schiebt bei revoked/suspended
ein disable hinterher — dieselbe Begruendung wie beim schon gebauten "ein
angelegtes Konto sofort vermerken". Vorher endete der Ablauf mit
status=revoked, nc_state=synced und einem aktiven Konto in der Nextcloud, ohne
jeden Knopf, es nachzuholen.
K2 — retry() konnte ein gescheitertes Entsperren nie wiederholen: die Ableitung
kannte disable, invite und role, aber kein enable, und waehlte deshalb role.
Der Auftrag fuhr Gruppen und Quota, gelang, die Zeile sprang auf "Aktiv" — und
user:enable war nie geschickt. Woran das zu erkennen waere, steht nirgends am
Sitz; ein Feld dafuer waere die naechste Behauptung ueber die Cloud, die
irgendwann nicht mehr stimmt. Deshalb raet retry() nicht, sondern schickt an
einer offenen Zeile beides: der neue Auftrag `restore` sperrt auf UND setzt die
Rolle. suspend() bleibt bei enable, denn dort ist bekannt, was fehlt.
W2 — der Inhaber konnte sich selbst aussperren. setRole() nahm jede Rolle aus
Seat::ROLES an, also auch owner; das Auswahlfeld bietet sie nicht an, die
Livewire-Methode ist trotzdem oeffentlich erreichbar. Mit zwei Inhaber-Sitzen
griff die Zaehlung in revoke() nicht mehr, und der echte Inhaber bekam
user:disable admin samt user:auth-tokens:delete admin in seine eigene Cloud.
revoke() und setRole() weisen owner jetzt genauso ab wie suspend(), und owner
ist keine zulaessige Zielrolle mehr. Damit faellt die Zaehlung selbst weg —
eine Sperre, die man sich erst erarbeiten muss, ist keine — und mit ihr die
Meldung users.last_owner.
Sechs Pruefungen, jede einzeln gegen den zurueckgedrehten Fix rot gesehen.
Umlaute in den beruehrten Dateien nachgezogen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Reste aus dem letzten Fix, beide dieselbe Klasse: die Zeile behauptet etwas
ueber den Gast, das dort nicht gilt.
Der Wiederholen-Knopf an einer entzogenen, nie gespiegelten Zeile lief auf
user:disable gegen einen Benutzer, den es nie gab — scheitert, dieselbe rote
Zeile, beliebig oft. retry() traegt jetzt denselben nc_synced_at-Vorbehalt wie
queueSync(), mit invite als Ausnahme, denn genau dafuer ist der Knopf da. Und
gezeichnet wird er nur noch, wo der Server ihn auch annimmt.
Nach einer Wiederaufnahme stand nc_state auf synced und status auf invited, also
zeichnete die Spalte "Eingeladen" — fuer jemanden, den niemand eingeladen hat
und dessen Konto im Gast weiter gesperrt ist. nc_state geht beim Wiederaufnehmen
zurueck auf none; nc_username und nc_synced_at bleiben, denn an letzterem haengt
das Entsperren beim folgenden Einladen. Eine Pruefung geht den ganzen Weg.
Der letzte Fix hat zwei eigene Loecher gerissen, beide dieselbe Klasse wie das,
was er schliessen sollte: die Oberflaeche versicherte etwas, das im Gast nicht
eingetreten war.
Entzogen stand VOR dem Fehlschlag, also verschwanden Grund und
Wiederholen-Knopf genau dort, wo sie am noetigsten sind: scheitert das disable,
ist das Konto weiter offen, waehrend die Zeile "entzogen" sagt. Es gibt keinen
Wiederholungslauf und keinen Abgleich, und diese Seite ist die einzige, die
nc_state anzeigt. Beides steht jetzt nebeneinander.
Und die Meldung empfahl einen neuen Sitz, den die Eindeutigkeit von
(customer_id, email) unmoeglich machte. addSeat() nimmt einen entzogenen Sitz
derselben Adresse wieder auf — durch dieselbe Platzpruefung wie jeder neue,
denn das ist der Grund, warum es die Vordertuer sein muss und kein Knopf an der
Zeile. Der Auftrag entsperrt dabei, was revoke() gesperrt hat; user:welcome tut
das nicht.
retry() bekommt die owner-Wache nach, die bisher nur am Knopf davor hing. Und
der Kommentar in mount() behauptet keine Heilung mehr, die nicht stattfindet.
Dass revoke() nicht mehr loescht, hat eine Kehrseite, die nirgends stand: der
Umschalter in suspend() kannte nur zwei Zustaende und machte aus einem
entzogenen Sitz beim zweiten Klick wieder einen aktiven — samt user:enable und
vorbei an der Platzgrenze, die nur beim Anlegen geprueft wird. sendInvite() bot
denselben Weg. Beide weisen 'revoked' jetzt ab, die Zeile traegt keinen
Handlungsknopf mehr, und wieder aufmachen kann man sie gar nicht: der Weg
zurueck ist ein neuer Sitz.
Der Einladen-Knopf stand auch an der Inhaber-Zeile. Ein Klick vor der fertigen
Bereitstellung haette spaeter ein zweites Konto in der Gruppe admin angelegt,
neben dem echten — und mount() haette den Sitz danach nie wieder verknuepft,
weil die Bedingung am Zustand hing statt am Anmeldenamen.
queueSync() entscheidet ueber nc_synced_at statt ueber nc_state: ein Sitz,
dessen Einladung an einem unerreichbaren Gast scheiterte, hat dort nichts, was
man sperren koennte. Damit das keine Luecke reisst, vermerkt der Auftrag ein
angelegtes Konto sofort, auch wenn die Rolle danach scheitert.
Vier Karten in einer schmalen Spalte, und die letzten beiden allein
zweiundzwanzig gleich aussehende Zeilen: fuenf Zwecke, siebzehn Mailarten,
jede „Beschriftung, Auswahlfeld", darunter zweimal derselbe Speichern-Knopf.
Wer die Zuordnung EINER Mailart aendern wollte, scrollte an allem anderen
vorbei und fand am Ende eine Wand. Der Betreiber hat die Seite als unlesbar
gemeldet.
Jetzt drei Reiter nach dem Muster von Admin\Integrations, das aus demselben
Grund umgebaut wurde — geteilt nach der Frage, die man mitbringt: WOMIT
gesendet wird, WER sendet, WAS von wo rausgeht. Der offene Reiter steht in der
Adresszeile, ein Neuladen landet dort, wo man war.
Die eigentliche Wand war die Zuordnung, und zwar nicht wegen ihrer Laenge:
dass die Wegwahl je Mailart auf den Zweck zurueckfaellt, stand nur als Satz in
der leeren Auswahl — siebzehnmal derselbe Satz neben siebzehn Zeilen, die alle
gleich aussahen. Jetzt steht jede Mailart unter ihrem Zweck: oben das Postfach
des Zwecks, darunter eingerueckt, was davon abweichen darf. Die Regel ist die
Form geworden.
Die leere Auswahl nennt die Adresse, die stattdessen greift — und zwar die,
die WIRKLICH greift. Ein Codex-Befund am eigenen Umbau: ein abgeschaltetes
Postfach faellt in MailboxResolver::for() auf „System" zurueck, und
writePurposes() erlaubt genau das. Das Etikett haette eine Adresse genannt,
die nichts verschickt, an der Stelle, an der man nachsieht, um es richtig zu
machen. Es fragt deshalb dieselbe Aufloesung, die beim Senden entscheidet.
Ein Knopf statt zwei: Zwecke und Wegwahl sind fuer den Betreiber ein Vorgang.
Die Reihenfolge ist die Zusicherung — die Zwecke werden geprueft und werfen,
BEVOR eine einzige Wegwahl geschrieben ist, sonst hinterliesse eine abgelehnte
Eingabe die halbe Zuordnung. savePurposes() und saveRoutes() bleiben, was sie
waren.
Beide Warnungen stehen ueber den Reitern: sie gelten fuer die ganze Seite, und
wer nachsieht, warum keine Mail ankam, soll nicht erst den richtigen Reiter
suchen.
Vier bestehende Tests zeigen jetzt auf den Postfach-Reiter, fuenf neue kamen
dazu — darunter der Regressionstest zum Codex-Befund, der gegen die alte
Fassung faellt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Anlegen und Einladen sind zwei Vorgaenge. Einladen schickt einen Auftrag auf
die Bereitstellungs-Warteschlange — die einzige, die einen Gast erreicht — und
der Sitz zeigt danach, was WIRKLICH passiert ist, samt Grund und
Wiederholen-Knopf. Ohne das drueckt der Inhaber wieder und wieder.
Entziehen loescht nichts mehr. Ratelimit 10 je Kunde und 3 je Sitz pro Stunde,
mit echter Restzeit in der Meldung statt stummer Verweigerung.
Eine Pruefung verbietet user:delete im ganzen app/-Verzeichnis.
Anlegen, einladen, Gruppe, sperren, freigeben. Keine Methode wirft — ein nicht
erreichbarer Gast gibt false zurueck, statt den Arbeiter mitzureissen, auf dem
die bezahlte Bereitstellung laeuft.
Zwei Fallen sind hier eingebaut statt umgangen: user:disable allein laesst
Sitzungen fuenf Minuten weiterleben (deshalb auth-tokens:delete daneben), und
ein Konto mit eigenem Speicherplatz folgt der Paketvorgabe nicht mehr
(deshalb --delete beim Verlassen von readonly, kein Ueberschreiben).
App\Listeners\RecordSentMail hoert auf MessageSent, und Laravel meldet die
Zuhoerer in app/Listeners von selbst an. Die zusaetzliche Anmeldung von Hand
im AppServiceProvider wirkte deshalb nicht als Bekraeftigung, sondern als
zweiter Zuhoerer: jeder Versand wurde zweimal verbucht.
An einer einzelnen Mail faellt das niemandem auf — der Trockenlauf des
Versandtakts schickte fuenfzig und fand hundert Zeilen. Alle zweiundzwanzig
echten Zeilen im Register sind entsprechend Doppelungen; das sind Daten, ueber
die der Betreiber entscheidet, und sie bleiben hier unangetastet.
Die Handanmeldung ist weg, ihre Begruendung steht an ihrer Stelle. Ein Test in
MailRegisterTest nagelt eine Mail auf genau eine Zeile fest, egal wie der
Zuhoerer angemeldet ist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
up() rief bisher Seat::linkToInstanceAdmin() auf. Ein migrate:fresh auf
frischer Datenbank haette damit die dann aktuelle Fassung dieser Methode
mitgefahren, nicht die von heute. Die Nachbefuellung ist jetzt eine eigene,
dokumentiert duplizierte Fassung ueber den Query Builder.
nc_admin_ref ist am Modell verschluesselt gecastet - roher Spaltenzugriff
haette Chiffretext statt Klartext geliefert. Crypt::decryptString() macht
denselben Aufruf wie der Modell-Cast, ohne eine Modellklasse zu laden.
Kleinere Nachbesserung: fill() statt forceFill() in linkToInstanceAdmin()
(alle drei Felder stehen in $fillable), und die Gruppen-Zuordnungspruefung
verifiziert jetzt die konkreten Namen 'mitarbeiter'/'nur-lesen', nicht nur
"irgendein Wert".
status = was der Inhaber will, nc_state = was in der Nextcloud ist. Ein
einzelnes Feld muesste luegen — und "fehlgeschlagen" liesse sich gar nicht
sagen.
Rollen werden Nextcloud-Gruppen. Der Inhaber-Sitz wird mit dem Admin-Konto
verknuepft, das die Bereitstellung laengst angelegt hat.
SeatFactory setzt nc_state jetzt explizit (wie status/locale bei Customer
und Instance): Eloquents create() liest DB-Spalten-Vorgaben nicht ins
In-Memory-Modell zurueck, sonst haette Seat::factory()->create()->nc_state
null statt 'none' geliefert.
$started++ stand an zwei Stellen: nach der Uebersprungsgruendung und nach
dem Dispatch. Im echten Lauf wurde jede Instanz zweimal gezaehlt.
Entfernt die redundante Zaehlung nach dem Dispatch und ergaenzt zwei Tests,
die die Zahlenwerte in der Abschlusszusammenfassung pruefen — diese Tests
haetten den Fehler gefangen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mail::to(...) loest den Standard-Mailer auf, und dessen queue() schreibt
danach `$view->mailer($this->name)`. Damit war der im Konstruktor gewaehlte
Mailer (cp_mail_<typ>) durch 'smtp' ersetzt, bevor der Auftrag ueberhaupt
gebaut war.
Mit dem Mailer fiel alles weg, was an ihm haengt: MailboxTransport, die
Postfachsuche samt passendem Absender — und der Notschalter aus
App\Support\MailDelivery, der genau dort sitzt. Eine abgeschaltete Zustellung
schaltete deshalb nichts ab, und die Mails meldeten sich mit den Zugangsdaten
aus der Konfiguration an, waehrend der Absender aus der Mail kam: „553 Sender
address rejected". Das lief hier im Minutentakt.
Zwei Stellen hatten es schon richtig — MaintenanceNotifier und MailPreview,
beide mit der Erklaerung an der Zeile. Neun andere nicht. Alle beginnen jetzt
mit Mail::mailer($mail->mailer), sodass das Ueberschreiben denselben Wert
zurueckschreibt.
Die Attrappen in drei Tests zielten auf to() und trafen damit nicht mehr den
Einstieg; drei von ihnen bestanden ohnehin nur zufaellig, weil ein Aufruf auf
null ebenfalls warf. Sie zielen jetzt auf mailer().
Erzwungen durch tests/Feature/MailDispatchTest.php: Mail::to( ist in app/
verboten, und ein Gegentest haelt das Framework-Verhalten fest, damit die
Regel fallen darf, wenn Laravel sie eines Tages unnoetig macht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. Übergebe $run->uuid statt $run->id an AdvanceRunJob (KRITISCH: Lauf
wird nie gefahren, da HasUuid eine separate uuid erzeugt)
2. Benutze STATUS_PENDING statt STATUS_RUNNING beim Anlegen
3. Fuege hasRunInFlight-Schutz gegen doppelte Laeufe ein
4. Stelle Umlaute in Betreibertexten her, halte Code umlautfrei
5. Reorganisiere handle() nach RefreshHostFirewall-Muster
6. Erweitere Tests um Queue::assertPushed() fuer AdvanceRunJob mit uuid
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ohne diesen Lauf bliebe die Mitarbeiterverwaltung fuer jeden Altkunden tot:
die Einladung geht von SEINER Nextcloud aus, und die kann bis heute nichts
verschicken. Ein Befehl, kein Zeitplan — dieselbe Begruendung wie bei
clupilot:refresh-host-firewall.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Log::warning allein reicht nicht: bis der Mailserver steht, trifft dieser
Zweig auf JEDE einzelne Bestellung zu, und die einzige Rueckmeldung darf
nicht in einer Logdatei verschwinden, in die niemand schaut.
RegisterMonitoring macht sein Ueberspringen genau deshalb am Lauf sichtbar
(outcome: info) statt nur im Log — ConfigureInstanceMail bekommt jetzt
dieselbe Behandlung, das Log bleibt daneben fuer die Nachschau ausserhalb
der Konsole.
Zwei weitere Befunde aus der Pruefung behoben:
- Der Test "traegt jeden Wert einzeln in den Gast" prueft jetzt alle acht
Schluessel aus GuestMailConfig::values() statt zwei — ein vergessener
Schluessel faellt jetzt hier auf, nicht erst beim Kunden.
- `$config->problem() ?? 'mail_unavailable'` entfernt: im Zweig
`! $config->available()` liefert problem() immer einen Grund, der
Rueckfall konnte nie greifen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ConfigureInstanceMail stand als Pflichtschritt in der customer-Pipeline und
liess den ganzen Lauf scheitern, wenn Server oder Postfach fehlten. Der
Mailserver dieses Produkts existiert noch nicht — er wird gerade erst
aufgesetzt. Jede neue bezahlte Bestellung waere damit an einer Nebenfunktion
haengengeblieben: der Kunde zahlt, die Cloud kommt nicht.
Denselben Fall hat dieses Projekt bei RegisterMonitoring schon richtig
entschieden: eine nicht erreichbare Ueberwachung blockiert die Bereitstellung
nie. Mailversand bekommt jetzt dieselbe Behandlung. Fehlt Server oder
Postfach, geht der Schritt mit advance() weiter und protokolliert den Grund
per Log::warning (Instanz-UUID + Grund) fuer den Betreiber; nachgeholt wird es
ueber clupilot:configure-instance-mail (Aufgabe 4), sobald beides steht.
Nicht eingerichtet bleibt etwas anderes als kaputt: sobald Server und
Postfach da sind, wird geschrieben wie zuvor, und ein echter Schreibfehler
(occ-Fehlercode, Gast antwortet nicht) bleibt weiterhin ein Fehlschlag ueber
CustomerStep::guest().
Der bestehende Test "schreibt GAR NICHTS, wenn der Mailserver fehlt" prueft
jetzt StepResult::ADVANCE statt FAIL und zusaetzlich, dass der Grund geloggt
wird. Ein neuer End-to-End-Test in CustomerProvisioningEndToEndTest faehrt
eine bezahlte Bestellung ohne jede Mail-Fixture vollstaendig bis "completed"
— die Zusicherung, um die es hier eigentlich geht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bis hierher konnte sie es nicht — keine Freigabe-Benachrichtigung, kein
"Passwort vergessen", nichts. Das ist die Voraussetzung dafuer, dass die
Einladung an einen Mitarbeiter aus SEINER Nextcloud kommt und das Passwort
dort entsteht, wo niemand sonst es sieht.
Ohne Server oder Postfach wird nichts geschrieben und der Schritt scheitert
mit Grund.
Zwei bestehende Pruefungen mitgezogen: CustomerStepBaseTest erwartete eine
feste Schrittzahl (16 -> 17), und CustomerProvisioningEndToEndTest lief ohne
Mailversand-Fixtures durch die volle Pipeline und scheiterte jetzt genau dort
— beide auf dieselbe Art nachgezogen wie ApplyStorageQuota es vormacht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Methode sollte das SMTP-Passwort aus der Prozessliste der Kunden-VM
halten. Nachgerechnet: guestExec faehrt die ganze Zeile als /bin/sh -c, die
innere Shell setzt den Wert vor dem exec ein, und Nextcloud legt ihn danach
ohnehin im Klartext in config.php ab. Eingespart wurde einzig das Argv des
docker-Aufrufs, auf einer Maschine, die den Wert an zwei anderen Stellen
zeigt.
Ein Versteck, das dieselbe Person nicht aussperrt, die den Ablageort lesen
kann, ist keines — und ein Kommentar, der Schutz behauptet, haelt den
Naechsten vom Nachsehen ab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Kopfkommentar versprach falsch, dass der Wert nur im Container staende.
Die Realitaet: guestExec() uebergibt die ganze Zeichenkette als sh -c, daher
steht der Praefx im Argv der VM-Shell. Debians dash optimiert ihn nicht weg.
Der echte Gewinn: Der Wert reist als Umgebungsvariable in den Container und
wird dort von sh eingesetzt — sitzt nicht im Prozessabbild DES CONTAINERS.
Das war auch bei command() mit OC_PASS=... schon so.
Testname: haelt den Wert aus dem occ-Aufruf heraus (prueft Innenteil).
Kommentar: Das "sh -c" trennt VM-Shell von Container.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
config:system:set will den Wert als Argument. Setzte ihn die aeussere Shell
ein, stuende das SMTP-Passwort in der Prozessliste der Kunden-VM. Mit `sh -c`
im Container steht dort nur der Variablenname.
Im Kopfkommentar steht ausdruecklich, dass das Hygiene ist und kein Schutz:
Nextcloud legt den Wert danach im Klartext in config.php ab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Passwort-Test prüfte nur die Abwesenheit des Schlüssels
mail_smtppassword — ein Leck unter anderem Namen wäre durchgerutscht. Prüft
jetzt zusätzlich, dass der Passwort-WERT in keinem values()-Eintrag steckt.
mail_smtpauth stand fest auf 'true', obwohl Mailbox::isConfigured() ein
Postfach ohne Anmeldung ausdrücklich zulässt. Nextcloud hätte sich dann mit
leerem Passwort anzumelden versucht — jeder Versand wäre still gescheitert.
Richtet sich jetzt nach $box->authenticates, mit Test für den false-Fall.
Server aus app_settings, Zugangsdaten aus dem Postfach, an einer Stelle
zusammengelegt. Fehlt eines von beiden, wird NICHTS geschrieben: eine halb
eingetragene Mailkonfiguration laesst Nextcloud bei jedem Versand still
scheitern.
mailboxFor() ist die Naht, an der spaeter ein Konto je Kunde haengt.
gedrosselteSpur() las die Spur nur aus $this->mailable->queue. Die setzt aber
allein RidesALane, und den tragen ausschliesslich unsere vierzehn Klassen. Eine
Mailklasse aus einem Paket — Fortify, ein kuenftiges Abhaengigkeitspaket —
bekam damit gar keine Drossel.
Das ist die Gegenrichtung zu der Entscheidung aus Aufgabe 1: dort faellt eine
unbekannte Klasse ausdruecklich in die ruhige Spur, weil eine zu langsam
verschickte Mail ein kleinerer Fehler ist als ein ungedrosselter Schub, den
niemand vorhergesehen hat. Steht keine Spur am Mailable, entscheidet jetzt
MailLane::for() — dieselbe Quelle, die Aufgabe 1 dafuer gebaut hat.
Schlange und Takt fallen fuer so eine Mail auseinander: sie bleibt auf default,
weil die Schlange der Trait waehlt, und faehrt trotzdem im ruhigen Takt, weil
den der Auftrag waehlt. Der Kommentar sagt, warum das kein Versehen ist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eine Spur, die steht, sah von außen aus wie eine Spur, die leer ist —
Queue::size() unterscheidet beides nicht. Die neue Prüfung liest das
Alter des ältesten wartenden Auftrags je Spur ausdrücklich über die
Redis-Verbindung `redis` (dieselbe, mit der der Arbeiter in
docker-compose.yml läuft), unabhängig von QUEUE_CONNECTION — und bleibt
damit auch unter der auf sync gezwungenen Testsuite ehrlich, weil Redis
in beiden Umgebungen erreichbar ist. Die Grenze liegt bei einer Stunde:
20 je 10 Minuten (die langsamste Spur) sind 120 in der Stunde, und ein
Lauf dieser Größe steht bei elf Kunden nicht so lange an.
Die eigene Seite admin/mail-pace (Task 5) bekam dabei einen eigenen
tab-Wert in Readiness::checkUrl() — der bestehende ReadinessPageTest
hält jeden Verweis gegen die Stelle, an der sein Feld wirklich liegt,
und 'mail' hätte auf die falsche Seite gezeigt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wenn die Drossel je klemmt, muss der Ausweg ein Klick sein und kein Deployment.
Eigene Seite (admin/mail-pace) statt eines fünften Abschnitts auf admin/mail:
die dortige Seite ist bereits "alles in einer Wurst", und diese hier
beobachtet laufenden Betrieb statt etwas einzurichten — deshalb auch in der
Navigation unter "Betrieb", nicht unter "System".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mein eigener Befund "die Zeile erneuert sich nach dem Aufheben nicht" war
falsch — eine Pruefung ueber den ganzen Weg (Modal schickt das Ereignis,
Seite faengt es) zeigt, dass die Zeile sehr wohl in den Verlauf wandert.
Dabei fiel der echte Fehler auf, der daneben lag:
HostFirewall::release() schluckt jeden Fehlschlag und meldet ihn nur ins
Log. Beide Aufrufer verwarfen den Rueckgabewert und meldeten in jedem Fall
"Sperre aufgehoben." War der Host im Moment des Aufhebens nicht erreichbar,
stand der Datensatz auf aufgehoben und die Regel noch drin: Portal und
Konsole zeigten "Aufgehoben", waehrend die Adresse weiter ausgesperrt blieb
— bis zum Ablauf der urspruenglichen Sperrzeit, ohne dass es jemand sagen
konnte.
- release() gibt zurueck, ob die Firewall schon nachgezogen hat; alle drei
Stellen (Portal, Host-Ansicht, Kunden-Ansicht) sagen es, wenn nicht.
- releaseMany() als Gegenstueck zu blockMany(): eine SSH-Sitzung statt einer
je Adresse.
- ScanForIntrusions gleicht jetzt in BEIDE Richtungen ab. Bisher trug er nur
ein; nichts nahm je einen haengengebliebenen Eintrag wieder heraus. Eine
Adresse, die eine ANDERE aktive Sperre desselben Hosts noch traegt, bleibt
stehen.
- Jeder Loeschbefehl traegt `2>/dev/null || true`: nft scheitert am
Loeschen eines Elements, das es nicht gibt, und weg ist genau das Ziel.
Ohne das meldete der Abgleich bei jedem Lauf einen Fehlschlag.
8 neue Pruefungen, Suite 2625 gruen.
Fix-Welle nach dem Gesamt-Review, Punkte 4 und 5. Beide sitzen in denselben
Dateien, deshalb ein Commit.
DIE HOST-MELDUNG WAR WORTWOERTLICH DIE KUNDENMAIL (Punkt 4).
notifyHostManagers() verschickte dieselbe SecurityBlockMail. Deren Text ist fuer
den Kunden geschrieben ("bei Ihrer Cloud", "…koennen Sie die Sperre auf der
Sicherheitsuebersicht aufheben"), der Knopf zeigte auf portal.security — wo ein
Betreiber kein Konto hat und Host-Sperren nie erscheinen —, welcher Host
betroffen ist stand nirgends, und die Anrede blieb leer, weil $block->instance
null ist.
Die Mail kann jetzt beide Faelle. Unterscheidungsmerkmal in der Ansicht ist
$hostName: gesetzt heisst Host-Sperre, null heisst Instanz-Sperre. Ein zweites
Flag daneben waere eine zweite Wahrheit.
- Betreff nennt den Host.
- Eigener Textblock (mail_heading_host, mail_preheader_host, mail_intro_host mit
"fehlgeschlagene SSH-Anmeldungen am Host :host"), eigene Tabellenzeile
field_host, eigener Hinweis mail_note_host.
- Der Knopf zeigt auf admin.hosts.show, also auf die Host-Detailseite der
Konsole, wo die Host-Sperren mit ihrem Aufheben-Knopf stehen.
- Keine Anrede im Host-Fall — sie war vorher leer, und der Empfaengerkreis ist
ohnehin mehrere Personen.
- Der Fehlalarm-Satz ist ein anderer und sagt, was ein Betreiber wissen will:
bestehende Verbindungen bleiben unberuehrt, die Regel steht unter
"established,related accept".
R19: die zwei Kommentare, die fuer Host-Sperren Unwahres behaupteten, sind
richtiggestellt — der Kopfkommentar von SecurityBlockMail und der
MailCatalogue-Eintrag. Beide beschreiben jetzt zwei Empfaengerkreise. Ein
Kommentar mit falscher Begruendung haelt den Naechsten vom Nachsehen ab.
DIE WEGWAHL VERSCHOB DEN ABSENDER, NICHT DAS KONTO (Punkt 5).
SecurityBlockMail nagelte mailer('cp_system') fest und holte das Postfach ueber
die Wegwahl. MailboxTransport loeste weiter ueber den ZWECK auf und
authentifizierte mit dessen Zugangsdaten. Legte der Betreiber security-block auf
info@ — der ausdrueckliche Anlass fuer Abschnitt 3a der Spec —, ging die Mail
mit From: info@ durch die SMTP-Sitzung von no-reply@. Viele Server weisen das ab
("553 5.7.1 Sender address rejected: not owned by user"), und dann verschwindet
ausgerechnet die Mail, die er gerade umgelegt hat. Betraf ALLE Mailarten.
Die Wegwahl bestimmt jetzt auch den Mailer. Die zweitbeste Loesung — die Auswahl
in der Konsole auf Postfaecher zu begrenzen, deren Konto benutzt wird — war
nicht noetig.
- config/mail.php erzeugt AUS MailCatalogue::all() je Mailart einen Mailer
cp_mail_<mailart> mit transport, purpose und mail_key. Erzeugt statt
aufgelistet, weil der Katalog ausdruecklich die EINE Liste ist; eine zweite
hier waere die, die beim siebzehnten Mail vergessen wird. Genauso statisch wie
die fuenf Zweck-Mailer: ein reines Array, keine Datenbank, auch unter
config:cache unveraendert.
- MailboxTransport nimmt den mail_key entgegen und loest damit ueber DIESELBE
MailRoute::purposeOrMailbox() auf, die auch der Umschlag fragt. Ohne
Schluessel entscheidet wie bisher allein der Zweck — jeder Mailer, der keinen
hat, verhaelt sich unveraendert.
- Jede Mailart mit Schluessel benennt ihren Mailer ueber
MailCatalogue::mailer(). Dessen Rueckfall auf cp_<zweck> fuer einen
unbekannten Schluessel ist nicht Zierde: DunningNoticeMail baut seinen
Schluessel aus der Mahnstufe, und ohne den Rueckfall waere eine Stufe
ausserhalb 0-3 ein "Mailer [...] is not defined" und damit ein Totalausfall
statt eines falschen Absenders.
- Betrifft alle Mailarten: 13 Mailables, die CloudReady-Notification und die
cloud-ready-Vorschau. ContactRequestMail bleibt auf cp_support — es hat keinen
Schluessel im Katalog, weil es keine eigene Mailart ist.
MailSenderOwnershipTest ist mitgezogen und dabei STRENGER geworden. Die alte
Fassung verglich "From-Zweck == Mailer-Zweck"; jetzt prueft sie, dass der
Katalog dem Zweck zustimmt, den der Umschlag verlangt. Katalog-Drift war bis
hierher nur als Warnung im Docblock von MailCatalogue festgehalten und durch
nichts erzwungen. Ein Literal, das selbst kein Schluessel ist, wird als PRAEFIX
behandelt (der Mahnlauf) — dann muessen alle Schluessel dahinter zustimmen, und
es muss mindestens einen geben.
MailPreviewTest fragte den Zweck bisher, indem es 'cp_' vom Mailer-Namen
abschnitt. Das war eine Zusicherung ueber das Namensschema, nicht ueber die
Zusage; sie liest den Zweck jetzt aus der Konfiguration des Mailers selbst.
tests/Feature/Mail/SenderAddressTest.php gehoert inhaltlich zur parallel
laufenden Sitzung. Vier Zusicherungen darin pruefen genau den Mailer-Namen, den
dieser Commit aendert, also sind sie mitgezogen — die Datei war dabei sauber,
es steckt keine fremde unfertige Arbeit in diesem Commit.
Committet mit ausdruecklicher Dateiangabe am Zeilenende, weil eine parallele
Sitzung an derselben Ablage arbeitet und der Index fremde Arbeit enthalten kann.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fix-Welle nach dem Gesamt-Review, zweite Haelfte von Punkt 3.
Die beiden nftables-Mengen clupilot_blocked und clupilot_blocked6 kamen mit dem
Fruehwarnsystem ins Regelwerk. Jeder Host, der VORHER uebernommen wurde, traegt
noch die alte Datei — dort scheitert `nft add element` bei jedem Versuch, und
die Sperre steht in Datenbank, Portal, Konsole und in der Mail an den Kunden als
aktiv, in der Firewall aber nie. Gemeldet wird dieser Fall seit dem Commit
davor; das hier ist der Griff, mit dem man ihn abstellt.
Der Uebernahme-Lauf hilft nicht, und genau das stand im Runbook falsch:
SecureHostFirewall kuerzt sich ueber den `host_firewall`-Brotkrumen ab, und der
haengt am LAUF, nicht am Host. Auf einem bereits uebernommenen Host taete der
Schritt gar nichts und meldete trotzdem "erledigt".
Das Repo hat fuer genau das ein Muster, und es passt: clupilot:apply-quotas
faehrt ueber eine EIN-SCHRITT-Pipeline (`quota`) einen einzelnen Schritt gegen
ein bestehendes Subjekt — gedrosselt, wiederholt, protokolliert und in der
Konsole sichtbar wie jede andere Fernarbeit, statt dass ein Konsolenbefehl
selbst auf die Maschine greift. Ein zweiter Weg, dieselbe Datei zu schreiben,
wuerde driften.
Also dasselbe hier:
- Pipeline `host-firewall` mit Host\SecureHostFirewall als einzigem Schritt. Ein
frischer Lauf hat den Brotkrumen nicht, fuehrt den Schritt also wirklich aus —
und weil der Schritt die Datei ohnehin vollstaendig neu schreibt und vorher
den Tunnel von der HOSTSEITE aus nachprueft, ist das dieselbe Arbeit wie beim
ersten Mal, nicht eine zweite Umsetzung davon.
- EIGENE Pipeline und nicht `host`, und das ist kein Ordnungssinn:
RunRunner::failRun() loest den Subjekt-Haken nur aus, wenn der gescheiterte
Lauf DER Lauf des Subjekts ist. Unter `host` wuerde ein gescheitertes
Nachziehen einen laufenden, bezahlten Host auf 'error' stellen. Dafuer gibt es
einen eigenen Test.
- php artisan clupilot:refresh-host-firewall, mit --dry-run und --host=, und mit
derselben "der Grund ist der Bericht"-Ausgabe wie beim Vorbild: "12
uebersprungen" und sonst nichts ist keine Auskunft, mit der jemand etwas
anfangen kann.
Kein Zeitplan, aus den drei Gruenden, die schon ueber clupilot:apply-quotas
stehen: das Loch ist endlich und schliesst sich endgueltig, ein naechtlicher
Lauf waere eine zweite Instanz, die dieselbe Datei auf laufende Maschinen
schreibt und am Tag eines still kaputten Pipeline-Schritts fuer ihn einspraenge,
und eine Reparatur, die der Betreiber anstoesst, ist eine, deren Ausgabe er
liest.
docs/runbooks/tunnel-recovery.md ist richtiggestellt. Dort stand, man solle nach
dem Notfallskript "den Schritt SecureHostFirewall erneut laufen lassen" — jetzt
steht dort der Befehl, mit der Warnung darunter, warum der alte Rat nicht trug.
Committet mit ausdruecklicher Dateiangabe am Zeilenende, weil eine parallele
Sitzung an derselben Ablage arbeitet und der Index fremde Arbeit enthalten kann.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>