Ziel: ARCHITEKTUR-devops.md
Ausgangspunkt: Der bestehende deploy/-Stack im Repository toob-registry.
Quellenlage: Ursprünglich auf Basis des DevOps-Runbooks geschrieben, danach gegen den
tatsächlichen Code nachgezogen (deploy/ und internal/ops/). Die ehemaligen
[Dateien nötig]-Markierungen sind aufgelöst; die betroffenen Tickets sind mit
⟳ nachgezogen gekennzeichnet und tragen die konkreten Datei- und Funktionsverweise.
Begleitdokument: BACKLOG-devops-befunde.md enthält zwanzig Punktbefunde aus demselben
Code-Durchgang (Präfix BEF-). Zwei davon sind Vorbedingungen für diesen Umbau und stehen
dort mit Begründung: BEF-003 (der Teardown-Pfad löscht Produktions-Vault-Backups) und
BEF-006 (der Backup-Bucket ist nicht umgebungsgetrennt). Beide müssen vor EPIC C erledigt
sein, weil sonst der Restore-Test auf einem Bucket aufsetzt, den ein Dev-Teardown wegräumen
kann.
Leitgedanke der Reihenfolge: Der Vault-Umzug (EPIC D) ist der einzige Schritt, der etwas
Unwiederbringliches anfassen kann. Alles davor existiert nur, um ihn zu entschärfen. Insbesondere
OPS-020 (Restore-Test) ist eine harte Vorbedingung — nicht weil es Prozess ist, sondern weil
dort das einzige Mal billig herauskommt, ob der Snapshot überhaupt zurückspielbar ist.
Prio: P0 blockierend · P1 für das Epic-Gate · P2 Robustheit · P3 Hygiene
Typ: infra security refactor dx test cleanup
Aufwand: S ≤ ½ Tag · M 1–2 Tage · L > 2 Tage
Risiko: ○ kein Eingriff in Bestand · ◐ reversibel · ● Wartungsfenster nötig
Definition of Done (global):
- Jede Änderung ist in
toob-infraversioniert. Kein Handgriff auf einem Server, der nicht in einer Rolle oder einem Modul steht. - Rückweg dokumentiert, bevor der Schritt beginnt. Bei ● zusätzlich: alte Ressource bleibt bis zur Bestätigung stehen.
- Betroffener Abschnitt in
ARCHITEKTUR-devops.mdbestätigt oder im selben PR angepasst. - Kein Secret in Shell-History, Commit oder Terraform-Output.
| ID | Titel | Prio | Risiko | Aufw. |
|---|---|---|---|---|
| EPIC A — Vorbereitung, kein Eingriff | ||||
| OPS-001 | Repository toob-infra anlegen, deploy/ überführen |
P0 | ○ | M |
| OPS-002 | Terraform-State der drei lokalen Module nach S3 | P0 | ◐ | S |
| OPS-003 | api_origins über terraform_remote_state |
P2 | ○ | S |
| OPS-004 | Golden Image von Registry-Artefakten entkoppeln | P1 | ○ | M |
| OPS-005 | Monolith-Playbook in Rollen zerlegen | P1 | ○ | L |
| OPS-006 | Verbindlicher CIDR-Plan | P0 | ○ | S |
| OPS-007 | topology um eine Projektdimension erweitern |
P0 | ○ | L |
| OPS-008 | --project-Flag, State-Dateien, Stage-Profile |
P0 | ○ | L |
| OPS-009 | Stages an Hub-and-Spoke anpassen | P1 | ○ | L |
| OPS-00A | Dashboards und Alert-Regeln projektfähig machen | P1 | ○ | M |
| OPS-00B | Cloudflare-Modul mehrprojektfähig | P1 | ◐ | M |
| EPIC B — Kontrollebene aufbauen | ||||
| OPS-010 | Projekt toob-ops, Netz, Firewall-Baseline |
P1 | ○ | M |
| OPS-011 | ops-hub mit WireGuard-Hub |
P1 | ○ | M |
| OPS-012 | Peer-Registrierung und Admin-Profil | P1 | ○ | S |
| OPS-013 | ops-ci als Build- und Deploy-Runner |
P1 | ○ | M |
| OPS-014 | Monitoring-Stack auf ops-hub, noch ohne Ziele |
P1 | ○ | M |
| EPIC C — Sicherheitsnetz vor dem Vault-Umzug | ||||
| OPS-020 | Restore-Test automatisieren | P0 | ○ | M |
| OPS-021 | Backup-Metriken vereinheitlichen | P2 | ○ | S |
| OPS-022 | Break-Glass dokumentieren und einmal üben | P1 | ○ | S |
| EPIC D — Vault-Migration | ||||
| OPS-030 | ops-kms aufsetzen, Unseal-Vault umziehen |
P0 | ● | M |
| OPS-031 | ops-vault-1/2/3, Raft-Snapshot, drei Knoten |
P0 | ● | L |
| OPS-032 | Vault-Pfade auf Projektstruktur | P1 | ◐ | M |
| OPS-033 | Transit-Key update-firmware-signing trennen |
P0 | ◐ | S |
| OPS-034 | Cosign-Dauertoken durch Token-pro-Lauf ersetzen | P1 | ◐ | M |
| OPS-035 | Alte Vault-Instanzen abbauen | P2 | ◐ | S |
| EPIC E — Beobachtung umziehen | ||||
| OPS-040 | Registry als ersten Spoke anbinden | P1 | ◐ | M |
| OPS-041 | Monitoring von reg-api-fsn1 nach ops-hub |
P1 | ◐ | L |
| OPS-042 | Zwei-Sonden-Muster für jeden öffentlichen Dienst | P1 | ○ | M |
| OPS-043 | Alert-Routing und Dead-Man-Switch pro Projekt | P1 | ○ | S |
| OPS-044 | Loki-Mandantentrennung vorbereiten | P2 | ○ | M |
| EPIC F — Identity-Spoke | ||||
| OPS-050 | Projekt toob-identity, zwei Knoten |
P1 | ○ | M |
| OPS-051 | Zitadel installieren, HA praktisch verifizieren | P1 | ○ | L |
| OPS-052 | Registry-Login auf Zitadel umstellen | P2 | ◐ | M |
| OPS-053 | Workload-Identity-Federation für Kunden-CI | P2 | ○ | M |
| EPIC G — Modul und Staging | ||||
| OPS-060 | project-baseline-Terraform-Modul |
P1 | ○ | L |
| OPS-061 | toob-staging als Nachweis des Moduls |
P1 | ○ | M |
| OPS-062 | Promotion: derselbe Digest von Staging nach Produktion | P2 | ○ | M |
| EPIC H — Update-Spoke | ||||
| OPS-070 | Projekt toob-update aus dem Modul |
P1 | ○ | M |
| OPS-071 | systemd-Units, Caddy, Rolling-Deploy-Skript | P1 | ○ | M |
| OPS-072 | Blob-Route fw. direkt auf Object Storage |
P0 | ○ | S |
| EPIC I — Invarianten nachweisen | ||||
| OPS-080 | Abschalttest gegen Staging, zweimal manuell | P1 | ○ | M |
| OPS-081 | Abschalttest gegen Produktion, einmal manuell | P0 | ● | S |
| OPS-082 | Abschalttest automatisieren, wöchentlich | P1 | ○ | M |
| OPS-083 | P2-Nachweis: Portscan aller Projektnetze | P1 | ○ | S |
| OPS-084 | P3-Nachweis: Vault 24 Stunden abgeschaltet | P1 | ◐ | S |
| EPIC J — Altlasten abbauen | ||||
| OPS-090 | Registry-Bastion abbauen | P2 | ◐ | S |
| OPS-091 | Nomad-Bootstrap-Token nach Vault | P2 | ◐ | S |
| OPS-092 | Secret-Seeding ohne Operator-Environment | P2 | ◐ | M |
| OPS-093 | Cloudflare-Token pro Projekt einschränken | P3 | ◐ | S |
| OPS-094 | DB-Firewall-Pruner als Plattformdienst | P3 | ◐ | S |
Prio: P0 · Risiko: ○ · Aufwand: M
Problem
Der gesamte Infrastrukturcode liegt als deploy/ innerhalb von toob-registry. Mit fünf
Projekten ist das nicht haltbar: Der Update-Service kann nicht aus dem Registry-Repo deployt
werden, ohne dass jeder mit Deploy-Rechten für den Update-Pfad auch Schreibrechte am
Registry-Quellcode braucht.
Lösung Eigenes Repository mit dieser Struktur:
toob-infra/
├── packer/ Golden Image (geteilt)
├── modules/
│ ├── project-baseline/ OPS-060
│ ├── spoke-node/
│ └── wg-peer/
├── projects/
│ ├── ops/ identity/ registry/ update/ staging/
├── ansible/
│ ├── roles/ common, wg-peer, vault, monitoring, caddy, nomad, zitadel
│ └── playbooks/
├── scripts/ restore-test, shutdown-test, rolling-deploy
└── runbooks/
Git-History der deploy/-Dateien per git filter-repo mitnehmen — bei einem
Infrastruktur-Repo ist die Historie der eigentliche Wert.
toob-registry behält nur, was zum Bauen der Anwendung gehört (Dockerfile.api,
deploy/compiler/, deploy/worker/Makefile). Die Grenze verläuft entlang „baut das Produkt"
gegen „stellt das Produkt bereit".
⟳ nachgezogen — der exakte Schnitt
Bleibt in toob-registry |
Wandert nach toob-infra |
|---|---|
deploy/api/Dockerfile.api |
deploy/api/deploy.sh, seccomp-api.json, toob-api-hardening.service |
deploy/compiler/ (vollständig) |
deploy/packer/, deploy/terraform/, deploy/ansible/ |
deploy/worker/Makefile |
deploy/nomad/, deploy/caddy/, deploy/monitoring/, deploy/vault/ |
Die offene Frage, die das Ticket bisher nicht stellte: wohin gehört internal/ops/?
Die toob-ops-CLI liegt heute in toob-registry und ist an zwei Stellen an dessen
Domänenmodell gekoppelt: cmd/toob-ops/list_compilers.go importiert
internal/domain.CompilerVersion, test_build.go zusätzlich domain.RegistryIndex und
domain.VMResult. Alles andere — engine, stages, topology, ui, exec, dashboard —
ist produktunabhängig.
Drei Optionen:
| Vorgehen | Bewertung | |
|---|---|---|
| A | CLI bleibt in toob-registry, toob-infra wird nur Datenhaltung |
Verfehlt den Zweck — Update-Deploys bräuchten weiter Registry-Checkout |
| B | CLI vollständig nach toob-infra, list-compilers/test-build entfallen dort |
Diese beiden Kommandos sind registry-spezifische Verifikationswerkzeuge und gehören ohnehin nicht in ein Plattformwerkzeug |
| C | CLI nach toob-infra, Domänentypen in ein geteiltes toob-types-Modul |
Drittes Repository für drei Structs |
Empfehlung: B. list-compilers und test-build bleiben als
cmd/toob-registry-verify im Registry-Repo — sie prüfen Compiler-Images und Rootfs-Builds,
also Registry-Produktfunktionen, nicht Infrastruktur. Der Rest der CLI wird produktneutral und
verliert damit seine einzige Kopplung.
Akzeptanzkriterien
-
terraform planaus dem neuen Repo zeigt für alle bestehenden Module keine Änderung. - Ein Ansible-Lauf aus dem neuen Repo ist idempotent gegen den Bestand.
-
toob-registryenthält kein Terraform mehr. -
toob-infraimportiert nichts ausgithub.com/Toob-Boot/Toob-Registry/internal/domain.
Prio: P0 · Risiko: ◐ · Aufwand: S
⟳ nachgezogen — weitgehend erledigt, Ticket wird zur Verifikation
Das Runbook beschrieb einen älteren Stand. Im Code ist der Remote-State bereits verdrahtet:
terraform/cloudflare/main.tf enthält backend "s3" {}, und stages/terraform.go übergibt
die Konfiguration zur Laufzeit für jedes Modul der Liste
{s3, database, control-plane, cloudflare}:
initArgs := []string{
"init",
fmt.Sprintf("-backend-config=bucket=%s", bucket), // toob-terraform-state-<env>
fmt.Sprintf("-backend-config=key=%s", key), // <env>/<modul>/terraform.tfstate
"-backend-config=region=fsn1",
…
"-reconfigure",
}Zusätzlich legt bootstrapStateBucket den Bucket an und aktiviert Versionierung — der
Henne-Ei-Schritt aus dem Runbook ist automatisiert.
Was bleibt
- Bestätigen, dass
database/main.tfunds3/main.tfebenfalls einebackend "s3" {}-Stanza haben. Ohne sie ignoriert Terraform die-backend-config-Flags stillschweigend und schreibt weiter lokal — das ist genau der Fall, den man nicht sieht, bis der State weg ist. terraform planaus einer frischen Arbeitskopie je Modul. Nur das beweist, dass der Remote-State vollständig ist.- Etwaige verbliebene
terraform.tfstate-Dateien im Arbeitsverzeichnis sichern und entfernen.
Nachrangig, aber im selben Zug: terraform/cloudflare/tfplan liegt als Binärdatei im
Repository. Ein Plan-File enthält aufgelöste Variablenwerte — bei diesem Modul unter anderem
das Cloudflare-API-Token. Löschen und in .gitignore aufnehmen (siehe BEF-002).
control-plane/terraform.tfstate (bestehend)
worker/terraform.tfstate (bestehend)
cloudflare/terraform.tfstate neu
database/terraform.tfstate neu
s3/terraform.tfstate neu
Vor der Migration den lokalen State sichern. Danach ein terraform plan aus einer frischen
Arbeitskopie — nur das beweist, dass der Remote-State vollständig ist.
Akzeptanzkriterien
- Kein Modul mehr ohne Remote-Backend.
-
terraform planaus frischem Clone: keine Änderungen für alle fünf Module. - Bucket-Versionierung auf dem State-Bucket ist aktiv.
Eine Stunde Arbeit, und das billigste Ticket im ganzen Backlog gemessen am vermiedenen Schaden.
Prio: P2 · Risiko: ○ · Aufwand: S
Problem
Runbook Phase 3.4 verlangt, die Public-IPs aus dem control-plane-Output von Hand ins
Cloudflare-Modul zu übertragen. Bei jedem Server-Replace veraltet das stillschweigend — der
Load-Balancer zeigt dann auf eine recycelte IP.
Lösung
terraform_remote_state wie im worker-Modul bereits vorhanden. Das Muster existiert im Repo,
es wird nur an einer Stelle nicht angewandt.
Akzeptanzkriterien
- Cloudflare-Modul hat keine
api_origins-Variable mehr. - Ein Server-Replace im control-plane-Modul aktualisiert die Origins ohne Handgriff.
Prio: P1 · Risiko: ○ · Aufwand: M
Problem
packer/image.pkr.hcl spielt per file-Provisioner build/worker und build/rootfs.ext4 ein
und bricht hart ab, wenn sie fehlen. Für ops-hub, idp-* und upd-edge-* sind Firecracker,
Jailer, der Gast-Kernel und das Worker-Binary toter Ballast — und jeder Registry-Rebuild würde
alle anderen Projekte zu einem neuen Image zwingen.
Lösung Zwei Images aus gemeinsamer Basis:
| Image | Inhalt |
|---|---|
toob-base |
Ubuntu 24.04, Härtung, Docker, Caddy, node-exporter, Alloy, WireGuard |
toob-worker |
toob-base + Firecracker, Jailer, vmlinux, Worker-Binary, rootfs |
Die HashiCorp-Binaries (Nomad, Vault) mit GPG-Fingerprint-Pinning bleiben in toob-base — Vault
braucht ops-vault-*, Nomad braucht die Registry, und ein zusätzliches Image dafür lohnt nicht.
Akzeptanzkriterien
-
toob-basebaut ohnebuild/workerundbuild/rootfs.ext4. - Ein Rebuild von
toob-workerzwingt keine anderen Projekte zu einem Update. - Beide Images tragen ein Label mit Build-Datum und Basis-Image-Digest.
Prio: P1 · Risiko: ○ · Aufwand: L
Problem
ansible/playbook.yml enthält zehn Plays, die von der CA-Erzeugung über beide Vault-Inits bis
zum Nomad-ACL-Bootstrap alles in einer Datei erledigen. Für fünf Projekte mit unterschiedlichen
Rollenprofilen ist das nicht teilbar: ops-hub braucht Monitoring aber kein Nomad, idp-*
braucht Caddy aber kein Vault, upd-edge-* braucht beides nicht.
Lösung Rollen mit klaren Grenzen:
| Rolle | Inhalt | Verwendet von |
|---|---|---|
common |
node-exporter, Textfile-Collector, notify-failure@, Alloy, Swap aus |
alle |
wg-peer |
WireGuard-Client mit Dial-Out zum Hub | alle Spokes |
wg-hub |
Hub, AllowedIPs je Peer, Firewall |
ops-hub |
vault-kms |
Unseal-Vault, Init-Zeremonie, Backup-Timer | ops-kms |
vault-primary |
Raft, Auto-Unseal, init.sh, seed.sh |
ops-vault-* |
monitoring |
Prometheus, Loki, Grafana, Alertmanager | ops-hub |
caddy |
Origin-Cert, Reverse-Proxy, Header | alle mit Ingress |
nomad |
Server/Client, TLS-Rotation, ACL-Bootstrap | reg-* |
zitadel |
Zitadel + systemd | idp-* |
Die beiden Vault-Zeremonien (Runbook 5.3 und 5.4) bleiben unverändert als Abbruchpunkte erhalten — sie sind kein Umbaukandidat.
⟳ nachgezogen — der eigentliche Blocker
Der Aufwand liegt nicht in der Zahl der Plays, sondern in einem durchgehenden Muster:
Rollenzuordnung erfolgt über den hartkodierten Hostnamen toob-api-fsn1, nicht über
Gruppen oder Variablen. Das Konstrukt
when: inventory_hostname == 'toob-api-fsn1'steht über das gesamte Playbook verteilt und entscheidet, wer Vault initialisiert, wer den
Monitoring-Stack bekommt, wer die Backup-Timer bekommt, wer den Token-TTL-Exporter bekommt
und wer den Nomad-ACL-Bootstrap ausführt. Dieselbe Kopplung steckt in der Nomad-Konfiguration
(server.hcl vs. client.hcl werden nach demselben Vergleich verteilt) und in
stages/ansible.go, das inventory.ini mit genau diesen Namen generiert.
Für fünf Projekte ist das nicht teilbar: ops-vault-1 müsste „toob-api-fsn1 sein", um die
Vault-Rolle zu bekommen.
Zusätzlich zu entflechten: Der Play „Setup and Initialize Primary Vault Nodes" vermischt
vier Belange — Vault-Init und -Seeding, den kompletten Monitoring-Compose-Stack, die
Backup-Timer für Vault und Nomad, und den Token-TTL-Exporter. In der Zielarchitektur landen
diese auf zwei verschiedenen Projekten (ops-vault-* und ops-hub).
Vorgehen in dieser Reihenfolge
inventory_hostname ==-Vergleiche durch Gruppen- oder Rollenzugehörigkeit ersetzen (when: "'vault_primary' in group_names"). Rein mechanisch, gegen den Bestand testbar.- Den großen Vault-Play entlang der vier Belange aufteilen.
- Erst dann Rollen extrahieren.
Schritt 1 ist die Voraussetzung dafür, dass Schritt 3 überhaupt Sinn ergibt — und er ist einzeln gegen die bestehende Dev-Umgebung verifizierbar.
Akzeptanzkriterien
- Kein
inventory_hostname ==-Vergleich mehr im Playbook (Grep-Test). - Der Bestand lässt sich mit den neuen Rollen idempotent reprovisionieren.
- Ein Play für
ops-hubläuft ohne Nomad- und ohne Vault-Rolle durch. - Keine Rolle setzt
/etc/hosts-Einträge fremder Projekte.
Verwandt: BEF-012 (Regex-replace auf Nomad-Konfigurationen) gehört in denselben
Arbeitsgang — die fünf replace-Tasks fallen weg, sobald server.hcl und client.hcl
Templates sind.
Prio: P0 · Risiko: ○ · Aufwand: S
Problem
Die Projektnetze wurden bisher pro Ticket vergeben. Zwei Kollisionen sind so entstanden:
toob-identity auf 10.2.1.0/24 (belegt von registry/staging) und toob-update auf
10.1.1.0/24 — bitgenau das Subnetz von registry/dev.
Am WireGuard-Hub ist das nicht heilbar: zwei Peers mit identischem AllowedIPs sind nicht
routbar, und WireGuard meldet keinen Fehler, sondern liefert an den zuletzt passenden Peer.
Der Fehler fällt als sporadisch nicht erreichbarer Knoten auf, nicht als Konfigurationsfehler.
Lösung
Ein Plan, festgeschrieben in topology.go und in ARCHITEKTUR-devops.md:
| Bereich | Belegung |
|---|---|
10.0.0.0/16 |
registry / production (Bestand) |
10.1.0.0/16 |
registry / dev (Bestand) |
10.2.0.0/16 |
registry / staging (Bestand) |
10.9.0.0/16 |
ops — einmalig, ohne Umgebungsvariante |
10.20.0.0/16 |
identity / production |
10.21.0.0/16 |
identity / dev |
10.30.0.0/16 |
update / production |
10.31.0.0/16 |
update / dev |
Warum die Kontrollebene keine Dev-Variante bekommt: Sie bedient alle Projekte einschließlich
deren Dev-Umgebungen. Ein zweiter Vault-Cluster und ein zweiter Hub nur für Dev-Spokes würden
die Zeremonien verdoppeln, ohne etwas zu isolieren — die Trennung läuft über Vault-Pfade und
Peer-AllowedIPs. Das entspricht dem Bestand, in dem ein Vault über VAULT_ENV alle
Umgebungen bedient.
Akzeptanzkriterien
- Eine Testfunktion in
topologyprüft alle Paare auf Überschneidung und schlägt fehl, wenn eine hinzukommt. - Kein Ticket vergibt mehr ein Netz außerhalb dieses Plans.
Prio: P0 · Risiko: ○ · Aufwand: L
Datei: internal/ops/topology/topology.go
Problem
topology.Get(env) liefert eine feste Struktur mit BastionIP, UnsealVaultIP und
APINodes — ein Modell mit genau einem Produkt und drei festen Rollen. Jede Stage liest
daraus: terraform.go, ansible.go, vpn.go, verify.go, deploy.go, workers.go und
dashboard/poller.go.
Solange das so bleibt, ist toob-ops wizard --project identity nicht aufrufbar, und die
Projekte aus EPIC F bis H müssten von Hand provisioniert werden — was OPS-060 (Modul) und
OPS-061 (Nachweis) ihren Sinn nimmt.
Lösung
type Project struct {
Name string
Environment string
NetworkCIDR string
SubnetCIDR string
Nodes map[string]Node // rolle → knoten
Hostnames map[string]string // zweck → fqdn
Buckets map[string]string
}
func Get(project, env string) ProjectNode bekommt Roles []string, damit reg-api-fsn1 gleichzeitig nomad-server, caddy und
wg-peer sein kann — genau die Mehrfachrolle, die heute über
inventory_hostname == 'toob-api-fsn1' ausgedrückt wird und in OPS-005 verschwindet.
Die drei Bestandsumgebungen werden zu Get("registry", env) mit bitidentischen Werten. Die
Migration läuft damit in zwei Schritten: erst Struktur ohne Verhaltensänderung, dann neue
Projekte.
Akzeptanzkriterien
-
Get("registry", "dev")liefert exakt die heutigen Werte. - Ein Dev-Deployment nach dem Refactor erzeugt identische Terraform-Pläne.
- Keine IP-Literale mehr außerhalb des
topology-Pakets (Grep-Test im CI).
Prio: P0 · Risiko: ○ · Aufwand: L
Dateien: cmd/toob-ops/main.go, internal/ops/engine/state.go, internal/ops/stages/prereqs.go
Problem Vier Stellen in der CLI sind auf ein Produkt festgelegt:
| Stelle | Heute | Problem |
|---|---|---|
DefaultStatePath(deployDir, env) |
.toob-ops-state.<env>.json |
Kollidiert zwischen Projekten |
main.go Stage-Registrierung |
alle neun Stages, unbedingt | ops-hub braucht kein Packer, kein Workers |
AllEnvRequirements() |
~40 Variablen, projektunabhängig Required |
Ein Identity-Spoke bräuchte SEED_GH_APP_ID, SEED_DOCKERHUB_*, NOMAD_GOSSIP_KEY — Phase 0 blockiert |
mapCredentials() |
hartkodierte Key-Liste, Präfixe TOOB_DEV_/TOOB_STAGING_/TOOB_PROD_ |
Keine Projektdimension |
Lösung
-
--projectmit Defaultregistry, State- und Secrets-Pfad.toob-ops-<project>.<env>.json. Die Mismatch-Prüfung inrunWizard(state environment mismatch) hat sich bewährt und wird um das Projekt erweitert. -
Stage-Profil je Projekt:
Projekt Stages registryalle neun, unverändert opsprereqs, terraform, vpn, ansible, verify identityprereqs, terraform, ansible, deploy, verify updateprereqs, terraform, ansible, deploy, verify Die Phasennummern bleiben stabil — Einträge entfallen, sie verschieben sich nicht, damit State-Dateien und Logverzeichnisse vergleichbar bleiben.
-
EnvRequirementbekommt ein FeldProjects []string. Fehlt es, gilt die Variable für alle. -
mapCredentialsliest die Key-Liste aus dem Profil statt aus einem Literal.
Akzeptanzkriterien
-
--project registryverhält sich in allen neun Phasen exakt wie heute. -
--project identityverlangt in Phase 0 keine Registry-spezifischen Variablen. - Ein Profil ohne Packer-Stage läuft durch, ohne dass eine Precondition auf Phase 2 wartet.
Prio: P1 · Risiko: ○ · Aufwand: L
Dateien: stages/vpn.go, stages/verify.go, stages/packer.go, dashboard/
Problem Vier Stellen setzen die heutige Topologie voraus und funktionieren nach dem Umbau nicht mehr:
stages/vpn.go — EnsureVPN und ensureVPNForStatus sondieren topo.UnsealVaultIP:22
und starten wg-quick up wg0.<env>.conf. Mit Dial-Out-Peers und zentralem Hub entfällt das
projektspezifische Client-Profil; die Sonde muss gegen einen Knoten des jeweiligen Projekts
gehen. Zusätzlich schreiben ensureHostsMapping und setupHostsForStatus fest
vault.the-toob.com → api-fsn1 in /etc/hosts — nach OPS-031 zeigt der Name auf
ops-vault-*.
stages/verify.go — prüft Vault-Seal-Status über topo.APINodes und die
Nomad-Erreichbarkeit unbedingt. Für Projekte ohne Vault und ohne Nomad muss die Prüfliste aus
dem Profil kommen.
stages/packer.go — findExistingHetznerSnapshot sucht nach Description
toob-golden-image-<env> und den Labels name=toob-golden-image. Mit toob-base und
toob-worker aus OPS-004 braucht das eine Imagedimension, sonst wird das falsche Snapshot
wiederverwendet.
dashboard/render.go — baut die Knotenkarte aus den Literalen toob-unseal-vault,
<prefix>-api-fsn1 und <prefix>-api-hel1. Für andere Projekte zeigt das Dashboard nichts.
Lösung
Alle vier auf topology.Project und das Stage-Profil aus OPS-008 umstellen. Das Dashboard
rendert Knoten und deren Rollen generisch aus Nodes, statt sie zu erraten.
Akzeptanzkriterien
-
toob-ops status --project identityzeigt die Identity-Knoten mit ihren Rollen. -
verifyprüft für ein Projekt ohne Vault keine Seal-Status. - Ein
toob-base-Rebuild macht ein bestehendestoob-worker-Snapshot nicht ungültig.
Prio: P1 · Risiko: ○ · Aufwand: M
Dateien: monitoring/prometheus/alert.rules.yml, monitoring/grafana/dashboards/*.json
Problem
OPS-043 routet Alarme über ein project-Label — das keine der 21 Regeln setzt. Ohne dieses
Ticket landet nach dem Umbau alles im Default-Empfänger.
Zusätzlich brechen mehrere Panels und Regeln durch den Umbau selbst:
| Stelle | Bricht durch |
|---|---|
ops-home.json: up{job="node-exporter", instance="bastion"} |
OPS-090 (Bastion-Abbau) → dauerhaft rot |
ops-home.json: instance="unseal-vault" |
OPS-030 → Knoten heißt ops-kms |
alert.rules.yml: blackbox-public auf registry.the-toob.com |
weitere Hostnames kommen dazu |
AutoscalerIneffective: job="registry-worker" |
bleibt registry-spezifisch, braucht Projektfilter |
VaultSealed, NomadTLSRotationFailed |
gelten nur für Projekte mit Vault bzw. Nomad |
Lösung
Regeln nach common (InstanceDown, DiskSpace, Backup, Watchdog) und projektspezifisch
aufteilen. project als Pflichtlabel in jeder Regelgruppe. Dashboards mit einer
project-Template-Variablen statt fester Instanznamen; die Statusmatrix rendert Zeilen aus
den vorhandenen Zielen, statt sie zu benennen.
Akzeptanzkriterien
- Jede Regel trägt ein
project-Label. - Nach dem Bastion-Abbau ist kein Panel dauerhaft rot.
- Ein neues Projekt erscheint im Ops-Home-Dashboard, ohne dass es angepasst wird.
Prio: P1 · Risiko: ◐ · Aufwand: M
Datei: terraform/cloudflare/main.tf
Problem Das Modul ist auf die Registry zugeschnitten:
local.domain_mapkennt nurregistry.the-toob.comund dessen Staging-/Dev-Varianten- Load-Balancer, SSL-Einstellungen und Rate-Limits entstehen nur bei
var.environment == "production"— Dev und Staging haben deshalb wederssl = strictnoch Rate-Limits cloudflare_dns_record.cdnzeigt fest auf${name_prefix}-packages- Die Cache-Regel greift auf
/download, was fürfw.nicht passt
Die Hostnames id., ota., api. und fw. aus EPIC F bis H haben damit kein Modul, das sie
anlegt. OPS-072 beschreibt die Regeln für fw., aber nicht, wer sie erzeugt.
Lösung
Modul auf eine Hostname-Liste umstellen, die aus topology.Project.Hostnames gespeist wird.
Je Eintrag: Typ (LB oder CNAME), Ziel, Cache-Policy, Rate-Limit, Ruleset. Die
Zone-Einstellungen (ssl = strict, min TLS, TLS 1.3) sind zonenweit und gehören aus der
environment == "production"-Bedingung heraus — dass Dev heute ohne strict läuft, ist
vermutlich nicht beabsichtigt.
Akzeptanzkriterien
-
id.,ota.,api.undfw.entstehen aus dem Modul, nicht von Hand. -
fw.hat Compress off,immutable, kein Redirect (Prüfung ausOPS-072). - Getrennte Rulesets pro Hostname — ein WAF-Fehler auf
ci.trifftota.nicht.
Alles in diesem Epic läuft parallel zum unveränderten Bestand.
Prio: P1 · Risiko: ○ · Aufwand: M
Lösung
Eigenes Hetzner-Projekt mit eigenem API-Token. Netz 10.9.0.0/16, Subnetz 10.9.1.0/24
(bewusst weit weg von den Spoke-Bereichen 10.0–10.3, damit eine Fehlkonfiguration nicht
zufällig überlappt).
Firewall-Baseline: kein eingehender Port außer dem WireGuard-UDP-Port am Hub. Kein SSH von außen, auch nicht temporär.
Akzeptanzkriterien
- Portscan von außen: genau ein UDP-Port offen.
- Das Projekt hat ein eigenes
HCLOUD_TOKEN, das keine anderen Projekte sieht.
Prio: P1 · Risiko: ○ · Aufwand: M
Lösung
CX32 aus toob-base. Hub-Konfiguration mit einem Peer je Projekt-Umgebung-Paar plus
Admin-Peers:
[Interface]
Address = 10.9.9.1/24
ListenPort = 51820
PostUp = iptables -A FORWARD -i %i -o %i -j DROP
[Peer] # registry / production
AllowedIPs = 10.9.9.10/32, 10.0.1.0/24
[Peer] # registry / dev
AllowedIPs = 10.9.9.11/32, 10.1.1.0/24
[Peer] # registry / staging
AllowedIPs = 10.9.9.12/32, 10.2.1.0/24
[Peer] # identity / production
AllowedIPs = 10.9.9.20/32, 10.20.1.0/24
[Peer] # identity / dev
AllowedIPs = 10.9.9.21/32, 10.21.1.0/24
[Peer] # update / production
AllowedIPs = 10.9.9.30/32, 10.30.1.0/24
[Peer] # update / dev
AllowedIPs = 10.9.9.31/32, 10.31.1.0/24⟳ nachgezogen — zwei Korrekturen
Die erste Fassung listete drei Peers mit den Netzen 10.0.1.0/24, 10.1.1.0/24 und
10.2.1.0/24 für registry, update und identity. Beide Annahmen waren falsch:
- Die Netze waren die der Registry-Umgebungen, nicht die neuer Projekte.
10.1.1.0/24ist registry/dev,10.2.1.0/24ist registry/staging. Der Hub hätte Update- und Identity-Verkehr in die Registry-Netze geroutet. - Es ist ein Peer pro Projekt-Umgebung-Paar, nicht pro Projekt.
mapCredentialsinmain.gomapptTOOB_DEV_HCLOUD_TOKEN,TOOB_STAGING_HCLOUD_TOKENundTOOB_PROD_HCLOUD_TOKEN— die drei Registry-Umgebungen sind bereits heute drei getrennte Hetzner-Projekte mit eigenen Netzen. Der Hub bedient also sieben Peers, nicht drei.
Die IP-Vergabe im Hub-Netz folgt demselben Schema wie die Subnetze: Zehnerblock pro Produkt,
Einerstelle pro Umgebung. Damit ist aus 10.9.9.31 ohne Nachschlagen ablesbar, dass es
update/dev ist.
Die PostUp-Regel ist der Kern: Sie unterbindet Peer-zu-Peer-Routing. Ohne sie kann ein
kompromittierter Registry-Knoten über den Hub ins Update-Netz.
Akzeptanzkriterien
- Ein Peer kann das Subnetz eines anderen Projekts nicht erreichen (aktiv getestet, nicht aus der Konfiguration abgeleitet).
- Der Hub verwirft eingehenden Verkehr von Peers bis auf den Loki-Port.
Prio: P1 · Risiko: ○ · Aufwand: S
Lösung Peer-Schlüssel werden beim Provisionieren eines Spoke-Knotens erzeugt, der öffentliche Teil geht als Terraform-Output an das Hub-Modul. Kein manuelles Kopieren von Schlüsseln.
Ein Admin-Profil erreicht alle Subnetze — ein Profil statt fünf, kein Doppel-Hop.
Akzeptanzkriterien
- Ein neuer Spoke-Knoten ist ohne Handgriff am Hub erreichbar.
- Das Admin-Profil erreicht alle vier Spoke-Subnetze.
Prio: P1 · Risiko: ○ · Aufwand: M
Problem Ein Build zieht Abhängigkeiten Dritter. Diese dürfen nicht im selben Prozessraum landen wie der WireGuard-Hub und die Metriken aller Projekte — das wäre ein Supply-Chain-Pfad direkt in die Kontrollebene.
Lösung
Eigener CX22, self-hosted Runner. Übernimmt schrittweise deploy/api/deploy.sh (Build → Cosign
→ Verify → Deploy) und später das Secret-Seeding (OPS-092).
Sparoption, falls das Budget drückt: mit ops-hub zusammenlegen. Dann aber mit einem Ticket zur
späteren Trennung, nicht stillschweigend.
Akzeptanzkriterien
-
deploy.sh allläuft vollständig vonops-ci. -
ops-cihat keinen Zugriff auf die Monitoring-Daten und keinen Hub-Schlüssel.
Prio: P1 · Risiko: ○ · Aufwand: M
Lösung
Prometheus, Loki, Grafana, Alertmanager als Compose-Stack, alle UIs an 127.0.0.1 gebunden
(Zugriff per WireGuard). Zunächst ohne Scrape-Ziele — der Bestand wird erst in OPS-041
umgehängt. Die fünf bestehenden Dashboards werden übernommen.
Akzeptanzkriterien
- Stack läuft, Grafana erreichbar über den Tunnel.
- Der Bestands-Stack auf
reg-api-fsn1läuft unverändert weiter.
Prio: P0 · Risiko: ○ · Aufwand: M
Problem
Das Runbook führt „periodisch Raft-Snapshot-Restore proben" als manuelle Daueraufgabe. Das
passiert erfahrungsgemäß genau einmal. BackupStale misst heute nur, ob eine Datei neu genug
ist — nicht, ob sie etwas enthält, das sich zurückspielen lässt.
Vor OPS-031 ist das die einzige Absicherung, die zählt.
⟳ nachgezogen — Korrektur am ursprünglichen Entwurf
Die erste Fassung dieses Tickets schrieb „Wegwerf-Vault-Container starten (File-Storage, eigener Unseal-Key)". Das funktioniert nicht.
Ein Raft-Snapshot enthält den verschlüsselten Barrier-Zustand einschließlich des gewrappten
Master-Keys. Der Primary-Vault ist über seal "transit" gegen ops-kms versiegelt
(Key autounseal-toob-registry). Ein Ziel-Vault mit eigenem Unseal-Key kann diesen Master-Key
nicht auspacken — snapshot restore -force überspringt nur die Konsistenzprüfung, nicht die
kryptografische Voraussetzung. Das Ergebnis wäre ein Vault, der nach dem Restore unbrauchbar
ist, und ein Test, der zuverlässig rot leuchtet, ohne etwas über die Backups auszusagen.
Korrigiertes Vorgehen
1. Neuesten Snapshot aus toob-vault-backups-<env> ziehen
2. Wegwerf-Vault-Container starten, konfiguriert mit derselben
seal "transit"-Stanza wie der Primary (ops-kms, gleicher Key)
3. vault operator raft snapshot restore
4. Prüfen: entsiegelt sich automatisch, secret/platform/canary lesbar,
und transit/keys/toob-image-signing-<env> liefert denselben
öffentlichen Schlüssel wie der Produktivcluster
5. Container zerstören, Ergebnis als Prometheus-Metrik
Schritt 4 prüft bewusst auch den Transit-Key: das ist der Wert, dessen Verlust in OPS-031 irreversibel wäre, und ein Snapshot, der ihn nicht mitbringt, ist für den Umzug wertlos.
Was der Test damit beweist — und was nicht
| Szenario | Abgedeckt |
|---|---|
Primary-Cluster verloren, ops-kms lebt |
✓ automatisiert, monatlich |
Primary und ops-kms verloren |
✗ — braucht das 3-von-5-PGP-Quorum |
Der zweite Fall lässt sich nicht automatisieren, weil er drei Menschen mit ihren PGP-Keys erfordert. Er gehört deshalb in die halbjährliche Übung aus OPS-022 und nicht hierher. Beide zusammen ergeben die Aussage „wir könnten wiederherstellen"; einer allein nicht.
Verwendbare Bausteine im Bestand
vault/scripts/backup.shauthentifiziert sich perbackup-AppRole (/opt/vault/config/backup-role-idund-secret-id) und holt die S3-Credentials übervault kv get secret/s3. Der Restore-Test kann dieselbe AppRole nutzen.- Das Textfile-Collector-Muster ist etabliert (
last_backup_timestamp_seconds,nomad_tls_rotation_timestamp_seconds,last_db_firewall_cleanup_success_timestamp_seconds) und wird hier nur fortgesetzt. - Der Kanarienvogel-Wert wird in
seed.shangelegt und nie geändert.
Der KMS-Snapshot ist ein anderer Fall. backup-unseal.sh sichert kein Raft-Snapshot,
sondern ein tar des File-Storage-Verzeichnisses, mit statischen S3-Credentials aus
/etc/vault.d/backup-s3.env — notwendigerweise, weil der KMS sich nicht selbst entsiegeln
kann, um sie aus Vault zu holen. Sein Restore-Test ist entsprechend simpler: entpacken,
starten, entsiegeln, prüfen dass transit/keys/autounseal-toob-registry existiert. Er gehört
mit ins Ticket, weil OPS-030 auf genau diesem Tarball aufsetzt.
Akzeptanzkriterien
- Der Lauf ist mit einem absichtlich beschädigten Snapshot rot.
- Der Lauf ist rot, wenn der Ziel-Vault eine abweichende Seal-Konfiguration hat (Negativtest gegen den ursprünglichen Denkfehler).
- Der wiederhergestellte Vault liefert denselben Transit-Public-Key wie der Produktivcluster.
- Der KMS-Tarball ist ebenfalls testweise zurückgespielt.
- Metrik
vault_restore_test_successexistiert und hat einen Alter-Alert. - Ein grüner Lauf liegt vor, bevor OPS-030 beginnt.
Abhängigkeit: BEF-006 (Backup-Bucket pro Umgebung trennen) und BEF-003 (Teardown
löscht den Bucket) müssen davor liegen — sonst testet man gegen einen Bucket, den der nächste
Dev-Teardown entfernt.
Prio: P2 · Risiko: ○ · Aufwand: S
Lösung
Alle Backup-Jobs (Primary Vault, Unseal-Vault, Nomad, künftig Zitadel) schreiben nach demselben
Muster in den Textfile-Collector: toob_backup_last_success_timestamp{job="…",project="…"}.
Ein Alert-Ausdruck statt vier.
Prio: P1 · Risiko: ○ · Aufwand: S
Problem
Mit dem zentralen Hub entsteht ein neues Risiko: Stirbt ops-hub, kommt niemand mehr irgendwo
hinein. Das gilt ab OPS-011, nicht erst am Ende des Umbaus.
Lösung Zwei Pfade ins Runbook:
- Hetzner Cloud Console (VNC) — funktioniert ohne jedes Netz, primärer Weg.
- Schlafendes WireGuard-Peer-Profil je Projekt, offline hinterlegt, das erst nach manueller Aktivierung über die Console akzeptiert wird.
Einmal durchführen und die Zeit bis zum Zugriff protokollieren.
Akzeptanzkriterien
- Übung durchgeführt, Dauer notiert.
- Das Runbook liegt nicht ausschließlich in einem System, das selbst ausfallen kann.
Der einzige Teil mit echtem Risiko.
OPS-020muss grün sein.
Prio: P0 · Risiko: ● · Aufwand: M
Lösung
Der leichteste Teil: Der Unseal-Vault ist bereits ein eigenständiger Knoten mit File-Storage und
täglichem Tarball nach S3 (backup-unseal.sh).
1. ops-kms aus toob-base, Rolle vault-kms
2. Tarball aus toob-vault-backups/unseal-snapshots/ einspielen
3. TLS-Zertifikat mit neuen SANs, Firewall auf die künftigen ops-vault-IPs
4. seal "transit"-Stanza der bestehenden Primaries auf ops-kms zeigen lassen
5. Primary neu starten → entsiegelt sich über den neuen KMS
Zwischen Schritt 4 und 5 darf der Primary nicht unabsichtlich neu starten. Die alte Instanz bleibt bis zur Bestätigung stehen.
Akzeptanzkriterien
- Primary entsiegelt sich nach kontrolliertem Neustart über
ops-kms. -
token-ttl-exporter.shmeldet den Autounseal-Token des neuen KMS. - Alte Instanz läuft noch und ist als Rückweg dokumentiert.
Prio: P0 · Risiko: ● · Aufwand: L
Problem Zwei Themen in einem Eingriff, weil sie denselben Umzug teilen:
- Vault liegt im Netz des Systems, das fremden Code in Firecracker ausführt.
- Der Cluster hat zwei Raft-Knoten. Quorum ist 2 — er verträgt null Ausfälle, exakt wie ein Einzelknoten, bei doppelter Ausfallfläche.
Lösung
Drei Knoten in toob-ops, verteilt auf fsn1/hel1/nbg1. Raft-Snapshot des Bestandsclusters
einspielen, Entsiegelung über ops-kms.
Die Transit-Keys dürfen unter keinen Umständen neu erzeugt werden. Ihre öffentlichen Hälften sind Vertrauensanker in eFuses ausgelieferter Geräte und in signierten Images. Der Snapshot enthält sie. Wer hier
vault write -f transit/keys/…tippt, hat jede Vertrauensbeziehung im Feld zerstört.
Danach die Clients umhängen: Vault-Agents auf den Registry-Knoten, deploy.sh, Backup-Skripte.
⟳ nachgezogen — es sind drei Cluster, nicht einer
Der Entwurf sprach von „dem Bestandscluster". Tatsächlich existieren nach topology.go drei
vollständige Vault-Installationen, je eine pro Umgebung:
| Umgebung | Primary | Unseal-Vault | Transit-Keys |
|---|---|---|---|
| production | 10.0.1.10 + 10.0.1.11 |
10.0.1.12 |
…-production |
| dev | 10.1.1.10 |
10.1.1.12 |
…-dev |
| staging | 10.2.1.10 |
10.2.1.12 |
…-staging |
Drei Raft-Snapshots lassen sich nicht in einen Cluster restoren — ein Restore ersetzt den gesamten Clusterzustand, er fügt nichts hinzu. Der Umzug ist deshalb kein einheitlicher Vorgang, sondern zwei verschiedene:
Produktion: Snapshot-Restore. Nur hier sind die Transit-Keys unersetzbar — sie haben Images signiert, die deployt sind, und Pakete, die veröffentlicht wurden. Der Snapshot erhält sie.
Dev und Staging: Inhaltskopie, neue Keys. KV-Secrets per vault kv get | put in die
Pfade aus OPS-032 übertragen, Transit-Keys neu erzeugen. Das ist zulässig, weil keine
Dev- oder Staging-Signatur außerhalb der jeweiligen Umgebung Vertrauen genießt.
Nebenwirkung, die ins Runbook gehört: Nach dem Neuerzeugen der Dev-/Staging-Keys schlägt
deploy.sh verifyfür bereits gebaute Images fehl, weil deren Signatur gegen den alten Schlüssel entstand.deploy.sh allist unproblematisch — es signiert neu. Ein isoliertesdeploy.sh deployohne vorherigessignbricht ab, und das ist richtig so.
Zusätzlich zu beachten — Schlüsseltypen unterscheiden sich:
stages/deploy.go prüft vor jedem Deploy, dass transit/keys/toob-image-signing-<env> vom
Typ ecdsa-p256 ist, und bricht sonst ab — Cosign unterstützt für Vault-Transit kein
Ed25519. Der Firmware-Schlüssel aus OPS-033 muss dagegen Ed25519 sein, weil der
Bootloader nichts anderes verifiziert. Wer beide über dieselbe Zeile anlegt, produziert
entweder einen Deploy, der abbricht, oder eine Firmware, die kein Gerät akzeptiert.
Reihenfolge innerhalb des Tickets
- Produktions-Snapshot restoren, Clients umhängen, verifizieren.
- Erst danach dev und staging inhaltlich übertragen — bei Problemen ist die Produktion dann schon stabil.
Akzeptanzkriterien
-
vault read transit/keys/toob-package-signing-productionliefert dieselbe Key-Version und denselben öffentlichen Schlüssel wie vorher. - Ein Cosign-Verify eines vor der Migration signierten Produktions-Images ist gültig.
- Dev und Staging sind funktionsfähig, ihre Keys sind neu und als solche dokumentiert.
- Ausfall eines Vault-Knotens beeinträchtigt den Betrieb nicht (aktiv getestet).
- Angekündigtes Wartungsfenster, alte Cluster bleiben bis zur Bestätigung stehen.
Prio: P1 · Risiko: ◐ · Aufwand: M
Lösung
secret/projects/{registry,update,identity,staging}/…
secret/platform/… nur Kontrollebene
Eine AppRole je Projekt und Rolle, Zugriff auf genau den eigenen Präfix. Die bestehenden neun
Policies aus init.sh werden entsprechend umgeschrieben, die PKI bleibt registry-intern.
Akzeptanzkriterien
- Negativtest je Projekt: keine AppRole liest den Präfix eines anderen.
-
seed.shschreibt in die neuen Pfade.
Prio: P0 · Risiko: ◐ · Aufwand: S
Problem
Würde Firmware mit toob-image-signing oder toob-package-signing signiert, könnte ein
kompromittierter Registry-Release-Pfad Firmware signieren. Das ist die wichtigste
Einzelmaßnahme der Produkttrennung.
Lösung Eigener Ed25519-Transit-Key mit eigener Policy, ausschließlich für den Signing-Pfad des Update Service erreichbar.
Akzeptanzkriterien
- Die Registry-Release-Policy hat keinen Zugriff (Negativtest).
- Falls bereits mit einem geteilten Key signiert wurde: Migrationspfad für Bestandsgeräte
ist dokumentiert — der alte öffentliche Schlüssel liegt in eFuses und wird nicht ersetzt,
der neue kommt als zusätzlicher
key_indexin die Provisionierung.
Prio: P1 · Risiko: ◐ · Aufwand: M
Problem
Runbook 5.6 erzeugt einen Token mit -period=720h für die Release-Pipeline, mit dem Hinweis,
ihn manuell zu erneuern — ein langlebiges Signier-Credential außerhalb von Vault.
Lösung
ops-ci authentifiziert sich per AppRole und zieht pro Lauf einen kurzlebigen Token. Das
Dauercredential entfällt ersatzlos.
Akzeptanzkriterien
- Kein Token mit Laufzeit über 24 h in der Pipeline.
- Ein Build ohne gültige AppRole-Credentials bricht ab, statt unsigniert zu deployen.
Prio: P2 · Risiko: ◐ · Aufwand: S
Nach mindestens sieben Tagen stabilem Betrieb: alten Primary-Cluster und alte Unseal-Instanz abbauen, Firewall-Regeln entfernen, Vault-Ports auf den Registry-Knoten schließen.
Prio: P1 · Risiko: ◐ · Aufwand: M
Lösung
wg-peer-Rolle auf reg-api-fsn1 (aktiv) und reg-api-hel1 (Standby), Dial-Out zum Hub,
Routing für 10.0.1.0/24 inklusive der Worker-Subnetze.
Die Registry ist bewusst der erste Spoke: Wenn das Muster dort trägt — mit dynamischen Workern, die kommen und gehen — trägt es überall.
Akzeptanzkriterien
-
ops-huberreicht alle Registry-Knoten inklusive frisch skalierter Worker. - Der bestehende Bastion funktioniert weiter (Abbau erst in OPS-090).
Prio: P1 · Risiko: ◐ · Aufwand: L
Lösung
Scrape-Ziele über den Tunnel, inklusive nomad_sd_configs für die dynamischen Worker. Alloy auf
allen Registry-Knoten auf Push zum Hub-Loki umstellen (mTLS, Pflicht-Label project=registry,
Rate-Limit, Retention-Cap).
Der alte Stack auf reg-api-fsn1 bleibt eine Woche parallel — beide Grafana-Instanzen müssen
dieselben Werte zeigen, bevor der alte abgebaut wird.
Akzeptanzkriterien
- Alle fünf Dashboards funktionieren gegen die neue Instanz.
- Ein neu skalierter Worker erscheint innerhalb von zwei Minuten in Prometheus.
- Ein Spoke kann über den Loki-Port keine anderen Hub-Dienste erreichen.
Prio: P1 · Risiko: ○ · Aufwand: M
Lösung Für jeden öffentlichen Dienst zwei unabhängige Sonden: interner Scrape über den Tunnel und externe Blackbox-Probe über Cloudflare. Der Nutzen ist Diagnose:
| Extern | Intern | Diagnose |
|---|---|---|
| up | down | Tunnel oder Scrape-Konfiguration |
| down | up | Edge, DNS oder Zertifikat |
| down | down | der Dienst |
Ohne die zweite Sonde weiß man im Ernstfall nicht, wo zu suchen ist.
Akzeptanzkriterien
- Beide Sonden je Hostname, in einem Dashboard nebeneinander.
- Ein absichtlich abgeschalteter Tunnel erzeugt das Muster „extern up, intern down".
Prio: P1 · Risiko: ○ · Aufwand: S
Problem Ein globaler Watchdog beweist nur, dass Alertmanager lebt. Er verdeckt, dass die Metriken eines einzelnen Projekts seit Stunden fehlen.
Lösung
Routing über das project-Label, eigener Kanal je Projekt, eigener Heartbeat je Projekt.
Schweregrade nach Ausfallwirkung: upd-edge beide Knoten kritisch, toob-identity hoch,
Vault niedrig — dass Vault der niedrigste ist, bestätigt, dass P3 hält.
Prio: P2 · Risiko: ○ · Aufwand: M
Problem Sobald Kunden Grafana-Leserechte auf ihre Dashboards bekommen — ein erklärtes Ziel — müssen Logs und Metriken mandantengefiltert sein. Heute enthalten sie Geräte-IDs aller Mandanten.
Lösung
Loki-Mandantentrennung über X-Scope-OrgID, Grafana-Datenquellen pro Ordner mit festem
Mandantenkontext. Vor der ersten Freischaltung für Kunden, nicht danach.
Prio: P1 · Risiko: ○ · Aufwand: M
Lösung
Netz 10.20.0.0/16, Subnetz 10.20.1.0/24, zwei CX22 aus toob-base mit wg-peer- und
caddy-Rolle, Ubicloud-Instanz toob-idp-db. Cloudflare-Hostname id.the-toob.com mit
Geo-LB über beide Knoten.
Korrektur: Die erste Fassung sah
10.2.1.0/24vor. Das kollidiert mittopology.Get("staging"), das bereits10.2.0.0/16belegt. Verbindlicher Plan sieheOPS-006.
Zitadel liegt bewusst nicht in der Kontrollebene: Ein öffentlich erreichbarer Dienst mit eigenem CVE-Strom gehört nicht in dasselbe Netz wie Vault und der WireGuard-Hub.
Prio: P1 · Risiko: ○ · Aufwand: L
Lösung Zitadel als systemd-Unit auf beiden Knoten gegen dieselbe Postgres-Instanz, Caddy davor mit dem jeweils anderen Knoten als Fallback.
Offener Punkt aus der Architektur: Dass zwei Instanzen gegen eine Postgres-Instanz tragen, steht in der Dokumentation und ist praktisch zu bestätigen. Dieses Ticket ist erst fertig, wenn ein Knoten während eines Logins abgeschaltet wurde und die Session überlebt hat.
Akzeptanzkriterien
- Login funktioniert über beide Knoten.
- Abschalten eines Knotens während einer aktiven Session unterbricht diese nicht.
- Backup der Zitadel-Datenbank läuft und ist in OPS-021 eingehängt.
- Scrape und Blackbox-Probe nach OPS-042 sind eingerichtet.
Prio: P2 · Risiko: ◐ · Aufwand: M
Lösung GitHub bleibt als Anmeldemethode erhalten, aber hinter Zitadel statt daneben. Industriekunden haben nicht alle GitHub, und zwei Identitätsmodelle für dieselben Menschen sind ein Dauerärgernis.
Grafana-SSO wandert mit — dann eine Identität für Registry, Grafana und später das Fleet-Management.
Akzeptanzkriterien
- Bestehende Nutzer behalten ihre Zuordnung.
- Der alte OAuth-Pfad ist entfernt, nicht nur deaktiviert.
Prio: P2 · Risiko: ○ · Aufwand: M
Lösung GitHub-/GitLab-OIDC-Token gegen ein kurzlebiges Toob-Token eintauschbar. Damit braucht keine Kunden-Pipeline ein langlebiges Secret bei uns — und wir haben keines zu rotieren.
Prio: P1 · Risiko: ○ · Aufwand: L
Lösung
Ein Modul, das aus einem Projektnamen erzeugt: Netz, Subnetz, Firewall-Baseline, Knoten aus
toob-base, WireGuard-Peer mit Registrierung am Hub, Vault-Pfad mit Policies und AppRoles,
Ubicloud-Instanz mit Firewall-Pinning, Prometheus-Scrape-Job, Grafana-Ordner, Alert-Route,
Dead-Man-Switch, Backup-Eintrag, Terraform-State-Key.
Die bestehenden Projekte werden nachträglich auf das Modul umgestellt, nicht vorher — sonst baut man das Modul gegen eine Annahme statt gegen die Realität.
Akzeptanzkriterien
- Ein neues Projekt entsteht aus einer Variablendatei, ohne manuelle Schritte außerhalb der dokumentierten Vault-Zeremonien.
- OPS-061 ist der Nachweis.
Prio: P1 · Risiko: ○ · Aufwand: M
Lösung Erster Spoke vollständig aus dem Modul, ein Knoten, kleinere Größen. Staging spiegelt die Software, nicht die Topologie — der Zweck ist, Migrationen, Deploys und den Abschalttest zu üben.
Akzeptanzkriterien
- Das Projekt entsteht ohne Handgriffe außerhalb des Moduls.
- Was am Modul nachgebessert werden musste, ist als Commit sichtbar.
Prio: P2 · Risiko: ○ · Aufwand: M
Lösung Ein Artefakt wird einmal gebaut und signiert, durchläuft Staging und wandert mit demselben Digest nach Produktion. Kein zweiter Build, kein „lokal ging es".
Akzeptanzkriterien
- Der Digest ist in beiden Umgebungen identisch (nachgewiesen, nicht angenommen).
- Ein Deploy nach Produktion ohne vorherigen grünen Staging-Lauf ist nicht möglich.
Prio: P1 · Risiko: ○ · Aufwand: M
Netz 10.30.0.0/16, Subnetz 10.30.1.0/24, upd-edge-fsn1, upd-edge-hel1, upd-fleet,
Ubicloud toob-update-db. Hostnames ota. (Geo-LB über beide Edge-Knoten) und api.
(auf upd-fleet).
Korrektur, und die schwerwiegendste im ganzen Dokument: Die erste Fassung sah
10.1.1.0/24vor. Das ist bitgenau dasselbe Subnetz, dastopology.Get("dev")für die Registry-Dev-Umgebung belegt — inklusive10.1.1.10fürapi-fsn1und10.1.1.12für den Unseal-Vault.upd-edge-fsn1hätte dieselbe Adresse bekommen wie ein Registry-Dev-Knoten.Aufgefallen wäre das erst am WireGuard-Hub: Zwei Peers mit identischem
AllowedIPssind nicht routbar, und WireGuard meldet das nicht als Fehler — es liefert Pakete an den zuletzt passenden Peer. Verbindlicher Plan sieheOPS-006.
Prio: P1 · Risiko: ○ · Aufwand: M
Lösung
Kein Nomad. Systemd mit Restart=on-failure, NoNewPrivileges, ProtectSystem=strict,
CapabilityBoundingSet=, cgroup-Grenzen. Caddy mit lb_policy first auf den Nachbarknoten.
Rolling Deploy als Skript in scripts/rolling-deploy.sh:
Knoten A aus dem Caddy-Upstream nehmen
Binary tauschen, Unit neu starten
/ready pollen bis grün (Timeout → Abbruch, kein Weitermachen)
Knoten A zurück in den Upstream
dasselbe für B
Rund fünfzig Zeilen. Die Ausfallfläche dieses Mechanismus ist bei Nichtbenutzung null — die eines Orchestrators nicht.
Akzeptanzkriterien
- Ein Deploy erzeugt keine Unterbrechung (Lasttest während des Deploys).
- Ein fehlschlagender Health-Check bricht ab und lässt Knoten B unberührt.
Prio: P0 · Risiko: ○ · Aufwand: S
Lösung
Bucket toob-firmware, fw.the-toob.com als CNAME darauf, wie cdn. es für Pakete bereits
tut. Zwingende Regeln:
- Compress off — transparentes gzip verschiebt Bytegrenzen und zerstört jeden Resume.
Cache-Control: immutable, Origin Cache Lock gegen den Miss-Sturm nach Publish.- Kein Redirect — der MCU-HTTP-Client folgt ihnen nicht zuverlässig.
- Bucket-Listing aus, Lifecycle: niemals löschen, solange Geräte im Feld sind.
Akzeptanzkriterien
-
Range: bytes=999999999-liefert416, nicht200. -
Accept-Encoding: gzipliefert trotzdemidentity. - Kein Response auf dieser Route hat Status
3xx.
Prio: P1 · Risiko: ○ · Aufwand: M
toob-ops herunterfahren, prüfen dass die Staging-Endpunkte weiter antworten. Zweimal, weil der
erste Lauf erfahrungsgemäß etwas findet und man das Ergebnis der Behebung bestätigen will.
Prio: P0 · Risiko: ● · Aufwand: S
Wartungsfenster mit niedrigem Verkehr. Geprüft wird, dass ci., ota. und fw. antworten und
ein Geräte-Check-in durchläuft. Das Ergebnis muss von außerhalb erhoben werden — der eigene
Prometheus ist ja aus.
Ein Test, der beim ersten automatischen Lauf die Produktion trifft, ist selbst ein Risiko.
Prio: P1 · Risiko: ○ · Aufwand: M
Wöchentlich, Ergebnis über eine externe Sonde. Negativtest: Baut man absichtlich eine Laufzeitabhängigkeit ein, muss der Test rot werden — sonst prüft er nichts.
Prio: P1 · Risiko: ○ · Aufwand: S
Von außerhalb: null offene Ports in allen Spoke-Projekten, genau ein UDP-Port in toob-ops.
Als wiederkehrender Job, nicht als einmalige Prüfung.
Prio: P1 · Risiko: ◐ · Aufwand: S
Beweist, dass kein Datenpfad-Secret unter 30 Tagen TTL liegt. Nach 24 Stunden müssen Check-in und Download unverändert funktionieren.
Erst gegen Staging, dann gegen Produktion.
Prio: P2 · Risiko: ◐ · Aufwand: S
Nach OPS-040 ist er redundant. Server abbauen, öffentlichen WireGuard-Port schließen, Firewall bereinigen.
Prio: P2 · Risiko: ◐ · Aufwand: S
/root/nomad-bootstrap-token.txt ist ein Management-Token im Klartext auf der Platte. Nach
secret/platform/nomad-bootstrap, Datei schreddern — analog zum Root-Token, der bereits
automatisch revoked wird.
Prio: P2 · Risiko: ◐ · Aufwand: M
Problem
Der SEED_*-Mechanismus bedeutet, dass beim Bootstrap jedes Produktionsgeheimnis in der Shell
des Operators steht — inklusive History, sofern nicht bewusst abgeschaltet.
Lösung
Kurzfristig: Seeding aus einer 0600-Datei via vault kv put @file.
Mittelfristig: ops-ci führt das Seeding aus und holt die Werte aus einem verschlüsselten
Repo-Secret. Kein Mensch sieht sie im Klartext.
Prio: P3 · Risiko: ◐ · Aufwand: S
Statt eines Tokens mit Zone:Edit und LB:Edit für alles: ein Token je Projekt, auf die
jeweiligen Hostnames und Regeln begrenzt.
Prio: P3 · Risiko: ◐ · Aufwand: S
Vom Unseal-Vault nach ops-hub, bedient alle Ubicloud-Instanzen aller Projekte. Eine
Konfigurationszeile pro Projekt statt eines Timers pro Projekt. STATIC_WHITELIST_IPS bleibt
erhalten.
| Phase | Tickets | Gate |
|---|---|---|
| 0 — Aufräumen | OPS-001 … OPS-005 | terraform plan aus frischem Clone: keine Änderungen in allen fünf Modulen |
| 1 — Kontrollebene | OPS-010 … OPS-014, OPS-022 | Admin erreicht ops-hub, Break-Glass einmal geübt |
| 2 — Sicherheitsnetz | OPS-020, OPS-021 | Restore-Test grün. Ohne dieses Gate beginnt Phase 3 nicht |
| 3 — Vault | OPS-030 … OPS-035 | Cosign-Verify eines alten Images gilt weiterhin |
| 4 — Beobachtung | OPS-040 … OPS-044, OPS-090 | Beide Grafana-Instanzen zeigen eine Woche dieselben Werte |
| 5 — Identity | OPS-050 … OPS-053 | Knotenausfall während einer Session unterbricht sie nicht |
| 6 — Modul + Staging | OPS-060 … OPS-062 | Ein Projekt entsteht aus einer Variablendatei |
| 7 — Update-Spoke | OPS-070 … OPS-072 | fw. liefert 416 und identity korrekt |
| 8 — Nachweise | OPS-080 … OPS-084 | Alle vier Invarianten getestet, nicht behauptet |
| 9 — Hygiene | OPS-091 … OPS-094 | — |
Phase 0 und 1 laufen ohne jeden Eingriff in den Bestand. Wer den Umbau abbrechen will, kann es dort ohne Rückbau tun.
toob-update (Phase 7) hängt technisch nur an Phase 6. Wer den Update-Service früher braucht,
kann ihn nach Phase 1 bauen und übergangsweise Secrets aus dem alten Vault beziehen — die
Migration ist keine Vorbedingung, nur eine Aufräumarbeit, die man sonst später doppelt macht.
Der Anhang kam leer an. Für drei Tickets ist die Schätzung ohne die tatsächlichen Dateien unsicher:
| Ticket | Warum |
|---|---|
| OPS-001 | Der genaue Schnitt zwischen Build- und Deploy-Artefakten hängt an den Makefiles |
| OPS-005 | Der Aufwand der Rollen-Zerlegung folgt direkt aus der Struktur von playbook.yml |
| OPS-031 | Die Client-Liste (wer zeigt auf Vault) bestimmt den Umfang des Wartungsfensters |
Nützlich wären: ansible/playbook.yml, terraform/control-plane/*.tf, api/deploy.sh,
packer/image.pkr.hcl und die Liste der Vault-Policies aus init.sh.
Die riskanteste Stunde des ganzen Umbaus ist der Vault-Umzug, und die wertvollste ist der Restore-Test davor. Alles andere in diesem Backlog ist reversibel — die Transit-Keys sind es nicht.