Compare commits
4
Commits
acae1bc2a4
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9310164bc2 | ||
|
|
4285785a4c | ||
|
|
76bc0e4269 | ||
|
|
01d22aac3a |
+20
-3
@@ -8,9 +8,6 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
- Katalog als Daten: Debian 12/13 und Ubuntu 22.04/24.04/26.04 sowie die Profile base, docker und zabbix-agent als YAML unter `catalog/`, beim Start in die Datenbank abgeglichen (#004)
|
|
||||||
- Katalogseite mit Aktiv-Schaltern für Distributionen und Profile; `GET /api/v1/distributionen?typ=vm|lxc`, `GET /api/v1/profile` (#004)
|
|
||||||
- README mit Stand, Fähigkeiten und Entwicklungsumgebung (#018)
|
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
||||||
@@ -20,6 +17,26 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.2.0] – 2026-10-12
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Katalog als Daten: Debian 12/13 und Ubuntu 22.04/24.04/26.04 sowie die Profile base, docker und zabbix-agent als YAML unter `catalog/`, beim Start in die Datenbank abgeglichen (#004)
|
||||||
|
- Katalogseite mit Aktiv-Schaltern für Distributionen und Profile; `GET /api/v1/distributionen?typ=vm|lxc`, `GET /api/v1/profile` (#004)
|
||||||
|
- README mit Stand, Fähigkeiten und Entwicklungsumgebung (#018)
|
||||||
|
- Provisionierungs-Adapter `debian` und Profil-Engine: Profile werden zu einem idempotenten Skript; geprüft mit shellcheck und echt in Containern für alle fünf Distributionen (`pnpm test:container`) (#005)
|
||||||
|
- `docs/katalog.md`: neue Distribution, neuer Adapter, eigenes Profil (#005)
|
||||||
|
- Aufträge im worker (pg-boss) mit Phasen, Detail-Log und Live-Fortschritt per Server-Sent Events; Live-Ansicht im Aufbau der CLI-Ausgabe, Verlaufsseite (#006)
|
||||||
|
- Template-Bau: Cloud-Image mit Prüfsumme laden, VM mit q35/UEFI anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren; Templates-Seite (#007)
|
||||||
|
- VM ausrollen: Klonen, Hardware, cloud-init, Start, Guest-Agent, Provisionierung der Profile über den Guest-Agent – ohne SSH (#008)
|
||||||
|
- Deploy-Assistent mit 9 Schritten, Dry-Run mit allen geplanten Proxmox-Aufrufen und passendem CLI-Befehl (#008)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Provisionierungsskripte mit Umlauten scheiterten an Proxmox' file-write; sie werden jetzt base64-kodiert übertragen (#008)
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- Profil-Variablen werden in Shell-Befehlen immer gequotet; Zeitzone, Locale und Tastatur gegen strenge Muster geprüft (#005)
|
||||||
|
- VMIDs werden unter einer Sperre je Server vergeben – parallele Aufträge bekommen nie dieselbe ID (#006)
|
||||||
|
|
||||||
## [0.1.0] – 2026-10-12
|
## [0.1.0] – 2026-10-12
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 1 h 24 min (laufende Sitzung)
|
**Gesamt:** 2 h 50 min (Sitzung 1)
|
||||||
|
|
||||||
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
||||||
|
|
||||||
@@ -12,17 +12,21 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
| #002 Server-Verwaltung | 0 h 11 min |
|
| #002 Server-Verwaltung | 0 h 11 min |
|
||||||
| #003 Verbindungstest | 0 h 25 min |
|
| #003 Verbindungstest | 0 h 25 min |
|
||||||
| #004 Katalog | 0 h 09 min |
|
| #004 Katalog | 0 h 09 min |
|
||||||
|
| #005 Adapter und Profile | 0 h 20 min |
|
||||||
|
| #006 Aufträge und Live-Fortschritt | 0 h 15 min |
|
||||||
|
| #007 Template-Bau | 0 h 12 min |
|
||||||
|
| #008 VM ausrollen und Assistent | 0 h 36 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
| v0.1.0 | 1 h 15 min | **abgeschlossen**
|
| v0.1.0 | 1 h 15 min | **abgeschlossen**
|
||||||
| v0.2.0 | – | in Arbeit
|
| v0.2.0 | 1 h 32 min | **abgeschlossen**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-10-12 · Sitzung 1 · 08:45–laufend
|
## 2026-10-12 · Sitzung 1 · 08:45–11:35
|
||||||
**Issue:** #001
|
**Issue:** #001
|
||||||
**Version:** 0.1.0-dev
|
**Version:** 0.1.0, 0.2.0
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt: Reverse-Proxy (ja), OIDC (implementieren, vorerst nur lokal testen), Testumgebung (folgt), CLI (Go)
|
- Auftrag gelesen, Rückfragen gestellt: Reverse-Proxy (ja), OIDC (implementieren, vorerst nur lokal testen), Testumgebung (folgt), CLI (Go)
|
||||||
@@ -35,6 +39,11 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
- Feldtest auf dem Testhost `pve-demo` (PVE 9.2.2): Rolle/Token nach Doku per SSH angelegt, Fingerprint und Host-Key gegen den Node verglichen, Test mit und ohne SSH – alles wie erwartet
|
- Feldtest auf dem Testhost `pve-demo` (PVE 9.2.2): Rolle/Token nach Doku per SSH angelegt, Fingerprint und Host-Key gegen den Node verglichen, Test mit und ohne SSH – alles wie erwartet
|
||||||
- Meilenstein v0.1.0 abgeschlossen, getaggt und nach Gitea gepusht
|
- Meilenstein v0.1.0 abgeschlossen, getaggt und nach Gitea gepusht
|
||||||
- README als Zwischenstand angelegt (Florian: „readme im projekt fehlt“)
|
- README als Zwischenstand angelegt (Florian: „readme im projekt fehlt“)
|
||||||
|
- #005 abgeschlossen: Adapter debian, Profil-Engine; base+docker+zabbix-agent echt in Containern auf Debian 12/13 und Ubuntu 22.04/24.04/26.04 inkl. Idempotenz
|
||||||
|
- #006 abgeschlossen: worker, Phasen, SSE, Live-Ansicht, VMID-Sperre
|
||||||
|
- #007 abgeschlossen: erstes echtes Template (9000, Debian 13) auf pve-demo in 5 min 26 s
|
||||||
|
- #008 abgeschlossen: erste echte VMs auf pve-demo – pd-test01 per API (base, docker), pd-test02 über den Assistenten (base, zabbix-agent), per SSH geprüft; 219 Tests, 12 Playwright-Abläufe
|
||||||
|
- Meilenstein v0.2.0 abgeschlossen, getaggt und gepusht
|
||||||
- #004 abgeschlossen: Katalog mit fünf Distributionen und drei Profilen, Abgleich, Katalogseite; 150 Tests, 12 Playwright-Abläufe
|
- #004 abgeschlossen: Katalog mit fünf Distributionen und drei Profilen, Abgleich, Katalogseite; 150 Tests, 12 Playwright-Abläufe
|
||||||
- #002 abgeschlossen: Server-Verwaltung mit Fingerprint-Pinning, Proxmox-Client, Proxmox-Mock (HTTPS, selbst signiert); 99 Tests, 10 Playwright-Abläufe
|
- #002 abgeschlossen: Server-Verwaltung mit Fingerprint-Pinning, Proxmox-Client, Proxmox-Mock (HTTPS, selbst signiert); 99 Tests, 10 Playwright-Abläufe
|
||||||
- Testhost 10.0.10.228 von Florian bereitgestellt (Installation läuft)
|
- Testhost 10.0.10.228 von Florian bereitgestellt (Installation läuft)
|
||||||
@@ -52,4 +61,8 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
- **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1.
|
- **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1.
|
||||||
- **TLS-Pinning über einen eigenen undici-Connector** statt `rejectUnauthorized: false` + Nachprüfen: die Verbindung wird getrennt, bevor das Token gesendet wird. Sitzungs-Cache aus, sonst fehlt bei Wiederaufnahme das Zertifikat.
|
- **TLS-Pinning über einen eigenen undici-Connector** statt `rejectUnauthorized: false` + Nachprüfen: die Verbindung wird getrennt, bevor das Token gesendet wird. Sitzungs-Cache aus, sonst fehlt bei Wiederaufnahme das Zertifikat.
|
||||||
- **Zugang zum Testhost über einen eigenen SSH-Schlüssel** (`~/.ssh/proxdeploy-testhost`), nicht über das root-Passwort: Claude Code verweigert das Anlegen von Zugängen mit Passwort, und ein Schlüssel nur für diesen Host ist ohnehin der sauberere Weg.
|
- **Zugang zum Testhost über einen eigenen SSH-Schlüssel** (`~/.ssh/proxdeploy-testhost`), nicht über das root-Passwort: Claude Code verweigert das Anlegen von Zugängen mit Passwort, und ein Schlüssel nur für diesen Host ist ohnehin der sauberere Weg.
|
||||||
|
- **Trockenmodus im Proxmox-Client statt eigenem Planer:** Dry-Run und Ernstfall laufen durch dieselben Phasen; Lesendes geht echt an Proxmox, Schreibendes wird aufgezeichnet. So können Plan und Wirklichkeit nicht auseinanderlaufen.
|
||||||
|
- **Guest-Agent wird beim Template-Bau per Vorbereitungsstart eingebaut** (vendor-data-Snippet), nicht bei jedem Deployment – Deployments laufen danach ohne SSH.
|
||||||
|
- **Skripte base64 über file-write:** Proxmox' eigene Kodierung scheitert an Umlauten (Fund beim ersten echten Deploy).
|
||||||
|
- **Template-Bau ohne SSH (Bau-Adresse) ausgegliedert** als #021, damit Etappe 2 nicht wartet.
|
||||||
- **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe).
|
- **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe).
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ In Entwicklung. Version und Fortschritt stehen im [CHANGELOG](CHANGELOG.md), der
|
|||||||
| Etappe | Inhalt | Stand |
|
| Etappe | Inhalt | Stand |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft |
|
| 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft |
|
||||||
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt | ◐ in Arbeit |
|
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt, Assistent | ✓ fertig, auf PVE 9.2 geprüft |
|
||||||
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen | ○ geplant |
|
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen | ◐ als Nächstes |
|
||||||
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ○ geplant |
|
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ○ geplant |
|
||||||
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ geplant |
|
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ geplant |
|
||||||
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
|
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
# #005 Adapter debian und Profil-Engine
|
# #005 Adapter debian und Profil-Engine
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** feature, engine
|
**Labels:** feature, engine
|
||||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
**Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Interface `FamilienAdapter`, Umsetzung `debian`, Profil `base` wird zu einem idempotenten Skript.
|
Interface `FamilienAdapter`, Umsetzung `debian`, Profile werden zu einem idempotenten Skript.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Interface + Registry; `debian`-Adapter (apt, systemd, deb822, locale, keyboard, sshd_config.d)
|
- [x] Interface + Registry; `debian`-Adapter (apt mit Lock-Timeout, deb822, systemd, locale, keyboard, sshd_config.d, sudoers mit visudo-Prüfung)
|
||||||
- [ ] Profilauflösung (requires, Reihenfolge, Variablen, Shell-Quoting)
|
- [x] Profilauflösung (requires transitiv, Zyklen, Variablen mit Typen und Standardwerten, Familien-Prüfung)
|
||||||
- [ ] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, sudo-User, SSH-Härtung, chrony, fail2ban optional)
|
- [x] Platzhalter `{{ var.x }}`, `{{ os.* }}`, `{{ guest.* }}`, `a ?? b`; in `run` jeder Wert shell-gequotet
|
||||||
- [ ] Snapshot-Tests der erzeugten Skripte; shellcheck auf die Ausgabe
|
- [x] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, chrony, SSH-Härtung, fail2ban optional)
|
||||||
|
- [x] Tests der erzeugten Skripte; shellcheck auf die Ausgabe aller Distributionen
|
||||||
|
- [x] `pnpm test:container`: Skripte wirklich ausführen (Debian 12/13, Ubuntu 22.04/24.04/26.04), Ergebnis prüfen, zweiter Lauf auf Idempotenz
|
||||||
|
- [x] `docs/katalog.md`: neue Distribution, neuer Adapter, eigenes Profil
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code
|
- [x] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code
|
||||||
|
- [x] base + docker + zabbix-agent laufen auf allen fünf Distributionen durch und sind idempotent (Container, 2026-10-12)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#004
|
#004
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Fund im Container:** frische Images haben keine Paketlisten – der Vorspann macht jetzt immer `apt-get update`, sonst scheiterte schon die Benutzeranlage an „Unable to locate package sudo“.
|
||||||
|
- **Zeitzone, Locale und Tastatur werden gegen strenge Muster geprüft**, bevor sie ins Skript kommen (shellcheck fand verschachteltes Quoting im sed-Ausdruck).
|
||||||
|
- **SSH-Härtung läuft immer zuletzt:** läuft die Provisionierung per SSH als root (LXC ohne Node-SSH), schlösse sie sich sonst selbst aus.
|
||||||
|
- **Marken `::proxdeploy:: profil <id>`** im Skript zeigen im Live-Log, welches Profil gerade läuft.
|
||||||
|
- Dateiinhalte gehen base64-kodiert ins Skript – beliebige Zeichen kommen unverändert an.
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
# #006 Aufträge, Phasen, worker und Live-Fortschritt
|
# #006 Aufträge, Phasen, worker und Live-Fortschritt
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** feature, engine
|
**Labels:** feature, engine
|
||||||
**Geschätzt:** 8 h **Tatsächlich:** –
|
**Geschätzt:** 8 h **Tatsächlich:** 0 h 15 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Hintergrund-Jobs mit Phasen in der DB, live per SSE in der Web-GUI.
|
Hintergrund-Jobs mit Phasen in der DB, live per SSE in der Web-GUI.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung`
|
- [x] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung`
|
||||||
- [ ] Phasen-Runner mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis
|
- [x] Phasen-Runner (frameworkfrei) mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis, Abbruch zwischen Phasen
|
||||||
- [ ] SSE-Endpunkt mit Last-Event-ID, Polling-Fallback
|
- [x] SSE-Endpunkt mit Last-Event-ID, Herzschlag, `X-Accel-Buffering: no`; Polling-Fallback im Client
|
||||||
- [ ] VMID-Vergabe mit Advisory-Lock, Test mit parallelen Aufträgen
|
- [x] VMID-Vergabe mit Advisory-Lock, Test mit zehn parallelen Reservierungen
|
||||||
- [ ] Live-Ansicht als Terminal-Karte, Detail-Log, Ergebnis-Karte
|
- [x] Live-Ansicht als Terminal-Karte (Aufbau der CLI-Ausgabe), Detail-Log, Ergebnis-Box; Verlaufsseite
|
||||||
|
- [x] Verwaiste Aufträge nach worker-Neustart als fehlgeschlagen markieren; doppelt gelieferte Jobs laufen nur einmal
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test)
|
- [x] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test)
|
||||||
- [ ] Neuladen der Seite mitten im Auftrag zeigt den vollständigen Stand
|
- [x] Neuladen der Seite mitten im Auftrag zeigt den vollständigen Stand (Stand kommt immer ganz, Log ab Last-Event-ID)
|
||||||
|
- [x] Live geprüft mit einem echten Template-Bau auf pve-demo (#007)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#003
|
#003
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **ssh2 und pg sind CommonJS:** benannte Importe gehen im Next-Bundle, aber nicht im worker unter reinem Node-ESM – jetzt Default-Importe.
|
||||||
|
- **Der worker lädt `.env` selbst** (`process.loadEnvFile`, falls vorhanden); im Container kommt die Umgebung von außen.
|
||||||
|
- **Kein automatisches Wiederholen** (`retryLimit: 0`): halb angelegte Gäste soll ein Mensch ansehen.
|
||||||
|
- **Abfrageschleifen** (Task-Status, VM-Status) erscheinen im Detail-Log nur bei Fehlern.
|
||||||
|
- **Kopfzeile** (`Auftrag.kopf`) setzt die Engine – Web und CLI zeigen denselben Text.
|
||||||
|
- Client-Komponenten importieren nichts aus `src/server` (struktureller Test in `tests/zugang.test.ts`).
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
# #007 Template-Bau für VMs
|
# #007 Template-Bau für VMs
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** feature, proxmox
|
**Labels:** feature, proxmox
|
||||||
**Geschätzt:** 8 h **Tatsächlich:** –
|
**Geschätzt:** 8 h **Tatsächlich:** 0 h 12 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren.
|
Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Weg A (≥ 8.4): `download-url` (content=import, checksum) + `import-from` per Volume-ID
|
- [x] Weg A (≥ 8.4): `download-url` (content=import, checksum) + `import-from` per Volume-ID
|
||||||
- [ ] Weg B (SSH): Download + Prüfung auf dem Node, Import per `qm importdisk`/`import-from`
|
- [x] Weg B (SSH): Download + Prüfung auf dem Node, Import per `qm importdisk`/`import-from`
|
||||||
- [ ] VMID: nächste freie im Template-Bereich (Entscheidung 2026-10-12)
|
- [x] VMID: nächste freie im Template-Bereich (Entscheidung 2026-10-12)
|
||||||
- [ ] Hardware: q35, OVMF+EFI-Disk (Standard) oder SeaBIOS, virtio-scsi-single, discard/ssd/iothread, CPU host, serielle Konsole, agent=1, cloud-init-Laufwerk
|
- [x] Hardware: q35, OVMF+EFI-Disk (Standard) oder SeaBIOS, virtio-scsi-single, discard/ssd/iothread, CPU host, serielle Konsole, agent=1, cloud-init-Laufwerk
|
||||||
- [ ] Guest-Agent einbauen: Snippet (SSH) oder Bau-Adresse + SSH in die VM
|
- [x] Guest-Agent einbauen per Snippet (SSH); Weg über Bau-Adresse ohne SSH → #021
|
||||||
- [ ] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar
|
- [x] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar
|
||||||
- [ ] Singleton je Server+Distribution
|
- [x] Singleton je Server+Distribution
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Debian-13-Template auf echtem Proxmox gebaut (8.x und 9.x, sobald verfügbar)
|
- [x] Debian-13-Template auf pve-demo (PVE 9.2.2) gebaut, Auftrag t-8zca3, 5 min 26 s; 8.x folgt im Feldtest (#020)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#005, #006
|
#005, #006
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Image-Dateiname mit Prüfsummen-Präfix** (`proxdeploy-debian-13-f46f0671a6e5.qcow2`): ein neues „latest“-Image wird neu geladen, ein gleiches wiederverwendet.
|
||||||
|
- **Proxmox prüft die Prüfsumme selbst** (`download-url` mit `checksum`); ProxDeploy holt sie vorher aus SHA512SUMS/SHA256SUMS.
|
||||||
|
- **Vorbereitungsstart mit vendor-data:** qemu-guest-agent, Maschinen-ID leeren (sonst teilen sich Klone DHCP-Client-ID), `cloud-init clean`, poweroff. Snippet und `cicustom` werden auch nach einem Fehler entfernt.
|
||||||
|
- **UEFI mit `pre-enrolled-keys=1`** (Secure Boot an) – Debian 13 bootet damit; Proxmox setzt selbst `ms-cert=2023k`.
|
||||||
|
- Auf dem Testhost dauerte der Vorbereitungsstart ~70 s.
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
# #008 Deploy-Assistent und VM ausrollen
|
# #008 Deploy-Assistent und VM ausrollen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** feature
|
**Labels:** feature
|
||||||
**Geschätzt:** 10 h **Tatsächlich:** –
|
**Geschätzt:** 10 h **Tatsächlich:** 0 h 36 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Debian-13-VM aus Template vollständig unattended, Assistent mit Dry-Run und CLI-Befehl.
|
Debian-13-VM aus Template vollständig unattended, Assistent mit Dry-Run und CLI-Befehl.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Planer VM (Phasen + geplante API-Aufrufe), `POST /auftraege/plan`
|
- [x] Anfrageschema für Web, CLI, Batch und Vorlagen (`src/lib/schemas/ausrollen.ts`), CLI-Befehl daraus
|
||||||
- [ ] Ausführung: Klonen, Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Agent, Provisionierung per Agent
|
- [x] Ablauf VM: Prüfungen (Node, Template, Storage, Bridge/VLAN, VMID), Klonen (mit einem Wiederholversuch bei belegter VMID), Hardware + Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Guest-Agent (ping, IP), Provisionierung per Agent mit Live-Fortschritt
|
||||||
- [ ] Fehlendes Template → automatisch bauen anbieten
|
- [x] Dry-Run über denselben Ablauf mit Trocken-Client: `POST /api/v1/auftraege/plan`
|
||||||
- [ ] Assistent mit 9 Schritten, URL-Zustand, Handy-tauglich
|
- [x] Fehlendes Template → „Template vorher bauen“ im Assistenten, läuft als Phase im selben Auftrag
|
||||||
- [ ] Aufräumen nach Bestätigung
|
- [x] Assistent mit 9 Schritten, Zustand in der URL, jederzeit zurückspringbar, Handy-tauglich
|
||||||
|
- [ ] Aufräumen nach Bestätigung → folgt mit #014 (Verlauf); bis dahin werden angelegte VMIDs je Auftrag festgehalten
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] VM mit base-Profil per SSH erreichbar; Fehlerfall zeigt Ursache + Hinweis
|
- [x] VM mit base-Profil per SSH erreichbar: pd-test01 (base, docker) per API, pd-test02 (base, zabbix-agent) über den Assistenten auf pve-demo, 2026-10-12
|
||||||
|
- [x] Fehlerfall zeigt Ursache + Hinweis (Tests: VLAN auf nicht VLAN-fähiger Bridge, Agent antwortet nicht, Profil scheitert mit Code)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#007
|
#007
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Fund beim ersten echten Deploy:** `agent/file-write` scheitert an Nicht-ASCII („Wide character in subroutine entry“). ProxDeploy kodiert die Skriptstücke jetzt selbst als base64 (`encode=0`) und teilt nach Bytes.
|
||||||
|
- **Die Agent-Endpunkte antworten unterschiedlich** (exec → `{pid}`, exec-status/file-read direkt, ping/network-get-interfaces → `{result}`).
|
||||||
|
- **Fortschritt der Provisionierung** aus der Logdatei im Gast (`/var/log/proxdeploy-provisionierung.log`, per file-read), Marken je Profil.
|
||||||
|
- **Klon auf anderen Node** nur bei geteiltem Storage; sonst sagt die Prüfung, welchen Node man wählen soll.
|
||||||
|
- Zwei Felder hießen „Server“ (Umschalter und Assistent) – der Umschalter heißt jetzt „Aktiver Server“.
|
||||||
|
- Testgäste auf pve-demo: VM 100 `pd-test01`, VM 101 `pd-test02` (Tag `proxdeploy-test`).
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# #021 Template-Bau ohne SSH (Bau-Adresse)
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v0.3.0
|
||||||
|
**Labels:** feature, proxmox
|
||||||
|
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet,
|
||||||
|
sondern über SSH **in die Vorbereitungs-VM** eingebaut.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [ ] Server-Einstellung `bauNetz`: Bridge, VLAN, statische IP/CIDR, Gateway, DNS
|
||||||
|
- [ ] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys`
|
||||||
|
- [ ] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten
|
||||||
|
- [ ] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH
|
||||||
|
|
||||||
|
## Abhängigkeiten
|
||||||
|
#007
|
||||||
|
|
||||||
|
## Notizen / Entscheidungen
|
||||||
|
- Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH.
|
||||||
@@ -8,10 +8,10 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min |
|
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min |
|
||||||
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min |
|
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min |
|
||||||
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | erledigt | v0.2.0 | 5 h | 0 h 09 min |
|
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | erledigt | v0.2.0 | 5 h | 0 h 09 min |
|
||||||
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | offen | v0.2.0 | 6 h | – |
|
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | erledigt | v0.2.0 | 6 h | 0 h 20 min |
|
||||||
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | offen | v0.2.0 | 8 h | – |
|
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min |
|
||||||
| [#007](007-template-bau.md) | Template-Bau für VMs | offen | v0.2.0 | 8 h | – |
|
| [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min |
|
||||||
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | offen | v0.2.0 | 10 h | – |
|
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min |
|
||||||
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – |
|
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – |
|
||||||
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – |
|
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – |
|
||||||
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – |
|
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – |
|
||||||
@@ -24,3 +24,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
|
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
|
||||||
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
||||||
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
|
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
|
||||||
|
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | offen | v0.3.0 | 3 h | – |
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
# Katalog: Distributionen und Profile
|
||||||
|
|
||||||
|
Distributionen und Profile sind Daten, kein Code. Jede liegt als YAML-Datei unter `catalog/` und wird
|
||||||
|
beim Start von ProxDeploy in die Datenbank abgeglichen. Die Datei ist maßgeblich für den Inhalt; in der
|
||||||
|
Oberfläche (*Katalog*) lassen sich Einträge nur aktivieren und deaktivieren. Eine fehlerhafte Datei
|
||||||
|
wird mit Dateiname und Fehlerstelle im Log gemeldet und übersprungen.
|
||||||
|
|
||||||
|
Regeln für beide Arten:
|
||||||
|
|
||||||
|
- Dateiname = `id` + `.yaml` (`catalog/distros/debian-13.yaml` → `id: debian-13`)
|
||||||
|
- unbekannte Schlüssel sind ein Fehler – ein Tippfehler fällt so sofort auf
|
||||||
|
- nur HTTPS-Quellen
|
||||||
|
|
||||||
|
## Neue Distribution hinzufügen
|
||||||
|
|
||||||
|
Beispiel Debian 13:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: debian-13
|
||||||
|
name: Debian 13 „Trixie“
|
||||||
|
family: debian # wählt den Provisionierungs-Adapter
|
||||||
|
os:
|
||||||
|
id: debian # für Platzhalter in Profilen: {{ os.id }}
|
||||||
|
version_id: "13" # {{ os.version_id }} – in Anführungszeichen, sonst wird 22.04 zur Zahl
|
||||||
|
codename: trixie # {{ os.codename }}
|
||||||
|
arch: amd64
|
||||||
|
vm:
|
||||||
|
image_url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
|
||||||
|
checksum_url: https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS
|
||||||
|
checksum_type: sha512 # sha256 | sha512
|
||||||
|
format: qcow2 # qcow2 | raw | vmdk
|
||||||
|
default_user: debian
|
||||||
|
guest_agent_preinstalled: false # sonst baut ProxDeploy ihn beim Template-Bau ein
|
||||||
|
bios: ovmf # ovmf (UEFI, Standard) | seabios
|
||||||
|
min_disk_gb: 4
|
||||||
|
lxc:
|
||||||
|
template_pattern: "debian-13-standard_*_amd64.tar.zst" # wie in `pveam available`
|
||||||
|
ostype: debian
|
||||||
|
```
|
||||||
|
|
||||||
|
`vm` oder `lxc` darf fehlen, wenn die Distribution nur eins davon kann. Bei mehreren passenden
|
||||||
|
LXC-Templates gewinnt die höchste Version. Die Prüfsummen-Datei darf im GNU-Format (`<hash> <datei>`
|
||||||
|
oder `<hash> *<datei>`) oder im BSD-Format (`SHA256 (<datei>) = <hash>`) vorliegen.
|
||||||
|
|
||||||
|
Gehört die Distribution zu einer Familie, für die es noch keinen Adapter gibt (etwa `rhel` für Rocky
|
||||||
|
oder Alma, `alpine`), braucht es zusätzlich einen Adapter:
|
||||||
|
|
||||||
|
1. `src/engine/adapter/<familie>.ts` anlegen, das Interface `FamilienAdapter` aus
|
||||||
|
`src/engine/adapter/schnittstelle.ts` umsetzen (Pakete, Dienste, Paketquellen, Zeitzone, Locale,
|
||||||
|
Tastatur, Benutzer, SSH-Härtung, Guest-Agent, automatische Updates)
|
||||||
|
2. in `src/engine/adapter/index.ts` eintragen
|
||||||
|
3. Tests nach dem Muster von `tests/engine/profile.test.ts`, dazu ein Image in
|
||||||
|
`tests/container/profile-pruefen.sh`
|
||||||
|
4. in den Profilen unter `families:` eine Umsetzung für die neue Familie ergänzen – Profile ohne
|
||||||
|
Umsetzung erscheinen im Assistenten ausgegraut
|
||||||
|
|
||||||
|
## Eigenes Profil anlegen
|
||||||
|
|
||||||
|
Ein Profil ist eine Liste von Schritten je Distributionsfamilie. Der Adapter übersetzt sie in ein
|
||||||
|
Shell-Skript, das bei VMs über den Guest-Agent und bei Containern per SSH läuft.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: nginx
|
||||||
|
name: nginx
|
||||||
|
description: Webserver mit Startseite
|
||||||
|
requires: [base] # wird automatisch davor ausgeführt
|
||||||
|
variables:
|
||||||
|
- { name: titel, label: Seitentitel, default: Willkommen }
|
||||||
|
- { name: tls, label: Zertifikat per certbot, type: boolean, default: false }
|
||||||
|
lxc:
|
||||||
|
features: { nesting: false } # Container-Features, die das Profil braucht
|
||||||
|
families:
|
||||||
|
debian:
|
||||||
|
- packages: [nginx]
|
||||||
|
- file:
|
||||||
|
path: /var/www/html/index.html
|
||||||
|
content: |
|
||||||
|
<h1>{{ var.titel }}</h1>
|
||||||
|
<p>{{ guest.name }} · {{ os.id }} {{ os.version_id }}</p>
|
||||||
|
- service: { name: nginx, enable: true, start: true }
|
||||||
|
- packages: [certbot, python3-certbot-nginx]
|
||||||
|
if: tls
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritte
|
||||||
|
|
||||||
|
| Schritt | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| `update: true` | Paketlisten aktualisieren und alle Pakete aktualisieren |
|
||||||
|
| `packages: [a, b]` | Pakete installieren |
|
||||||
|
| `service: { name, enable, start, restart }` | Dienst aktivieren, starten oder neu starten |
|
||||||
|
| `repo: { name, key_url, uris, suites, components }` | Paketquelle mit Schlüssel einrichten (deb822) |
|
||||||
|
| `repo_package: <url>` | ein Paket installieren, das selbst eine Quelle einrichtet (z. B. zabbix-release) |
|
||||||
|
| `file: { path, content, mode }` | Datei schreiben (Inhalt wird unverändert übertragen) |
|
||||||
|
| `timezone`, `locale`, `keyboard` | Zeitzone, Sprache, Tastaturbelegung |
|
||||||
|
| `ssh_harden: { password_auth, root_login }` | SSH absichern – läuft immer ganz am Ende |
|
||||||
|
| `guest_agent: true` | QEMU-Guest-Agent (in Containern übersprungen) |
|
||||||
|
| `unattended_upgrades: true` | automatische Sicherheitsupdates |
|
||||||
|
| `run: <befehl>` | freier Shell-Befehl – nicht portabel, nur wenn nichts anderes passt |
|
||||||
|
|
||||||
|
Jeder Schritt kann `if: <variable>` (oder `if: "!<variable>"`) tragen und läuft dann nur, wenn die
|
||||||
|
Variable wahr bzw. falsch ist.
|
||||||
|
|
||||||
|
### Platzhalter
|
||||||
|
|
||||||
|
`{{ var.<name> }}` (Profil-Variable), `{{ os.id }}`, `{{ os.version_id }}`, `{{ os.codename }}`,
|
||||||
|
`{{ guest.name }}`, `{{ guest.user }}`. `{{ var.a ?? var.b }}` nimmt `b`, wenn `a` leer ist. Ein
|
||||||
|
unbekannter Platzhalter ist ein Fehler, kein leerer Text.
|
||||||
|
|
||||||
|
In `run`-Schritten wird jeder eingesetzte Wert für die Shell gequotet – eine Eingabe im Formular kann
|
||||||
|
so nie zu Code werden. Variablen dürfen keine Zeilenumbrüche enthalten.
|
||||||
|
|
||||||
|
### Variablentypen
|
||||||
|
|
||||||
|
`string` (Standard), `number`, `boolean`, `select` (mit `options: [...]`). `required: true` macht
|
||||||
|
das Feld im Assistenten zur Pflicht; `default` füllt es vor; `help` erscheint darunter.
|
||||||
|
|
||||||
|
### Prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec tsx scripts/profil-rendern.ts debian-13 nginx titel=Hallo # Skript ansehen
|
||||||
|
pnpm test:container debian-13 # wirklich ausführen
|
||||||
|
```
|
||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxdeploy",
|
"name": "proxdeploy",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
||||||
@@ -24,7 +24,9 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"typecheck": "tsc --noEmit"
|
"test:container": "tests/container/profile-pruefen.sh",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"worker": "tsx src/worker/main.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
"lucide-react": "1.47.0",
|
"lucide-react": "1.47.0",
|
||||||
"next": "16.4.0",
|
"next": "16.4.0",
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
|
"pg-boss": "12.37.1",
|
||||||
"pino": "10.4.0",
|
"pino": "10.4.0",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.0",
|
||||||
|
|||||||
Generated
+87
-4
@@ -192,10 +192,13 @@ importers:
|
|||||||
version: 1.47.0(react@19.3.0)
|
version: 1.47.0(react@19.3.0)
|
||||||
next:
|
next:
|
||||||
specifier: 16.4.0
|
specifier: 16.4.0
|
||||||
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||||
pg:
|
pg:
|
||||||
specifier: ^8.23.0
|
specifier: ^8.23.0
|
||||||
version: 8.23.1
|
version: 8.23.1
|
||||||
|
pg-boss:
|
||||||
|
specifier: 12.37.1
|
||||||
|
version: 12.37.1(@opentelemetry/api@1.9.1)
|
||||||
pino:
|
pino:
|
||||||
specifier: 10.4.0
|
specifier: 10.4.0
|
||||||
version: 10.4.0
|
version: 10.4.0
|
||||||
@@ -286,7 +289,7 @@ importers:
|
|||||||
version: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
|
version: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 5.0.1
|
specifier: 5.0.1
|
||||||
version: 5.0.1(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
|
version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -1199,6 +1202,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
engines: {node: '>=12.4.0'}
|
||||||
|
|
||||||
|
'@opentelemetry/api@1.9.1':
|
||||||
|
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
'@oxc-project/types@0.153.0':
|
'@oxc-project/types@0.153.0':
|
||||||
resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
|
resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
|
||||||
|
|
||||||
@@ -2109,6 +2116,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
|
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
cron-parser@5.10.2:
|
||||||
|
resolution: {integrity: sha512-JfAYMY0LZaWQTuict8PhtF/ksSnq8xefSDmQUnnoqvkPYkU/y3UP0DTWzJKFBz0P1IXsDG8q0vY3zHkoFZOhvw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -3080,6 +3091,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
luxon@3.7.2:
|
||||||
|
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
@@ -3164,6 +3179,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
|
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
non-error@0.1.0:
|
||||||
|
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3248,6 +3267,13 @@ packages:
|
|||||||
perfect-debounce@2.1.0:
|
perfect-debounce@2.1.0:
|
||||||
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
|
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
|
||||||
|
|
||||||
|
pg-boss@12.37.1:
|
||||||
|
resolution: {integrity: sha512-LghWeOIbMCiLcTY3+8MInXaJ0wk0AOkbeUZ8oE+cGO0uoheGSW3GK57eDqEbrDGpwTIpX9BVphvdvWEDYTcJ9A==}
|
||||||
|
engines: {node: '>=22.12.0'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@opentelemetry/api': ^1.9.0
|
||||||
|
|
||||||
pg-cloudflare@1.4.1:
|
pg-cloudflare@1.4.1:
|
||||||
resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==}
|
resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==}
|
||||||
|
|
||||||
@@ -3469,6 +3495,9 @@ packages:
|
|||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
rrule-temporal@2.2.8:
|
||||||
|
resolution: {integrity: sha512-+wWUe0J02oMh1UWKIt1RTu2H0Cz+gP11O/iweK0MN2UP6JLGeepTUkbSBa90Hxq0W+ywSxM9IlJNouIUdMOwhg==}
|
||||||
|
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
|
|
||||||
@@ -3510,6 +3539,10 @@ packages:
|
|||||||
seq-queue@0.0.5:
|
seq-queue@0.0.5:
|
||||||
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
|
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
|
||||||
|
|
||||||
|
serialize-error@13.0.2:
|
||||||
|
resolution: {integrity: sha512-G+YKwp9I6IvWMe5RlwqxdmTPIcq/G8HK/sU51gdV1XAJznYkRSTB0ph+zB9AfSxG0fx6xrqYA6i9HeIcHFTXow==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3668,6 +3701,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
tagged-tag@1.0.0:
|
||||||
|
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
tailwindcss@4.3.3:
|
tailwindcss@4.3.3:
|
||||||
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
||||||
|
|
||||||
@@ -3675,6 +3712,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
temporal-spec@1.0.1:
|
||||||
|
resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==}
|
||||||
|
|
||||||
thread-stream@4.2.0:
|
thread-stream@4.2.0:
|
||||||
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -3723,6 +3763,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
type-fest@5.10.0:
|
||||||
|
resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
typed-array-buffer@1.0.3:
|
typed-array-buffer@1.0.3:
|
||||||
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4663,6 +4707,8 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
|
'@opentelemetry/api@1.9.1': {}
|
||||||
|
|
||||||
'@oxc-project/types@0.153.0': {}
|
'@oxc-project/types@0.153.0': {}
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
@@ -5557,6 +5603,10 @@ snapshots:
|
|||||||
nan: 2.29.0
|
nan: 2.29.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
cron-parser@5.10.2:
|
||||||
|
dependencies:
|
||||||
|
luxon: 3.7.2
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -6640,6 +6690,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.3.0
|
react: 19.3.0
|
||||||
|
|
||||||
|
luxon@3.7.2: {}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.6.0
|
'@jridgewell/sourcemap-codec': 1.6.0
|
||||||
@@ -6694,7 +6746,7 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
|
next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.4.0
|
'@next/env': 16.4.0
|
||||||
'@swc/helpers': 0.5.23
|
'@swc/helpers': 0.5.23
|
||||||
@@ -6713,6 +6765,7 @@ snapshots:
|
|||||||
'@next/swc-linux-x64-musl': 16.4.0
|
'@next/swc-linux-x64-musl': 16.4.0
|
||||||
'@next/swc-win32-arm64-msvc': 16.4.0
|
'@next/swc-win32-arm64-msvc': 16.4.0
|
||||||
'@next/swc-win32-x64-msvc': 16.4.0
|
'@next/swc-win32-x64-msvc': 16.4.0
|
||||||
|
'@opentelemetry/api': 1.9.1
|
||||||
'@playwright/test': 1.63.0
|
'@playwright/test': 1.63.0
|
||||||
sharp: 0.35.5(@types/node@24.19.2)
|
sharp: 0.35.5(@types/node@24.19.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -6729,6 +6782,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.58: {}
|
node-releases@2.0.58: {}
|
||||||
|
|
||||||
|
non-error@0.1.0: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -6822,6 +6877,16 @@ snapshots:
|
|||||||
|
|
||||||
perfect-debounce@2.1.0: {}
|
perfect-debounce@2.1.0: {}
|
||||||
|
|
||||||
|
pg-boss@12.37.1(@opentelemetry/api@1.9.1):
|
||||||
|
dependencies:
|
||||||
|
'@opentelemetry/api': 1.9.1
|
||||||
|
cron-parser: 5.10.2
|
||||||
|
pg: 8.23.1
|
||||||
|
rrule-temporal: 2.2.8
|
||||||
|
serialize-error: 13.0.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- pg-native
|
||||||
|
|
||||||
pg-cloudflare@1.4.1:
|
pg-cloudflare@1.4.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -7052,6 +7117,10 @@ snapshots:
|
|||||||
'@rolldown/binding-win32-arm64-msvc': 1.2.13
|
'@rolldown/binding-win32-arm64-msvc': 1.2.13
|
||||||
'@rolldown/binding-win32-x64-msvc': 1.2.13
|
'@rolldown/binding-win32-x64-msvc': 1.2.13
|
||||||
|
|
||||||
|
rrule-temporal@2.2.8:
|
||||||
|
dependencies:
|
||||||
|
temporal-spec: 1.0.1
|
||||||
|
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask: 1.2.3
|
queue-microtask: 1.2.3
|
||||||
@@ -7091,6 +7160,11 @@ snapshots:
|
|||||||
|
|
||||||
seq-queue@0.0.5: {}
|
seq-queue@0.0.5: {}
|
||||||
|
|
||||||
|
serialize-error@13.0.2:
|
||||||
|
dependencies:
|
||||||
|
non-error: 0.1.0
|
||||||
|
type-fest: 5.10.0
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-data-property: 1.1.4
|
define-data-property: 1.1.4
|
||||||
@@ -7303,10 +7377,14 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
tagged-tag@1.0.0: {}
|
||||||
|
|
||||||
tailwindcss@4.3.3: {}
|
tailwindcss@4.3.3: {}
|
||||||
|
|
||||||
tapable@2.3.3: {}
|
tapable@2.3.3: {}
|
||||||
|
|
||||||
|
temporal-spec@1.0.1: {}
|
||||||
|
|
||||||
thread-stream@4.2.0:
|
thread-stream@4.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
real-require: 1.0.0
|
real-require: 1.0.0
|
||||||
@@ -7351,6 +7429,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
|
|
||||||
|
type-fest@5.10.0:
|
||||||
|
dependencies:
|
||||||
|
tagged-tag: 1.0.0
|
||||||
|
|
||||||
typed-array-buffer@1.0.3:
|
typed-array-buffer@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
@@ -7467,7 +7549,7 @@ snapshots:
|
|||||||
tsx: 4.23.15
|
tsx: 4.23.15
|
||||||
yaml: 2.9.1
|
yaml: 2.9.1
|
||||||
|
|
||||||
vitest@5.0.1(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
|
vitest@5.0.1(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
|
'@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
|
||||||
@@ -7484,6 +7566,7 @@ snapshots:
|
|||||||
vite: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
|
vite: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@opentelemetry/api': 1.9.1
|
||||||
'@types/node': 24.19.2
|
'@types/node': 24.19.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- msw
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "GastTyp" AS ENUM ('VM', 'LXC');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AuftragsArt" AS ENUM ('AUSROLLEN', 'TEMPLATE_BAUEN', 'AUFRAEUMEN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AuftragsStatus" AS ENUM ('WARTEND', 'LAEUFT', 'ERFOLGREICH', 'FEHLGESCHLAGEN', 'ABGEBROCHEN', 'AUFGERAEUMT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PhasenStatus" AS ENUM ('OFFEN', 'LAEUFT', 'ERLEDIGT', 'FEHLER', 'UEBERSPRUNGEN');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Auftrag" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kurzId" TEXT NOT NULL,
|
||||||
|
"art" "AuftragsArt" NOT NULL,
|
||||||
|
"status" "AuftragsStatus" NOT NULL DEFAULT 'WARTEND',
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"knoten" TEXT,
|
||||||
|
"gastTyp" "GastTyp",
|
||||||
|
"vmid" INTEGER,
|
||||||
|
"name" TEXT,
|
||||||
|
"distributionId" TEXT,
|
||||||
|
"profile" TEXT[],
|
||||||
|
"anfrage" JSONB NOT NULL,
|
||||||
|
"plan" JSONB,
|
||||||
|
"kopf" TEXT,
|
||||||
|
"ergebnis" JSONB,
|
||||||
|
"angelegt" JSONB,
|
||||||
|
"fehler" JSONB,
|
||||||
|
"quelle" "Quelle" NOT NULL,
|
||||||
|
"erstelltVonId" TEXT NOT NULL,
|
||||||
|
"wiederholtVonId" TEXT,
|
||||||
|
"bezugId" TEXT,
|
||||||
|
"abbrechen" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"gestartetAm" TIMESTAMP(3),
|
||||||
|
"beendetAm" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "Auftrag_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Phase" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"auftragId" TEXT NOT NULL,
|
||||||
|
"reihenfolge" INTEGER NOT NULL,
|
||||||
|
"schluessel" TEXT NOT NULL,
|
||||||
|
"titel" TEXT NOT NULL,
|
||||||
|
"status" "PhasenStatus" NOT NULL DEFAULT 'OFFEN',
|
||||||
|
"detail" TEXT,
|
||||||
|
"ursache" TEXT,
|
||||||
|
"hinweis" TEXT,
|
||||||
|
"gestartetAm" TIMESTAMP(3),
|
||||||
|
"beendetAm" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "Phase_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Logeintrag" (
|
||||||
|
"id" BIGSERIAL NOT NULL,
|
||||||
|
"auftragId" TEXT NOT NULL,
|
||||||
|
"zeit" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"stufe" TEXT NOT NULL,
|
||||||
|
"phase" TEXT,
|
||||||
|
"nachricht" TEXT NOT NULL,
|
||||||
|
"daten" JSONB,
|
||||||
|
|
||||||
|
CONSTRAINT "Logeintrag_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VmidReservierung" (
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"vmid" INTEGER NOT NULL,
|
||||||
|
"auftragId" TEXT NOT NULL,
|
||||||
|
"ablaufAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "VmidReservierung_pkey" PRIMARY KEY ("serverId","vmid")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Auftrag_kurzId_key" ON "Auftrag"("kurzId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Auftrag_serverId_erstelltAm_idx" ON "Auftrag"("serverId", "erstelltAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Auftrag_status_idx" ON "Auftrag"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Auftrag_erstelltAm_idx" ON "Auftrag"("erstelltAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Phase_auftragId_reihenfolge_idx" ON "Phase"("auftragId", "reihenfolge");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Phase_auftragId_schluessel_key" ON "Phase"("auftragId", "schluessel");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Logeintrag_auftragId_id_idx" ON "Logeintrag"("auftragId", "id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VmidReservierung_auftragId_idx" ON "VmidReservierung"("auftragId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Auftrag" ADD CONSTRAINT "Auftrag_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Auftrag" ADD CONSTRAINT "Auftrag_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Phase" ADD CONSTRAINT "Phase_auftragId_fkey" FOREIGN KEY ("auftragId") REFERENCES "Auftrag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Logeintrag" ADD CONSTRAINT "Logeintrag_auftragId_fkey" FOREIGN KEY ("auftragId") REFERENCES "Auftrag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Template" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"knoten" TEXT NOT NULL,
|
||||||
|
"vmid" INTEGER NOT NULL,
|
||||||
|
"distributionId" TEXT NOT NULL,
|
||||||
|
"storage" TEXT NOT NULL,
|
||||||
|
"imageDatei" TEXT NOT NULL,
|
||||||
|
"pruefsumme" TEXT NOT NULL,
|
||||||
|
"gebautVonAuftragId" TEXT,
|
||||||
|
"gebautAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Template_serverId_distributionId_idx" ON "Template"("serverId", "distributionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Template_serverId_vmid_key" ON "Template"("serverId", "vmid");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Template" ADD CONSTRAINT "Template_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -38,6 +38,7 @@ model Benutzer {
|
|||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
protokoll Protokoll[]
|
protokoll Protokoll[]
|
||||||
|
auftraege Auftrag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
|
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
|
||||||
@@ -99,6 +100,8 @@ model Server {
|
|||||||
geprueftAm DateTime?
|
geprueftAm DateTime?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
|
auftraege Auftrag[]
|
||||||
|
templates Template[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Herkunft {
|
enum Herkunft {
|
||||||
@@ -139,3 +142,138 @@ model Profil {
|
|||||||
geaendertVonId String?
|
geaendertVonId String?
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum GastTyp {
|
||||||
|
VM
|
||||||
|
LXC
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuftragsArt {
|
||||||
|
AUSROLLEN
|
||||||
|
TEMPLATE_BAUEN
|
||||||
|
AUFRAEUMEN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuftragsStatus {
|
||||||
|
WARTEND
|
||||||
|
LAEUFT
|
||||||
|
ERFOLGREICH
|
||||||
|
FEHLGESCHLAGEN
|
||||||
|
ABGEBROCHEN
|
||||||
|
AUFGERAEUMT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PhasenStatus {
|
||||||
|
OFFEN
|
||||||
|
LAEUFT
|
||||||
|
ERLEDIGT
|
||||||
|
FEHLER
|
||||||
|
UEBERSPRUNGEN
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Hintergrundjob: Ausrollen, Template bauen oder Aufräumen. Läuft im worker.
|
||||||
|
model Auftrag {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Kurz und tippbar für CLI und URLs: „d-7f3k2“.
|
||||||
|
kurzId String @unique
|
||||||
|
art AuftragsArt
|
||||||
|
status AuftragsStatus @default(WARTEND)
|
||||||
|
serverId String
|
||||||
|
server Server @relation(fields: [serverId], references: [id], onDelete: Restrict)
|
||||||
|
knoten String?
|
||||||
|
gastTyp GastTyp?
|
||||||
|
vmid Int?
|
||||||
|
name String?
|
||||||
|
distributionId String?
|
||||||
|
profile String[]
|
||||||
|
/// Validierte Anfrage – Grundlage für „Wiederholen“.
|
||||||
|
anfrage Json
|
||||||
|
/// Plan zum Zeitpunkt des Starts (Phasen und geplante API-Aufrufe).
|
||||||
|
plan Json?
|
||||||
|
/// Kopfzeile wie in der CLI: „Server rz1 · Node pve01 · Debian 13 (Template 9013)“ – von der Engine gesetzt.
|
||||||
|
kopf String?
|
||||||
|
/// IP, SSH-Befehl, Dauer …
|
||||||
|
ergebnis Json?
|
||||||
|
/// Was ProxDeploy angelegt hat – Grundlage fürs Aufräumen.
|
||||||
|
angelegt Json?
|
||||||
|
/// { phase, ursache, hinweis }
|
||||||
|
fehler Json?
|
||||||
|
quelle Quelle
|
||||||
|
erstelltVonId String
|
||||||
|
erstelltVon Benutzer @relation(fields: [erstelltVonId], references: [id], onDelete: Restrict)
|
||||||
|
wiederholtVonId String?
|
||||||
|
/// Bei AUFRAEUMEN: der Auftrag, dessen Reste entfernt werden.
|
||||||
|
bezugId String?
|
||||||
|
abbrechen Boolean @default(false)
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
gestartetAm DateTime?
|
||||||
|
beendetAm DateTime?
|
||||||
|
phasen Phase[]
|
||||||
|
log Logeintrag[]
|
||||||
|
|
||||||
|
@@index([serverId, erstelltAm])
|
||||||
|
@@index([status])
|
||||||
|
@@index([erstelltAm])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Phase {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
auftragId String
|
||||||
|
auftrag Auftrag @relation(fields: [auftragId], references: [id], onDelete: Cascade)
|
||||||
|
reihenfolge Int
|
||||||
|
schluessel String
|
||||||
|
titel String
|
||||||
|
status PhasenStatus @default(OFFEN)
|
||||||
|
/// Der graue Detailtext hinter dem Phasennamen – in Web und CLI derselbe.
|
||||||
|
detail String?
|
||||||
|
ursache String?
|
||||||
|
hinweis String?
|
||||||
|
gestartetAm DateTime?
|
||||||
|
beendetAm DateTime?
|
||||||
|
|
||||||
|
@@unique([auftragId, schluessel])
|
||||||
|
@@index([auftragId, reihenfolge])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Logeintrag {
|
||||||
|
/// Zugleich die Event-ID im SSE-Strom (Last-Event-ID).
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
auftragId String
|
||||||
|
auftrag Auftrag @relation(fields: [auftragId], references: [id], onDelete: Cascade)
|
||||||
|
zeit DateTime @default(now())
|
||||||
|
stufe String
|
||||||
|
phase String?
|
||||||
|
nachricht String
|
||||||
|
daten Json?
|
||||||
|
|
||||||
|
@@index([auftragId, id])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vergebene VMIDs, solange ein Auftrag läuft – verhindert Doppelvergabe zwischen parallelen Aufträgen.
|
||||||
|
model VmidReservierung {
|
||||||
|
serverId String
|
||||||
|
vmid Int
|
||||||
|
auftragId String
|
||||||
|
ablaufAm DateTime
|
||||||
|
|
||||||
|
@@id([serverId, vmid])
|
||||||
|
@@index([auftragId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Von ProxDeploy gebaute Templates. Maßgeblich ist Proxmox (Tag proxdeploy-template); hier steht, woraus gebaut wurde.
|
||||||
|
model Template {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
serverId String
|
||||||
|
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||||
|
knoten String
|
||||||
|
vmid Int
|
||||||
|
distributionId String
|
||||||
|
storage String
|
||||||
|
imageDatei String
|
||||||
|
pruefsumme String
|
||||||
|
gebautVonAuftragId String?
|
||||||
|
gebautAm DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([serverId, vmid])
|
||||||
|
@@index([serverId, distributionId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Rendert Profile für eine Distribution auf stdout – zum Ansehen und für die Integrationsprüfung
|
||||||
|
* in einem Container: `pnpm exec tsx scripts/profil-rendern.ts debian-13 base,docker server=zbx.local`
|
||||||
|
*/
|
||||||
|
import { adapterFuer } from "@/engine/adapter";
|
||||||
|
import { katalogLaden } from "@/engine/katalog/laden";
|
||||||
|
import { profileAufloesen } from "@/engine/profile/aufloesen";
|
||||||
|
import { profileRendern } from "@/engine/profile/rendern";
|
||||||
|
|
||||||
|
const [distroId = "debian-13", auswahl = "base", ...zuweisungen] = process.argv.slice(2);
|
||||||
|
const katalog = await katalogLaden("catalog");
|
||||||
|
const distro = katalog.distributionen.find((d) => d.eintrag.id === distroId)?.eintrag;
|
||||||
|
if (!distro) throw new Error(`Unbekannte Distribution ${distroId}`);
|
||||||
|
const profile = new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag]));
|
||||||
|
const eingaben: Record<string, Record<string, string>> = {};
|
||||||
|
for (const z of zuweisungen) {
|
||||||
|
const [name, wert] = z.split("=");
|
||||||
|
for (const p of profile.values())
|
||||||
|
if (p.variables.some((v) => v.name === name)) (eingaben[p.id] ??= {})[name!] = wert!;
|
||||||
|
}
|
||||||
|
const aufloesung = profileAufloesen(auswahl.split(","), profile, distro.family, eingaben);
|
||||||
|
if (!aufloesung.ok) {
|
||||||
|
console.error(aufloesung.fehler);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const adapter = adapterFuer(distro.family)!;
|
||||||
|
const { skript } = profileRendern(
|
||||||
|
aufloesung.profile,
|
||||||
|
adapter,
|
||||||
|
{
|
||||||
|
os: distro.os,
|
||||||
|
gast: { name: "testgast", benutzer: "florian" },
|
||||||
|
},
|
||||||
|
[
|
||||||
|
adapter.benutzerAnlegen({
|
||||||
|
name: "florian",
|
||||||
|
sshSchluessel: ["ssh-ed25519 AAAAtest florian@test"],
|
||||||
|
sudo: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
process.stdout.write(skript);
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Code } from "@/components/formular";
|
||||||
|
import { Knopf } from "@/components/knopf";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
||||||
|
import { TerminalKarte, type TerminalErgebnis } from "@/components/terminal-karte";
|
||||||
|
import { apiAufruf } from "@/lib/api-client";
|
||||||
|
import { dauer } from "@/lib/formate";
|
||||||
|
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
|
||||||
|
|
||||||
|
const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
||||||
|
|
||||||
|
function ergebnisAus(a: AuftragSicht): TerminalErgebnis | null {
|
||||||
|
if (!ENDE.has(a.status)) return null;
|
||||||
|
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
|
||||||
|
const gesamt =
|
||||||
|
a.gestartetAm && a.beendetAm
|
||||||
|
? dauer(new Date(a.beendetAm).getTime() - new Date(a.gestartetAm).getTime())
|
||||||
|
: null;
|
||||||
|
const name = a.name ?? a.kurzId;
|
||||||
|
if (a.status !== "ERFOLGREICH") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
titel: `${name} ${a.status === "ABGEBROCHEN" ? "abgebrochen" : "fehlgeschlagen"}`,
|
||||||
|
dauer: gesamt,
|
||||||
|
zeilen: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const zeilen: Array<[string, string]> = [];
|
||||||
|
if (a.vmid) zeilen.push([a.gastTyp === "LXC" ? "CTID" : "VMID", String(a.vmid)]);
|
||||||
|
if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]);
|
||||||
|
if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]);
|
||||||
|
if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]);
|
||||||
|
return { ok: true, titel: `${name} bereit`, dauer: gesamt, zeilen };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hängt sich per SSE an den Auftrag. Fällt SSE aus (z. B. ein Proxy, der Streams puffert), wird
|
||||||
|
* alle 3 s abgefragt – die Anzeige bleibt dieselbe.
|
||||||
|
*/
|
||||||
|
export function LiveAnsicht({
|
||||||
|
anfang,
|
||||||
|
anfangsLog,
|
||||||
|
version,
|
||||||
|
abbrechenErlaubt,
|
||||||
|
}: {
|
||||||
|
anfang: AuftragSicht;
|
||||||
|
anfangsLog: LogZeile[];
|
||||||
|
version: string;
|
||||||
|
abbrechenErlaubt: boolean;
|
||||||
|
}) {
|
||||||
|
const [auftrag, setAuftrag] = useState(anfang);
|
||||||
|
const [log, setLog] = useState(anfangsLog);
|
||||||
|
const [verbindung, setVerbindung] = useState<"live" | "abfrage" | "beendet">(
|
||||||
|
ENDE.has(anfang.status) ? "beendet" : "live",
|
||||||
|
);
|
||||||
|
const logBis = useRef(anfangsLog.at(-1)?.id ?? "0");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ENDE.has(anfang.status)) return;
|
||||||
|
let fehlschlaege = 0;
|
||||||
|
let abfrage: ReturnType<typeof setInterval> | undefined;
|
||||||
|
const quelle = new EventSource(`/api/v1/auftraege/${anfang.kurzId}/ereignisse?ab=${logBis.current}`);
|
||||||
|
|
||||||
|
const zeilenAnhaengen = (neu: LogZeile[]) => {
|
||||||
|
const bekannt = BigInt(logBis.current);
|
||||||
|
const frisch = neu.filter((z) => BigInt(z.id) > bekannt);
|
||||||
|
if (frisch.length === 0) return;
|
||||||
|
logBis.current = frisch.at(-1)!.id;
|
||||||
|
setLog((alt) => [...alt, ...frisch]);
|
||||||
|
};
|
||||||
|
|
||||||
|
quelle.addEventListener("stand", (e) => {
|
||||||
|
fehlschlaege = 0;
|
||||||
|
setAuftrag(JSON.parse((e as MessageEvent).data));
|
||||||
|
});
|
||||||
|
quelle.addEventListener("log", (e) => zeilenAnhaengen(JSON.parse((e as MessageEvent).data)));
|
||||||
|
quelle.addEventListener("ende", () => {
|
||||||
|
quelle.close();
|
||||||
|
setVerbindung("beendet");
|
||||||
|
});
|
||||||
|
quelle.onerror = () => {
|
||||||
|
fehlschlaege += 1;
|
||||||
|
if (fehlschlaege < 3 || abfrage) return;
|
||||||
|
quelle.close();
|
||||||
|
setVerbindung("abfrage");
|
||||||
|
abfrage = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const stand = await apiAufruf<AuftragSicht>("GET", `/api/v1/auftraege/${anfang.kurzId}`);
|
||||||
|
setAuftrag(stand);
|
||||||
|
zeilenAnhaengen(
|
||||||
|
await apiAufruf<LogZeile[]>("GET", `/api/v1/auftraege/${anfang.kurzId}/log?ab=${logBis.current}`),
|
||||||
|
);
|
||||||
|
if (ENDE.has(stand.status)) {
|
||||||
|
clearInterval(abfrage);
|
||||||
|
setVerbindung("beendet");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// nächster Versuch in 3 s
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
quelle.close();
|
||||||
|
if (abfrage) clearInterval(abfrage);
|
||||||
|
};
|
||||||
|
}, [anfang.kurzId, anfang.status]);
|
||||||
|
|
||||||
|
const [art, text] =
|
||||||
|
auftrag.status === "WARTEND"
|
||||||
|
? (["offen", "wartet auf den worker"] as const)
|
||||||
|
: STATUS_ANZEIGE[auftrag.status];
|
||||||
|
const ergebnis = ergebnisAus(auftrag);
|
||||||
|
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-muted font-mono text-sm">
|
||||||
|
{auftrag.kurzId} · gestartet von {auftrag.erstelltVon}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
|
{auftrag.art === "TEMPLATE_BAUEN"
|
||||||
|
? "Template bauen"
|
||||||
|
: auftrag.art === "AUFRAEUMEN"
|
||||||
|
? "Aufräumen"
|
||||||
|
: (auftrag.name ?? "Ausrollen")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Status art={art}>{text}</Status>
|
||||||
|
{abbrechenErlaubt && !ENDE.has(auftrag.status) ? (
|
||||||
|
<Knopf
|
||||||
|
variante="gefahr"
|
||||||
|
onClick={() => void apiAufruf("POST", `/api/v1/auftraege/${auftrag.kurzId}/abbrechen`)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Knopf>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<TerminalKarte version={version} kopf={auftrag.kopf} phasen={auftrag.phasen} ergebnis={ergebnis} />
|
||||||
|
|
||||||
|
{typeof ssh === "string" ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-muted text-sm">SSH-Befehl</span>
|
||||||
|
<Code>{ssh}</Code>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<details
|
||||||
|
className="bg-panel border-linie rounded-karte border"
|
||||||
|
open={auftrag.status === "FEHLGESCHLAGEN"}
|
||||||
|
>
|
||||||
|
<summary className="cursor-pointer px-5 py-3.5 font-semibold">
|
||||||
|
Detail-Log <span className="text-muted font-normal">({log.length} Zeilen)</span>
|
||||||
|
</summary>
|
||||||
|
<ol className="border-linie max-h-[60vh] overflow-auto border-t px-5 py-3 font-mono text-xs leading-5">
|
||||||
|
{log.map((zeile) => (
|
||||||
|
<li key={zeile.id} className="flex gap-3">
|
||||||
|
<time className="text-muted shrink-0">{new Date(zeile.zeit).toLocaleTimeString("de-DE")}</time>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
zeile.stufe === "error"
|
||||||
|
? "text-fehler"
|
||||||
|
: zeile.stufe === "warn"
|
||||||
|
? "text-laeuft"
|
||||||
|
: zeile.stufe === "debug"
|
||||||
|
? "text-muted"
|
||||||
|
: "text-text"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{zeile.phase ? <span className="text-muted">[{zeile.phase}] </span> : null}
|
||||||
|
<span className="break-all whitespace-pre-wrap">{zeile.nachricht}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<p className="text-muted text-xs" aria-live="polite">
|
||||||
|
{verbindung === "live"
|
||||||
|
? "Live verbunden."
|
||||||
|
: verbindung === "abfrage"
|
||||||
|
? "Live-Verbindung nicht möglich – Aktualisierung alle 3 s."
|
||||||
|
: null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { versionText } from "@/lib/version";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { AnwendungsFehler } from "@/server/fehler";
|
||||||
|
import { auftragHolen, logAb } from "@/server/services/auftraege";
|
||||||
|
import { LiveAnsicht } from "./live-ansicht";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ kurzId: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
return { title: (await params).kurzId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AuftragSeite({ params }: { params: Promise<{ kurzId: string }> }) {
|
||||||
|
const { kurzId } = await params;
|
||||||
|
const benutzer = await seiteSchuetzen("lesen", `/auftraege/${kurzId}`);
|
||||||
|
const auftrag = await auftragHolen(kurzId).catch((fehler) => {
|
||||||
|
if (fehler instanceof AnwendungsFehler && fehler.status === 404) notFound();
|
||||||
|
throw fehler;
|
||||||
|
});
|
||||||
|
const log = await logAb(auftrag.id, BigInt(0), 2000);
|
||||||
|
return (
|
||||||
|
<LiveAnsicht
|
||||||
|
anfang={auftrag}
|
||||||
|
anfangsLog={log}
|
||||||
|
version={versionText()}
|
||||||
|
abbrechenErlaubt={darf(benutzer.rolle, "ausrollen")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { ART_TEXT, STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
||||||
|
import { zeitpunkt } from "@/lib/formate";
|
||||||
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { auftraegeListe } from "@/server/services/auftraege";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Verlauf" };
|
||||||
|
|
||||||
|
/** Verlauf aller Aufträge. Filter und Wiederholen kommen mit #014. */
|
||||||
|
export default async function Verlauf() {
|
||||||
|
await seiteSchuetzen("lesen", "/auftraege");
|
||||||
|
const auftraege = await auftraegeListe({ grenze: 100 });
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
|
||||||
|
<Karte>
|
||||||
|
{auftraege.length === 0 ? (
|
||||||
|
<Status art="offen">Noch keine Aufträge.</Status>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-linie flex flex-col divide-y">
|
||||||
|
{auftraege.map((a) => {
|
||||||
|
const [art, text] = STATUS_ANZEIGE[a.status];
|
||||||
|
return (
|
||||||
|
<li key={a.id}>
|
||||||
|
<Link
|
||||||
|
href={`/auftraege/${a.kurzId}`}
|
||||||
|
className="hover:bg-panel-hoch/60 -mx-2 flex flex-col gap-1 rounded-lg px-2 py-3 sm:flex-row sm:items-center sm:gap-4"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm sm:w-20">{a.kurzId}</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium">{a.name ?? ART_TEXT[a.art]}</span>
|
||||||
|
<span className="text-muted text-sm">
|
||||||
|
{" "}
|
||||||
|
· {ART_TEXT[a.art]} · {a.server.name}
|
||||||
|
{a.distributionId ? ` · ${a.distributionId}` : ""}
|
||||||
|
{a.vmid ? ` · ${a.vmid}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-sm sm:w-28">{zeitpunkt(a.erstelltAm)}</span>
|
||||||
|
<Status art={art} className="text-sm sm:w-32">
|
||||||
|
{text}
|
||||||
|
</Status>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,982 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { ArrowLeft, ArrowRight, Copy } from "lucide-react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Code, EINGABE_KLASSE, Feld, Schalter } from "@/components/formular";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Knopf } from "@/components/knopf";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
|
||||||
|
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||||
|
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen";
|
||||||
|
|
||||||
|
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ProfilVariable = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: "string" | "number" | "boolean" | "select";
|
||||||
|
required: boolean;
|
||||||
|
default?: string | number | boolean;
|
||||||
|
options?: string[];
|
||||||
|
help?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssistentDaten = {
|
||||||
|
server: Array<{
|
||||||
|
name: string;
|
||||||
|
titel: string;
|
||||||
|
knoten: Array<{
|
||||||
|
name: string;
|
||||||
|
speicherFrei: number;
|
||||||
|
storages: Array<{ name: string; vm: boolean; lxc: boolean; frei: number | null }>;
|
||||||
|
bridges: Array<{ name: string; vlan: boolean }>;
|
||||||
|
}>;
|
||||||
|
templates: Array<{ distribution: string; knoten: string; vmid: number }>;
|
||||||
|
}>;
|
||||||
|
distributionen: Array<{ id: string; name: string; familie: string; vm: boolean; lxc: boolean }>;
|
||||||
|
profile: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
beschreibung: string | null;
|
||||||
|
familien: string[];
|
||||||
|
requires: string[];
|
||||||
|
variablen: ProfilVariable[];
|
||||||
|
}>;
|
||||||
|
benutzer: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Plan = {
|
||||||
|
kopf: string;
|
||||||
|
phasen: Array<{
|
||||||
|
schluessel: string;
|
||||||
|
titel: string;
|
||||||
|
status: string;
|
||||||
|
detail: string | null;
|
||||||
|
ursache: string | null;
|
||||||
|
hinweis: string | null;
|
||||||
|
}>;
|
||||||
|
aufrufe: Array<{ methode: string; pfad: string; parameter?: Record<string, unknown> }>;
|
||||||
|
skript: string | null;
|
||||||
|
cliBefehl: string;
|
||||||
|
automatisch: string[];
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Zustand ─────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Werte = {
|
||||||
|
server: string;
|
||||||
|
knoten: string;
|
||||||
|
typ: "vm" | "lxc";
|
||||||
|
distribution: string;
|
||||||
|
name: string;
|
||||||
|
vmid: string;
|
||||||
|
tags: string;
|
||||||
|
cores: string;
|
||||||
|
speicherMb: string;
|
||||||
|
diskGb: string;
|
||||||
|
storage: string;
|
||||||
|
swapMb: string;
|
||||||
|
unprivilegiert: boolean;
|
||||||
|
nesting: boolean;
|
||||||
|
bridge: string;
|
||||||
|
vlan: string;
|
||||||
|
modus: "dhcp" | "statisch";
|
||||||
|
ip: string;
|
||||||
|
gateway: string;
|
||||||
|
dns: string;
|
||||||
|
suchdomain: string;
|
||||||
|
benutzer: string;
|
||||||
|
schluessel: string;
|
||||||
|
profile: string[];
|
||||||
|
variablen: Record<string, Record<string, string | boolean>>;
|
||||||
|
templateBauen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHRITTE = [
|
||||||
|
"Server",
|
||||||
|
"Typ",
|
||||||
|
"Distribution",
|
||||||
|
"Name",
|
||||||
|
"Ressourcen",
|
||||||
|
"Netzwerk",
|
||||||
|
"Zugang",
|
||||||
|
"Profile",
|
||||||
|
"Zusammenfassung",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function kodieren(werte: Werte): string {
|
||||||
|
return btoa(unescape(encodeURIComponent(JSON.stringify(werte))))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dekodieren(text: string | null): Partial<Werte> {
|
||||||
|
if (!text) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(decodeURIComponent(escape(atob(text.replace(/-/g, "+").replace(/_/g, "/")))));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHLUESSEL_SPEICHER = "proxdeploy.ssh-schluessel";
|
||||||
|
|
||||||
|
function anfrageAus(w: Werte): AusrollEingabe {
|
||||||
|
const zahl = (text: string) => (text.trim() === "" ? undefined : Number(text));
|
||||||
|
return {
|
||||||
|
typ: w.typ,
|
||||||
|
server: w.server,
|
||||||
|
knoten: w.knoten || undefined,
|
||||||
|
distribution: w.distribution,
|
||||||
|
name: w.name,
|
||||||
|
vmid: zahl(w.vmid),
|
||||||
|
tags: w.tags.split(/[\s,;]+/).filter(Boolean),
|
||||||
|
cores: Number(w.cores),
|
||||||
|
speicherMb: Number(w.speicherMb),
|
||||||
|
diskGb: Number(w.diskGb),
|
||||||
|
storage: w.storage,
|
||||||
|
swapMb: Number(w.swapMb),
|
||||||
|
unprivilegiert: w.unprivilegiert,
|
||||||
|
features: { nesting: w.nesting, keyctl: false },
|
||||||
|
netz: {
|
||||||
|
bridge: w.bridge,
|
||||||
|
vlan: zahl(w.vlan),
|
||||||
|
modus: w.modus,
|
||||||
|
ip: w.modus === "statisch" ? w.ip : undefined,
|
||||||
|
gateway: w.modus === "statisch" && w.gateway ? w.gateway : undefined,
|
||||||
|
dns: w.dns.split(/[\s,;]+/).filter(Boolean),
|
||||||
|
suchdomain: w.suchdomain || undefined,
|
||||||
|
},
|
||||||
|
zugang: {
|
||||||
|
benutzer: w.benutzer,
|
||||||
|
sshSchluessel: w.schluessel
|
||||||
|
.split("\n")
|
||||||
|
.map((z) => z.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
},
|
||||||
|
profile: w.profile,
|
||||||
|
variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))),
|
||||||
|
templateBauen: w.templateBauen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Komponente ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Deploy-Assistent. Der Zustand steht in der URL (`?s=<schritt>&d=<werte>`): Zurückspringen,
|
||||||
|
* Neuladen und Weitergeben funktionieren damit ohne Server-Zwischenspeicher. Geprüft wird
|
||||||
|
* endgültig auf dem Server – hier nur so viel, dass niemand ins Leere klickt.
|
||||||
|
*/
|
||||||
|
export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; vorauswahl: string | null }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pfad = usePathname();
|
||||||
|
const parameter = useSearchParams();
|
||||||
|
const schritt = Math.min(Math.max(Number(parameter.get("s") ?? 0), 0), SCHRITTE.length - 1);
|
||||||
|
|
||||||
|
const anfang = useMemo<Werte>(() => {
|
||||||
|
const server = daten.server.find((s) => s.name === vorauswahl) ?? daten.server[0]!;
|
||||||
|
const knoten = server.knoten[0];
|
||||||
|
return {
|
||||||
|
server: server.name,
|
||||||
|
knoten: knoten?.name ?? "",
|
||||||
|
typ: "vm",
|
||||||
|
distribution:
|
||||||
|
daten.distributionen.find((d) => d.id === "debian-13")?.id ?? daten.distributionen[0]?.id ?? "",
|
||||||
|
name: "",
|
||||||
|
vmid: "",
|
||||||
|
tags: "",
|
||||||
|
cores: "2",
|
||||||
|
speicherMb: "2048",
|
||||||
|
diskGb: "20",
|
||||||
|
storage: knoten?.storages.find((s) => s.vm)?.name ?? "",
|
||||||
|
swapMb: "512",
|
||||||
|
unprivilegiert: true,
|
||||||
|
nesting: false,
|
||||||
|
bridge: knoten?.bridges.find((b) => b.name === "vmbr0")?.name ?? knoten?.bridges[0]?.name ?? "",
|
||||||
|
vlan: "",
|
||||||
|
modus: "dhcp",
|
||||||
|
ip: "",
|
||||||
|
gateway: "",
|
||||||
|
dns: "",
|
||||||
|
suchdomain: "",
|
||||||
|
benutzer: daten.benutzer.replace(/[^a-z0-9_-]/g, "") || "admin",
|
||||||
|
schluessel: "",
|
||||||
|
profile: ["base"],
|
||||||
|
variablen: {},
|
||||||
|
templateBauen: false,
|
||||||
|
};
|
||||||
|
}, [daten, vorauswahl]);
|
||||||
|
|
||||||
|
const [werte, setWerte] = useState<Werte>(() => ({ ...anfang, ...dekodieren(parameter.get("d")) }));
|
||||||
|
const [plan, setPlan] = useState<Plan | null>(null);
|
||||||
|
const [fehler, setFehler] = useState<{
|
||||||
|
text: string;
|
||||||
|
hinweis?: string;
|
||||||
|
felder?: Record<string, string>;
|
||||||
|
} | null>(null);
|
||||||
|
const [laeuft, setLaeuft] = useState(false);
|
||||||
|
const [kopiert, setKopiert] = useState(false);
|
||||||
|
|
||||||
|
const server = daten.server.find((s) => s.name === werte.server)!;
|
||||||
|
const knoten = server.knoten.find((k) => k.name === werte.knoten) ?? server.knoten[0];
|
||||||
|
const distribution = daten.distributionen.find((d) => d.id === werte.distribution);
|
||||||
|
const template = server.templates.find(
|
||||||
|
(t) => t.distribution === werte.distribution && (!werte.knoten || t.knoten === werte.knoten),
|
||||||
|
);
|
||||||
|
const bridge = knoten?.bridges.find((b) => b.name === werte.bridge);
|
||||||
|
|
||||||
|
const setze = (aenderung: Partial<Werte>) => {
|
||||||
|
setWerte((alt) => ({ ...alt, ...aenderung }));
|
||||||
|
setPlan(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gehe = (ziel: number) => {
|
||||||
|
let naechste = werte;
|
||||||
|
// Gespeicherte Schlüssel als Bequemlichkeit vorbelegen (bis die Schlüsselverwaltung kommt, #015).
|
||||||
|
if (SCHRITTE[ziel] === "Zugang" && !werte.schluessel) {
|
||||||
|
try {
|
||||||
|
const gemerkt = localStorage.getItem(SCHLUESSEL_SPEICHER);
|
||||||
|
if (gemerkt) {
|
||||||
|
naechste = { ...werte, schluessel: gemerkt };
|
||||||
|
setWerte(naechste);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// kein Speicher verfügbar – dann eben nicht
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (SCHRITTE[ziel] === "Zusammenfassung") void planen(naechste);
|
||||||
|
const neu = new URLSearchParams({ s: String(ziel), d: kodieren(naechste) });
|
||||||
|
router.replace(`${pfad}?${neu.toString()}`, { scroll: false });
|
||||||
|
setFehler(null);
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Was einen Schritt blockiert – als Text, damit „Weiter“ sagt, warum es nicht geht.
|
||||||
|
const sperre = (nr: number): string | null => {
|
||||||
|
switch (SCHRITTE[nr]) {
|
||||||
|
case "Server":
|
||||||
|
return !werte.server ? "Server wählen." : null;
|
||||||
|
case "Distribution":
|
||||||
|
if (!distribution) return "Distribution wählen.";
|
||||||
|
if (!distribution[werte.typ])
|
||||||
|
return `${distribution.name} gibt es nicht als ${werte.typ.toUpperCase()}.`;
|
||||||
|
if (werte.typ === "vm" && !template && !werte.templateBauen)
|
||||||
|
return "Template fehlt – „Template vorher bauen“ wählen.";
|
||||||
|
return null;
|
||||||
|
case "Name":
|
||||||
|
if (!GAST_NAME.test(werte.name)) return "Name: Kleinbuchstaben, Ziffern und Bindestrich.";
|
||||||
|
if (werte.vmid && (!/^\d+$/.test(werte.vmid) || Number(werte.vmid) < 100))
|
||||||
|
return "VMID ab 100 oder leer lassen.";
|
||||||
|
return null;
|
||||||
|
case "Ressourcen":
|
||||||
|
return !werte.storage ? "Storage wählen." : null;
|
||||||
|
case "Netzwerk":
|
||||||
|
if (!werte.bridge) return "Bridge wählen.";
|
||||||
|
if (werte.vlan && bridge && !bridge.vlan) return `Bridge ${bridge.name} ist nicht VLAN-fähig.`;
|
||||||
|
if (werte.modus === "statisch" && !/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(werte.ip))
|
||||||
|
return "IP mit Präfix angeben, z. B. 10.0.20.15/24.";
|
||||||
|
return null;
|
||||||
|
case "Zugang": {
|
||||||
|
const schluessel = werte.schluessel
|
||||||
|
.split("\n")
|
||||||
|
.map((z) => z.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(werte.benutzer) || werte.benutzer === "root")
|
||||||
|
return "Gültigen Benutzernamen angeben (nicht root).";
|
||||||
|
if (schluessel.length === 0) return "Mindestens einen öffentlichen SSH-Schlüssel angeben.";
|
||||||
|
if (schluessel.some((z) => !SSH_SCHLUESSEL.test(z)))
|
||||||
|
return "Eine Zeile ist kein öffentlicher SSH-Schlüssel.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ersteSperre = SCHRITTE.findIndex((_, nr) => nr < schritt && sperre(nr) !== null);
|
||||||
|
|
||||||
|
async function planen(stand: Werte = werte) {
|
||||||
|
setLaeuft(true);
|
||||||
|
setFehler(null);
|
||||||
|
try {
|
||||||
|
setPlan(await apiAufruf<Plan>("POST", "/api/v1/auftraege/plan", anfrageAus(stand)));
|
||||||
|
} catch (ursache) {
|
||||||
|
setFehler(
|
||||||
|
ursache instanceof ApiFehler
|
||||||
|
? { text: ursache.message, hinweis: ursache.hinweis, felder: ursache.felder }
|
||||||
|
: { text: "Unerwarteter Fehler." },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ausrollen() {
|
||||||
|
setLaeuft(true);
|
||||||
|
setFehler(null);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SCHLUESSEL_SPEICHER, werte.schluessel);
|
||||||
|
} catch {
|
||||||
|
// egal
|
||||||
|
}
|
||||||
|
const { kurzId } = await apiAufruf<{ kurzId: string }>("POST", "/api/v1/auftraege", anfrageAus(werte));
|
||||||
|
router.push(`/auftraege/${kurzId}`);
|
||||||
|
} catch (ursache) {
|
||||||
|
setFehler(
|
||||||
|
ursache instanceof ApiFehler
|
||||||
|
? { text: ursache.message, hinweis: ursache.hinweis, felder: ursache.felder }
|
||||||
|
: { text: "Unerwarteter Fehler." },
|
||||||
|
);
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direkt geöffneter Link auf die Zusammenfassung: nach dem ersten Rendern planen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (SCHRITTE[schritt] !== "Zusammenfassung" || ersteSperre >= 0) return;
|
||||||
|
const uhr = setTimeout(() => void planen(), 0);
|
||||||
|
return () => clearTimeout(uhr);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const aktuelleSperre = sperre(schritt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Ausrollen</h1>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
Schritt {schritt + 1} von {SCHRITTE.length} · {SCHRITTE[schritt]}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[14rem_1fr]">
|
||||||
|
{/* Schrittleiste: jederzeit zurückspringbar, nach vorne nur bis zum ersten offenen Schritt. */}
|
||||||
|
<nav aria-label="Schritte" className="-mx-4 overflow-x-auto px-4 lg:mx-0 lg:px-0">
|
||||||
|
<ol className="flex gap-2 lg:flex-col lg:gap-1">
|
||||||
|
{SCHRITTE.map((titel, nr) => {
|
||||||
|
const gesperrt = SCHRITTE.slice(0, nr).some((_, vorher) => sperre(vorher) !== null);
|
||||||
|
return (
|
||||||
|
<li key={titel} className="shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={gesperrt}
|
||||||
|
onClick={() => gehe(nr)}
|
||||||
|
aria-current={nr === schritt ? "step" : undefined}
|
||||||
|
className={clsx(
|
||||||
|
"flex h-10 items-center gap-2 rounded-lg px-3 text-sm whitespace-nowrap transition disabled:opacity-40 lg:w-full",
|
||||||
|
nr === schritt ? "bg-panel-hoch text-text font-semibold" : "text-muted hover:text-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={clsx("font-mono text-xs", nr === schritt && "text-akzent")}>
|
||||||
|
{String(nr + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
{titel}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
|
{SCHRITTE[schritt] === "Server" ? (
|
||||||
|
<Karte titel="Server und Node">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld id="server" label="Server">
|
||||||
|
<select
|
||||||
|
id="server"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.server}
|
||||||
|
onChange={(e) => {
|
||||||
|
const s = daten.server.find((x) => x.name === e.target.value)!;
|
||||||
|
const k = s.knoten[0];
|
||||||
|
setze({
|
||||||
|
server: s.name,
|
||||||
|
knoten: k?.name ?? "",
|
||||||
|
storage: k?.storages.find((x) => x.vm)?.name ?? "",
|
||||||
|
bridge: k?.bridges[0]?.name ?? "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{daten.server.map((s) => (
|
||||||
|
<option key={s.name} value={s.name}>
|
||||||
|
{s.name} · {s.titel}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="knoten"
|
||||||
|
label="Node"
|
||||||
|
hilfe="Leer = ProxDeploy wählt (Node mit dem Template, sonst der mit dem meisten freien RAM)."
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="knoten"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.knoten}
|
||||||
|
onChange={(e) => {
|
||||||
|
const k = server.knoten.find((x) => x.name === e.target.value);
|
||||||
|
setze({
|
||||||
|
knoten: e.target.value,
|
||||||
|
storage: k?.storages.find((x) => x.vm)?.name ?? werte.storage,
|
||||||
|
bridge: k?.bridges[0]?.name ?? werte.bridge,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">automatisch</option>
|
||||||
|
{server.knoten.map((k) => (
|
||||||
|
<option key={k.name} value={k.name}>
|
||||||
|
{k.name} · {Math.round(k.speicherFrei / 1024 ** 3)} GiB RAM frei
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Typ" ? (
|
||||||
|
<Karte titel="VM oder Container">
|
||||||
|
<fieldset className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<legend className="sr-only">Typ</legend>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"vm",
|
||||||
|
"VM",
|
||||||
|
"Eigener Kernel, aus Cloud-Image-Template, Provisionierung über den Guest-Agent.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lxc",
|
||||||
|
"LXC-Container",
|
||||||
|
"Leichtgewichtig, aus Proxmox-Container-Template. Folgt mit #009.",
|
||||||
|
],
|
||||||
|
] as const
|
||||||
|
).map(([wert, titel, text]) => (
|
||||||
|
<label
|
||||||
|
key={wert}
|
||||||
|
className={clsx(
|
||||||
|
"border-linie flex cursor-pointer flex-col gap-1 rounded-lg border p-4",
|
||||||
|
werte.typ === wert && "border-akzent",
|
||||||
|
wert === "lxc" && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-semibold">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="typ"
|
||||||
|
value={wert}
|
||||||
|
checked={werte.typ === wert}
|
||||||
|
disabled={wert === "lxc"}
|
||||||
|
onChange={() => setze({ typ: wert })}
|
||||||
|
className="accent-akzent h-4 w-4"
|
||||||
|
/>
|
||||||
|
{titel}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-sm">{text}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Distribution" ? (
|
||||||
|
<Karte titel="Distribution">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<fieldset className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<legend className="sr-only">Distribution</legend>
|
||||||
|
{daten.distributionen
|
||||||
|
.filter((d) => d[werte.typ])
|
||||||
|
.map((d) => {
|
||||||
|
const hatTemplate = server.templates.some((t) => t.distribution === d.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={d.id}
|
||||||
|
className={clsx(
|
||||||
|
"border-linie flex cursor-pointer items-start gap-3 rounded-lg border p-3",
|
||||||
|
werte.distribution === d.id && "border-akzent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="distribution"
|
||||||
|
checked={werte.distribution === d.id}
|
||||||
|
onChange={() => setze({ distribution: d.id, templateBauen: false })}
|
||||||
|
className="accent-akzent mt-1 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span className="font-medium">{d.name}</span>
|
||||||
|
{werte.typ === "vm" ? (
|
||||||
|
<Status art={hatTemplate ? "erledigt" : "offen"} className="text-xs">
|
||||||
|
{hatTemplate ? "Template vorhanden" : "noch kein Template"}
|
||||||
|
</Status>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</fieldset>
|
||||||
|
{werte.typ === "vm" && distribution && !template ? (
|
||||||
|
<Schalter
|
||||||
|
id="templateBauen"
|
||||||
|
label="Template vorher bauen"
|
||||||
|
hilfe={`Für ${distribution.name} gibt es auf ${server.name} noch kein Template. Der Bau dauert einige Minuten und läuft als Teil dieses Auftrags.`}
|
||||||
|
checked={werte.templateBauen}
|
||||||
|
onChange={(an) => setze({ templateBauen: an })}
|
||||||
|
/>
|
||||||
|
) : template ? (
|
||||||
|
<Status art="erledigt">
|
||||||
|
Template {template.vmid} auf {template.knoten}
|
||||||
|
</Status>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Name" ? (
|
||||||
|
<Karte titel="Name, ID und Tags">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Feld id="name" label="Name (Hostname)" fehler={fehler?.felder?.["name"]}>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.name}
|
||||||
|
onChange={(e) => setze({ name: e.target.value.toLowerCase() })}
|
||||||
|
placeholder="web01"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="vmid" label="VMID" hilfe="Leer = nächste freie">
|
||||||
|
<input
|
||||||
|
id="vmid"
|
||||||
|
inputMode="numeric"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.vmid}
|
||||||
|
onChange={(e) => setze({ vmid: e.target.value })}
|
||||||
|
placeholder="automatisch"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="tags" label="Tags" hilfe="Kommagetrennt">
|
||||||
|
<input
|
||||||
|
id="tags"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.tags}
|
||||||
|
onChange={(e) => setze({ tags: e.target.value })}
|
||||||
|
placeholder="web,produktion"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Ressourcen" ? (
|
||||||
|
<Karte titel="Ressourcen">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Feld id="cores" label="CPU-Kerne">
|
||||||
|
<input
|
||||||
|
id="cores"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={128}
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.cores}
|
||||||
|
onChange={(e) => setze({ cores: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="speicher" label="RAM (MB)">
|
||||||
|
<input
|
||||||
|
id="speicher"
|
||||||
|
type="number"
|
||||||
|
min={256}
|
||||||
|
step={256}
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.speicherMb}
|
||||||
|
onChange={(e) => setze({ speicherMb: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="disk" label="Disk (GB)">
|
||||||
|
<input
|
||||||
|
id="disk"
|
||||||
|
type="number"
|
||||||
|
min={4}
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.diskGb}
|
||||||
|
onChange={(e) => setze({ diskGb: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="storage" label="Storage">
|
||||||
|
<select
|
||||||
|
id="storage"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.storage}
|
||||||
|
onChange={(e) => setze({ storage: e.target.value })}
|
||||||
|
>
|
||||||
|
{(knoten?.storages ?? [])
|
||||||
|
.filter((s) => (werte.typ === "vm" ? s.vm : s.lxc))
|
||||||
|
.map((s) => (
|
||||||
|
<option key={s.name} value={s.name}>
|
||||||
|
{s.name}
|
||||||
|
{s.frei !== null ? ` · ${Math.round(s.frei / 1024 ** 3)} GiB frei` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Netzwerk" ? (
|
||||||
|
<Karte titel="Netzwerk">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld id="bridge" label="Bridge">
|
||||||
|
<select
|
||||||
|
id="bridge"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.bridge}
|
||||||
|
onChange={(e) => setze({ bridge: e.target.value })}
|
||||||
|
>
|
||||||
|
{(knoten?.bridges ?? []).map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>
|
||||||
|
{b.name}
|
||||||
|
{b.vlan ? " · VLAN-fähig" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="vlan"
|
||||||
|
label="VLAN"
|
||||||
|
hilfe={bridge && !bridge.vlan ? "Diese Bridge ist nicht VLAN-fähig." : "Leer = ohne Tag"}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="vlan"
|
||||||
|
inputMode="numeric"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.vlan}
|
||||||
|
onChange={(e) => setze({ vlan: e.target.value })}
|
||||||
|
placeholder="–"
|
||||||
|
disabled={Boolean(bridge && !bridge.vlan)}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
<fieldset className="flex gap-6">
|
||||||
|
<legend className="mb-2 text-sm font-medium">Adresse</legend>
|
||||||
|
{(["dhcp", "statisch"] as const).map((m) => (
|
||||||
|
<label key={m} className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="modus"
|
||||||
|
checked={werte.modus === m}
|
||||||
|
onChange={() => setze({ modus: m })}
|
||||||
|
className="accent-akzent h-4 w-4"
|
||||||
|
/>
|
||||||
|
{m === "dhcp" ? "DHCP" : "Statisch"}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
{werte.modus === "statisch" ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld id="ip" label="IP/Präfix">
|
||||||
|
<input
|
||||||
|
id="ip"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.ip}
|
||||||
|
onChange={(e) => setze({ ip: e.target.value })}
|
||||||
|
placeholder="10.0.20.15/24"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="gateway" label="Gateway">
|
||||||
|
<input
|
||||||
|
id="gateway"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.gateway}
|
||||||
|
onChange={(e) => setze({ gateway: e.target.value })}
|
||||||
|
placeholder="10.0.20.1"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld id="dns" label="DNS-Server" hilfe="Leer = vom Host übernehmen">
|
||||||
|
<input
|
||||||
|
id="dns"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.dns}
|
||||||
|
onChange={(e) => setze({ dns: e.target.value })}
|
||||||
|
placeholder="10.0.20.1"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="suchdomain" label="Suchdomain">
|
||||||
|
<input
|
||||||
|
id="suchdomain"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.suchdomain}
|
||||||
|
onChange={(e) => setze({ suchdomain: e.target.value })}
|
||||||
|
placeholder="example.lan"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Zugang" ? (
|
||||||
|
<Karte titel="Zugang">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Feld id="benutzer" label="Benutzer" hilfe="Wird mit sudo angelegt. Passwort-Login ist aus.">
|
||||||
|
<input
|
||||||
|
id="benutzer"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono sm:max-w-xs`}
|
||||||
|
value={werte.benutzer}
|
||||||
|
onChange={(e) => setze({ benutzer: e.target.value })}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="schluessel"
|
||||||
|
label="Öffentliche SSH-Schlüssel"
|
||||||
|
hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Gespeicherte Schlüssel folgen mit #015."
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="schluessel"
|
||||||
|
rows={4}
|
||||||
|
className={`${EINGABE_KLASSE} h-auto py-2 font-mono text-xs`}
|
||||||
|
value={werte.schluessel}
|
||||||
|
onChange={(e) => setze({ schluessel: e.target.value })}
|
||||||
|
placeholder="ssh-ed25519 AAAA… name@rechner"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Profile" ? (
|
||||||
|
<Karte titel="Profile">
|
||||||
|
<ul className="flex flex-col gap-4">
|
||||||
|
{daten.profile.map((p) => {
|
||||||
|
const umsetzbar = distribution ? p.familien.includes(distribution.familie) : false;
|
||||||
|
const gewaehlt = werte.profile.includes(p.id);
|
||||||
|
const benoetigtVon = daten.profile
|
||||||
|
.filter((x) => werte.profile.includes(x.id) && x.requires.includes(p.id))
|
||||||
|
.map((x) => x.name);
|
||||||
|
return (
|
||||||
|
<li key={p.id} className="flex flex-col gap-3">
|
||||||
|
<Schalter
|
||||||
|
id={`profil-${p.id}`}
|
||||||
|
label={p.name}
|
||||||
|
hilfe={
|
||||||
|
!umsetzbar
|
||||||
|
? `Keine Umsetzung für ${distribution?.name ?? "diese Distribution"} (Familie ${distribution?.familie}).`
|
||||||
|
: benoetigtVon.length > 0 && !gewaehlt
|
||||||
|
? `Wird automatisch mit ausgeführt (Voraussetzung für ${benoetigtVon.join(", ")}).`
|
||||||
|
: (p.beschreibung ?? undefined)
|
||||||
|
}
|
||||||
|
checked={gewaehlt || benoetigtVon.length > 0}
|
||||||
|
disabled={!umsetzbar || (benoetigtVon.length > 0 && !gewaehlt)}
|
||||||
|
onChange={(an) =>
|
||||||
|
setze({
|
||||||
|
profile: an ? [...werte.profile, p.id] : werte.profile.filter((x) => x !== p.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{(gewaehlt || benoetigtVon.length > 0) && p.variablen.length > 0 ? (
|
||||||
|
<div className="border-linie ml-8 grid gap-4 border-l pl-4 sm:grid-cols-2">
|
||||||
|
{p.variablen.map((v) => {
|
||||||
|
const id = `var-${p.id}-${v.name}`;
|
||||||
|
const wert =
|
||||||
|
werte.variablen[p.id]?.[v.name] ??
|
||||||
|
v.default ??
|
||||||
|
(v.type === "boolean" ? false : "");
|
||||||
|
const setzeWert = (neu: string | boolean) =>
|
||||||
|
setze({
|
||||||
|
variablen: {
|
||||||
|
...werte.variablen,
|
||||||
|
[p.id]: { ...werte.variablen[p.id], [v.name]: neu },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return v.type === "boolean" ? (
|
||||||
|
<Schalter
|
||||||
|
key={v.name}
|
||||||
|
id={id}
|
||||||
|
label={v.label}
|
||||||
|
hilfe={v.help}
|
||||||
|
checked={Boolean(wert)}
|
||||||
|
onChange={setzeWert}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Feld
|
||||||
|
key={v.name}
|
||||||
|
id={id}
|
||||||
|
label={`${v.label}${v.required ? " *" : ""}`}
|
||||||
|
hilfe={v.help}
|
||||||
|
fehler={fehler?.felder?.[`variablen.${p.id}.${v.name}`]}
|
||||||
|
>
|
||||||
|
{v.type === "select" ? (
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={String(wert)}
|
||||||
|
onChange={(e) => setzeWert(e.target.value)}
|
||||||
|
>
|
||||||
|
{v.options!.map((o) => (
|
||||||
|
<option key={o}>{o}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
inputMode={v.type === "number" ? "numeric" : undefined}
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={String(wert)}
|
||||||
|
onChange={(e) => setzeWert(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Feld>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Zusammenfassung" ? (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{ersteSperre >= 0 ? (
|
||||||
|
<Karte>
|
||||||
|
<Status art="fehler">
|
||||||
|
Schritt {ersteSperre + 1} ({SCHRITTE[ersteSperre]}) ist unvollständig:{" "}
|
||||||
|
{sperre(ersteSperre)}
|
||||||
|
</Status>
|
||||||
|
</Karte>
|
||||||
|
) : laeuft && !plan ? (
|
||||||
|
<Karte>
|
||||||
|
<Status art="laeuft">
|
||||||
|
Plane – prüft Template, VMID, Storage und Netz gegen den Server …
|
||||||
|
</Status>
|
||||||
|
</Karte>
|
||||||
|
) : plan ? (
|
||||||
|
<>
|
||||||
|
<TerminalKarte
|
||||||
|
version={daten.version}
|
||||||
|
kopf={plan.kopf}
|
||||||
|
trockenlauf
|
||||||
|
phasen={plan.phasen.map((p): TerminalPhase => ({
|
||||||
|
...p,
|
||||||
|
// Im Trockenlauf ist nichts erledigt – geplante Phasen erscheinen als offen.
|
||||||
|
status:
|
||||||
|
p.status === "FEHLER"
|
||||||
|
? "FEHLER"
|
||||||
|
: p.status === "UEBERSPRUNGEN"
|
||||||
|
? "UEBERSPRUNGEN"
|
||||||
|
: "OFFEN",
|
||||||
|
gestartetAm: null,
|
||||||
|
}))}
|
||||||
|
ergebnis={
|
||||||
|
plan.ok
|
||||||
|
? null
|
||||||
|
: { ok: false, titel: `${werte.name} würde fehlschlagen`, dauer: null, zeilen: [] }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Karte titel="Geplante Proxmox-Aufrufe">
|
||||||
|
<ol className="flex flex-col gap-1 font-mono text-xs sm:text-sm">
|
||||||
|
{plan.aufrufe.map((aufruf, nr) => (
|
||||||
|
<li key={nr} className="break-all">
|
||||||
|
<span className="text-laeuft">→ </span>
|
||||||
|
{aufruf.methode} {aufruf.pfad}
|
||||||
|
{aufruf.parameter && Object.keys(aufruf.parameter).length > 0 ? (
|
||||||
|
<span className="text-muted">
|
||||||
|
{" "}
|
||||||
|
{Object.entries(aufruf.parameter)
|
||||||
|
.map(
|
||||||
|
([k, v]) =>
|
||||||
|
`${k}=${String(v).length > 60 ? `${String(v).slice(0, 57)}…` : String(v)}`,
|
||||||
|
)
|
||||||
|
.join(" ")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="text-muted">
|
||||||
|
→ Guest-Agent: Provisionierungsskript übertragen und ausführen
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</Karte>
|
||||||
|
{plan.skript ? (
|
||||||
|
<details className="bg-panel border-linie rounded-karte border">
|
||||||
|
<summary className="cursor-pointer px-5 py-3.5 font-semibold">
|
||||||
|
Provisionierungsskript
|
||||||
|
</summary>
|
||||||
|
<pre className="border-linie max-h-[50vh] overflow-auto border-t p-5 font-mono text-xs">
|
||||||
|
{plan.skript}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm font-medium">Dasselbe per CLI</span>
|
||||||
|
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-start">
|
||||||
|
<Code className="flex-1">{plan.cliBefehl}</Code>
|
||||||
|
<Knopf
|
||||||
|
variante="sekundaer"
|
||||||
|
aria-label="CLI-Befehl kopieren"
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(plan.cliBefehl);
|
||||||
|
setKopiert(true);
|
||||||
|
setTimeout(() => setKopiert(false), 2000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy size={16} aria-hidden="true" />
|
||||||
|
{kopiert ? "Kopiert" : "Kopieren"}
|
||||||
|
</Knopf>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div aria-live="polite">
|
||||||
|
{fehler ? (
|
||||||
|
<div>
|
||||||
|
<Status art="fehler">{fehler.text}</Status>
|
||||||
|
{fehler.hinweis ? <p className="text-muted text-sm">{fehler.hinweis}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Knopf variante="sekundaer" onClick={() => gehe(schritt - 1)} disabled={schritt === 0}>
|
||||||
|
<ArrowLeft size={16} aria-hidden="true" /> Zurück
|
||||||
|
</Knopf>
|
||||||
|
{SCHRITTE[schritt] === "Zusammenfassung" ? (
|
||||||
|
<Knopf onClick={ausrollen} disabled={laeuft || !plan?.ok}>
|
||||||
|
{laeuft && plan ? "Startet …" : "Ausrollen"}
|
||||||
|
</Knopf>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{aktuelleSperre ? <span className="text-muted text-sm">{aktuelleSperre}</span> : null}
|
||||||
|
<Knopf onClick={() => gehe(schritt + 1)} disabled={aktuelleSperre !== null}>
|
||||||
|
Weiter <ArrowRight size={16} aria-hidden="true" />
|
||||||
|
</Knopf>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||||
|
import { versionText } from "@/lib/version";
|
||||||
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
||||||
|
import { Assistent, type AssistentDaten } from "./assistent";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Ausrollen" };
|
||||||
|
|
||||||
|
/** Der Deploy-Assistent. Alle Daten für die Auswahlfelder kommen hier einmal vom Server. */
|
||||||
|
export default async function Ausrollen() {
|
||||||
|
const benutzer = await seiteSchuetzen("ausrollen", "/ausrollen");
|
||||||
|
const server = await prisma.server.findMany({ orderBy: { name: "asc" } });
|
||||||
|
const mitTest = server.filter((s) => s.letzterTest);
|
||||||
|
if (mitTest.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Ausrollen</h1>
|
||||||
|
<Karte>
|
||||||
|
<Status art="offen">Erst einen Server anlegen und die Verbindung testen.</Status>
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const templates = await prisma.template.findMany({
|
||||||
|
select: { serverId: true, distributionId: true, knoten: true, vmid: true },
|
||||||
|
});
|
||||||
|
const daten: AssistentDaten = {
|
||||||
|
server: mitTest.map((s) => {
|
||||||
|
const bericht = s.letzterTest as VerbindungsBericht;
|
||||||
|
return {
|
||||||
|
name: s.name,
|
||||||
|
titel: s.titel,
|
||||||
|
knoten: bericht.knoten
|
||||||
|
.filter((k) => k.online)
|
||||||
|
.map((k) => ({
|
||||||
|
name: k.name,
|
||||||
|
speicherFrei: (k.speicherGesamt ?? 0) - (k.speicherBelegt ?? 0),
|
||||||
|
storages: k.storages
|
||||||
|
.filter((st) => st.aktiv && (st.inhalte.includes("images") || st.inhalte.includes("rootdir")))
|
||||||
|
.map((st) => ({
|
||||||
|
name: st.storage,
|
||||||
|
vm: st.inhalte.includes("images"),
|
||||||
|
lxc: st.inhalte.includes("rootdir"),
|
||||||
|
frei: st.frei,
|
||||||
|
})),
|
||||||
|
bridges: k.bridges.map((b) => ({ name: b.name, vlan: b.vlanBewusst })),
|
||||||
|
})),
|
||||||
|
templates: templates
|
||||||
|
.filter((t) => t.serverId === s.id)
|
||||||
|
.map((t) => ({ distribution: t.distributionId, knoten: t.knoten, vmid: t.vmid })),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
distributionen: (await distributionenListe({ nurAktive: true })).map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
familie: d.familie,
|
||||||
|
vm: d.vm,
|
||||||
|
lxc: d.lxc,
|
||||||
|
})),
|
||||||
|
profile: (await profileListe({ nurAktive: true })).map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
beschreibung: p.beschreibung,
|
||||||
|
familien: p.familien,
|
||||||
|
requires: p.definition.requires,
|
||||||
|
variablen: p.definition.variables,
|
||||||
|
})),
|
||||||
|
benutzer: benutzer.benutzername,
|
||||||
|
version: versionText(),
|
||||||
|
};
|
||||||
|
const vorauswahl = aktiverServer(
|
||||||
|
daten.server.map((s) => s.name),
|
||||||
|
(await cookies()).get(SERVER_COOKIE)?.value,
|
||||||
|
);
|
||||||
|
return <Assistent daten={daten} vorauswahl={vorauswahl} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { zeitpunkt } from "@/lib/formate";
|
||||||
|
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { AnwendungsFehler } from "@/server/fehler";
|
||||||
|
import { distributionenListe } from "@/server/services/katalog";
|
||||||
|
import { templatesListe, type TemplateSicht } from "@/server/services/templates";
|
||||||
|
import { TemplateBauen } from "./template-bauen";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Templates" };
|
||||||
|
|
||||||
|
export default async function TemplatesSeite() {
|
||||||
|
const benutzer = await seiteSchuetzen("lesen", "/templates");
|
||||||
|
const alle = await prisma.server.findMany({ orderBy: { name: "asc" } });
|
||||||
|
const name = aktiverServer(
|
||||||
|
alle.map((s) => s.name),
|
||||||
|
(await cookies()).get(SERVER_COOKIE)?.value,
|
||||||
|
);
|
||||||
|
const server = alle.find((s) => s.name === name);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Templates</h1>
|
||||||
|
<Karte>
|
||||||
|
<Status art="offen">Noch kein Server angelegt.</Status>
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let templates: TemplateSicht[] = [];
|
||||||
|
let fehler: { ursache: string; hinweis?: string } | null = null;
|
||||||
|
try {
|
||||||
|
templates = await templatesListe(server.name);
|
||||||
|
} catch (ursache) {
|
||||||
|
if (!(ursache instanceof AnwendungsFehler)) throw ursache;
|
||||||
|
fehler = { ursache: ursache.message, hinweis: ursache.hinweis };
|
||||||
|
}
|
||||||
|
const bericht = server.letzterTest as VerbindungsBericht | null;
|
||||||
|
const distributionen = (await distributionenListe({ typ: "vm", nurAktive: true })).map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header>
|
||||||
|
<p className="text-muted font-mono text-sm">{server.name}</p>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Templates</h1>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
VM-Templates mit eingebautem Guest-Agent, VMID-Bereich {server.templateVmidVon}–
|
||||||
|
{server.templateVmidBis}.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Karte titel="Vorhanden">
|
||||||
|
{fehler ? (
|
||||||
|
<div>
|
||||||
|
<Status art="fehler">{fehler.ursache}</Status>
|
||||||
|
{fehler.hinweis ? <p className="text-muted mt-1 text-sm">{fehler.hinweis}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<Status art="offen">Noch kein ProxDeploy-Template auf diesem Server.</Status>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-linie flex flex-col divide-y">
|
||||||
|
{templates.map((t) => (
|
||||||
|
<li
|
||||||
|
key={t.vmid}
|
||||||
|
className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:gap-4"
|
||||||
|
>
|
||||||
|
<span className="font-mono sm:w-16">{t.vmid}</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium">{t.name}</span>
|
||||||
|
<span className="text-muted text-sm">
|
||||||
|
{" "}
|
||||||
|
· {t.knoten}
|
||||||
|
{t.distributionId ? ` · ${t.distributionId}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-sm">
|
||||||
|
{t.gebautAm ? `gebaut ${zeitpunkt(t.gebautAm)}` : "nicht von hier gebaut"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{darf(benutzer.rolle, "ausrollen") ? (
|
||||||
|
bericht ? (
|
||||||
|
<Karte titel="Template bauen">
|
||||||
|
<TemplateBauen
|
||||||
|
server={server.name}
|
||||||
|
distributionen={distributionen}
|
||||||
|
knoten={bericht.knoten
|
||||||
|
.filter((k) => k.online)
|
||||||
|
.map((k) => ({
|
||||||
|
name: k.name,
|
||||||
|
storages: k.storages
|
||||||
|
.filter((s) => s.aktiv && s.inhalte.includes("images"))
|
||||||
|
.map((s) => s.storage),
|
||||||
|
bridges: k.bridges.map((b) => b.name),
|
||||||
|
}))}
|
||||||
|
faehigkeit={bericht.faehigkeiten.templateBau}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
) : (
|
||||||
|
<Karte titel="Template bauen">
|
||||||
|
<Status art="offen">
|
||||||
|
Erst die Verbindung testen –{" "}
|
||||||
|
<Link href={`/server/${server.name}`} className="underline">
|
||||||
|
zur Serverseite
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Status>
|
||||||
|
</Karte>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { EINGABE_KLASSE, Feld } from "@/components/formular";
|
||||||
|
import { Knopf } from "@/components/knopf";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||||
|
|
||||||
|
type Knoten = { name: string; storages: string[]; bridges: string[] };
|
||||||
|
|
||||||
|
export function TemplateBauen({
|
||||||
|
server,
|
||||||
|
distributionen,
|
||||||
|
knoten,
|
||||||
|
faehigkeit,
|
||||||
|
}: {
|
||||||
|
server: string;
|
||||||
|
distributionen: Array<{ id: string; name: string }>;
|
||||||
|
knoten: Knoten[];
|
||||||
|
faehigkeit: { weg: "api" | "ssh" | "nicht_moeglich"; grund: string };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [werte, setWerte] = useState({
|
||||||
|
distribution: distributionen.find((d) => d.id === "debian-13")?.id ?? distributionen[0]?.id ?? "",
|
||||||
|
knoten: knoten[0]?.name ?? "",
|
||||||
|
storage: knoten[0]?.storages[0] ?? "",
|
||||||
|
bridge: knoten[0]?.bridges.includes("vmbr0") ? "vmbr0" : (knoten[0]?.bridges[0] ?? ""),
|
||||||
|
});
|
||||||
|
const [fehler, setFehler] = useState<{ text: string; hinweis?: string } | null>(null);
|
||||||
|
const [sendet, setSendet] = useState(false);
|
||||||
|
const aktuell = knoten.find((k) => k.name === werte.knoten);
|
||||||
|
|
||||||
|
async function bauen(ereignis: React.FormEvent) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
setSendet(true);
|
||||||
|
setFehler(null);
|
||||||
|
try {
|
||||||
|
const { kurzId } = await apiAufruf<{ kurzId: string }>(
|
||||||
|
"POST",
|
||||||
|
`/api/v1/server/${server}/templates`,
|
||||||
|
werte,
|
||||||
|
);
|
||||||
|
router.push(`/auftraege/${kurzId}`);
|
||||||
|
} catch (ursache) {
|
||||||
|
setFehler(
|
||||||
|
ursache instanceof ApiFehler
|
||||||
|
? { text: ursache.message, hinweis: ursache.hinweis }
|
||||||
|
: { text: "Unerwarteter Fehler." },
|
||||||
|
);
|
||||||
|
setSendet(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={bauen} className="flex flex-col gap-4">
|
||||||
|
<Status art={faehigkeit.weg === "api" ? "erledigt" : faehigkeit.weg === "ssh" ? "laeuft" : "fehler"}>
|
||||||
|
{faehigkeit.weg === "api" ? "Per API" : faehigkeit.weg === "ssh" ? "Per SSH" : "Nicht möglich"} –{" "}
|
||||||
|
{faehigkeit.grund}
|
||||||
|
</Status>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Feld id="distribution" label="Distribution">
|
||||||
|
<select
|
||||||
|
id="distribution"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.distribution}
|
||||||
|
onChange={(e) => setWerte({ ...werte, distribution: e.target.value })}
|
||||||
|
>
|
||||||
|
{distributionen.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="knoten" label="Node">
|
||||||
|
<select
|
||||||
|
id="knoten"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.knoten}
|
||||||
|
onChange={(e) => {
|
||||||
|
const k = knoten.find((x) => x.name === e.target.value);
|
||||||
|
setWerte({
|
||||||
|
...werte,
|
||||||
|
knoten: e.target.value,
|
||||||
|
storage: k?.storages[0] ?? "",
|
||||||
|
bridge: k?.bridges[0] ?? "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{knoten.map((k) => (
|
||||||
|
<option key={k.name}>{k.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="storage" label="Storage">
|
||||||
|
<select
|
||||||
|
id="storage"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.storage}
|
||||||
|
onChange={(e) => setWerte({ ...werte, storage: e.target.value })}
|
||||||
|
>
|
||||||
|
{aktuell?.storages.map((s) => (
|
||||||
|
<option key={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="bridge" label="Bridge (Vorbereitungsstart)">
|
||||||
|
<select
|
||||||
|
id="bridge"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.bridge}
|
||||||
|
onChange={(e) => setWerte({ ...werte, bridge: e.target.value })}
|
||||||
|
>
|
||||||
|
{aktuell?.bridges.map((b) => (
|
||||||
|
<option key={b}>{b}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
Der Vorbereitungsstart braucht DHCP und Internetzugang auf der Bridge, um qemu-guest-agent zu
|
||||||
|
installieren.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Knopf type="submit" disabled={sendet || faehigkeit.weg === "nicht_moeglich" || !werte.distribution}>
|
||||||
|
{sendet ? "Startet …" : "Template bauen"}
|
||||||
|
</Knopf>
|
||||||
|
<div aria-live="polite" className="text-sm">
|
||||||
|
{fehler ? (
|
||||||
|
<div>
|
||||||
|
<Status art="fehler">{fehler.text}</Status>
|
||||||
|
{fehler.hinweis ? <p className="text-muted">{fehler.hinweis}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { auftragAbbrechen } from "@/server/services/auftraege";
|
||||||
|
|
||||||
|
type Kontext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
|
||||||
|
await auftragAbbrechen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { api } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { abonnieren } from "@/server/ereignisse/hoeren";
|
||||||
|
import { auftragHolen, ENDZUSTAENDE, logAb } from "@/server/services/auftraege";
|
||||||
|
|
||||||
|
type Kontext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-Fortschritt als Server-Sent Events – für Web-GUI und CLI derselbe Strom.
|
||||||
|
*
|
||||||
|
* - `stand`: der vollständige Auftrag mit allen Phasen (klein, deshalb immer ganz)
|
||||||
|
* - `log`: neue Detail-Log-Zeilen; ihre ID ist die Event-ID, damit ein Wiederverbinden mit
|
||||||
|
* `Last-Event-ID` (oder `?ab=`) genau dort weitermacht
|
||||||
|
* - `ende`: der Auftrag ist fertig, der Strom schließt
|
||||||
|
*/
|
||||||
|
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
await rechtErforderlich(anfrage, "lesen");
|
||||||
|
const anfangsStand = await auftragHolen((await params).id);
|
||||||
|
const auftragId = anfangsStand.id;
|
||||||
|
const letzte = anfrage.headers.get("last-event-id") ?? new URL(anfrage.url).searchParams.get("ab") ?? "0";
|
||||||
|
let logBis = BigInt(/^\d+$/.test(letzte) ? letzte : "0");
|
||||||
|
|
||||||
|
const kodierer = new TextEncoder();
|
||||||
|
let aufraeumen = () => {};
|
||||||
|
|
||||||
|
const strom = new ReadableStream<Uint8Array>({
|
||||||
|
async start(steuerung) {
|
||||||
|
let offen = true;
|
||||||
|
const senden = (text: string) => {
|
||||||
|
if (offen) steuerung.enqueue(kodierer.encode(text));
|
||||||
|
};
|
||||||
|
const ereignis = (art: string, daten: unknown, id?: string) =>
|
||||||
|
senden(`${id ? `id: ${id}\n` : ""}event: ${art}\ndata: ${JSON.stringify(daten)}\n\n`);
|
||||||
|
|
||||||
|
let laeuft = false;
|
||||||
|
let nochmal = false;
|
||||||
|
const aktualisieren = async () => {
|
||||||
|
// Signale können dicht kommen; zusammenfassen statt parallel abzufragen.
|
||||||
|
if (laeuft) {
|
||||||
|
nochmal = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
laeuft = true;
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
nochmal = false;
|
||||||
|
const stand = await auftragHolen(auftragId);
|
||||||
|
const zeilen = await logAb(auftragId, logBis);
|
||||||
|
if (zeilen.length > 0) {
|
||||||
|
logBis = BigInt(zeilen.at(-1)!.id);
|
||||||
|
ereignis("log", zeilen, zeilen.at(-1)!.id);
|
||||||
|
}
|
||||||
|
ereignis("stand", stand);
|
||||||
|
if (ENDZUSTAENDE.has(stand.status)) {
|
||||||
|
ereignis("ende", { status: stand.status });
|
||||||
|
schliessen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} while (nochmal);
|
||||||
|
} finally {
|
||||||
|
laeuft = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const abmelden = await abonnieren(auftragId, () => void aktualisieren());
|
||||||
|
// Herzschlag: hält Proxys davon ab, die Verbindung als tot zu schließen.
|
||||||
|
const herz = setInterval(() => senden(": ping\n\n"), 15_000);
|
||||||
|
const schliessen = () => {
|
||||||
|
if (!offen) return;
|
||||||
|
offen = false;
|
||||||
|
clearInterval(herz);
|
||||||
|
abmelden();
|
||||||
|
try {
|
||||||
|
steuerung.close();
|
||||||
|
} catch {
|
||||||
|
// schon geschlossen
|
||||||
|
}
|
||||||
|
};
|
||||||
|
aufraeumen = schliessen;
|
||||||
|
anfrage.signal.addEventListener("abort", schliessen);
|
||||||
|
|
||||||
|
senden("retry: 3000\n\n");
|
||||||
|
await aktualisieren();
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
aufraeumen();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(strom, {
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8",
|
||||||
|
"cache-control": "no-cache, no-transform",
|
||||||
|
connection: "keep-alive",
|
||||||
|
// nginx puffert sonst und der Fortschritt käme in Brocken an.
|
||||||
|
"x-accel-buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { api } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { auftragHolen, logAb } from "@/server/services/auftraege";
|
||||||
|
|
||||||
|
type Kontext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Detail-Log ab einer Zeilen-ID (`?ab=`), für Polling und `proxdeploy logs`. */
|
||||||
|
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
await rechtErforderlich(anfrage, "lesen");
|
||||||
|
const auftrag = await auftragHolen((await params).id);
|
||||||
|
const ab = new URL(anfrage.url).searchParams.get("ab");
|
||||||
|
return NextResponse.json(await logAb(auftrag.id, BigInt(/^\d+$/.test(ab ?? "") ? ab! : "0")));
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { api } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { auftragHolen } from "@/server/services/auftraege";
|
||||||
|
|
||||||
|
type Kontext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
await rechtErforderlich(anfrage, "lesen");
|
||||||
|
return NextResponse.json(await auftragHolen((await params).id));
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { ausrollenPlanen } from "@/server/services/ausrollen";
|
||||||
|
|
||||||
|
/** Dry-Run: Phasen, geplante Proxmox-Aufrufe, Skript und CLI-Befehl – angelegt wird nichts. */
|
||||||
|
export const POST = api(async (anfrage: Request) => {
|
||||||
|
await rechtErforderlich(anfrage, "lesen");
|
||||||
|
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage)));
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { ausrollenStarten } from "@/server/services/ausrollen";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { auftraegeListe } from "@/server/services/auftraege";
|
||||||
|
|
||||||
|
/** Verlauf. Filter und Seitenblättern folgen mit #014. */
|
||||||
|
export const GET = api(async (anfrage: Request) => {
|
||||||
|
await rechtErforderlich(anfrage, "lesen");
|
||||||
|
return NextResponse.json(await auftraegeListe());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Ausrollen → 202 mit Kurz-ID; Fortschritt unter /api/v1/auftraege/<kurzId>/ereignisse. */
|
||||||
|
export const POST = api(async (anfrage: Request) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
|
||||||
|
const auftrag = await ausrollenStarten(
|
||||||
|
await jsonLesen(anfrage),
|
||||||
|
aufrufer,
|
||||||
|
aufruferAdresse(anfrage.headers),
|
||||||
|
);
|
||||||
|
return NextResponse.json({ id: auftrag.id, kurzId: auftrag.kurzId }, { status: 202 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { templateBauStarten, templatesListe } from "@/server/services/templates";
|
||||||
|
|
||||||
|
type Kontext = { params: Promise<{ name: string }> };
|
||||||
|
|
||||||
|
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
await rechtErforderlich(anfrage, "lesen");
|
||||||
|
return NextResponse.json(await templatesListe((await params).name));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Template bauen → Auftrag; Fortschritt unter /api/v1/auftraege/<kurzId>/ereignisse. */
|
||||||
|
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
|
||||||
|
const auftrag = await templateBauStarten(
|
||||||
|
(await params).name,
|
||||||
|
await jsonLesen(anfrage),
|
||||||
|
aufrufer,
|
||||||
|
aufruferAdresse(anfrage.headers),
|
||||||
|
);
|
||||||
|
return NextResponse.json({ id: auftrag.id, kurzId: auftrag.kurzId }, { status: 202 });
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ export function ServerUmschalter({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5 px-2">
|
<div className="flex flex-col gap-1.5 px-2">
|
||||||
<label htmlFor="server-umschalter" className="text-muted text-xs font-medium tracking-wide uppercase">
|
<label htmlFor="server-umschalter" className="text-muted text-xs font-medium tracking-wide uppercase">
|
||||||
Server
|
Aktiver Server
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="server-umschalter"
|
id="server-umschalter"
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StatusSymbol, type StatusArt } from "@/components/status";
|
||||||
|
import { dauer } from "@/lib/formate";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Live-Ansicht im Aufbau der CLI-Ausgabe: Kopfzeile, eine Zeile je Phase (Symbol, Name in
|
||||||
|
* fester Breite, Detail grau), am Ende die Ergebnis-Box. Alle Texte kommen aus der Engine.
|
||||||
|
*/
|
||||||
|
export type TerminalPhase = {
|
||||||
|
schluessel: string;
|
||||||
|
titel: string;
|
||||||
|
status: "OFFEN" | "LAEUFT" | "ERLEDIGT" | "FEHLER" | "UEBERSPRUNGEN";
|
||||||
|
detail: string | null;
|
||||||
|
ursache: string | null;
|
||||||
|
hinweis: string | null;
|
||||||
|
gestartetAm: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalErgebnis = {
|
||||||
|
ok: boolean;
|
||||||
|
titel: string;
|
||||||
|
dauer: string | null;
|
||||||
|
zeilen: Array<[string, string]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ART: Record<TerminalPhase["status"], StatusArt> = {
|
||||||
|
OFFEN: "offen",
|
||||||
|
LAEUFT: "laeuft",
|
||||||
|
ERLEDIGT: "erledigt",
|
||||||
|
FEHLER: "fehler",
|
||||||
|
UEBERSPRUNGEN: "offen",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FARBE: Record<StatusArt, string> = {
|
||||||
|
erledigt: "text-akzent",
|
||||||
|
laeuft: "text-laeuft",
|
||||||
|
fehler: "text-fehler",
|
||||||
|
offen: "text-muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
function Laufzeit({ seit }: { seit: string }) {
|
||||||
|
const [jetzt, setJetzt] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const uhr = setInterval(() => setJetzt(Date.now()), 1000);
|
||||||
|
return () => clearInterval(uhr);
|
||||||
|
}, []);
|
||||||
|
return <span>{dauer(jetzt - new Date(seit).getTime())}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalKarte({
|
||||||
|
version,
|
||||||
|
kopf,
|
||||||
|
trockenlauf = false,
|
||||||
|
phasen,
|
||||||
|
ergebnis,
|
||||||
|
}: {
|
||||||
|
version: string;
|
||||||
|
kopf: string;
|
||||||
|
trockenlauf?: boolean;
|
||||||
|
phasen: TerminalPhase[];
|
||||||
|
ergebnis: TerminalErgebnis | null;
|
||||||
|
}) {
|
||||||
|
const breite = Math.max(12, ...phasen.map((p) => p.titel.length)) + 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-panel border-linie rounded-karte overflow-hidden border font-mono text-[13px] leading-6 sm:text-sm">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 border-b border-[var(--farbe-linie)] px-4 py-2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
|
||||||
|
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
|
||||||
|
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 p-4 sm:p-5">
|
||||||
|
<p className="break-words">
|
||||||
|
<span className="text-muted">⬡ </span>
|
||||||
|
<span className="text-text">Prox</span>
|
||||||
|
<span className="text-akzent font-bold">Deploy</span>
|
||||||
|
<span className="text-muted">
|
||||||
|
{" "}
|
||||||
|
{version} · {kopf}
|
||||||
|
</span>
|
||||||
|
{trockenlauf ? <span className="text-laeuft font-bold"> · DRY-RUN</span> : null}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol className="flex flex-col" aria-label="Phasen">
|
||||||
|
{phasen.map((phase) => {
|
||||||
|
const art = ART[phase.status];
|
||||||
|
return (
|
||||||
|
<li key={phase.schluessel} className="flex flex-col sm:flex-row sm:gap-2">
|
||||||
|
<span
|
||||||
|
className={clsx("inline-flex shrink-0 items-baseline gap-2", FARBE[art])}
|
||||||
|
style={{ minWidth: `${breite + 2}ch` }}
|
||||||
|
>
|
||||||
|
<StatusSymbol
|
||||||
|
art={art}
|
||||||
|
className={clsx(
|
||||||
|
"translate-y-[0.15em]",
|
||||||
|
phase.status === "LAEUFT" && "motion-safe:animate-spin",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
phase.status === "OFFEN" || phase.status === "UEBERSPRUNGEN"
|
||||||
|
? "text-muted"
|
||||||
|
: "text-text"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{phase.titel}
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">
|
||||||
|
{" "}
|
||||||
|
–{" "}
|
||||||
|
{
|
||||||
|
{
|
||||||
|
OFFEN: "offen",
|
||||||
|
LAEUFT: "läuft",
|
||||||
|
ERLEDIGT: "erledigt",
|
||||||
|
FEHLER: "Fehler",
|
||||||
|
UEBERSPRUNGEN: "übersprungen",
|
||||||
|
}[phase.status]
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted min-w-0 pl-6 break-words sm:pl-0">
|
||||||
|
{phase.status === "FEHLER" ? (
|
||||||
|
<>
|
||||||
|
<span className="text-fehler">{phase.ursache}</span>
|
||||||
|
{phase.hinweis ? <span className="block">→ {phase.hinweis}</span> : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{phase.detail}
|
||||||
|
{phase.status === "LAEUFT" && phase.gestartetAm ? (
|
||||||
|
<>
|
||||||
|
{phase.detail ? " … " : ""}
|
||||||
|
<Laufzeit seit={phase.gestartetAm} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{ergebnis ? (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"max-w-xl rounded-lg border px-4 py-3",
|
||||||
|
ergebnis.ok ? "border-akzent" : "border-fehler",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className={ergebnis.ok ? "text-akzent" : "text-fehler"}>
|
||||||
|
{ergebnis.ok ? "● " : "✗ "}
|
||||||
|
<span className="text-text font-bold">{ergebnis.titel}</span>
|
||||||
|
{ergebnis.dauer ? <span className="text-muted"> · {ergebnis.dauer}</span> : null}
|
||||||
|
</p>
|
||||||
|
{ergebnis.zeilen.length > 0 ? (
|
||||||
|
<dl className="mt-1 grid grid-cols-[9ch_1fr] gap-x-2">
|
||||||
|
{ergebnis.zeilen.map(([name, wert]) => (
|
||||||
|
<div key={name} className="contents">
|
||||||
|
<dt className="text-muted">{name}</dt>
|
||||||
|
<dd className="text-text break-all">{wert}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||||
|
import type { PhasenKontext, PhasenSchritt } from "@/engine/ablauf/ablauf";
|
||||||
|
import type { Distribution } from "@/engine/katalog/schema";
|
||||||
|
import { dateiAusUrl, pruefsummeFinden } from "@/engine/katalog/pruefsumme";
|
||||||
|
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
|
||||||
|
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten";
|
||||||
|
import { q } from "@/engine/shell";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template-Bau für VMs: Cloud-Image laden und prüfen, VM anlegen, Disk importieren, Guest-Agent
|
||||||
|
* einbauen, als Template markieren. Geprüft gegen die Proxmox-API (docs/architektur.md, Abschnitt 3):
|
||||||
|
*
|
||||||
|
* - **per API** ab PVE 8.4 mit einem Storage vom Inhaltstyp `import`: `download-url` (mit
|
||||||
|
* Prüfsumme, Proxmox prüft selbst) und `import-from=<storage>:import/<datei>`
|
||||||
|
* - **per SSH** davor oder ohne import-Storage: Download und Prüfung auf dem Node,
|
||||||
|
* `qm set --scsi0 …,import-from=<pfad>` als root
|
||||||
|
*
|
||||||
|
* Der Guest-Agent ist in keinem der mitgelieferten Images enthalten. Er wird mit einem einmaligen
|
||||||
|
* Vorbereitungsstart eingebaut (cloud-init vendor-data als Snippet, braucht SSH), damit jeder Klon
|
||||||
|
* sofort einen Agent hat – ohne ihn gäbe es bei DHCP weder IP noch Provisionierung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Führt einen Befehl als root auf einem Node aus (ssh2, gepinnter Host-Key). */
|
||||||
|
export type NodeBefehl = (
|
||||||
|
befehl: string,
|
||||||
|
optionen?: { eingabe?: string; zeitlimitMs?: number },
|
||||||
|
) => Promise<{ code: number | null; stdout: string; stderr: string }>;
|
||||||
|
|
||||||
|
export type TemplateBauEingabe = {
|
||||||
|
client: ProxmoxClient;
|
||||||
|
distribution: Distribution;
|
||||||
|
knoten: string;
|
||||||
|
/** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */
|
||||||
|
storage: string;
|
||||||
|
bridge: string;
|
||||||
|
vlan?: number;
|
||||||
|
/** null, wenn SSH nicht eingerichtet oder der Host-Key nicht bestätigt ist. */
|
||||||
|
ssh: NodeBefehl | null;
|
||||||
|
/** VMID im Template-Bereich reservieren (nach Prüfung, wer schon was belegt). */
|
||||||
|
vmidReservieren: (belegt: Set<number>) => Promise<number>;
|
||||||
|
/** Prüfsummen-Datei holen – im Test ersetzbar. */
|
||||||
|
holen?: (url: string) => Promise<string>;
|
||||||
|
auftragKurzId: string;
|
||||||
|
jetzt?: () => Date;
|
||||||
|
/** Zeitlimit für den Vorbereitungsstart (Paketinstallation im Gast). */
|
||||||
|
vorbereitungMs?: number;
|
||||||
|
warten?: (ms: number) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplateBauStand = {
|
||||||
|
vmid?: number;
|
||||||
|
weg?: "api" | "ssh";
|
||||||
|
importStorage?: string;
|
||||||
|
snippetStorage?: string;
|
||||||
|
imageDatei?: string;
|
||||||
|
pruefsumme?: string;
|
||||||
|
volid?: string;
|
||||||
|
nodePfad?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StorageEintrag = {
|
||||||
|
storage: string;
|
||||||
|
type: string;
|
||||||
|
content: string;
|
||||||
|
active?: number;
|
||||||
|
enabled?: number;
|
||||||
|
shared?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
|
||||||
|
|
||||||
|
async function standardHolen(url: string): Promise<string> {
|
||||||
|
const antwort = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
||||||
|
if (!antwort.ok)
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Prüfsummen-Datei nicht abrufbar (HTTP ${antwort.status}): ${url}`,
|
||||||
|
"Hat ProxDeploy Internetzugang? Stimmt die URL im Katalog?",
|
||||||
|
);
|
||||||
|
return await antwort.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function templateName(distribution: Distribution): string {
|
||||||
|
return `tpl-${distribution.id.replace(/[^a-z0-9-]/g, "-")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vendor-data für den Vorbereitungsstart: Guest-Agent installieren, Maschinen-ID leeren (sonst
|
||||||
|
* teilen sich alle Klone DHCP-Client-ID und journald-ID), cloud-init zurücksetzen, ausschalten.
|
||||||
|
* SSH-Host-Keys erzeugt cloud-init bei jedem Klon ohnehin neu (neue instance-id).
|
||||||
|
*/
|
||||||
|
export function vorbereitungsSnippet(): string {
|
||||||
|
return `#cloud-config
|
||||||
|
# Von ProxDeploy: einmaliger Vorbereitungsstart beim Template-Bau
|
||||||
|
package_update: true
|
||||||
|
packages:
|
||||||
|
- qemu-guest-agent
|
||||||
|
runcmd:
|
||||||
|
- [systemctl, enable, qemu-guest-agent]
|
||||||
|
- [sh, -c, "truncate -s 0 /etc/machine-id && rm -f /var/lib/dbus/machine-id && cloud-init clean --logs"]
|
||||||
|
power_state:
|
||||||
|
mode: poweroff
|
||||||
|
delay: now
|
||||||
|
message: ProxDeploy-Vorbereitung abgeschlossen
|
||||||
|
timeout: 300
|
||||||
|
condition: true
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauStand = {}): PhasenSchritt[] {
|
||||||
|
const { client, distribution: d, knoten } = e;
|
||||||
|
const vm = d.vm;
|
||||||
|
if (!vm)
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`${d.name} bietet kein VM-Image.`,
|
||||||
|
"Im Katalog vm: ergänzen oder eine andere Distribution wählen.",
|
||||||
|
);
|
||||||
|
const holen = e.holen ?? standardHolen;
|
||||||
|
const warten = e.warten ?? schlafen;
|
||||||
|
const snippetDatei = `proxdeploy-vorbereitung-${e.auftragKurzId}.yaml`;
|
||||||
|
|
||||||
|
const ssh = async (
|
||||||
|
befehl: string,
|
||||||
|
k: PhasenKontext,
|
||||||
|
optionen?: { eingabe?: string; zeitlimitMs?: number },
|
||||||
|
) => {
|
||||||
|
if (!e.ssh)
|
||||||
|
throw new AblaufFehler(
|
||||||
|
"Dafür ist SSH zum Node nötig.",
|
||||||
|
"SSH in den Server-Einstellungen einrichten und den Host-Key bestätigen.",
|
||||||
|
);
|
||||||
|
if (client.trocken) {
|
||||||
|
await k.log(`[ssh ${knoten}] ${befehl}`, "info");
|
||||||
|
return { code: 0, stdout: "", stderr: "" };
|
||||||
|
}
|
||||||
|
await k.log(`[ssh ${knoten}] ${befehl.split("\n")[0]}`, "debug");
|
||||||
|
const ergebnis = await e.ssh(befehl, optionen);
|
||||||
|
if (ergebnis.code !== 0) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Befehl auf ${knoten} fehlgeschlagen (Code ${ergebnis.code}): ${(ergebnis.stderr || ergebnis.stdout).trim().split("\n").slice(-3).join(" | ")}`,
|
||||||
|
"Detail-Log prüfen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ergebnis;
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
schluessel: "pruefungen",
|
||||||
|
titel: "Prüfungen",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
const version = await client.get<{ version: string }>(pfad`/nodes/${knoten}/version`).catch(() => {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Node ${knoten} ist nicht erreichbar oder unbekannt.`,
|
||||||
|
"Node-Namen und Cluster-Status prüfen.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const pve = versionLesen(version.version)!;
|
||||||
|
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${knoten}/storage`);
|
||||||
|
const aktiv = (s: StorageEintrag) => s.active === 1 && s.enabled !== 0;
|
||||||
|
const ziel = storages.find((s) => s.storage === e.storage);
|
||||||
|
if (!ziel || !aktiv(ziel) || !ziel.content.split(",").includes("images")) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Storage ${e.storage} ist auf ${knoten} nicht aktiv oder nimmt keine VM-Disks auf.`,
|
||||||
|
`Geeignet sind: ${
|
||||||
|
storages
|
||||||
|
.filter((s) => aktiv(s) && s.content.includes("images"))
|
||||||
|
.map((s) => s.storage)
|
||||||
|
.join(", ") || "keiner"
|
||||||
|
}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const importSpeicher = storages.find((s) => aktiv(s) && s.content.split(",").includes("import"));
|
||||||
|
stand.snippetStorage = storages.find(
|
||||||
|
(s) => aktiv(s) && s.content.split(",").includes("snippets"),
|
||||||
|
)?.storage;
|
||||||
|
if (mindestens(pve, 8, 4) && importSpeicher) {
|
||||||
|
stand.weg = "api";
|
||||||
|
stand.importStorage = importSpeicher.storage;
|
||||||
|
} else if (e.ssh) {
|
||||||
|
stand.weg = "ssh";
|
||||||
|
} else {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
mindestens(pve, 8, 4)
|
||||||
|
? `Auf ${knoten} gibt es keinen Storage mit Inhaltstyp „Import“.`
|
||||||
|
: `PVE ${pve.text} kann Disk-Images erst ab 8.4 per API laden.`,
|
||||||
|
mindestens(pve, 8, 4)
|
||||||
|
? "Einem Dir-Storage den Inhaltstyp „Import“ geben (Datacenter → Storage) oder SSH einrichten."
|
||||||
|
: "SSH zum Node einrichten oder auf PVE 8.4+ aktualisieren.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!vm.guest_agent_preinstalled && !(e.ssh && stand.snippetStorage)) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
"Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node und einen Storage mit Inhaltstyp „Snippets“.",
|
||||||
|
e.ssh
|
||||||
|
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben."
|
||||||
|
: "SSH in den Server-Einstellungen einrichten.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const netze = await client.get<Array<{ iface: string; type: string }>>(
|
||||||
|
pfad`/nodes/${knoten}/network`,
|
||||||
|
);
|
||||||
|
if (!netze.some((n) => n.iface === e.bridge && (n.type === "bridge" || n.type === "OVSBridge"))) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Bridge ${e.bridge} gibt es auf ${knoten} nicht.`,
|
||||||
|
"Bridge in den Server-Standards oder im Formular korrigieren.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const belegt = new Set(
|
||||||
|
(await client.get<Array<{ vmid?: number }>>("/cluster/resources", { type: "vm" }))
|
||||||
|
.map((r) => r.vmid!)
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
stand.vmid = await e.vmidReservieren(belegt);
|
||||||
|
return {
|
||||||
|
detail: `VMID ${stand.vmid} frei · ${e.storage} · ${stand.weg === "api" ? `Import über ${stand.importStorage}` : "Import per SSH"} · ${e.bridge}${e.vlan ? ` VLAN ${e.vlan}` : ""}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "image",
|
||||||
|
titel: "Image laden",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
const quelle = dateiAusUrl(vm.image_url);
|
||||||
|
await k.detail("Prüfsumme holen …");
|
||||||
|
const summen = await holen(vm.checksum_url);
|
||||||
|
const hash = pruefsummeFinden(summen, quelle, vm.checksum_type);
|
||||||
|
if (!hash) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`${quelle} steht nicht in ${vm.checksum_url}.`,
|
||||||
|
"Image- und Prüfsummen-URL im Katalog prüfen – der Anbieter hat die Dateien vielleicht umbenannt.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stand.pruefsumme = hash;
|
||||||
|
// Die Prüfsumme im Dateinamen: ein neues „latest“-Image wird neu geladen, ein gleiches nicht.
|
||||||
|
stand.imageDatei = `proxdeploy-${d.id}-${hash.slice(0, 12)}.${vm.format}`;
|
||||||
|
await k.log(`${vm.checksum_type} ${hash} für ${quelle}`);
|
||||||
|
|
||||||
|
if (stand.weg === "api") {
|
||||||
|
const volid = `${stand.importStorage}:import/${stand.imageDatei}`;
|
||||||
|
stand.volid = volid;
|
||||||
|
const vorhanden = await client.get<Array<{ volid: string }>>(
|
||||||
|
pfad`/nodes/${knoten}/storage/${stand.importStorage!}/content`,
|
||||||
|
{ content: "import" },
|
||||||
|
);
|
||||||
|
if (vorhanden.some((v) => v.volid === volid))
|
||||||
|
return { detail: `${stand.imageDatei} schon vorhanden` };
|
||||||
|
const upid = await client.post<string>(
|
||||||
|
pfad`/nodes/${knoten}/storage/${stand.importStorage!}/download-url`,
|
||||||
|
{
|
||||||
|
content: "import",
|
||||||
|
filename: stand.imageDatei,
|
||||||
|
url: vm.image_url,
|
||||||
|
checksum: hash,
|
||||||
|
"checksum-algorithm": vm.checksum_type,
|
||||||
|
"verify-certificates": true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await aufgabeAbwarten(client, upid, {
|
||||||
|
zeitlimitMs: 60 * 60 * 1000,
|
||||||
|
intervallMs: 2000,
|
||||||
|
beiLog: (zeilen) => {
|
||||||
|
const prozent = zeilen
|
||||||
|
.map((z) => /(\d+(?:\.\d+)?)%/.exec(z)?.[1])
|
||||||
|
.filter(Boolean)
|
||||||
|
.at(-1);
|
||||||
|
if (prozent) void k.detail(`${quelle} · ${Math.round(Number(prozent))} %`);
|
||||||
|
for (const z of zeilen) void k.log(z, "debug");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { detail: `${quelle} geladen und geprüft (${vm.checksum_type})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH: auf den Node laden und dort prüfen; vorhandene gültige Datei wird wiederverwendet.
|
||||||
|
const ordner = "/var/tmp/proxdeploy";
|
||||||
|
stand.nodePfad = `${ordner}/${stand.imageDatei}`;
|
||||||
|
const pruefen = `echo ${q(`${hash} ${stand.nodePfad}`)} | ${vm.checksum_type}sum -c --status`;
|
||||||
|
await k.detail(`${quelle} wird auf ${knoten} geladen …`);
|
||||||
|
await ssh(
|
||||||
|
`set -e; mkdir -p ${ordner}; if ! ${pruefen} 2>/dev/null; then curl -fsSL --retry 3 -o ${q(stand.nodePfad + ".teil")} ${q(vm.image_url)} && mv ${q(stand.nodePfad + ".teil")} ${q(stand.nodePfad)}; fi; ${pruefen}`,
|
||||||
|
k,
|
||||||
|
{ zeitlimitMs: 60 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
return { detail: `${quelle} auf ${knoten} geladen und geprüft (${vm.checksum_type})` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "vm",
|
||||||
|
titel: "VM anlegen",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
const vmid = stand.vmid!;
|
||||||
|
const jetzt = (e.jetzt?.() ?? new Date()).toISOString();
|
||||||
|
const importVon = stand.weg === "api" ? stand.volid! : stand.nodePfad!;
|
||||||
|
const disk = `${e.storage}:0,import-from=${importVon},discard=on,ssd=1,iothread=1`;
|
||||||
|
const parameter = {
|
||||||
|
vmid,
|
||||||
|
name: templateName(d),
|
||||||
|
ostype: "l26",
|
||||||
|
machine: "q35",
|
||||||
|
bios: vm.bios,
|
||||||
|
...(vm.bios === "ovmf" ? { efidisk0: `${e.storage}:1,efitype=4m,pre-enrolled-keys=1` } : {}),
|
||||||
|
cpu: "host",
|
||||||
|
cores: 2,
|
||||||
|
memory: 2048,
|
||||||
|
scsihw: "virtio-scsi-single",
|
||||||
|
net0: `virtio,bridge=${e.bridge}${e.vlan ? `,tag=${e.vlan}` : ""}`,
|
||||||
|
serial0: "socket",
|
||||||
|
vga: "serial0",
|
||||||
|
agent: "enabled=1,fstrim_cloned_disks=1",
|
||||||
|
ide2: `${e.storage}:cloudinit`,
|
||||||
|
ipconfig0: "ip=dhcp",
|
||||||
|
ciupgrade: false,
|
||||||
|
tags: `proxdeploy-template;${d.id}`,
|
||||||
|
description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}\nGebaut: ${jetzt} (Auftrag ${e.auftragKurzId})`,
|
||||||
|
};
|
||||||
|
if (stand.weg === "api") {
|
||||||
|
await aufgabeAbwarten(
|
||||||
|
client,
|
||||||
|
await client.post<string>(pfad`/nodes/${knoten}/qemu`, {
|
||||||
|
...parameter,
|
||||||
|
scsi0: disk,
|
||||||
|
boot: "order=scsi0",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
zeitlimitMs: 30 * 60 * 1000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Ein absoluter Pfad in import-from ist per API nur root@pam erlaubt – daher qm über SSH.
|
||||||
|
await aufgabeAbwarten(client, await client.post<string>(pfad`/nodes/${knoten}/qemu`, parameter), {
|
||||||
|
zeitlimitMs: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
await k.detail("Disk wird importiert …");
|
||||||
|
await ssh(`qm set ${vmid} --scsi0 ${q(disk)} --boot order=scsi0`, k, {
|
||||||
|
zeitlimitMs: 30 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
detail: `VM ${vmid} (${templateName(d)}) · q35 · ${vm.bios === "ovmf" ? "UEFI" : "SeaBIOS"} · Disk importiert`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "agent",
|
||||||
|
titel: "Guest-Agent",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
if (vm.guest_agent_preinstalled) return { uebersprungen: true, detail: "im Image enthalten" };
|
||||||
|
const vmid = stand.vmid!;
|
||||||
|
const vmPfad = pfad`/nodes/${knoten}/qemu/${vmid}`;
|
||||||
|
const snippet = `${stand.snippetStorage}:snippets/${snippetDatei}`;
|
||||||
|
await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, {
|
||||||
|
eingabe: vorbereitungsSnippet(),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.put(`${vmPfad}/config`, { cicustom: `vendor=${snippet}` });
|
||||||
|
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/start`), {
|
||||||
|
zeitlimitMs: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
if (!client.trocken) {
|
||||||
|
const beginn = Date.now();
|
||||||
|
const grenze = e.vorbereitungMs ?? 20 * 60 * 1000;
|
||||||
|
for (;;) {
|
||||||
|
await k.pruefeAbbruch();
|
||||||
|
const status = await client.get<{ status: string }>(`${vmPfad}/status/current`);
|
||||||
|
if (status.status === "stopped") break;
|
||||||
|
if (Date.now() - beginn > grenze) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Die Vorbereitung von VM ${vmid} ist nach ${Math.round(grenze / 60000)} min nicht fertig.`,
|
||||||
|
"Hat die VM per DHCP eine Adresse und Internetzugang (für qemu-guest-agent)? Konsole der VM ansehen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await k.detail(
|
||||||
|
`Vorbereitungsstart: qemu-guest-agent wird installiert … ${Math.round((Date.now() - beginn) / 1000)}s`,
|
||||||
|
);
|
||||||
|
await warten(5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Auch nach einem Fehler: das Snippet soll nicht im Template hängen bleiben.
|
||||||
|
await client.put(`${vmPfad}/config`, { delete: "cicustom" }).catch(() => undefined);
|
||||||
|
await ssh(`rm -f "$(pvesm path ${q(snippet)})"`, k).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return { detail: "qemu-guest-agent installiert · Maschinen-ID geleert" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "template",
|
||||||
|
titel: "Template",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
await aufgabeAbwarten(
|
||||||
|
client,
|
||||||
|
await client.post<string>(pfad`/nodes/${knoten}/qemu/${stand.vmid!}/template`),
|
||||||
|
{ zeitlimitMs: 10 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
return { detail: `VM ${stand.vmid} ist jetzt Template ${templateName(d)}` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||||
|
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
|
||||||
|
import { adapterFuer } from "@/engine/adapter";
|
||||||
|
import type { Distribution } from "@/engine/katalog/schema";
|
||||||
|
import { profileRendern, MARKE } from "@/engine/profile/rendern";
|
||||||
|
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||||
|
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
|
||||||
|
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||||
|
import type { AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||||
|
import { templateBauSchritte, type TemplateBauEingabe, type TemplateBauStand } from "./template-bau";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine VM aus einem ProxDeploy-Template ausrollen – vollständig per API, ohne SSH:
|
||||||
|
* Klonen, Hardware, cloud-init (Benutzer, Schlüssel, Netz), Start, Guest-Agent, Provisionierung
|
||||||
|
* der Profile über den Guest-Agent (Skript per file-write, Ausführung per exec, Fortschritt aus der
|
||||||
|
* Logdatei im Gast).
|
||||||
|
*/
|
||||||
|
export type VmAusrollEingabe = {
|
||||||
|
client: ProxmoxClient;
|
||||||
|
anfrage: AusrollAnfrage;
|
||||||
|
distribution: Distribution;
|
||||||
|
profile: AufgeloestesProfil[];
|
||||||
|
/** Gast-VMID reservieren (nach Prüfung, was belegt ist); im Trockenlauf nur ermitteln. */
|
||||||
|
vmidReservieren: (belegt: Set<number>, wunsch?: number) => Promise<number>;
|
||||||
|
/** Für ein fehlendes Template (nur mit anfrage.templateBauen). */
|
||||||
|
templateBau?: Omit<
|
||||||
|
TemplateBauEingabe,
|
||||||
|
"client" | "distribution" | "knoten" | "storage" | "bridge" | "vlan" | "ssh"
|
||||||
|
> & {
|
||||||
|
/** SSH zum Node – erst bekannt, wenn die Prüfung den Node gewählt hat. */
|
||||||
|
sshFuer: (knoten: string) => TemplateBauEingabe["ssh"];
|
||||||
|
};
|
||||||
|
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
|
||||||
|
auftragKurzId: string;
|
||||||
|
warten?: (ms: number) => Promise<void>;
|
||||||
|
/** Zeitlimits – im Test kürzer. */
|
||||||
|
agentMs?: number;
|
||||||
|
provisionierungMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VmAusrollStand = {
|
||||||
|
knoten?: string;
|
||||||
|
template?: { vmid: number; knoten: string };
|
||||||
|
vmid?: number;
|
||||||
|
ip?: string;
|
||||||
|
agentNachMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Ressource = {
|
||||||
|
vmid?: number;
|
||||||
|
node?: string;
|
||||||
|
template?: number;
|
||||||
|
tags?: string;
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
maxmem?: number;
|
||||||
|
mem?: number;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
type StorageEintrag = {
|
||||||
|
storage: string;
|
||||||
|
content: string;
|
||||||
|
active?: number;
|
||||||
|
enabled?: number;
|
||||||
|
shared?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
|
||||||
|
|
||||||
|
/** Proxmox erwartet die Schlüssel URI-kodiert – strenger als encodeURIComponent (auch ! ' ( ) *). */
|
||||||
|
export function sshkeysKodieren(schluessel: string[]): string {
|
||||||
|
return encodeURIComponent(schluessel.join("\n")).replace(
|
||||||
|
/[!'()*]/g,
|
||||||
|
(z) => `%${z.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ipconfig(netz: AusrollAnfrage["netz"]): string {
|
||||||
|
if (netz.modus === "dhcp") return "ip=dhcp";
|
||||||
|
return `ip=${netz.ip}${netz.gateway ? `,gw=${netz.gateway}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKRIPT = "/root/proxdeploy-provisionierung.sh";
|
||||||
|
const LOG = "/var/log/proxdeploy-provisionierung.log";
|
||||||
|
/**
|
||||||
|
* file-write nimmt höchstens 60 KiB je Aufruf (API-Schema, Feld content). ProxDeploy kodiert selbst
|
||||||
|
* als base64 (encode=0): Proxmox' eigene Kodierung scheitert an Nicht-ASCII („Wide character in
|
||||||
|
* subroutine entry“, gefunden beim ersten echten Deploy). 40 KiB Rohdaten werden ~54 KiB base64.
|
||||||
|
*/
|
||||||
|
const STUECK = 40 * 1024;
|
||||||
|
|
||||||
|
type AgentAntwort<T> = { result: T };
|
||||||
|
type ExecStatus = { exited: number | boolean; exitcode?: number; "out-data"?: string; "err-data"?: string };
|
||||||
|
type Netzschnittstelle = {
|
||||||
|
name: string;
|
||||||
|
"ip-addresses"?: Array<{ "ip-address-type": string; "ip-address": string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {}): PhasenSchritt[] {
|
||||||
|
const { client, anfrage: a, distribution: d } = e;
|
||||||
|
const warten = e.warten ?? schlafen;
|
||||||
|
const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`;
|
||||||
|
// Die Agent-Endpunkte antworten unterschiedlich: exec → {pid}, exec-status und file-read direkt,
|
||||||
|
// ping und network-get-interfaces → {result}. Deshalb liefert der Helfer die Rohdaten.
|
||||||
|
const agent = async <T>(
|
||||||
|
befehl: string,
|
||||||
|
methode: "GET" | "POST" = "POST",
|
||||||
|
parameter?: Record<string, string | readonly string[]>,
|
||||||
|
) => await client.aufrufen<T>({ methode, pfad: `${vmPfad()}/agent/${befehl}`, parameter });
|
||||||
|
const templateStand: TemplateBauStand = {};
|
||||||
|
|
||||||
|
const schritte: PhasenSchritt[] = [
|
||||||
|
{
|
||||||
|
schluessel: "pruefungen",
|
||||||
|
titel: "Prüfungen",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
if (!d.vm)
|
||||||
|
throw new AblaufFehler(`${d.name} bietet kein VM-Image.`, "Eine andere Distribution wählen.");
|
||||||
|
const ressourcen = await client.get<Ressource[]>("/cluster/resources");
|
||||||
|
const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online");
|
||||||
|
const templates = ressourcen.filter(
|
||||||
|
(r) =>
|
||||||
|
r.type === "qemu" &&
|
||||||
|
r.template === 1 &&
|
||||||
|
(r.tags ?? "").split(/[;, ]/).includes("proxdeploy-template") &&
|
||||||
|
(r.tags ?? "").split(/[;, ]/).includes(d.id),
|
||||||
|
);
|
||||||
|
// Node: gewünscht, sonst der mit dem Template, sonst der mit dem meisten freien RAM.
|
||||||
|
stand.knoten =
|
||||||
|
a.knoten ??
|
||||||
|
templates.find((t) => nodes.some((n) => n.node === t.node))?.node ??
|
||||||
|
[...nodes].sort((x, y) => (y.maxmem ?? 0) - (y.mem ?? 0) - ((x.maxmem ?? 0) - (x.mem ?? 0)))[0]
|
||||||
|
?.node;
|
||||||
|
if (!stand.knoten || !nodes.some((n) => n.node === stand.knoten)) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Node ${stand.knoten ?? "?"} ist nicht online.`,
|
||||||
|
"Anderen Node wählen oder Cluster-Status prüfen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tpl = templates.find((t) => t.node === stand.knoten) ?? templates[0];
|
||||||
|
if (tpl) stand.template = { vmid: tpl.vmid!, knoten: tpl.node! };
|
||||||
|
|
||||||
|
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${stand.knoten}/storage`);
|
||||||
|
const ziel = storages.find((s) => s.storage === a.storage);
|
||||||
|
if (!ziel || ziel.active !== 1 || !ziel.content.split(",").includes("images")) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Storage ${a.storage} nimmt auf ${stand.knoten} keine VM-Disks auf.`,
|
||||||
|
"Anderen Storage wählen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tpl && tpl.node !== stand.knoten && ziel.shared !== 1) {
|
||||||
|
// Ein Voll-Klon auf einen anderen Node geht nur, wenn das Template auf geteiltem Storage liegt.
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Das Template ${tpl.vmid} liegt auf ${tpl.node}, die VM soll auf ${stand.knoten}.`,
|
||||||
|
`Node ${tpl.node} wählen oder auf ${stand.knoten} ein eigenes Template bauen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const netze = await client.get<Array<{ iface: string; type: string; bridge_vlan_aware?: number }>>(
|
||||||
|
pfad`/nodes/${stand.knoten}/network`,
|
||||||
|
);
|
||||||
|
const bridge = netze.find(
|
||||||
|
(n) => n.iface === a.netz.bridge && (n.type === "bridge" || n.type === "OVSBridge"),
|
||||||
|
);
|
||||||
|
if (!bridge)
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Bridge ${a.netz.bridge} gibt es auf ${stand.knoten} nicht.`,
|
||||||
|
"Andere Bridge wählen.",
|
||||||
|
);
|
||||||
|
if (a.netz.vlan && bridge.type === "bridge" && bridge.bridge_vlan_aware !== 1) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Bridge ${a.netz.bridge} ist nicht VLAN-fähig – VLAN ${a.netz.vlan} würde nicht ankommen.`,
|
||||||
|
"In Proxmox „VLAN aware“ an der Bridge einschalten oder ohne VLAN ausrollen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!tpl && !a.templateBauen) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Auf diesem Server gibt es kein ProxDeploy-Template für ${d.name}.`,
|
||||||
|
"Unter „Templates“ eins bauen oder beim Ausrollen „Template vorher bauen“ wählen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const belegt = new Set(
|
||||||
|
ressourcen.map((r) => r.vmid).filter((v): v is number => typeof v === "number"),
|
||||||
|
);
|
||||||
|
stand.vmid = await e.vmidReservieren(belegt, a.vmid);
|
||||||
|
await e.angelegt({ knoten: stand.knoten, typ: "VM" });
|
||||||
|
return {
|
||||||
|
detail: `VMID ${stand.vmid} frei · ${a.storage} · ${a.netz.bridge}${a.netz.vlan ? ` VLAN ${a.netz.vlan}` : ""}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "template",
|
||||||
|
titel: "Template",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
if (stand.template)
|
||||||
|
return { uebersprungen: true, detail: `${stand.template.vmid} auf ${stand.template.knoten}` };
|
||||||
|
if (!e.templateBau) throw new AblaufFehler("Template fehlt.", "Template bauen.");
|
||||||
|
// Die Phasen des Template-Baus laufen hier als Unterschritte; ihr Fortschritt erscheint als Detail.
|
||||||
|
const unter = templateBauSchritte(
|
||||||
|
{
|
||||||
|
...e.templateBau,
|
||||||
|
ssh: e.templateBau.sshFuer(stand.knoten!),
|
||||||
|
client,
|
||||||
|
distribution: d,
|
||||||
|
knoten: stand.knoten!,
|
||||||
|
storage: a.storage,
|
||||||
|
bridge: a.netz.bridge,
|
||||||
|
vlan: a.netz.vlan,
|
||||||
|
},
|
||||||
|
templateStand,
|
||||||
|
);
|
||||||
|
for (const schritt of unter) {
|
||||||
|
await k.detail(`${schritt.titel} …`);
|
||||||
|
const ergebnis = await schritt.ausfuehren({
|
||||||
|
...k,
|
||||||
|
detail: (text) => k.detail(`${schritt.titel}: ${text}`),
|
||||||
|
});
|
||||||
|
await k.log(`Template · ${schritt.titel}: ${ergebnis?.detail ?? "erledigt"}`);
|
||||||
|
}
|
||||||
|
stand.template = { vmid: templateStand.vmid!, knoten: stand.knoten! };
|
||||||
|
return { detail: `${templateStand.vmid} gebaut` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "klonen",
|
||||||
|
titel: "Klonen",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
const tpl = stand.template!;
|
||||||
|
const klonen = async (vmid: number) =>
|
||||||
|
await aufgabeAbwarten(
|
||||||
|
client,
|
||||||
|
await client.post<string>(pfad`/nodes/${tpl.knoten}/qemu/${tpl.vmid}/clone`, {
|
||||||
|
newid: vmid,
|
||||||
|
name: a.name,
|
||||||
|
full: true,
|
||||||
|
target: stand.knoten!,
|
||||||
|
storage: a.storage,
|
||||||
|
}),
|
||||||
|
{ zeitlimitMs: 30 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await klonen(stand.vmid!);
|
||||||
|
} catch (fehler) {
|
||||||
|
// Gleichzeitig außerhalb von ProxDeploy vergeben? Einmal mit neuer ID versuchen.
|
||||||
|
if (!(fehler instanceof ProxmoxFehler) || !/already exists/i.test(fehler.ursache) || a.vmid)
|
||||||
|
throw fehler;
|
||||||
|
const belegt = new Set(
|
||||||
|
(await client.get<Ressource[]>("/cluster/resources", { type: "vm" })).map((r) => r.vmid!),
|
||||||
|
);
|
||||||
|
stand.vmid = await e.vmidReservieren(belegt);
|
||||||
|
await k.log(`VMID war inzwischen belegt – neuer Versuch mit ${stand.vmid}`, "warn");
|
||||||
|
await klonen(stand.vmid);
|
||||||
|
}
|
||||||
|
await e.angelegt({ vmid: stand.vmid });
|
||||||
|
return { detail: `full clone ${tpl.vmid} → ${stand.vmid}` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "hardware",
|
||||||
|
titel: "Hardware",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
await client.put(`${vmPfad()}/config`, {
|
||||||
|
cores: a.cores,
|
||||||
|
memory: a.speicherMb,
|
||||||
|
cpu: "host",
|
||||||
|
net0: `virtio,bridge=${a.netz.bridge}${a.netz.vlan ? `,tag=${a.netz.vlan}` : ""}`,
|
||||||
|
onboot: a.startBeiBoot,
|
||||||
|
tags: ["proxdeploy", d.id, ...a.tags].join(";"),
|
||||||
|
description: `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`,
|
||||||
|
});
|
||||||
|
// Vergrößern nur, wenn größer: Proxmox kann Disks nicht verkleinern.
|
||||||
|
const konfig = client.trocken ? {} : await client.get<Record<string, string>>(`${vmPfad()}/config`);
|
||||||
|
const groesse = /size=(\d+)G/.exec(konfig["scsi0"] ?? "")?.[1];
|
||||||
|
if (client.trocken || !groesse || Number(groesse) < a.diskGb) {
|
||||||
|
await client.put(`${vmPfad()}/resize`, { disk: "scsi0", size: `${a.diskGb}G` });
|
||||||
|
}
|
||||||
|
return { detail: `${a.cores} CPU · ${a.speicherMb} MB · Disk ${a.diskGb}G` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "cloudinit",
|
||||||
|
titel: "cloud-init",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
await client.put(`${vmPfad()}/config`, {
|
||||||
|
ciuser: a.zugang.benutzer,
|
||||||
|
sshkeys: sshkeysKodieren(a.zugang.sshSchluessel),
|
||||||
|
ipconfig0: ipconfig(a.netz),
|
||||||
|
...(a.netz.dns.length > 0 ? { nameserver: a.netz.dns.join(" ") } : {}),
|
||||||
|
...(a.netz.suchdomain ? { searchdomain: a.netz.suchdomain } : {}),
|
||||||
|
// Updates macht das Profil base als sichtbare Phase, nicht stumm beim ersten Boot.
|
||||||
|
ciupgrade: false,
|
||||||
|
});
|
||||||
|
const schluessel = a.zugang.sshSchluessel.length;
|
||||||
|
return {
|
||||||
|
detail: `user ${a.zugang.benutzer} · ${schluessel} SSH-Key${schluessel === 1 ? "" : "s"} · ${a.netz.modus === "dhcp" ? "DHCP" : a.netz.ip}${
|
||||||
|
e.profile.length > 0 ? ` · Profile ${e.profile.map((p) => p.profil.id).join(", ")}` : ""
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "start",
|
||||||
|
titel: "Start",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad()}/status/start`), {
|
||||||
|
zeitlimitMs: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
return { detail: `VM ${stand.vmid} läuft` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "agent",
|
||||||
|
titel: "Guest-Agent",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
if (client.trocken) return { detail: "wartet auf Antwort (ping)" };
|
||||||
|
const beginn = Date.now();
|
||||||
|
const grenze = e.agentMs ?? 5 * 60 * 1000;
|
||||||
|
for (;;) {
|
||||||
|
await k.pruefeAbbruch();
|
||||||
|
try {
|
||||||
|
await agent("ping");
|
||||||
|
break;
|
||||||
|
} catch (fehler) {
|
||||||
|
if (Date.now() - beginn > grenze) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Der Guest-Agent antwortet nach ${Math.round(grenze / 1000)} s nicht.`,
|
||||||
|
"Bootet die VM? Konsole ansehen. Wurde das Template von ProxDeploy gebaut (Agent eingebaut)?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fehler instanceof ProxmoxFehler && fehler.art === "recht") throw fehler;
|
||||||
|
}
|
||||||
|
await k.detail(`wartet … ${Math.round((Date.now() - beginn) / 1000)}s`);
|
||||||
|
await warten(3000);
|
||||||
|
}
|
||||||
|
stand.agentNachMs = Date.now() - beginn;
|
||||||
|
// IP: bei statischer Adresse bekannt, sonst aus dem Agent (erste IPv4 außer Loopback).
|
||||||
|
if (a.netz.modus === "statisch") {
|
||||||
|
stand.ip = a.netz.ip!.split("/")[0];
|
||||||
|
} else {
|
||||||
|
for (let versuch = 0; versuch < 20 && !stand.ip; versuch += 1) {
|
||||||
|
const schnittstellen = (
|
||||||
|
await agent<AgentAntwort<Netzschnittstelle[]>>("network-get-interfaces", "GET")
|
||||||
|
).result;
|
||||||
|
stand.ip = schnittstellen
|
||||||
|
.filter((s) => s.name !== "lo")
|
||||||
|
.flatMap((s) => s["ip-addresses"] ?? [])
|
||||||
|
.find((ip) => ip["ip-address-type"] === "ipv4" && !ip["ip-address"].startsWith("169.254."))?.[
|
||||||
|
"ip-address"
|
||||||
|
];
|
||||||
|
if (!stand.ip) await warten(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
detail: `antwortet nach ${Math.round(stand.agentNachMs / 1000)}s${stand.ip ? ` · IP ${stand.ip}` : ""}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "provisionierung",
|
||||||
|
titel: "Provisionierung",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
const adapter = adapterFuer(d.family);
|
||||||
|
if (!adapter)
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Für die Familie „${d.family}“ gibt es keinen Adapter.`,
|
||||||
|
"Adapter ergänzen (docs/katalog.md).",
|
||||||
|
);
|
||||||
|
const { skript } = profileRendern(
|
||||||
|
e.profile,
|
||||||
|
adapter,
|
||||||
|
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer } },
|
||||||
|
[
|
||||||
|
adapter.benutzerAnlegen({
|
||||||
|
name: a.zugang.benutzer,
|
||||||
|
sshSchluessel: a.zugang.sshSchluessel,
|
||||||
|
sudo: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await k.log(
|
||||||
|
`Skript: ${skript.split("\n").length} Zeilen, ${Math.round(skript.length / 1024)} KiB`,
|
||||||
|
"debug",
|
||||||
|
);
|
||||||
|
if (client.trocken) {
|
||||||
|
await k.log(skript, "info");
|
||||||
|
return {
|
||||||
|
detail:
|
||||||
|
e.profile.length > 0
|
||||||
|
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")}`
|
||||||
|
: "nur Benutzer",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hochladen in Stücken (file-write ersetzt die Datei, hängt nicht an) und zusammensetzen.
|
||||||
|
const bytes = Buffer.from(skript, "utf8");
|
||||||
|
const stuecke: Buffer[] = [];
|
||||||
|
for (let i = 0; i < bytes.length; i += STUECK) stuecke.push(bytes.subarray(i, i + STUECK));
|
||||||
|
for (const [nr, inhalt] of stuecke.entries()) {
|
||||||
|
await agent("file-write", "POST", {
|
||||||
|
file: `${SKRIPT}.${nr}`,
|
||||||
|
content: inhalt.toString("base64"),
|
||||||
|
encode: "0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const zusammensetzen = stuecke.map((_, nr) => `${SKRIPT}.${nr}`).join(" ");
|
||||||
|
const { pid } = await agent<{ pid: number }>("exec", "POST", {
|
||||||
|
command: [
|
||||||
|
"/bin/bash",
|
||||||
|
"-c",
|
||||||
|
`cat ${zusammensetzen} > ${SKRIPT} && rm -f ${zusammensetzen} && chmod 700 ${SKRIPT} && ${SKRIPT} > ${LOG} 2>&1`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const beginn = Date.now();
|
||||||
|
const grenze = e.provisionierungMs ?? 60 * 60 * 1000;
|
||||||
|
let gelesen = 0;
|
||||||
|
let profil = "";
|
||||||
|
for (;;) {
|
||||||
|
await warten(3000);
|
||||||
|
const status = await agent<ExecStatus>("exec-status", "GET", { pid: String(pid) });
|
||||||
|
// Fortschritt: neue Zeilen der Logdatei im Gast.
|
||||||
|
try {
|
||||||
|
const datei = await agent<{ content: string; truncated?: boolean }>("file-read", "GET", {
|
||||||
|
file: LOG,
|
||||||
|
});
|
||||||
|
const zeilen = datei.content.split("\n");
|
||||||
|
for (const zeile of zeilen.slice(gelesen, zeilen.length - 1)) {
|
||||||
|
const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile);
|
||||||
|
if (marke) profil = marke[1]!;
|
||||||
|
await k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug");
|
||||||
|
}
|
||||||
|
gelesen = Math.max(gelesen, zeilen.length - 1);
|
||||||
|
} catch {
|
||||||
|
// Logdatei noch nicht da – beim nächsten Mal.
|
||||||
|
}
|
||||||
|
const sekunden = Math.round((Date.now() - beginn) / 1000);
|
||||||
|
if (status.exited) {
|
||||||
|
if (status.exitcode !== 0) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Provisionierung ${profil ? `im Profil ${profil} ` : ""}mit Code ${status.exitcode} abgebrochen.`,
|
||||||
|
`Detail-Log oder ${LOG} in der VM ansehen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (Date.now() - beginn > grenze) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Provisionierung läuft länger als ${Math.round(grenze / 60000)} min.`,
|
||||||
|
`${LOG} in der VM ansehen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await k.detail(
|
||||||
|
`${profil ? `Profil ${profil}` : "Vorbereitung"} läuft … ${Math.floor(sekunden / 60)}m ${String(sekunden % 60).padStart(2, "0")}s`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await agent("exec", "POST", { command: ["rm", "-f", SKRIPT] }).catch(() => undefined);
|
||||||
|
return {
|
||||||
|
detail:
|
||||||
|
e.profile.length > 0
|
||||||
|
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet`
|
||||||
|
: "Benutzer eingerichtet",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return schritte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSH-Befehl für die Ergebnis-Karte. */
|
||||||
|
export function sshBefehl(benutzer: string, ip: string | undefined): string | undefined {
|
||||||
|
return ip ? `ssh ${benutzer}@${ip}` : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { AbbruchFehler, fehlerBeschreiben, type FehlerBeschreibung } from "./fehler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Phasen-Runner. Er kennt weder Proxmox noch die Datenbank: Phasen sind Funktionen, Ereignisse
|
||||||
|
* gehen an eine Senke. Der worker hängt eine Senke an, die in die Datenbank schreibt und per
|
||||||
|
* NOTIFY meldet; Tests hängen eine an, die mitschreibt.
|
||||||
|
*
|
||||||
|
* Die Texte (Titel, Detail, Ursache, Hinweis) entstehen nur hier und in den Phasen – Web und CLI
|
||||||
|
* zeigen sie unverändert an.
|
||||||
|
*/
|
||||||
|
export type PhasenStatusWert = "OFFEN" | "LAEUFT" | "ERLEDIGT" | "FEHLER" | "UEBERSPRUNGEN";
|
||||||
|
|
||||||
|
export type LogStufe = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
|
export interface EreignisSenke {
|
||||||
|
phasenAnlegen(phasen: Array<{ schluessel: string; titel: string }>): Promise<void>;
|
||||||
|
phaseGestartet(schluessel: string, detail?: string): Promise<void>;
|
||||||
|
/** Zwischenstand, z. B. „Download 43 %“ oder „cloud-init läuft …“. */
|
||||||
|
phaseDetail(schluessel: string, detail: string): Promise<void>;
|
||||||
|
phaseBeendet(
|
||||||
|
schluessel: string,
|
||||||
|
status: Exclude<PhasenStatusWert, "OFFEN" | "LAEUFT">,
|
||||||
|
angaben: { detail?: string; fehler?: FehlerBeschreibung },
|
||||||
|
): Promise<void>;
|
||||||
|
log(stufe: LogStufe, nachricht: string, angaben?: { phase?: string; daten?: unknown }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PhasenKontext = {
|
||||||
|
/** Detailtext der laufenden Phase aktualisieren. */
|
||||||
|
detail(text: string): Promise<void>;
|
||||||
|
log(nachricht: string, stufe?: LogStufe, daten?: unknown): Promise<void>;
|
||||||
|
/** Wirft AbbruchFehler, wenn der Auftrag abgebrochen werden soll. */
|
||||||
|
pruefeAbbruch(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PhasenErgebnis = { detail?: string; uebersprungen?: boolean };
|
||||||
|
|
||||||
|
export type PhasenSchritt = {
|
||||||
|
schluessel: string;
|
||||||
|
titel: string;
|
||||||
|
ausfuehren: (kontext: PhasenKontext) => Promise<PhasenErgebnis | void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AblaufErgebnis =
|
||||||
|
{ ok: true } | { ok: false; abgebrochen: boolean; phase: string; fehler: FehlerBeschreibung };
|
||||||
|
|
||||||
|
export async function phasenAusfuehren(
|
||||||
|
schritte: PhasenSchritt[],
|
||||||
|
senke: EreignisSenke,
|
||||||
|
optionen: { abbruchGewuenscht?: () => Promise<boolean> } = {},
|
||||||
|
): Promise<AblaufErgebnis> {
|
||||||
|
await senke.phasenAnlegen(schritte.map(({ schluessel, titel }) => ({ schluessel, titel })));
|
||||||
|
|
||||||
|
for (const schritt of schritte) {
|
||||||
|
const kontext: PhasenKontext = {
|
||||||
|
detail: (text) => senke.phaseDetail(schritt.schluessel, text),
|
||||||
|
log: (nachricht, stufe = "info", daten) =>
|
||||||
|
senke.log(stufe, nachricht, { phase: schritt.schluessel, daten }),
|
||||||
|
pruefeAbbruch: async () => {
|
||||||
|
if (await optionen.abbruchGewuenscht?.()) throw new AbbruchFehler();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await kontext.pruefeAbbruch();
|
||||||
|
await senke.phaseGestartet(schritt.schluessel);
|
||||||
|
const ergebnis = (await schritt.ausfuehren(kontext)) ?? {};
|
||||||
|
await senke.phaseBeendet(schritt.schluessel, ergebnis.uebersprungen ? "UEBERSPRUNGEN" : "ERLEDIGT", {
|
||||||
|
detail: ergebnis.detail,
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
const beschreibung = fehlerBeschreiben(fehler);
|
||||||
|
await senke.phaseBeendet(schritt.schluessel, "FEHLER", { fehler: beschreibung });
|
||||||
|
await senke.log("error", `${schritt.titel}: ${beschreibung.ursache}`, {
|
||||||
|
phase: schritt.schluessel,
|
||||||
|
daten: fehler instanceof Error ? { name: fehler.name, stack: fehler.stack } : undefined,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
abgebrochen: fehler instanceof AbbruchFehler,
|
||||||
|
phase: schritt.schluessel,
|
||||||
|
fehler: beschreibung,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { AdapterFehler } from "@/engine/adapter/debian";
|
||||||
|
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||||
|
import { VorlagenFehler } from "@/engine/profile/vorlage";
|
||||||
|
import { SshFehler } from "@/engine/ssh/verbindung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Fehler, den eine Phase bewusst wirft – mit Ursache und Lösungshinweis, wie sie unter der
|
||||||
|
* Phase in Web und CLI stehen.
|
||||||
|
*/
|
||||||
|
export class AblaufFehler extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly ursache: string,
|
||||||
|
readonly hinweis: string,
|
||||||
|
) {
|
||||||
|
super(ursache);
|
||||||
|
this.name = "AblaufFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Auftrag wurde abgebrochen – kein Fehler im eigentlichen Sinn. */
|
||||||
|
export class AbbruchFehler extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Abgebrochen");
|
||||||
|
this.name = "AbbruchFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FehlerBeschreibung = { ursache: string; hinweis: string };
|
||||||
|
|
||||||
|
/** Macht aus jedem Fehler Ursache und Hinweis; Unbekanntes wird ehrlich als solches benannt. */
|
||||||
|
export function fehlerBeschreiben(fehler: unknown): FehlerBeschreibung {
|
||||||
|
if (fehler instanceof AblaufFehler || fehler instanceof ProxmoxFehler || fehler instanceof SshFehler) {
|
||||||
|
return { ursache: fehler.ursache, hinweis: fehler.hinweis };
|
||||||
|
}
|
||||||
|
if (fehler instanceof AdapterFehler || fehler instanceof VorlagenFehler) {
|
||||||
|
return { ursache: fehler.message, hinweis: "Eingaben bzw. Profil-Variablen prüfen." };
|
||||||
|
}
|
||||||
|
if (fehler instanceof AbbruchFehler) {
|
||||||
|
return { ursache: "Abgebrochen.", hinweis: "Bereits Angelegtes lässt sich über „Aufräumen“ entfernen." };
|
||||||
|
}
|
||||||
|
const text = fehler instanceof Error ? fehler.message : String(fehler);
|
||||||
|
return {
|
||||||
|
ursache: `Unerwarteter Fehler: ${text}`,
|
||||||
|
hinweis: "Detail-Log prüfen; das ist vermutlich ein Fehler in ProxDeploy.",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { base64, q } from "@/engine/shell";
|
||||||
|
import type { Benutzerangaben, FamilienAdapter, PaketQuelle, SshHaertung } from "./schnittstelle";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debian und Ubuntu: apt, deb822-Quellen, systemd. Geprüft gegen Debian 12/13 und Ubuntu
|
||||||
|
* 22.04/24.04/26.04 (Cloud-Images und Proxmox-LXC-Templates).
|
||||||
|
*/
|
||||||
|
const APT = "apt_get";
|
||||||
|
|
||||||
|
/** Ungültige Eingaben fallen beim Rendern auf, nicht erst mitten im Gast. */
|
||||||
|
export class AdapterFehler extends Error {
|
||||||
|
constructor(meldung: string) {
|
||||||
|
super(meldung);
|
||||||
|
this.name = "AdapterFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruefen(wert: string, muster: RegExp, was: string): string {
|
||||||
|
if (!muster.test(wert)) throw new AdapterFehler(`${was} „${wert}“ ist ungültig.`);
|
||||||
|
return wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZEITZONE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+){0,2}$/;
|
||||||
|
const LOCALE = /^[a-z]{2,3}_[A-Z]{2}(\.[A-Za-z0-9-]+)?(@[a-z]+)?$/;
|
||||||
|
const LAYOUT = /^[a-z]{2,8}(,[a-z]{2,8})*$/;
|
||||||
|
|
||||||
|
export const debianAdapter: FamilienAdapter = {
|
||||||
|
familie: "debian",
|
||||||
|
|
||||||
|
kopf: () => `set -euo pipefail
|
||||||
|
export DEBIAN_FRONTEND=noninteractive LC_ALL=C.UTF-8
|
||||||
|
# apt wartet selbst bis zu 10 min auf die Paketsperre – cloud-init und unattended-upgrades laufen
|
||||||
|
# beim ersten Start oft noch.
|
||||||
|
${APT}() { apt-get -y -q -o DPkg::Lock::Timeout=600 -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "$@"; }
|
||||||
|
if command -v cloud-init >/dev/null 2>&1; then cloud-init status --wait >/dev/null 2>&1 || true; fi
|
||||||
|
# Frische Images und Container haben oft keine Paketlisten – ohne dieses update findet apt nichts.
|
||||||
|
${APT} update
|
||||||
|
`,
|
||||||
|
|
||||||
|
aktualisieren: () => `${APT} update
|
||||||
|
${APT} dist-upgrade
|
||||||
|
`,
|
||||||
|
|
||||||
|
paketeInstallieren: (pakete) => `${APT} install ${pakete.map(q).join(" ")}
|
||||||
|
`,
|
||||||
|
|
||||||
|
dienstAktivieren: (name, { starten, neuStarten }) => {
|
||||||
|
// In Containern ohne systemd (selten, aber möglich) nicht scheitern.
|
||||||
|
const zeilen = [`systemctl enable ${q(name)}`];
|
||||||
|
if (neuStarten) zeilen.push(`systemctl restart ${q(name)}`);
|
||||||
|
else if (starten) zeilen.push(`systemctl start ${q(name)}`);
|
||||||
|
return zeilen.join("\n") + "\n";
|
||||||
|
},
|
||||||
|
|
||||||
|
repoHinzufuegen: (quelle: PaketQuelle) => {
|
||||||
|
const schluessel = `/etc/apt/keyrings/${quelle.name}.asc`;
|
||||||
|
const quellen = `/etc/apt/sources.list.d/${quelle.name}.sources`;
|
||||||
|
return `${APT} install ca-certificates curl
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL ${q(quelle.schluesselUrl)} -o ${q(schluessel)}
|
||||||
|
chmod a+r ${q(schluessel)}
|
||||||
|
cat > ${q(quellen)} <<PROXDEPLOY_QUELLE
|
||||||
|
Types: deb
|
||||||
|
URIs: ${quelle.uris}
|
||||||
|
Suites: ${quelle.suites}
|
||||||
|
Components: ${quelle.komponenten}
|
||||||
|
Architectures: $(dpkg --print-architecture)
|
||||||
|
Signed-By: ${schluessel}
|
||||||
|
PROXDEPLOY_QUELLE
|
||||||
|
${APT} update
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
repoPaketInstallieren: (url) => `${APT} install ca-certificates curl
|
||||||
|
pd_deb="$(mktemp --suffix=.deb)"
|
||||||
|
curl -fsSL ${q(url)} -o "$pd_deb"
|
||||||
|
${APT} install "$pd_deb"
|
||||||
|
rm -f "$pd_deb"
|
||||||
|
${APT} update
|
||||||
|
`,
|
||||||
|
|
||||||
|
dateiSchreiben: (pfad, inhalt, modus) => `install -d -m 0755 "$(dirname ${q(pfad)})"
|
||||||
|
printf '%s' ${q(base64(inhalt))} | base64 -d > ${q(pfad)}
|
||||||
|
chmod ${q(modus)} ${q(pfad)}
|
||||||
|
`,
|
||||||
|
|
||||||
|
zeitzoneSetzen: (eingabe) => {
|
||||||
|
const zeitzone = pruefen(eingabe, ZEITZONE, "Zeitzone");
|
||||||
|
return `${APT} install tzdata
|
||||||
|
test -f /usr/share/zoneinfo/${q(zeitzone)} || { echo "Unbekannte Zeitzone: "${q(zeitzone)} >&2; exit 1; }
|
||||||
|
ln -sf /usr/share/zoneinfo/${q(zeitzone)} /etc/localtime
|
||||||
|
echo ${q(zeitzone)} > /etc/timezone
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
localeSetzen: (eingabe) => {
|
||||||
|
const locale = pruefen(eingabe, LOCALE, "Locale");
|
||||||
|
const zeichensatz = locale.includes(".") ? locale.split(".")[1]! : "UTF-8";
|
||||||
|
const zeile = `${locale} ${zeichensatz}`;
|
||||||
|
// Debian erzeugt aus /etc/locale.gen; Ubuntus locale-gen nimmt die Locale zusätzlich als Argument.
|
||||||
|
return `${APT} install locales
|
||||||
|
touch /etc/locale.gen
|
||||||
|
grep -qxF ${q(zeile)} /etc/locale.gen || echo ${q(zeile)} >> /etc/locale.gen
|
||||||
|
if grep -qi ubuntu /etc/os-release; then locale-gen ${q(locale)}; else locale-gen; fi
|
||||||
|
update-locale LANG=${q(locale)}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
tastaturSetzen: (eingabe) => {
|
||||||
|
const layout = pruefen(eingabe, LAYOUT, "Tastaturbelegung");
|
||||||
|
return `echo "keyboard-configuration keyboard-configuration/layoutcode string ${layout}" | debconf-set-selections
|
||||||
|
${APT} install keyboard-configuration console-setup
|
||||||
|
sed -i 's/^XKBLAYOUT=.*/XKBLAYOUT="${layout}"/' /etc/default/keyboard
|
||||||
|
setupcon --save-only >/dev/null 2>&1 || true
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
benutzerAnlegen: (b: Benutzerangaben) => {
|
||||||
|
const name = q(b.name);
|
||||||
|
const schluessel = b.sshSchluessel.join("\n") + "\n";
|
||||||
|
const zeilen = [
|
||||||
|
`id -u ${name} >/dev/null 2>&1 || useradd -m -s /bin/bash ${name}`,
|
||||||
|
`pd_home="$(getent passwd ${name} | cut -d: -f6)"`,
|
||||||
|
`install -d -m 0700 -o ${name} -g ${name} "$pd_home/.ssh"`,
|
||||||
|
// Schlüssel ergänzen statt ersetzen: cloud-init hat sie bei VMs vielleicht schon eingetragen.
|
||||||
|
`printf '%s' ${q(base64(schluessel))} | base64 -d | while IFS= read -r pd_key; do [ -n "$pd_key" ] && { grep -qxF "$pd_key" "$pd_home/.ssh/authorized_keys" 2>/dev/null || echo "$pd_key" >> "$pd_home/.ssh/authorized_keys"; }; done`,
|
||||||
|
`touch "$pd_home/.ssh/authorized_keys" && chown ${name}:${name} "$pd_home/.ssh/authorized_keys" && chmod 0600 "$pd_home/.ssh/authorized_keys"`,
|
||||||
|
];
|
||||||
|
if (b.sudo) {
|
||||||
|
const datei = `/etc/sudoers.d/90-proxdeploy-${b.name}`;
|
||||||
|
zeilen.push(
|
||||||
|
`${APT} install sudo`,
|
||||||
|
`usermod -aG sudo ${name}`,
|
||||||
|
// Ohne Passwort, weil der Benutzer keins hat (nur SSH-Schlüssel). visudo prüft vor dem Aktivieren.
|
||||||
|
`echo ${q(`${b.name} ALL=(ALL) NOPASSWD:ALL`)} > ${q(datei)}.neu`,
|
||||||
|
`visudo -cf ${q(datei)}.neu >/dev/null && mv ${q(datei)}.neu ${q(datei)} && chmod 0440 ${q(datei)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return zeilen.join("\n") + "\n";
|
||||||
|
},
|
||||||
|
|
||||||
|
sshHaerten: (h: SshHaertung) => `install -d /etc/ssh/sshd_config.d
|
||||||
|
cat > /etc/ssh/sshd_config.d/10-proxdeploy.conf <<'PROXDEPLOY_SSH'
|
||||||
|
# Von ProxDeploy angelegt
|
||||||
|
PasswordAuthentication ${h.passwortAnmeldung ? "yes" : "no"}
|
||||||
|
KbdInteractiveAuthentication ${h.passwortAnmeldung ? "yes" : "no"}
|
||||||
|
PermitRootLogin ${h.rootAnmeldung ? "prohibit-password" : "no"}
|
||||||
|
PROXDEPLOY_SSH
|
||||||
|
if command -v sshd >/dev/null 2>&1; then
|
||||||
|
sshd -t
|
||||||
|
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
`,
|
||||||
|
|
||||||
|
gastAgentInstallieren: () => `# Im Container gibt es keinen Guest-Agent.
|
||||||
|
if ! systemd-detect-virt --container --quiet; then
|
||||||
|
${APT} install qemu-guest-agent
|
||||||
|
systemctl enable --now qemu-guest-agent 2>/dev/null || systemctl start qemu-guest-agent || true
|
||||||
|
fi
|
||||||
|
`,
|
||||||
|
|
||||||
|
automatischeUpdates: (an) => `${APT} install unattended-upgrades
|
||||||
|
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'PROXDEPLOY_UPGRADES'
|
||||||
|
APT::Periodic::Update-Package-Lists "${an ? 1 : 0}";
|
||||||
|
APT::Periodic::Unattended-Upgrade "${an ? 1 : 0}";
|
||||||
|
PROXDEPLOY_UPGRADES
|
||||||
|
`,
|
||||||
|
|
||||||
|
fakten: () => `. /etc/os-release
|
||||||
|
printf '{"id":"%s","version_id":"%s","codename":"%s"}\\n' "$ID" "$VERSION_ID" "\${VERSION_CODENAME:-}"
|
||||||
|
`,
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { debianAdapter } from "./debian";
|
||||||
|
import type { FamilienAdapter } from "./schnittstelle";
|
||||||
|
|
||||||
|
/** Alle bekannten Adapter. Eine neue Familie (rhel, alpine, suse) ist eine Zeile mehr. */
|
||||||
|
const ADAPTER: Record<string, FamilienAdapter> = {
|
||||||
|
[debianAdapter.familie]: debianAdapter,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function adapterFuer(familie: string): FamilienAdapter | null {
|
||||||
|
return ADAPTER[familie] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bekannteFamilien(): string[] {
|
||||||
|
return Object.keys(ADAPTER);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Ein Provisionierungs-Adapter übersetzt die deklarativen Schritte eines Profils in Shell für eine
|
||||||
|
* Distributionsfamilie (debian: apt/systemd, später rhel: dnf, alpine: apk/openrc …).
|
||||||
|
*
|
||||||
|
* Jede Methode liefert ein **idempotentes** Skriptfragment und führt selbst nichts aus – so lässt
|
||||||
|
* sich jeder Adapter ohne Gast testen und im Dry-Run anzeigen. Ein neuer Adapter ist eine neue
|
||||||
|
* Datei, die dieses Interface umsetzt, und ein Eintrag in `adapter/index.ts`.
|
||||||
|
*/
|
||||||
|
export type Skript = string;
|
||||||
|
|
||||||
|
export type PaketQuelle = {
|
||||||
|
name: string;
|
||||||
|
schluesselUrl: string;
|
||||||
|
uris: string;
|
||||||
|
suites: string;
|
||||||
|
komponenten: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Benutzerangaben = {
|
||||||
|
name: string;
|
||||||
|
sshSchluessel: string[];
|
||||||
|
sudo: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SshHaertung = { passwortAnmeldung: boolean; rootAnmeldung: boolean };
|
||||||
|
|
||||||
|
export interface FamilienAdapter {
|
||||||
|
readonly familie: string;
|
||||||
|
/** Vorspann: strikte Shell, nicht interaktiv, Warten auf Paketsperren. */
|
||||||
|
kopf(): Skript;
|
||||||
|
aktualisieren(): Skript;
|
||||||
|
paketeInstallieren(pakete: string[]): Skript;
|
||||||
|
dienstAktivieren(name: string, optionen: { starten: boolean; neuStarten: boolean }): Skript;
|
||||||
|
repoHinzufuegen(quelle: PaketQuelle): Skript;
|
||||||
|
/** Ein Paket von einer URL, das selbst eine Paketquelle einrichtet (z. B. zabbix-release). */
|
||||||
|
repoPaketInstallieren(url: string): Skript;
|
||||||
|
dateiSchreiben(pfad: string, inhalt: string, modus: string): Skript;
|
||||||
|
zeitzoneSetzen(zeitzone: string): Skript;
|
||||||
|
localeSetzen(locale: string): Skript;
|
||||||
|
tastaturSetzen(layout: string): Skript;
|
||||||
|
benutzerAnlegen(benutzer: Benutzerangaben): Skript;
|
||||||
|
sshHaerten(haertung: SshHaertung): Skript;
|
||||||
|
gastAgentInstallieren(): Skript;
|
||||||
|
automatischeUpdates(an: boolean): Skript;
|
||||||
|
/** Gibt OS-Fakten als eine Zeile JSON aus: {"id","version_id","codename"}. */
|
||||||
|
fakten(): Skript;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { Profil, ProfilVariable } from "@/engine/katalog/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aus der Auswahl im Assistenten wird eine geordnete Liste: Voraussetzungen zuerst (`requires`,
|
||||||
|
* transitiv), sonst die Reihenfolge der Auswahl. Dazu die Variablen mit Standardwerten und
|
||||||
|
* Typprüfung. Fehler werden gesammelt, damit das Formular alle auf einmal zeigen kann.
|
||||||
|
*/
|
||||||
|
export type AufgeloestesProfil = { profil: Profil; werte: Record<string, string | number | boolean> };
|
||||||
|
|
||||||
|
export type ProfilFehler = { profil: string; variable?: string; meldung: string };
|
||||||
|
|
||||||
|
export type Aufloesung =
|
||||||
|
{ ok: true; profile: AufgeloestesProfil[]; automatisch: string[] } | { ok: false; fehler: ProfilFehler[] };
|
||||||
|
|
||||||
|
export function profileAufloesen(
|
||||||
|
auswahl: string[],
|
||||||
|
verfuegbar: ReadonlyMap<string, Profil>,
|
||||||
|
familie: string,
|
||||||
|
eingaben: Record<string, Record<string, unknown>> = {},
|
||||||
|
): Aufloesung {
|
||||||
|
const fehler: ProfilFehler[] = [];
|
||||||
|
const reihenfolge: string[] = [];
|
||||||
|
const besucht = new Set<string>();
|
||||||
|
const inArbeit = new Set<string>();
|
||||||
|
|
||||||
|
const besuchen = (id: string, von: string | null) => {
|
||||||
|
if (besucht.has(id)) return;
|
||||||
|
if (inArbeit.has(id)) {
|
||||||
|
fehler.push({ profil: id, meldung: `Zyklische Voraussetzung über „${von}“.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const profil = verfuegbar.get(id);
|
||||||
|
if (!profil) {
|
||||||
|
fehler.push({
|
||||||
|
profil: id,
|
||||||
|
meldung: von
|
||||||
|
? `Wird von „${von}“ vorausgesetzt, ist aber nicht verfügbar.`
|
||||||
|
: "Unbekanntes oder deaktiviertes Profil.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inArbeit.add(id);
|
||||||
|
for (const voraussetzung of profil.requires) besuchen(voraussetzung, id);
|
||||||
|
inArbeit.delete(id);
|
||||||
|
besucht.add(id);
|
||||||
|
reihenfolge.push(id);
|
||||||
|
};
|
||||||
|
for (const id of auswahl) besuchen(id, null);
|
||||||
|
|
||||||
|
const profile: AufgeloestesProfil[] = [];
|
||||||
|
for (const id of reihenfolge) {
|
||||||
|
const profil = verfuegbar.get(id)!;
|
||||||
|
if (!profil.families[familie]) {
|
||||||
|
fehler.push({ profil: id, meldung: `Keine Umsetzung für die Familie „${familie}“.` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const werte: Record<string, string | number | boolean> = {};
|
||||||
|
for (const variable of profil.variables) {
|
||||||
|
const ergebnis = wertPruefen(variable, eingaben[id]?.[variable.name]);
|
||||||
|
if ("fehler" in ergebnis)
|
||||||
|
fehler.push({ profil: id, variable: variable.name, meldung: ergebnis.fehler });
|
||||||
|
else werte[variable.name] = ergebnis.wert;
|
||||||
|
}
|
||||||
|
profile.push({ profil, werte });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fehler.length > 0) return { ok: false, fehler };
|
||||||
|
return { ok: true, profile, automatisch: reihenfolge.filter((id) => !auswahl.includes(id)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function wertPruefen(
|
||||||
|
variable: ProfilVariable,
|
||||||
|
eingabe: unknown,
|
||||||
|
): { wert: string | number | boolean } | { fehler: string } {
|
||||||
|
const roh = eingabe === undefined || eingabe === null || eingabe === "" ? variable.default : eingabe;
|
||||||
|
if (roh === undefined || roh === "") {
|
||||||
|
if (variable.required) return { fehler: `${variable.label} fehlt.` };
|
||||||
|
return { wert: variable.type === "boolean" ? false : "" };
|
||||||
|
}
|
||||||
|
switch (variable.type) {
|
||||||
|
case "boolean":
|
||||||
|
if (typeof roh === "boolean") return { wert: roh };
|
||||||
|
if (roh === "true" || roh === "1") return { wert: true };
|
||||||
|
if (roh === "false" || roh === "0") return { wert: false };
|
||||||
|
return { fehler: `${variable.label}: ja oder nein erwartet.` };
|
||||||
|
case "number": {
|
||||||
|
const zahl = typeof roh === "number" ? roh : Number(roh);
|
||||||
|
return Number.isFinite(zahl) ? { wert: zahl } : { fehler: `${variable.label}: Zahl erwartet.` };
|
||||||
|
}
|
||||||
|
case "select":
|
||||||
|
return variable.options!.includes(String(roh))
|
||||||
|
? { wert: String(roh) }
|
||||||
|
: { fehler: `${variable.label}: erlaubt sind ${variable.options!.join(", ")}.` };
|
||||||
|
default: {
|
||||||
|
const text = String(roh);
|
||||||
|
// Ein Zeilenumbruch in einer Variable ist fast immer ein Kopierfehler – und in Konfigurationsdateien gefährlich.
|
||||||
|
if (/[\r\n\0]/.test(text)) return { fehler: `${variable.label}: keine Zeilenumbrüche.` };
|
||||||
|
return { wert: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { FamilienAdapter } from "@/engine/adapter/schnittstelle";
|
||||||
|
import type { ProfilSchritt } from "@/engine/katalog/schema";
|
||||||
|
import type { AufgeloestesProfil } from "./aufloesen";
|
||||||
|
import { einsetzen, tiefEinsetzen, type VorlagenKontext } from "./vorlage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut aus aufgelösten Profilen ein Provisionierungsskript.
|
||||||
|
*
|
||||||
|
* Zwischen den Profilen steht eine Markenzeile (`::proxdeploy:: profil <id>`), an der die Engine
|
||||||
|
* im Live-Log erkennt, welches Profil gerade läuft. SSH-Härtung wird ans Ende verschoben: Läuft die
|
||||||
|
* Provisionierung selbst über SSH als root (LXC ohne Node-SSH), schlösse sie sich sonst aus.
|
||||||
|
*/
|
||||||
|
export const MARKE = "::proxdeploy::";
|
||||||
|
|
||||||
|
export type RenderKontext = {
|
||||||
|
os: { id: string; version_id: string; codename?: string };
|
||||||
|
gast: { name: string; benutzer: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Abschnitt = { profil: string; skript: string };
|
||||||
|
|
||||||
|
export type Gerendert = { skript: string; abschnitte: Abschnitt[] };
|
||||||
|
|
||||||
|
function bedingungErfuellt(schritt: ProfilSchritt, werte: Record<string, unknown>): boolean {
|
||||||
|
if (!schritt.if) return true;
|
||||||
|
const verneint = schritt.if.startsWith("!");
|
||||||
|
const wert = Boolean(werte[schritt.if.replace(/^!/, "")]);
|
||||||
|
return verneint ? !wert : wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schrittRendern(schritt: ProfilSchritt, adapter: FamilienAdapter, kontext: VorlagenKontext): string {
|
||||||
|
if ("update" in schritt) return adapter.aktualisieren();
|
||||||
|
if ("packages" in schritt)
|
||||||
|
return adapter.paketeInstallieren(schritt.packages.map((p) => einsetzen(p, kontext)));
|
||||||
|
if ("service" in schritt) {
|
||||||
|
return adapter.dienstAktivieren(einsetzen(schritt.service.name, kontext), {
|
||||||
|
starten: schritt.service.start,
|
||||||
|
neuStarten: schritt.service.restart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ("repo" in schritt) {
|
||||||
|
const r = tiefEinsetzen(schritt.repo, kontext);
|
||||||
|
return adapter.repoHinzufuegen({
|
||||||
|
name: r.name,
|
||||||
|
schluesselUrl: r.key_url,
|
||||||
|
uris: r.uris,
|
||||||
|
suites: r.suites,
|
||||||
|
komponenten: r.components,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ("repo_package" in schritt)
|
||||||
|
return adapter.repoPaketInstallieren(einsetzen(schritt.repo_package, kontext));
|
||||||
|
if ("file" in schritt) {
|
||||||
|
const f = tiefEinsetzen(schritt.file, kontext);
|
||||||
|
return adapter.dateiSchreiben(f.path, f.content, f.mode);
|
||||||
|
}
|
||||||
|
if ("timezone" in schritt) return adapter.zeitzoneSetzen(einsetzen(schritt.timezone, kontext));
|
||||||
|
if ("locale" in schritt) return adapter.localeSetzen(einsetzen(schritt.locale, kontext));
|
||||||
|
if ("keyboard" in schritt) return adapter.tastaturSetzen(einsetzen(schritt.keyboard, kontext));
|
||||||
|
if ("guest_agent" in schritt) return adapter.gastAgentInstallieren();
|
||||||
|
if ("unattended_upgrades" in schritt) return adapter.automatischeUpdates(schritt.unattended_upgrades);
|
||||||
|
if ("run" in schritt) return einsetzen(schritt.run, kontext, "shell") + "\n";
|
||||||
|
if ("ssh_harden" in schritt) {
|
||||||
|
return adapter.sshHaerten({
|
||||||
|
passwortAnmeldung: schritt.ssh_harden.password_auth,
|
||||||
|
rootAnmeldung: schritt.ssh_harden.root_login,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const unbekannt: never = schritt;
|
||||||
|
throw new Error(`Unbekannter Schritt: ${JSON.stringify(unbekannt)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileRendern(
|
||||||
|
profile: AufgeloestesProfil[],
|
||||||
|
adapter: FamilienAdapter,
|
||||||
|
kontext: RenderKontext,
|
||||||
|
vorspann: string[] = [],
|
||||||
|
): Gerendert {
|
||||||
|
const abschnitte: Abschnitt[] = [];
|
||||||
|
const zumSchluss: string[] = [];
|
||||||
|
|
||||||
|
for (const { profil, werte } of profile) {
|
||||||
|
const vorlagenKontext: VorlagenKontext = {
|
||||||
|
var: werte,
|
||||||
|
os: { id: kontext.os.id, version_id: kontext.os.version_id, codename: kontext.os.codename ?? "" },
|
||||||
|
guest: { name: kontext.gast.name, user: kontext.gast.benutzer },
|
||||||
|
};
|
||||||
|
const teile: string[] = [];
|
||||||
|
for (const schritt of profil.families[adapter.familie] ?? []) {
|
||||||
|
if (!bedingungErfuellt(schritt, werte)) continue;
|
||||||
|
const text = schrittRendern(schritt, adapter, vorlagenKontext);
|
||||||
|
if ("ssh_harden" in schritt) zumSchluss.push(text);
|
||||||
|
else teile.push(text);
|
||||||
|
}
|
||||||
|
abschnitte.push({ profil: profil.id, skript: teile.join("") });
|
||||||
|
}
|
||||||
|
|
||||||
|
const skript = [
|
||||||
|
"#!/bin/bash",
|
||||||
|
"# Von ProxDeploy erzeugt",
|
||||||
|
adapter.kopf(),
|
||||||
|
...vorspann,
|
||||||
|
...abschnitte.map((a) => `echo '${MARKE} profil ${a.profil}'\n${a.skript}`),
|
||||||
|
...(zumSchluss.length > 0 ? [`echo '${MARKE} abschluss'\n${zumSchluss.join("")}`] : []),
|
||||||
|
`echo '${MARKE} fertig'\n`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return { skript, abschnitte };
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { q } from "@/engine/shell";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platzhalter in Profilen: `{{ var.name }}`, `{{ os.id }}`, `{{ guest.user }}` und die Ausweichform
|
||||||
|
* `{{ var.a ?? var.b }}` (b, wenn a leer ist). Bewusst keine Logik, keine Filter, keine Schleifen –
|
||||||
|
* ein Profil soll lesbar bleiben.
|
||||||
|
*
|
||||||
|
* Im Modus `shell` (für `run`-Schritte) wird jeder eingesetzte Wert gequotet: eine Variable aus dem
|
||||||
|
* Formular kann so nie Shell-Code werden. Alle anderen Felder gehen an den Adapter, der selbst quotet.
|
||||||
|
*/
|
||||||
|
export type VorlagenKontext = Record<
|
||||||
|
"var" | "os" | "guest",
|
||||||
|
Record<string, string | number | boolean | undefined>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export class VorlagenFehler extends Error {
|
||||||
|
constructor(meldung: string) {
|
||||||
|
super(meldung);
|
||||||
|
this.name = "VorlagenFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATZHALTER = /\{\{\s*([^}]+?)\s*\}\}/g;
|
||||||
|
const VERWEIS = /^(var|os|guest)\.([a-z][a-z0-9_]*)$/;
|
||||||
|
|
||||||
|
function aufloesen(ausdruck: string, kontext: VorlagenKontext): string {
|
||||||
|
for (const teil of ausdruck.split("??").map((t) => t.trim())) {
|
||||||
|
const treffer = VERWEIS.exec(teil);
|
||||||
|
if (!treffer) throw new VorlagenFehler(`Unbekannter Platzhalter „{{ ${ausdruck} }}“.`);
|
||||||
|
const bereich = kontext[treffer[1] as keyof VorlagenKontext];
|
||||||
|
if (!(treffer[2]! in bereich)) throw new VorlagenFehler(`„${teil}“ ist nicht definiert.`);
|
||||||
|
const wert = bereich[treffer[2]!];
|
||||||
|
if (wert !== undefined && wert !== "") return String(wert);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function einsetzen(text: string, kontext: VorlagenKontext, modus: "roh" | "shell" = "roh"): string {
|
||||||
|
return text.replace(PLATZHALTER, (_, ausdruck: string) => {
|
||||||
|
const wert = aufloesen(ausdruck, kontext);
|
||||||
|
return modus === "shell" ? q(wert) : wert;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ersetzt in allen Strings eines Objekts (rekursiv) – für Schritte wie `repo` oder `file`. */
|
||||||
|
export function tiefEinsetzen<T>(wert: T, kontext: VorlagenKontext): T {
|
||||||
|
if (typeof wert === "string") return einsetzen(wert, kontext) as T;
|
||||||
|
if (Array.isArray(wert)) return wert.map((eintrag) => tiefEinsetzen(eintrag, kontext)) as T;
|
||||||
|
if (wert && typeof wert === "object") {
|
||||||
|
return Object.fromEntries(Object.entries(wert).map(([k, v]) => [k, tiefEinsetzen(v, kontext)])) as T;
|
||||||
|
}
|
||||||
|
return wert;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { pfad, type ProxmoxClient } from "./client";
|
import { istTrockenUpid, pfad, type ProxmoxClient } from "./client";
|
||||||
import { ProxmoxFehler } from "./fehler";
|
import { ProxmoxFehler } from "./fehler";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,6 +10,7 @@ type LogZeile = { n: number; t: string };
|
|||||||
|
|
||||||
/** UPID:node:pid:pstart:starttime:type:id:user: – der Node steht an zweiter Stelle. */
|
/** UPID:node:pid:pstart:starttime:type:id:user: – der Node steht an zweiter Stelle. */
|
||||||
export function nodeAusUpid(upid: string): string {
|
export function nodeAusUpid(upid: string): string {
|
||||||
|
if (istTrockenUpid(upid)) return upid.split(":")[2] ?? "node";
|
||||||
const teile = upid.split(":");
|
const teile = upid.split(":");
|
||||||
if (teile[0] !== "UPID" || !teile[1]) {
|
if (teile[0] !== "UPID" || !teile[1]) {
|
||||||
throw new ProxmoxFehler("api", `„${upid}“ ist keine UPID.`, "Antwort von Proxmox prüfen.");
|
throw new ProxmoxFehler("api", `„${upid}“ ist keine UPID.`, "Antwort von Proxmox prüfen.");
|
||||||
@@ -32,6 +33,7 @@ export async function aufgabeAbwarten(
|
|||||||
upid: string,
|
upid: string,
|
||||||
optionen: AbwartenOptionen = {},
|
optionen: AbwartenOptionen = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (istTrockenUpid(upid)) return;
|
||||||
const node = nodeAusUpid(upid);
|
const node = nodeAusUpid(upid);
|
||||||
const intervall = optionen.intervallMs ?? 1000;
|
const intervall = optionen.intervallMs ?? 1000;
|
||||||
const zeitlimit = optionen.zeitlimitMs ?? 30 * 60 * 1000;
|
const zeitlimit = optionen.zeitlimitMs ?? 30 * 60 * 1000;
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ export type AufrufBeobachter = (
|
|||||||
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler },
|
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler },
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trockenlauf (Dry-Run): lesende Aufrufe gehen wirklich an Proxmox, schreibende werden nur
|
||||||
|
* aufgezeichnet und mit einer Platzhalter-UPID beantwortet. Dieselben Phasen laufen so im
|
||||||
|
* Trockenlauf und im Ernstfall – der angezeigte Plan kann nicht vom tatsächlichen Ablauf abweichen.
|
||||||
|
*/
|
||||||
|
export const TROCKEN_UPID_PRAEFIX = "UPID:trocken:";
|
||||||
|
|
||||||
|
export function istTrockenUpid(upid: unknown): boolean {
|
||||||
|
return typeof upid === "string" && upid.startsWith(TROCKEN_UPID_PRAEFIX);
|
||||||
|
}
|
||||||
|
|
||||||
const STANDARD_ZEITLIMIT_MS = 30_000;
|
const STANDARD_ZEITLIMIT_MS = 30_000;
|
||||||
|
|
||||||
/** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */
|
/** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */
|
||||||
@@ -60,7 +71,11 @@ export class ProxmoxClient {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly verbindung: ProxmoxVerbindung,
|
private readonly verbindung: ProxmoxVerbindung,
|
||||||
private readonly optionen: { zeitlimitMs?: number; beobachter?: AufrufBeobachter } = {},
|
private readonly optionen: {
|
||||||
|
zeitlimitMs?: number;
|
||||||
|
beobachter?: AufrufBeobachter;
|
||||||
|
trocken?: boolean;
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) {
|
if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) {
|
||||||
throw new ProxmoxFehler(
|
throw new ProxmoxFehler(
|
||||||
@@ -90,7 +105,20 @@ export class ProxmoxClient {
|
|||||||
return await this.aufrufen<T>({ methode: "DELETE", pfad, parameter });
|
return await this.aufrufen<T>({ methode: "DELETE", pfad, parameter });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Im Trockenlauf aufgezeichnete schreibende Aufrufe, in Reihenfolge. */
|
||||||
|
readonly geplant: Aufruf[] = [];
|
||||||
|
|
||||||
|
get trocken(): boolean {
|
||||||
|
return Boolean(this.optionen.trocken);
|
||||||
|
}
|
||||||
|
|
||||||
async aufrufen<T>(aufruf: Aufruf): Promise<T> {
|
async aufrufen<T>(aufruf: Aufruf): Promise<T> {
|
||||||
|
if (this.optionen.trocken && aufruf.methode !== "GET") {
|
||||||
|
this.geplant.push(aufruf);
|
||||||
|
this.optionen.beobachter?.(aufruf, { dauerMs: 0 });
|
||||||
|
const knoten = /^\/nodes\/([^/]+)/.exec(aufruf.pfad)?.[1] ?? "node";
|
||||||
|
return `${TROCKEN_UPID_PRAEFIX}${knoten}:${this.geplant.length}` as T;
|
||||||
|
}
|
||||||
const beginn = Date.now();
|
const beginn = Date.now();
|
||||||
try {
|
try {
|
||||||
const ergebnis = await this.senden<T>(aufruf);
|
const ergebnis = await this.senden<T>(aufruf);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Shell-Quoting für alles, was in ein Provisionierungsskript wandert. Einfache Anführungszeichen
|
||||||
|
* sind in POSIX-sh die einzige Form, in der nichts mehr ausgewertet wird – kein $, kein `, kein \.
|
||||||
|
*/
|
||||||
|
export function q(wert: string): string {
|
||||||
|
if (wert === "") return "''";
|
||||||
|
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(wert)) return wert;
|
||||||
|
return `'${wert.replace(/'/g, `'"'"'`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inhalt als base64, damit beliebige Bytes (auch Zeilenumbrüche, $, ') unverändert ankommen. */
|
||||||
|
export function base64(text: string): string {
|
||||||
|
return Buffer.from(text, "utf8").toString("base64");
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { Client } from "ssh2";
|
// ssh2 ist CommonJS: unter reinem Node-ESM (worker) gibt es keine benannten Exporte.
|
||||||
|
import ssh2 from "ssh2";
|
||||||
|
|
||||||
|
const { Client } = ssh2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSH zu Proxmox-Nodes oder Gästen. Der Host-Key wird wie das TLS-Zertifikat gepinnt: Ist kein
|
* SSH zu Proxmox-Nodes oder Gästen. Der Host-Key wird wie das TLS-Zertifikat gepinnt: Ist kein
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { StatusArt } from "@/components/status";
|
||||||
|
import type { AuftragSicht } from "@/lib/schemas/auftrag";
|
||||||
|
|
||||||
|
/** Beschriftungen für Aufträge – geteilt von Verlauf, Übersicht und Live-Ansicht. */
|
||||||
|
export const STATUS_ANZEIGE: Record<AuftragSicht["status"], [StatusArt, string]> = {
|
||||||
|
WARTEND: ["offen", "wartet"],
|
||||||
|
LAEUFT: ["laeuft", "läuft"],
|
||||||
|
ERFOLGREICH: ["erledigt", "erfolgreich"],
|
||||||
|
FEHLGESCHLAGEN: ["fehler", "fehlgeschlagen"],
|
||||||
|
ABGEBROCHEN: ["fehler", "abgebrochen"],
|
||||||
|
AUFGERAEUMT: ["offen", "aufgeräumt"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ART_TEXT: Record<AuftragSicht["art"], string> = {
|
||||||
|
AUSROLLEN: "Ausrollen",
|
||||||
|
TEMPLATE_BAUEN: "Template",
|
||||||
|
AUFRAEUMEN: "Aufräumen",
|
||||||
|
};
|
||||||
@@ -22,9 +22,9 @@ export type NavigationsEintrag = {
|
|||||||
|
|
||||||
export const NAVIGATION: readonly NavigationsEintrag[] = [
|
export const NAVIGATION: readonly NavigationsEintrag[] = [
|
||||||
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
|
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
|
||||||
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" },
|
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" },
|
||||||
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen", ab: "#006" },
|
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
|
||||||
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
|
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" },
|
||||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
|
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
|
||||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
|
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
|
||||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { AuftragsArt, AuftragsStatus, GastTyp, PhasenStatus } from "@/generated/prisma/enums";
|
||||||
|
|
||||||
|
/** Was die API über einen Auftrag herausgibt – geteilt von Server, Web-GUI und (als JSON) CLI. */
|
||||||
|
export type AuftragSicht = {
|
||||||
|
id: string;
|
||||||
|
kurzId: string;
|
||||||
|
art: AuftragsArt;
|
||||||
|
status: AuftragsStatus;
|
||||||
|
kopf: string;
|
||||||
|
server: { name: string; titel: string };
|
||||||
|
knoten: string | null;
|
||||||
|
gastTyp: GastTyp | null;
|
||||||
|
vmid: number | null;
|
||||||
|
name: string | null;
|
||||||
|
distributionId: string | null;
|
||||||
|
profile: string[];
|
||||||
|
ergebnis: unknown;
|
||||||
|
fehler: unknown;
|
||||||
|
erstelltVon: string;
|
||||||
|
erstelltAm: string;
|
||||||
|
gestartetAm: string | null;
|
||||||
|
beendetAm: string | null;
|
||||||
|
phasen: Array<{
|
||||||
|
schluessel: string;
|
||||||
|
titel: string;
|
||||||
|
status: PhasenStatus;
|
||||||
|
detail: string | null;
|
||||||
|
ursache: string | null;
|
||||||
|
hinweis: string | null;
|
||||||
|
gestartetAm: string | null;
|
||||||
|
beendetAm: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LogZeile = { id: string; zeit: string; stufe: string; phase: string | null; nachricht: string };
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Anfrage „Ausrollen“ – eine Form für Web-Assistent, CLI (`proxdeploy vm …`), Batch und
|
||||||
|
* Vorlagen. Sie liegt in `src/lib`, weil Formular (Client) und API (Server) dieselben Regeln
|
||||||
|
* brauchen. Felder sind deutsch; die CLI übersetzt ihre Flags darauf (`--memory` → `speicherMb`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Hostname nach RFC 1123, ein Label: so heißt der Gast in Proxmox und im Netz. */
|
||||||
|
export const GAST_NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||||
|
|
||||||
|
const Ipv4Cidr = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, "Format: 10.0.20.15/24")
|
||||||
|
.refine((wert) => {
|
||||||
|
const [ip, praefix] = wert.split("/");
|
||||||
|
return (
|
||||||
|
ip!.split(".").every((teil) => Number(teil) <= 255) && Number(praefix) >= 1 && Number(praefix) <= 32
|
||||||
|
);
|
||||||
|
}, "Ungültige Adresse oder Präfixlänge.");
|
||||||
|
|
||||||
|
const Ipv4 = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^(\d{1,3}\.){3}\d{1,3}$/, "Format: 10.0.20.1")
|
||||||
|
.refine((ip) => ip.split(".").every((teil) => Number(teil) <= 255), "Ungültige Adresse.");
|
||||||
|
|
||||||
|
export const SSH_SCHLUESSEL =
|
||||||
|
/^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com) [A-Za-z0-9+/=]+( [^\r\n]*)?$/;
|
||||||
|
|
||||||
|
export const Netz = z
|
||||||
|
.object({
|
||||||
|
bridge: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Bridge fehlt.")
|
||||||
|
.regex(/^[A-Za-z0-9_.-]+$/, "Ungültiger Bridge-Name."),
|
||||||
|
vlan: z.coerce.number().int().min(1).max(4094).optional(),
|
||||||
|
modus: z.enum(["dhcp", "statisch"]).default("dhcp"),
|
||||||
|
ip: Ipv4Cidr.optional(),
|
||||||
|
gateway: Ipv4.optional(),
|
||||||
|
dns: z.array(Ipv4).max(3).default([]),
|
||||||
|
suchdomain: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^[a-z0-9.-]*$/i, "Ungültige Suchdomain.")
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.refine((n) => n.modus === "dhcp" || Boolean(n.ip), {
|
||||||
|
message: "Bei statischer Adresse fehlt die IP.",
|
||||||
|
path: ["ip"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Zugang = z.object({
|
||||||
|
benutzer: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^[a-z_][a-z0-9_-]{0,31}$/, "Benutzername: Kleinbuchstaben, Ziffern, _ und -")
|
||||||
|
.refine((name) => name !== "root", "root ist nicht erlaubt – ProxDeploy legt einen sudo-Benutzer an."),
|
||||||
|
sshSchluessel: z
|
||||||
|
.array(z.string().trim().regex(SSH_SCHLUESSEL, "Kein gültiger öffentlicher SSH-Schlüssel."))
|
||||||
|
.min(1, "Mindestens ein SSH-Schlüssel – Passwörter vergibt ProxDeploy nicht."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AusrollAnfrage = z
|
||||||
|
.object({
|
||||||
|
typ: z.enum(["vm", "lxc"]),
|
||||||
|
server: z.string().trim().min(1, "Server fehlt."),
|
||||||
|
/** Leer = ProxDeploy wählt: Node mit dem Template, sonst der mit dem meisten freien RAM. */
|
||||||
|
knoten: z.string().trim().optional(),
|
||||||
|
distribution: z.string().trim().min(1, "Distribution fehlt."),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(GAST_NAME, "Name: Kleinbuchstaben, Ziffern und Bindestrich (Hostname)."),
|
||||||
|
/** Leer = nächste freie ID. */
|
||||||
|
vmid: z.coerce.number().int().min(100).max(999_999_999).optional(),
|
||||||
|
tags: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^[a-z0-9_+.-]+$/i, "Tags: Buchstaben, Ziffern, _ + . -"),
|
||||||
|
)
|
||||||
|
.max(10)
|
||||||
|
.default([]),
|
||||||
|
cores: z.coerce.number().int().min(1).max(128).default(2),
|
||||||
|
speicherMb: z.coerce.number().int().min(256).max(1_048_576).default(2048),
|
||||||
|
diskGb: z.coerce.number().int().min(1).max(65_536).default(20),
|
||||||
|
storage: z.string().trim().min(1, "Storage fehlt."),
|
||||||
|
/** LXC */
|
||||||
|
swapMb: z.coerce.number().int().min(0).max(1_048_576).default(512),
|
||||||
|
unprivilegiert: z.boolean().default(true),
|
||||||
|
features: z
|
||||||
|
.object({ nesting: z.boolean().default(false), keyctl: z.boolean().default(false) })
|
||||||
|
.default({ nesting: false, keyctl: false }),
|
||||||
|
netz: Netz,
|
||||||
|
zugang: Zugang,
|
||||||
|
profile: z.array(z.string()).default(["base"]),
|
||||||
|
variablen: z
|
||||||
|
.record(z.string(), z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])))
|
||||||
|
.default({}),
|
||||||
|
/** Fehlt das Template, vorher bauen (sonst bricht die Prüfung mit Hinweis ab). */
|
||||||
|
templateBauen: z.boolean().default(false),
|
||||||
|
startBeiBoot: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.refine((a) => a.typ === "lxc" || a.diskGb >= 4, {
|
||||||
|
message: "Mindestens 4 GB für eine VM.",
|
||||||
|
path: ["diskGb"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AusrollAnfrage = z.infer<typeof AusrollAnfrage>;
|
||||||
|
export type AusrollEingabe = z.input<typeof AusrollAnfrage>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der passende CLI-Befehl zu einer Anfrage – im Assistenten zum Kopieren angezeigt. Nur Flags, die
|
||||||
|
* vom Standard abweichen, damit der Befehl lesbar bleibt.
|
||||||
|
*/
|
||||||
|
export function cliBefehl(a: AusrollAnfrage): string {
|
||||||
|
const teile = [`proxdeploy ${a.typ} ${a.name}`, `--server ${a.server}`];
|
||||||
|
if (a.knoten) teile.push(`--node ${a.knoten}`);
|
||||||
|
teile.push(`--distro ${a.distribution}`);
|
||||||
|
if (a.profile.length > 0 && !(a.profile.length === 1 && a.profile[0] === "base"))
|
||||||
|
teile.push(`--profile ${a.profile.join(",")}`);
|
||||||
|
if (a.netz.modus === "statisch" && a.netz.ip) {
|
||||||
|
teile.push(`--ip ${a.netz.ip}`);
|
||||||
|
if (a.netz.gateway) teile.push(`--gw ${a.netz.gateway}`);
|
||||||
|
}
|
||||||
|
if (a.netz.dns.length > 0) teile.push(`--dns ${a.netz.dns.join(",")}`);
|
||||||
|
if (a.netz.suchdomain) teile.push(`--search-domain ${a.netz.suchdomain}`);
|
||||||
|
if (a.netz.bridge !== "vmbr0") teile.push(`--bridge ${a.netz.bridge}`);
|
||||||
|
if (a.netz.vlan) teile.push(`--vlan ${a.netz.vlan}`);
|
||||||
|
if (a.vmid) teile.push(`--vmid ${a.vmid}`);
|
||||||
|
if (a.cores !== 2) teile.push(`--cores ${a.cores}`);
|
||||||
|
if (a.speicherMb !== 2048) teile.push(`--memory ${a.speicherMb}`);
|
||||||
|
if (a.diskGb !== 20) teile.push(`--disk ${a.diskGb}G`);
|
||||||
|
teile.push(`--storage ${a.storage}`);
|
||||||
|
teile.push(`--user ${a.zugang.benutzer}`);
|
||||||
|
if (a.tags.length > 0) teile.push(`--tags ${a.tags.join(",")}`);
|
||||||
|
for (const [profil, werte] of Object.entries(a.variablen)) {
|
||||||
|
for (const [name, wert] of Object.entries(werte)) {
|
||||||
|
const text = String(wert);
|
||||||
|
teile.push(
|
||||||
|
`--var ${profil}.${name}=${/^[\w.,:/@-]+$/.test(text) ? text : `'${text.replace(/'/g, `'"'"'`)}'`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (a.typ === "lxc") {
|
||||||
|
if (a.swapMb !== 512) teile.push(`--swap ${a.swapMb}`);
|
||||||
|
if (!a.unprivilegiert) teile.push("--privileged");
|
||||||
|
}
|
||||||
|
if (a.templateBauen) teile.push("--build-template");
|
||||||
|
return teile.join(" ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
|
||||||
|
/** Anfrage „Template bauen“ – aus Web-GUI, CLI (`proxdeploy template build`) und dem Assistenten. */
|
||||||
|
export const TemplateBauAnfrage = z.object({
|
||||||
|
distribution: z.string().min(1, "Distribution fehlt."),
|
||||||
|
knoten: z.string().min(1, "Node fehlt."),
|
||||||
|
storage: z.string().min(1, "Storage fehlt."),
|
||||||
|
bridge: z.string().min(1).default("vmbr0"),
|
||||||
|
vlan: z.coerce.number().int().min(1).max(4094).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TemplateBauAnfrage = z.infer<typeof TemplateBauAnfrage>;
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
||||||
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
||||||
*/
|
*/
|
||||||
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.1.0";
|
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.2.0";
|
||||||
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
||||||
|
|
||||||
export function versionText(): string {
|
export function versionText(): string {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function fehlerAntwort(fehler: unknown): NextResponse<FehlerAntwort> {
|
|||||||
code: fehler.code,
|
code: fehler.code,
|
||||||
nachricht: fehler.message,
|
nachricht: fehler.message,
|
||||||
...(fehler.hinweis ? { hinweis: fehler.hinweis } : {}),
|
...(fehler.hinweis ? { hinweis: fehler.hinweis } : {}),
|
||||||
|
...(fehler.felder ? { felder: fehler.felder } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ status: fehler.status },
|
{ status: fehler.status },
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// pg ist CommonJS – Default-Import, damit es auch im worker (reines Node-ESM) lädt.
|
||||||
|
import pg from "pg";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { KANAL } from "./senke";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine LISTEN-Verbindung je Prozess, an der beliebig viele SSE-Ströme hängen. Bricht sie ab, wird
|
||||||
|
* sie neu aufgebaut; die Ströme holen sich beim nächsten Signal ohnehin den vollen Stand.
|
||||||
|
*/
|
||||||
|
type Hoerer = (auftragId: string) => void;
|
||||||
|
|
||||||
|
const hoerer = new Set<Hoerer>();
|
||||||
|
let verbindung: Promise<pg.Client> | undefined;
|
||||||
|
|
||||||
|
async function verbinden(): Promise<pg.Client> {
|
||||||
|
const client = new pg.Client({
|
||||||
|
connectionString: process.env["DATABASE_URL"],
|
||||||
|
application_name: "proxdeploy-listen",
|
||||||
|
});
|
||||||
|
client.on("notification", (meldung) => {
|
||||||
|
if (meldung.channel !== KANAL || !meldung.payload) return;
|
||||||
|
for (const h of hoerer) h(meldung.payload);
|
||||||
|
});
|
||||||
|
client.on("error", (fehler) => {
|
||||||
|
logger.warn({ err: fehler }, "LISTEN-Verbindung verloren, baue neu auf");
|
||||||
|
verbindung = undefined;
|
||||||
|
setTimeout(() => void sicherstellen(), 1000);
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
await client.query(`LISTEN ${KANAL}`);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sicherstellen(): Promise<pg.Client> {
|
||||||
|
verbindung ??= verbinden().catch((fehler) => {
|
||||||
|
verbindung = undefined;
|
||||||
|
throw fehler;
|
||||||
|
});
|
||||||
|
return verbindung;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function abonnieren(auftragId: string, rueckruf: () => void): Promise<() => void> {
|
||||||
|
await sicherstellen();
|
||||||
|
const h: Hoerer = (id) => {
|
||||||
|
if (id === auftragId) rueckruf();
|
||||||
|
};
|
||||||
|
hoerer.add(h);
|
||||||
|
return () => hoerer.delete(h);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { EreignisSenke, LogStufe } from "@/engine/ablauf/ablauf";
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt die Ereignisse eines Auftrags in die Datenbank und meldet sie per NOTIFY. Die app hört
|
||||||
|
* mit (LISTEN) und schiebt sie an alle offenen SSE-Verbindungen – Web und CLI sehen dasselbe.
|
||||||
|
*/
|
||||||
|
export const KANAL = "proxdeploy_auftrag";
|
||||||
|
|
||||||
|
export async function melden(auftragId: string): Promise<void> {
|
||||||
|
await prisma.$executeRaw`SELECT pg_notify(${KANAL}, ${auftragId})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DatenbankSenke implements EreignisSenke {
|
||||||
|
constructor(private readonly auftragId: string) {}
|
||||||
|
|
||||||
|
async phasenAnlegen(phasen: Array<{ schluessel: string; titel: string }>): Promise<void> {
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.phase.deleteMany({ where: { auftragId: this.auftragId } }),
|
||||||
|
prisma.phase.createMany({
|
||||||
|
data: phasen.map((p, reihenfolge) => ({ auftragId: this.auftragId, reihenfolge, ...p })),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await melden(this.auftragId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async phaseGestartet(schluessel: string, detail?: string): Promise<void> {
|
||||||
|
await prisma.phase.update({
|
||||||
|
where: { auftragId_schluessel: { auftragId: this.auftragId, schluessel } },
|
||||||
|
data: { status: "LAEUFT", gestartetAm: new Date(), ...(detail ? { detail } : {}) },
|
||||||
|
});
|
||||||
|
await melden(this.auftragId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async phaseDetail(schluessel: string, detail: string): Promise<void> {
|
||||||
|
await prisma.phase.update({
|
||||||
|
where: { auftragId_schluessel: { auftragId: this.auftragId, schluessel } },
|
||||||
|
data: { detail },
|
||||||
|
});
|
||||||
|
await melden(this.auftragId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async phaseBeendet(
|
||||||
|
schluessel: string,
|
||||||
|
status: "ERLEDIGT" | "FEHLER" | "UEBERSPRUNGEN",
|
||||||
|
angaben: { detail?: string; fehler?: { ursache: string; hinweis: string } },
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.phase.update({
|
||||||
|
where: { auftragId_schluessel: { auftragId: this.auftragId, schluessel } },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
beendetAm: new Date(),
|
||||||
|
...(angaben.detail !== undefined ? { detail: angaben.detail } : {}),
|
||||||
|
...(angaben.fehler ? { ursache: angaben.fehler.ursache, hinweis: angaben.fehler.hinweis } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await melden(this.auftragId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async log(
|
||||||
|
stufe: LogStufe,
|
||||||
|
nachricht: string,
|
||||||
|
angaben: { phase?: string; daten?: unknown } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.logeintrag.create({
|
||||||
|
data: {
|
||||||
|
auftragId: this.auftragId,
|
||||||
|
stufe,
|
||||||
|
nachricht,
|
||||||
|
phase: angaben.phase ?? null,
|
||||||
|
daten: (angaben.daten ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Ins Betriebslog (JSON) geht jede Zeile mit Auftrags-ID – auch ohne GUI nachvollziehbar.
|
||||||
|
logger[stufe === "debug" ? "debug" : stufe](
|
||||||
|
{ auftragId: this.auftragId, phase: angaben.phase },
|
||||||
|
nachricht,
|
||||||
|
);
|
||||||
|
await melden(this.auftragId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ export class AnwendungsFehler extends Error {
|
|||||||
readonly code: string,
|
readonly code: string,
|
||||||
nachricht: string,
|
nachricht: string,
|
||||||
readonly hinweis?: string,
|
readonly hinweis?: string,
|
||||||
|
/** Feldfehler für Formulare, Schlüssel wie der Feldpfad (z. B. „variablen.zabbix-agent.server“). */
|
||||||
|
readonly felder?: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
super(nachricht);
|
super(nachricht);
|
||||||
this.name = "AnwendungsFehler";
|
this.name = "AnwendungsFehler";
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { randomInt } from "node:crypto";
|
||||||
|
import type { Auftrag, Prisma } from "@/generated/prisma/client";
|
||||||
|
import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { Aufrufer } from "@/server/auth/zugang";
|
||||||
|
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
||||||
|
import { melden } from "@/server/ereignisse/senke";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { auftragEinstellen } from "@/server/warteschlange";
|
||||||
|
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
|
||||||
|
|
||||||
|
export type { AuftragSicht, LogZeile };
|
||||||
|
|
||||||
|
/** Kurz-IDs ohne Verwechsler (kein 0/o, 1/l/i): „d-7f3k2“ für Ausrollen, „t-…“ für Templates. */
|
||||||
|
const ZEICHEN = "23456789abcdefghjkmnpqrstuvwxyz";
|
||||||
|
const PRAEFIX: Record<AuftragsArt, string> = { AUSROLLEN: "d", TEMPLATE_BAUEN: "t", AUFRAEUMEN: "c" };
|
||||||
|
|
||||||
|
export function kurzIdErzeugen(art: AuftragsArt): string {
|
||||||
|
let id = "";
|
||||||
|
for (let i = 0; i < 5; i += 1) id += ZEICHEN[randomInt(ZEICHEN.length)];
|
||||||
|
return `${PRAEFIX[art]}-${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NeuerAuftrag = {
|
||||||
|
art: AuftragsArt;
|
||||||
|
serverId: string;
|
||||||
|
knoten?: string;
|
||||||
|
gastTyp?: GastTyp;
|
||||||
|
name?: string;
|
||||||
|
distributionId?: string;
|
||||||
|
profile?: string[];
|
||||||
|
anfrage: Prisma.InputJsonValue;
|
||||||
|
plan?: Prisma.InputJsonValue;
|
||||||
|
bezugId?: string;
|
||||||
|
wiederholtVonId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function auftragAnlegen(
|
||||||
|
neu: NeuerAuftrag,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
einstellen: (id: string) => Promise<void> = auftragEinstellen,
|
||||||
|
): Promise<Auftrag> {
|
||||||
|
let auftrag: Auftrag | undefined;
|
||||||
|
for (let versuch = 0; versuch < 5 && !auftrag; versuch += 1) {
|
||||||
|
try {
|
||||||
|
auftrag = await prisma.$transaction(async (tx) => {
|
||||||
|
const angelegt = await tx.auftrag.create({
|
||||||
|
data: {
|
||||||
|
...neu,
|
||||||
|
profile: neu.profile ?? [],
|
||||||
|
kurzId: kurzIdErzeugen(neu.art),
|
||||||
|
quelle: aufrufer.quelle as Quelle,
|
||||||
|
erstelltVonId: aufrufer.benutzer.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: aufrufer.benutzer.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion:
|
||||||
|
neu.art === "AUSROLLEN"
|
||||||
|
? "Ausrollen gestartet"
|
||||||
|
: neu.art === "TEMPLATE_BAUEN"
|
||||||
|
? "Template-Bau gestartet"
|
||||||
|
: "Aufräumen gestartet",
|
||||||
|
ziel: `auftrag:${angelegt.kurzId}`,
|
||||||
|
details: { name: neu.name, distribution: neu.distributionId },
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return angelegt;
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
// Kurz-ID zufällig doppelt – selten, dann einfach neu würfeln.
|
||||||
|
if ((fehler as { code?: string }).code !== "P2002") throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!auftrag) throw new AnwendungsFehler(500, "intern", "Konnte keine freie Auftrags-ID erzeugen.");
|
||||||
|
await einstellen(auftrag.id);
|
||||||
|
return auftrag;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIT = {
|
||||||
|
server: { select: { name: true, titel: true } },
|
||||||
|
erstelltVon: { select: { benutzername: true } },
|
||||||
|
phasen: { orderBy: { reihenfolge: "asc" as const } },
|
||||||
|
};
|
||||||
|
|
||||||
|
type MitBeziehungen = Prisma.AuftragGetPayload<{ include: typeof MIT }>;
|
||||||
|
|
||||||
|
export function auftragSicht(a: MitBeziehungen): AuftragSicht {
|
||||||
|
const iso = (d: Date | null) => d?.toISOString() ?? null;
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
kurzId: a.kurzId,
|
||||||
|
art: a.art,
|
||||||
|
status: a.status,
|
||||||
|
kopf:
|
||||||
|
a.kopf ?? [`Server ${a.server.name}`, a.knoten ? `Node ${a.knoten}` : null].filter(Boolean).join(" · "),
|
||||||
|
server: a.server,
|
||||||
|
knoten: a.knoten,
|
||||||
|
gastTyp: a.gastTyp,
|
||||||
|
vmid: a.vmid,
|
||||||
|
name: a.name,
|
||||||
|
distributionId: a.distributionId,
|
||||||
|
profile: a.profile,
|
||||||
|
ergebnis: a.ergebnis,
|
||||||
|
fehler: a.fehler,
|
||||||
|
erstelltVon: a.erstelltVon.benutzername,
|
||||||
|
erstelltAm: a.erstelltAm.toISOString(),
|
||||||
|
gestartetAm: iso(a.gestartetAm),
|
||||||
|
beendetAm: iso(a.beendetAm),
|
||||||
|
phasen: a.phasen.map((p) => ({
|
||||||
|
schluessel: p.schluessel,
|
||||||
|
titel: p.titel,
|
||||||
|
status: p.status,
|
||||||
|
detail: p.detail,
|
||||||
|
ursache: p.ursache,
|
||||||
|
hinweis: p.hinweis,
|
||||||
|
gestartetAm: iso(p.gestartetAm),
|
||||||
|
beendetAm: iso(p.beendetAm),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sucht per Kurz-ID oder interner ID – die CLI nimmt, was der Benutzer tippt. */
|
||||||
|
export async function auftragHolen(kennung: string): Promise<AuftragSicht> {
|
||||||
|
const auftrag = await prisma.auftrag.findFirst({
|
||||||
|
where: { OR: [{ kurzId: kennung }, { id: kennung }] },
|
||||||
|
include: MIT,
|
||||||
|
});
|
||||||
|
if (!auftrag) throw nichtGefunden(`Den Auftrag „${kennung}“`);
|
||||||
|
return auftragSicht(auftrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function auftraegeListe(
|
||||||
|
filter: { serverId?: string; grenze?: number } = {},
|
||||||
|
): Promise<AuftragSicht[]> {
|
||||||
|
const zeilen = await prisma.auftrag.findMany({
|
||||||
|
where: filter.serverId ? { serverId: filter.serverId } : {},
|
||||||
|
include: MIT,
|
||||||
|
orderBy: { erstelltAm: "desc" },
|
||||||
|
take: filter.grenze ?? 50,
|
||||||
|
});
|
||||||
|
return zeilen.map(auftragSicht);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logAb(auftragId: string, abId: bigint, grenze = 500): Promise<LogZeile[]> {
|
||||||
|
const zeilen = await prisma.logeintrag.findMany({
|
||||||
|
where: { auftragId, id: { gt: abId } },
|
||||||
|
orderBy: { id: "asc" },
|
||||||
|
take: grenze,
|
||||||
|
});
|
||||||
|
return zeilen.map((z) => ({
|
||||||
|
id: z.id.toString(),
|
||||||
|
zeit: z.zeit.toISOString(),
|
||||||
|
stufe: z.stufe,
|
||||||
|
phase: z.phase,
|
||||||
|
nachricht: z.nachricht,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ENDZUSTAENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
||||||
|
|
||||||
|
/** Bittet den worker um Abbruch; er prüft das zwischen den Phasen. */
|
||||||
|
export async function auftragAbbrechen(
|
||||||
|
kennung: string,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const auftrag = await auftragHolen(kennung);
|
||||||
|
if (ENDZUSTAENDE.has(auftrag.status)) {
|
||||||
|
throw new AnwendungsFehler(409, "beendet", "Der Auftrag ist bereits beendet.");
|
||||||
|
}
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.auftrag.update({ where: { id: auftrag.id }, data: { abbrechen: true } });
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: aufrufer.benutzer.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion: "Abbruch angefordert",
|
||||||
|
ziel: `auftrag:${auftrag.kurzId}`,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await melden(auftrag.id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import type { Server } from "@/generated/prisma/client";
|
||||||
|
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||||
|
import { vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen";
|
||||||
|
import type { Distribution, Profil } from "@/engine/katalog/schema";
|
||||||
|
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||||
|
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen";
|
||||||
|
import type { Aufrufer } from "@/server/auth/zugang";
|
||||||
|
import { AnwendungsFehler } from "@/server/fehler";
|
||||||
|
import { auftragAnlegen } from "@/server/services/auftraege";
|
||||||
|
import { serverHolen, verbindungAus } from "@/server/services/server";
|
||||||
|
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||||
|
import { vmidVorschlagen } from "@/server/services/vmid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ausrollen vorbereiten: Anfrage prüfen, Distribution und Profile auflösen. Fehler kommen als
|
||||||
|
* Feldfehler zurück, damit der Assistent sie am richtigen Feld zeigt.
|
||||||
|
*/
|
||||||
|
export type Vorbereitet = {
|
||||||
|
anfrage: AusrollAnfrage;
|
||||||
|
server: Server;
|
||||||
|
distribution: Distribution;
|
||||||
|
profile: AufgeloestesProfil[];
|
||||||
|
automatisch: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> {
|
||||||
|
const anfrage = AusrollAnfrage.parse(roh);
|
||||||
|
const server = await serverHolen(anfrage.server);
|
||||||
|
const zeile = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } });
|
||||||
|
if (!zeile || zeile.entferntAm || !zeile.aktiv) {
|
||||||
|
throw new AnwendungsFehler(
|
||||||
|
400,
|
||||||
|
"ungueltig",
|
||||||
|
`Die Distribution „${anfrage.distribution}“ ist nicht verfügbar.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const distribution = zeile.definition as Distribution;
|
||||||
|
if (!distribution[anfrage.typ]) {
|
||||||
|
throw new AnwendungsFehler(
|
||||||
|
400,
|
||||||
|
"ungueltig",
|
||||||
|
`${distribution.name} gibt es nicht als ${anfrage.typ === "vm" ? "VM" : "LXC-Container"}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (anfrage.typ === "lxc") {
|
||||||
|
throw new AnwendungsFehler(
|
||||||
|
400,
|
||||||
|
"nicht_umgesetzt",
|
||||||
|
"LXC-Container folgen mit #009.",
|
||||||
|
"Vorerst als VM ausrollen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const verfuegbar = new Map(
|
||||||
|
(await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [
|
||||||
|
p.id,
|
||||||
|
p.definition as Profil,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const aufloesung = profileAufloesen(anfrage.profile, verfuegbar, distribution.family, anfrage.variablen);
|
||||||
|
if (!aufloesung.ok) {
|
||||||
|
const felder = Object.fromEntries(
|
||||||
|
aufloesung.fehler.map((f) => [`variablen.${f.profil}${f.variable ? `.${f.variable}` : ""}`, f.meldung]),
|
||||||
|
);
|
||||||
|
throw new AnwendungsFehler(
|
||||||
|
400,
|
||||||
|
"ungueltig",
|
||||||
|
aufloesung.fehler.map((f) => f.meldung).join(" "),
|
||||||
|
undefined,
|
||||||
|
felder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { anfrage, server, distribution, profile: aufloesung.profile, automatisch: aufloesung.automatisch };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: string | null) {
|
||||||
|
const v = await ausrollenVorbereiten(roh);
|
||||||
|
return await auftragAnlegen(
|
||||||
|
{
|
||||||
|
art: "AUSROLLEN",
|
||||||
|
serverId: v.server.id,
|
||||||
|
knoten: v.anfrage.knoten,
|
||||||
|
gastTyp: v.anfrage.typ === "vm" ? "VM" : "LXC",
|
||||||
|
name: v.anfrage.name,
|
||||||
|
distributionId: v.distribution.id,
|
||||||
|
profile: v.profile.map((p) => p.profil.id),
|
||||||
|
anfrage: v.anfrage,
|
||||||
|
},
|
||||||
|
aufrufer,
|
||||||
|
ip,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Plan = {
|
||||||
|
kopf: string;
|
||||||
|
phasen: Array<{
|
||||||
|
schluessel: string;
|
||||||
|
titel: string;
|
||||||
|
status: string;
|
||||||
|
detail: string | null;
|
||||||
|
ursache: string | null;
|
||||||
|
hinweis: string | null;
|
||||||
|
}>;
|
||||||
|
aufrufe: Aufruf[];
|
||||||
|
skript: string | null;
|
||||||
|
cliBefehl: string;
|
||||||
|
automatisch: string[];
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dry-Run: dieselben Phasen wie im Ernstfall, mit einem Proxmox-Client im Trockenmodus. Lesendes
|
||||||
|
* geht wirklich an Proxmox (VMID frei? Template da? Bridge VLAN-fähig?), Schreibendes wird nur
|
||||||
|
* aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde.
|
||||||
|
*/
|
||||||
|
export async function ausrollenPlanen(roh: unknown): Promise<Plan> {
|
||||||
|
const v = await ausrollenVorbereiten(roh);
|
||||||
|
const client = new ProxmoxClient(verbindungAus(v.server), { trocken: true, zeitlimitMs: 15_000 });
|
||||||
|
const phasen: Plan["phasen"] = [];
|
||||||
|
let skript: string | null = null;
|
||||||
|
const finde = (s: string) => phasen.find((p) => p.schluessel === s)!;
|
||||||
|
const senke: EreignisSenke = {
|
||||||
|
phasenAnlegen: async (liste) => {
|
||||||
|
for (const p of liste)
|
||||||
|
phasen.push({ ...p, status: "OFFEN", detail: null, ursache: null, hinweis: null });
|
||||||
|
},
|
||||||
|
phaseGestartet: async (s) => void (finde(s).status = "LAEUFT"),
|
||||||
|
phaseDetail: async (s, d) => void (finde(s).detail = d),
|
||||||
|
phaseBeendet: async (s, status, a) => {
|
||||||
|
Object.assign(finde(s), {
|
||||||
|
status,
|
||||||
|
...(a.detail !== undefined ? { detail: a.detail } : {}),
|
||||||
|
...(a.fehler ?? {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
log: async (_stufe, nachricht, angaben) => {
|
||||||
|
if (angaben?.phase === "provisionierung" && nachricht.startsWith("#!/bin/bash")) skript = nachricht;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const stand: VmAusrollStand = {};
|
||||||
|
try {
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
vmAusrollSchritte(
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
anfrage: v.anfrage,
|
||||||
|
distribution: v.distribution,
|
||||||
|
profile: v.profile,
|
||||||
|
vmidReservieren: (belegt, wunsch) =>
|
||||||
|
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.gastVmidVon, wunsch }),
|
||||||
|
templateBau: v.anfrage.templateBauen
|
||||||
|
? {
|
||||||
|
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten),
|
||||||
|
auftragKurzId: "dry-run",
|
||||||
|
vmidReservieren: (belegt) =>
|
||||||
|
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
angelegt: async () => {},
|
||||||
|
auftragKurzId: "dry-run",
|
||||||
|
},
|
||||||
|
stand,
|
||||||
|
),
|
||||||
|
senke,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kopf: kopfzeile(v, stand),
|
||||||
|
phasen,
|
||||||
|
aufrufe: client.geplant,
|
||||||
|
skript,
|
||||||
|
cliBefehl: cliBefehl(v.anfrage),
|
||||||
|
automatisch: v.automatisch,
|
||||||
|
ok: ergebnis.ok,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await client.schliessen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kopfzeile(v: Pick<Vorbereitet, "server" | "distribution">, stand: VmAusrollStand): string {
|
||||||
|
return [
|
||||||
|
`Server ${v.server.name}`,
|
||||||
|
stand.knoten ? `Node ${stand.knoten}` : null,
|
||||||
|
`${v.distribution.name}${stand.template ? ` (Template ${stand.template.vmid})` : ""}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { utils as sshUtils } from "ssh2";
|
import ssh2 from "ssh2";
|
||||||
import type { Server } from "@/generated/prisma/client";
|
import type { Server } from "@/generated/prisma/client";
|
||||||
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
||||||
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||||
@@ -170,7 +170,7 @@ export async function sshSchluesselErzeugen(
|
|||||||
ip: string | null,
|
ip: string | null,
|
||||||
): Promise<ServerSicht> {
|
): Promise<ServerSicht> {
|
||||||
const server = await serverHolen(name);
|
const server = await serverHolen(name);
|
||||||
const paar = sshUtils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
|
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
|
||||||
const neu = await prisma.$transaction(async (tx) => {
|
const neu = await prisma.$transaction(async (tx) => {
|
||||||
const aktualisiert = await tx.server.update({
|
const aktualisiert = await tx.server.update({
|
||||||
where: { id: server.id },
|
where: { id: server.id },
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Server } from "@/generated/prisma/client";
|
||||||
|
import type { NodeBefehl } from "@/engine/ablaeufe/template-bau";
|
||||||
|
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||||
|
import { sshAusfuehren } from "@/engine/ssh/verbindung";
|
||||||
|
import { entschluesseln } from "@/lib/geheimnisse";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSH zu einem Node – nur, wenn alles bestätigt ist: SSH eingeschaltet, Schlüssel vorhanden,
|
||||||
|
* Host-Key des Nodes gepinnt. Sonst null, und die Engine weicht aus oder sagt, was fehlt.
|
||||||
|
*/
|
||||||
|
export function nodeBefehlFuer(server: Server, knoten: string): NodeBefehl | null {
|
||||||
|
if (!server.sshAktiv || !server.sshPrivat) return null;
|
||||||
|
const pin = (server.sshHostSchluessel as Record<string, string> | null)?.[knoten];
|
||||||
|
if (!pin) return null;
|
||||||
|
const bericht = server.letzterTest as VerbindungsBericht | null;
|
||||||
|
const eintrag = bericht?.knoten.find((k) => k.name === knoten);
|
||||||
|
const host =
|
||||||
|
eintrag?.ssh?.host ??
|
||||||
|
eintrag?.ip ??
|
||||||
|
(bericht?.knoten.length === 1 ? new URL(server.url).hostname : knoten);
|
||||||
|
const privat = entschluesseln(server.sshPrivat);
|
||||||
|
return async (befehl, optionen = {}) =>
|
||||||
|
await sshAusfuehren(
|
||||||
|
{
|
||||||
|
host,
|
||||||
|
port: server.sshPort,
|
||||||
|
benutzer: server.sshBenutzer,
|
||||||
|
privatSchluessel: privat,
|
||||||
|
hostSchluessel: pin,
|
||||||
|
},
|
||||||
|
befehl,
|
||||||
|
{ eingabe: optionen.eingabe, zeitlimitMs: optionen.zeitlimitMs },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Distribution } from "@/engine/katalog/schema";
|
||||||
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { TemplateBauAnfrage } from "@/lib/schemas/template";
|
||||||
|
import type { Aufrufer } from "@/server/auth/zugang";
|
||||||
|
import { AnwendungsFehler } from "@/server/fehler";
|
||||||
|
import { auftragAnlegen } from "@/server/services/auftraege";
|
||||||
|
import { serverHolen, verbindungAus } from "@/server/services/server";
|
||||||
|
|
||||||
|
export async function templateBauStarten(
|
||||||
|
serverName: string,
|
||||||
|
roh: unknown,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
) {
|
||||||
|
const server = await serverHolen(serverName);
|
||||||
|
const anfrage = TemplateBauAnfrage.parse(roh);
|
||||||
|
const distribution = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } });
|
||||||
|
if (!distribution || distribution.entferntAm || !distribution.aktiv) {
|
||||||
|
throw new AnwendungsFehler(
|
||||||
|
400,
|
||||||
|
"ungueltig",
|
||||||
|
`Die Distribution „${anfrage.distribution}“ ist nicht verfügbar.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(distribution.definition as Distribution).vm) {
|
||||||
|
throw new AnwendungsFehler(400, "ungueltig", `${distribution.name} bietet kein VM-Image.`);
|
||||||
|
}
|
||||||
|
return await auftragAnlegen(
|
||||||
|
{
|
||||||
|
art: "TEMPLATE_BAUEN",
|
||||||
|
serverId: server.id,
|
||||||
|
knoten: anfrage.knoten,
|
||||||
|
gastTyp: "VM",
|
||||||
|
distributionId: distribution.id,
|
||||||
|
name: `Template ${distribution.name}`,
|
||||||
|
anfrage,
|
||||||
|
},
|
||||||
|
aufrufer,
|
||||||
|
ip,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TemplateSicht = {
|
||||||
|
vmid: number;
|
||||||
|
name: string;
|
||||||
|
knoten: string;
|
||||||
|
distributionId: string | null;
|
||||||
|
gebautAm: string | null;
|
||||||
|
imageDatei: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Templates eines Servers – Proxmox ist maßgeblich (Tag `proxdeploy-template`), die Datenbank
|
||||||
|
* ergänzt, woraus gebaut wurde. Ein in Proxmox gelöschtes Template verschwindet so auch hier.
|
||||||
|
*/
|
||||||
|
export async function templatesListe(serverName: string): Promise<TemplateSicht[]> {
|
||||||
|
const server = await serverHolen(serverName);
|
||||||
|
const client = new ProxmoxClient(verbindungAus(server), { zeitlimitMs: 10_000 });
|
||||||
|
try {
|
||||||
|
const ressourcen = await client.get<
|
||||||
|
Array<{ vmid: number; name?: string; node: string; template?: number; tags?: string }>
|
||||||
|
>("/cluster/resources", { type: "vm" });
|
||||||
|
const bekannt = new Map(
|
||||||
|
(await prisma.template.findMany({ where: { serverId: server.id } })).map((t) => [t.vmid, t]),
|
||||||
|
);
|
||||||
|
return ressourcen
|
||||||
|
.filter((r) => r.template === 1 && (r.tags ?? "").split(/[;, ]/).includes("proxdeploy-template"))
|
||||||
|
.map((r) => {
|
||||||
|
const db = bekannt.get(r.vmid);
|
||||||
|
const tags = (r.tags ?? "").split(/[;, ]/);
|
||||||
|
return {
|
||||||
|
vmid: r.vmid,
|
||||||
|
name: r.name ?? `VM ${r.vmid}`,
|
||||||
|
knoten: r.node,
|
||||||
|
distributionId: db?.distributionId ?? tags.find((t) => t !== "proxdeploy-template") ?? null,
|
||||||
|
gebautAm: db?.gebautAm.toISOString() ?? null,
|
||||||
|
imageDatei: db?.imageDatei ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.vmid - b.vmid);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof ProxmoxFehler)
|
||||||
|
throw new AnwendungsFehler(502, "proxmox", fehler.ursache, fehler.hinweis);
|
||||||
|
throw fehler;
|
||||||
|
} finally {
|
||||||
|
await client.schliessen();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergibt VMIDs ohne Doppelungen zwischen parallelen Aufträgen: unter einer Advisory-Sperre je
|
||||||
|
* Server wird die kleinste freie ID ≥ `von` gesucht, die weder in Proxmox belegt noch von einem
|
||||||
|
* anderen laufenden Auftrag reserviert ist, und sofort reserviert. Die Sperre fällt mit der
|
||||||
|
* Transaktion; die Reservierung bleibt bis zum Auftragsende (Sicherheitsnetz: Ablauf nach 4 h).
|
||||||
|
*
|
||||||
|
* Gegen Gäste, die gleichzeitig außerhalb von ProxDeploy entstehen, hilft das nicht – dort
|
||||||
|
* scheitert `clone`/`create` mit „already exists“, und die Phase wiederholt einmal mit neuer ID.
|
||||||
|
*/
|
||||||
|
const RESERVIERUNG_STUNDEN = 4;
|
||||||
|
|
||||||
|
export async function vmidReservieren(eingabe: {
|
||||||
|
serverId: string;
|
||||||
|
auftragId: string;
|
||||||
|
belegt: ReadonlySet<number>;
|
||||||
|
von: number;
|
||||||
|
bis?: number;
|
||||||
|
/** Eine bestimmte ID statt der nächsten freien (vom Benutzer gewählt). */
|
||||||
|
wunsch?: number;
|
||||||
|
}): Promise<number> {
|
||||||
|
const bis = eingabe.bis ?? 999_999_999;
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('proxdeploy:vmid'), hashtext(${eingabe.serverId}))`;
|
||||||
|
await tx.vmidReservierung.deleteMany({ where: { ablaufAm: { lt: new Date() } } });
|
||||||
|
const reserviert = new Set(
|
||||||
|
(
|
||||||
|
await tx.vmidReservierung.findMany({ where: { serverId: eingabe.serverId }, select: { vmid: true } })
|
||||||
|
).map((r) => r.vmid),
|
||||||
|
);
|
||||||
|
const frei = (id: number) => !eingabe.belegt.has(id) && !reserviert.has(id);
|
||||||
|
|
||||||
|
let vmid: number | null = null;
|
||||||
|
if (eingabe.wunsch !== undefined) {
|
||||||
|
if (!frei(eingabe.wunsch)) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`VMID ${eingabe.wunsch} ist bereits belegt oder reserviert.`,
|
||||||
|
"Eine andere ID wählen oder das Feld leer lassen – dann nimmt ProxDeploy die nächste freie.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
vmid = eingabe.wunsch;
|
||||||
|
} else {
|
||||||
|
for (let id = eingabe.von; id <= bis; id += 1) {
|
||||||
|
if (frei(id)) {
|
||||||
|
vmid = id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vmid === null) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Im Bereich ${eingabe.von}–${bis} ist keine VMID frei.`,
|
||||||
|
"Bereich in den Server-Einstellungen erweitern.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tx.vmidReservierung.create({
|
||||||
|
data: {
|
||||||
|
serverId: eingabe.serverId,
|
||||||
|
vmid,
|
||||||
|
auftragId: eingabe.auftragId,
|
||||||
|
ablaufAm: new Date(Date.now() + RESERVIERUNG_STUNDEN * 3600 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return vmid;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reservierungenFreigeben(auftragId: string): Promise<void> {
|
||||||
|
await prisma.vmidReservierung.deleteMany({ where: { auftragId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Für den Trockenlauf: die nächste freie ID nennen, ohne sie zu reservieren. */
|
||||||
|
export async function vmidVorschlagen(eingabe: {
|
||||||
|
serverId: string;
|
||||||
|
belegt: ReadonlySet<number>;
|
||||||
|
von: number;
|
||||||
|
wunsch?: number;
|
||||||
|
}): Promise<number> {
|
||||||
|
const reserviert = new Set(
|
||||||
|
(
|
||||||
|
await prisma.vmidReservierung.findMany({
|
||||||
|
where: { serverId: eingabe.serverId, ablaufAm: { gt: new Date() } },
|
||||||
|
select: { vmid: true },
|
||||||
|
})
|
||||||
|
).map((r) => r.vmid),
|
||||||
|
);
|
||||||
|
if (eingabe.wunsch !== undefined) {
|
||||||
|
if (eingabe.belegt.has(eingabe.wunsch) || reserviert.has(eingabe.wunsch)) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`VMID ${eingabe.wunsch} ist bereits belegt oder reserviert.`,
|
||||||
|
"Eine andere ID wählen oder das Feld leer lassen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return eingabe.wunsch;
|
||||||
|
}
|
||||||
|
let id = eingabe.von;
|
||||||
|
while (eingabe.belegt.has(id) || reserviert.has(id)) id += 1;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { PgBoss } from "pg-boss";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Auftragswarteschlange (pg-boss auf derselben PostgreSQL, Schema `pgboss`). Die app stellt
|
||||||
|
* nur ein, der worker arbeitet ab. Ein Auftrag wird nie automatisch wiederholt: halb angelegte
|
||||||
|
* Gäste soll ein Mensch ansehen, nicht eine Schleife.
|
||||||
|
*/
|
||||||
|
export const WARTESCHLANGE = "auftrag";
|
||||||
|
|
||||||
|
/** Template-Bau kann bei langsamem Download dauern; danach gilt ein Job als verwaist. */
|
||||||
|
const LAUFZEIT_SEKUNDEN = 4 * 3600;
|
||||||
|
|
||||||
|
export type AuftragsJob = { auftragId: string };
|
||||||
|
|
||||||
|
let instanz: Promise<PgBoss> | undefined;
|
||||||
|
|
||||||
|
export function boss(rolle: "app" | "worker"): Promise<PgBoss> {
|
||||||
|
instanz ??= (async () => {
|
||||||
|
const url = process.env["DATABASE_URL"];
|
||||||
|
if (!url) throw new Error("DATABASE_URL ist nicht gesetzt.");
|
||||||
|
const b = new PgBoss({
|
||||||
|
connectionString: url,
|
||||||
|
application_name: `proxdeploy-${rolle}`,
|
||||||
|
// Wartung und Zeitpläne macht nur der worker; die app stellt nur ein.
|
||||||
|
supervise: rolle === "worker",
|
||||||
|
schedule: false,
|
||||||
|
});
|
||||||
|
b.on("error", (fehler) => logger.error({ err: fehler }, "pg-boss"));
|
||||||
|
await b.start();
|
||||||
|
await b.createQueue(WARTESCHLANGE, { retryLimit: 0, expireInSeconds: LAUFZEIT_SEKUNDEN });
|
||||||
|
return b;
|
||||||
|
})();
|
||||||
|
return instanz;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function auftragEinstellen(auftragId: string): Promise<void> {
|
||||||
|
const b = await boss("app");
|
||||||
|
await b.send(WARTESCHLANGE, { auftragId } satisfies AuftragsJob, {
|
||||||
|
retryLimit: 0,
|
||||||
|
expireInSeconds: LAUFZEIT_SEKUNDEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** Meldet alle Abläufe beim worker an. Aufräumen folgt. */
|
||||||
|
import "./ablaeufe/ausrollen";
|
||||||
|
import "./ablaeufe/template";
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
|
||||||
|
import { sshBefehl, vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen";
|
||||||
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { melden } from "@/server/ereignisse/senke";
|
||||||
|
import { ausrollenVorbereiten, kopfzeile } from "@/server/services/ausrollen";
|
||||||
|
import { verbindungAus } from "@/server/services/server";
|
||||||
|
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||||
|
import { vmidReservieren } from "@/server/services/vmid";
|
||||||
|
import { ablaufRegistrieren } from "../ausfuehren";
|
||||||
|
import { aufrufLogger } from "../aufruf-log";
|
||||||
|
|
||||||
|
ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, angelegt }) => {
|
||||||
|
// Dieselbe Vorbereitung wie beim Anlegen – der Katalog könnte sich seitdem geändert haben.
|
||||||
|
const v = await ausrollenVorbereiten(auftrag.anfrage);
|
||||||
|
const stand: VmAusrollStand = {};
|
||||||
|
const kopfSetzen = async () => {
|
||||||
|
await prisma.auftrag.update({
|
||||||
|
where: { id: auftrag.id },
|
||||||
|
data: { kopf: kopfzeile(v, stand), knoten: stand.knoten ?? null },
|
||||||
|
});
|
||||||
|
await melden(auftrag.id);
|
||||||
|
};
|
||||||
|
await kopfSetzen();
|
||||||
|
|
||||||
|
const client = new ProxmoxClient(verbindungAus(v.server), { beobachter: aufrufLogger(senke) });
|
||||||
|
try {
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
vmAusrollSchritte(
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
anfrage: v.anfrage,
|
||||||
|
distribution: v.distribution,
|
||||||
|
profile: v.profile,
|
||||||
|
vmidReservieren: async (belegt, wunsch) => {
|
||||||
|
const vmid = await vmidReservieren({
|
||||||
|
serverId: v.server.id,
|
||||||
|
auftragId: auftrag.id,
|
||||||
|
belegt,
|
||||||
|
von: v.server.gastVmidVon,
|
||||||
|
wunsch,
|
||||||
|
});
|
||||||
|
await prisma.auftrag.update({ where: { id: auftrag.id }, data: { vmid } });
|
||||||
|
await kopfSetzen();
|
||||||
|
return vmid;
|
||||||
|
},
|
||||||
|
templateBau: v.anfrage.templateBauen
|
||||||
|
? {
|
||||||
|
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten),
|
||||||
|
auftragKurzId: auftrag.kurzId,
|
||||||
|
vmidReservieren: (belegt) =>
|
||||||
|
vmidReservieren({
|
||||||
|
serverId: v.server.id,
|
||||||
|
auftragId: auftrag.id,
|
||||||
|
belegt,
|
||||||
|
von: v.server.templateVmidVon,
|
||||||
|
bis: v.server.templateVmidBis,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
angelegt,
|
||||||
|
auftragKurzId: auftrag.kurzId,
|
||||||
|
},
|
||||||
|
stand,
|
||||||
|
),
|
||||||
|
senke,
|
||||||
|
{ abbruchGewuenscht },
|
||||||
|
);
|
||||||
|
await kopfSetzen();
|
||||||
|
if (!ergebnis.ok) return ergebnis;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
ergebnis: {
|
||||||
|
vmid: stand.vmid,
|
||||||
|
knoten: stand.knoten,
|
||||||
|
ip: stand.ip,
|
||||||
|
ssh: sshBefehl(v.anfrage.zugang.benutzer, stand.ip),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await client.schliessen();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
|
||||||
|
import { templateBauSchritte, templateName, type TemplateBauStand } from "@/engine/ablaeufe/template-bau";
|
||||||
|
import type { Distribution } from "@/engine/katalog/schema";
|
||||||
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { TemplateBauAnfrage } from "@/lib/schemas/template";
|
||||||
|
import { melden } from "@/server/ereignisse/senke";
|
||||||
|
import { verbindungAus } from "@/server/services/server";
|
||||||
|
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||||
|
import { vmidReservieren } from "@/server/services/vmid";
|
||||||
|
import { ablaufRegistrieren } from "../ausfuehren";
|
||||||
|
import { aufrufLogger } from "../aufruf-log";
|
||||||
|
|
||||||
|
ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht, angelegt }) => {
|
||||||
|
const anfrage = TemplateBauAnfrage.parse(auftrag.anfrage);
|
||||||
|
const server = await prisma.server.findUniqueOrThrow({ where: { id: auftrag.serverId } });
|
||||||
|
const zeile = await prisma.distribution.findUniqueOrThrow({ where: { id: anfrage.distribution } });
|
||||||
|
const distribution = zeile.definition as Distribution;
|
||||||
|
|
||||||
|
await prisma.auftrag.update({
|
||||||
|
where: { id: auftrag.id },
|
||||||
|
data: { kopf: `Server ${server.name} · Node ${anfrage.knoten} · Template ${distribution.name}` },
|
||||||
|
});
|
||||||
|
await melden(auftrag.id);
|
||||||
|
|
||||||
|
const client = new ProxmoxClient(verbindungAus(server), { beobachter: aufrufLogger(senke) });
|
||||||
|
const stand: TemplateBauStand = {};
|
||||||
|
try {
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
templateBauSchritte(
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
distribution,
|
||||||
|
knoten: anfrage.knoten,
|
||||||
|
storage: anfrage.storage,
|
||||||
|
bridge: anfrage.bridge,
|
||||||
|
vlan: anfrage.vlan,
|
||||||
|
ssh: nodeBefehlFuer(server, anfrage.knoten),
|
||||||
|
auftragKurzId: auftrag.kurzId,
|
||||||
|
vmidReservieren: async (belegt) => {
|
||||||
|
const vmid = await vmidReservieren({
|
||||||
|
serverId: server.id,
|
||||||
|
auftragId: auftrag.id,
|
||||||
|
belegt,
|
||||||
|
von: server.templateVmidVon,
|
||||||
|
bis: server.templateVmidBis,
|
||||||
|
});
|
||||||
|
await prisma.auftrag.update({ where: { id: auftrag.id }, data: { vmid } });
|
||||||
|
await angelegt({ vmid, knoten: anfrage.knoten, typ: "VM" });
|
||||||
|
return vmid;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stand,
|
||||||
|
),
|
||||||
|
senke,
|
||||||
|
{ abbruchGewuenscht },
|
||||||
|
);
|
||||||
|
if (!ergebnis.ok) return ergebnis;
|
||||||
|
await prisma.template.create({
|
||||||
|
data: {
|
||||||
|
serverId: server.id,
|
||||||
|
knoten: anfrage.knoten,
|
||||||
|
vmid: stand.vmid!,
|
||||||
|
distributionId: distribution.id,
|
||||||
|
storage: anfrage.storage,
|
||||||
|
imageDatei: stand.imageDatei!,
|
||||||
|
pruefsumme: stand.pruefsumme!,
|
||||||
|
gebautVonAuftragId: auftrag.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
ergebnis: { vmid: stand.vmid, template: templateName(distribution), knoten: anfrage.knoten },
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await client.schliessen();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||||
|
import type { AufrufBeobachter } from "@/engine/proxmox/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt Proxmox-Aufrufe ins Detail-Log. Die Abfrageschleifen (Task-Status, Task-Log,
|
||||||
|
* VM-Status) erscheinen nur bei Fehlern – sonst stünden bei jedem Download Hunderte gleicher
|
||||||
|
* Zeilen da, und das Wichtige ginge unter.
|
||||||
|
*/
|
||||||
|
const SCHLEIFE = /\/tasks\/[^/]+\/(status|log)$|\/status\/current$/;
|
||||||
|
|
||||||
|
export function aufrufLogger(senke: EreignisSenke): AufrufBeobachter {
|
||||||
|
return (aufruf, ergebnis) => {
|
||||||
|
const pfad = decodeURIComponent(aufruf.pfad);
|
||||||
|
if (!ergebnis.fehler && aufruf.methode === "GET" && SCHLEIFE.test(pfad)) return;
|
||||||
|
void senke.log(
|
||||||
|
ergebnis.fehler ? "warn" : "debug",
|
||||||
|
`${aufruf.methode} ${pfad} · ${ergebnis.dauerMs} ms${ergebnis.fehler ? ` · ${ergebnis.fehler.ursache}` : ""}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Auftrag, Prisma } from "@/generated/prisma/client";
|
||||||
|
import type { AuftragsArt } from "@/generated/prisma/enums";
|
||||||
|
import type { AblaufErgebnis, EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { DatenbankSenke, melden } from "@/server/ereignisse/senke";
|
||||||
|
import { reservierungenFreigeben } from "@/server/services/vmid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt einen Auftrag aus: Status setzen, den passenden Ablauf starten, Ergebnis festhalten.
|
||||||
|
* Die Abläufe selbst (Template bauen, VM/LXC ausrollen, aufräumen) melden sich hier an.
|
||||||
|
*/
|
||||||
|
export type AblaufKontext = {
|
||||||
|
auftrag: Auftrag;
|
||||||
|
senke: EreignisSenke;
|
||||||
|
abbruchGewuenscht: () => Promise<boolean>;
|
||||||
|
/** Merkt sich, was angelegt wurde – fürs Aufräumen, auch wenn später etwas scheitert. */
|
||||||
|
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AblaufAusgang = AblaufErgebnis & { ergebnis?: Record<string, unknown> };
|
||||||
|
|
||||||
|
export type AblaufFunktion = (kontext: AblaufKontext) => Promise<AblaufAusgang>;
|
||||||
|
|
||||||
|
const ABLAEUFE = new Map<AuftragsArt, AblaufFunktion>();
|
||||||
|
|
||||||
|
export function ablaufRegistrieren(art: AuftragsArt, funktion: AblaufFunktion): void {
|
||||||
|
ABLAEUFE.set(art, funktion);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function auftragAusfuehren(
|
||||||
|
auftragId: string,
|
||||||
|
senke: EreignisSenke = new DatenbankSenke(auftragId),
|
||||||
|
) {
|
||||||
|
// Nur einmal starten, auch wenn pg-boss einen Job doppelt liefern sollte.
|
||||||
|
const gestartet = await prisma.auftrag.updateMany({
|
||||||
|
where: { id: auftragId, status: "WARTEND" },
|
||||||
|
data: { status: "LAEUFT", gestartetAm: new Date() },
|
||||||
|
});
|
||||||
|
if (gestartet.count === 0) {
|
||||||
|
logger.warn({ auftragId }, "Auftrag ist nicht (mehr) wartend – übersprungen");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auftrag = await prisma.auftrag.findUniqueOrThrow({ where: { id: auftragId } });
|
||||||
|
await melden(auftragId);
|
||||||
|
|
||||||
|
let ausgang: AblaufAusgang;
|
||||||
|
const ablauf = ABLAEUFE.get(auftrag.art);
|
||||||
|
try {
|
||||||
|
if (!ablauf) throw new Error(`Für ${auftrag.art} ist kein Ablauf registriert.`);
|
||||||
|
ausgang = await ablauf({
|
||||||
|
auftrag,
|
||||||
|
senke,
|
||||||
|
abbruchGewuenscht: async () =>
|
||||||
|
(await prisma.auftrag.findUnique({ where: { id: auftragId }, select: { abbrechen: true } }))
|
||||||
|
?.abbrechen ?? false,
|
||||||
|
angelegt: async (eintrag) => {
|
||||||
|
const aktuell = (
|
||||||
|
await prisma.auftrag.findUnique({ where: { id: auftragId }, select: { angelegt: true } })
|
||||||
|
)?.angelegt as Record<string, unknown> | null;
|
||||||
|
await prisma.auftrag.update({
|
||||||
|
where: { id: auftragId },
|
||||||
|
data: { angelegt: { ...(aktuell ?? {}), ...eintrag } as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
// Fehler außerhalb einer Phase (Programmfehler) – trotzdem sauber beenden.
|
||||||
|
logger.error({ err: fehler, auftragId }, "Ablauf ist abgestürzt");
|
||||||
|
const text = fehler instanceof Error ? fehler.message : String(fehler);
|
||||||
|
ausgang = {
|
||||||
|
ok: false,
|
||||||
|
abgebrochen: false,
|
||||||
|
phase: "",
|
||||||
|
fehler: { ursache: `Unerwarteter Fehler: ${text}`, hinweis: "Detail-Log prüfen." },
|
||||||
|
};
|
||||||
|
await senke.log("error", `Unerwarteter Fehler: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.auftrag.update({
|
||||||
|
where: { id: auftragId },
|
||||||
|
data: {
|
||||||
|
status: ausgang.ok ? "ERFOLGREICH" : ausgang.abgebrochen ? "ABGEBROCHEN" : "FEHLGESCHLAGEN",
|
||||||
|
beendetAm: new Date(),
|
||||||
|
...(ausgang.ok
|
||||||
|
? { ergebnis: (ausgang.ergebnis ?? {}) as Prisma.InputJsonValue }
|
||||||
|
: { fehler: { phase: ausgang.phase, ...ausgang.fehler } }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await reservierungenFreigeben(auftragId);
|
||||||
|
await melden(auftragId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach einem Absturz oder Neustart des workers hängen Aufträge auf LAEUFT. Sie werden ehrlich als
|
||||||
|
* fehlgeschlagen markiert – fortsetzen wäre riskanter als neu starten.
|
||||||
|
*/
|
||||||
|
export async function verwaisteAuftraegeBeenden(): Promise<number> {
|
||||||
|
const verwaist = await prisma.auftrag.findMany({ where: { status: "LAEUFT" }, select: { id: true } });
|
||||||
|
for (const { id } of verwaist) {
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.phase.updateMany({
|
||||||
|
where: { auftragId: id, status: "LAEUFT" },
|
||||||
|
data: {
|
||||||
|
status: "FEHLER",
|
||||||
|
beendetAm: new Date(),
|
||||||
|
ursache: "Der worker wurde neu gestartet.",
|
||||||
|
hinweis: "Auftrag wiederholen; Reste ggf. aufräumen.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.auftrag.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: "FEHLGESCHLAGEN",
|
||||||
|
beendetAm: new Date(),
|
||||||
|
fehler: {
|
||||||
|
ursache: "Der worker wurde während des Auftrags neu gestartet.",
|
||||||
|
hinweis: "Auftrag wiederholen; Reste ggf. aufräumen.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await reservierungenFreigeben(id);
|
||||||
|
await melden(id);
|
||||||
|
}
|
||||||
|
return verwaist.length;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Der worker: arbeitet die Auftragswarteschlange ab. Derselbe Codestand wie die app, eigener
|
||||||
|
* Prozess – ein langer Template-Bau blockiert so nie eine Web-Anfrage.
|
||||||
|
*/
|
||||||
|
import "./umgebung";
|
||||||
|
import { schluesselPruefen } from "@/lib/geheimnisse";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { katalogSynchronisieren } from "@/server/services/katalog";
|
||||||
|
import { boss, WARTESCHLANGE, type AuftragsJob } from "@/server/warteschlange";
|
||||||
|
import { auftragAusfuehren, verwaisteAuftraegeBeenden } from "./ausfuehren";
|
||||||
|
import "./ablaeufe";
|
||||||
|
|
||||||
|
async function starten(): Promise<void> {
|
||||||
|
schluesselPruefen();
|
||||||
|
const sync = await katalogSynchronisieren();
|
||||||
|
logger.info({ distributionen: sync.distributionen, profile: sync.profile }, "Katalog abgeglichen");
|
||||||
|
const verwaist = await verwaisteAuftraegeBeenden();
|
||||||
|
if (verwaist > 0) logger.warn({ anzahl: verwaist }, "Verwaiste Aufträge als fehlgeschlagen markiert");
|
||||||
|
|
||||||
|
const parallel = Math.max(1, Number(process.env["WORKER_PARALLEL"] ?? 4));
|
||||||
|
const b = await boss("worker");
|
||||||
|
await b.work<AuftragsJob>(WARTESCHLANGE, { batchSize: 1, localConcurrency: parallel }, async ([job]) => {
|
||||||
|
if (job) await auftragAusfuehren(job.data.auftragId);
|
||||||
|
});
|
||||||
|
logger.info({ parallel }, "worker bereit");
|
||||||
|
|
||||||
|
const beenden = async (signal: string) => {
|
||||||
|
logger.info({ signal }, "worker fährt herunter");
|
||||||
|
await b.stop({ graceful: true, timeout: 30_000 });
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on("SIGTERM", () => void beenden("SIGTERM"));
|
||||||
|
process.on("SIGINT", () => void beenden("SIGINT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
starten().catch((fehler) => {
|
||||||
|
logger.fatal({ err: fehler }, "worker konnte nicht starten");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Muss das erste Modul des workers sein: der Logger liest LOG_LEVEL und den Dienstnamen beim
|
||||||
|
* Import. Next lädt `.env` von selbst, der worker nicht – im Container kommt die Umgebung von
|
||||||
|
* außen, dann gibt es keine Datei und nichts passiert.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
process.loadEnvFile();
|
||||||
|
} catch {
|
||||||
|
// keine .env – Umgebung kommt von außen
|
||||||
|
}
|
||||||
|
process.env["PROXDEPLOY_DIENST"] = "worker";
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { GET as ereignisseGet } from "@/app/api/v1/auftraege/[id]/ereignisse/route";
|
||||||
|
import { GET as auftragGet } from "@/app/api/v1/auftraege/[id]/route";
|
||||||
|
import { POST as abbrechenPost } from "@/app/api/v1/auftraege/[id]/abbrechen/route";
|
||||||
|
import { verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
|
||||||
|
import { auftragAnlegen } from "@/server/services/auftraege";
|
||||||
|
import { vmidReservieren } from "@/server/services/vmid";
|
||||||
|
import { ablaufRegistrieren, auftragAusfuehren, verwaisteAuftraegeBeenden } from "@/worker/ausfuehren";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
||||||
|
|
||||||
|
async function serverAnlegen() {
|
||||||
|
return await testPrisma.server.create({
|
||||||
|
data: {
|
||||||
|
name: "rz1",
|
||||||
|
titel: "RZ 1",
|
||||||
|
url: "https://127.0.0.1:8006",
|
||||||
|
tokenId: "a@pve!b",
|
||||||
|
tokenGeheimnis: verschluesseln("x"),
|
||||||
|
fingerabdruck: "AA",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest einen SSE-Strom, bis `ende` kommt, und gibt die Ereignisse zurück. */
|
||||||
|
async function stromLesen(
|
||||||
|
antwort: Response,
|
||||||
|
nebenbei?: () => Promise<void>,
|
||||||
|
): Promise<Array<{ art: string; daten: unknown; id?: string }>> {
|
||||||
|
const leser = antwort.body!.getReader();
|
||||||
|
const dekodierer = new TextDecoder();
|
||||||
|
let puffer = "";
|
||||||
|
const ereignisse: Array<{ art: string; daten: unknown; id?: string }> = [];
|
||||||
|
const parallel = nebenbei?.();
|
||||||
|
for (;;) {
|
||||||
|
const { value, done } = await leser.read();
|
||||||
|
if (done) break;
|
||||||
|
puffer += dekodierer.decode(value, { stream: true });
|
||||||
|
let grenze: number;
|
||||||
|
while ((grenze = puffer.indexOf("\n\n")) >= 0) {
|
||||||
|
const block = puffer.slice(0, grenze);
|
||||||
|
puffer = puffer.slice(grenze + 2);
|
||||||
|
const art = /^event: (.+)$/m.exec(block)?.[1];
|
||||||
|
const daten = /^data: (.+)$/m.exec(block)?.[1];
|
||||||
|
const id = /^id: (.+)$/m.exec(block)?.[1];
|
||||||
|
if (art && daten) ereignisse.push({ art, daten: JSON.parse(daten), id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await parallel;
|
||||||
|
return ereignisse;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Aufträge, worker und Live-Strom", () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("VMID-Vergabe: zehn parallele Reservierungen bekommen zehn verschiedene IDs, Belegtes wird übersprungen", async () => {
|
||||||
|
const server = await serverAnlegen();
|
||||||
|
const ids = await Promise.all(
|
||||||
|
Array.from({ length: 10 }, (_, i) =>
|
||||||
|
vmidReservieren({
|
||||||
|
serverId: server.id,
|
||||||
|
auftragId: `a${i}`,
|
||||||
|
belegt: new Set([100, 101, 103]),
|
||||||
|
von: 100,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(new Set(ids).size).toBe(10);
|
||||||
|
expect(ids).not.toContain(100);
|
||||||
|
expect(ids).not.toContain(103);
|
||||||
|
expect(Math.min(...ids)).toBe(102);
|
||||||
|
await expect(
|
||||||
|
vmidReservieren({ serverId: server.id, auftragId: "x", belegt: new Set(), von: 100, wunsch: 102 }),
|
||||||
|
).rejects.toThrow(/bereits belegt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("worker führt einen Auftrag aus; SSE liefert Stand, Log und Ende; Reservierungen werden frei", async () => {
|
||||||
|
const server = await serverAnlegen();
|
||||||
|
const { benutzer, cookie } = await angemeldet("OPERATOR");
|
||||||
|
ablaufRegistrieren("AUFRAEUMEN", async ({ auftrag, senke }) => {
|
||||||
|
await vmidReservieren({
|
||||||
|
serverId: auftrag.serverId,
|
||||||
|
auftragId: auftrag.id,
|
||||||
|
belegt: new Set(),
|
||||||
|
von: 100,
|
||||||
|
});
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
schluessel: "eins",
|
||||||
|
titel: "Eins",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
await k.log("Zeile 1");
|
||||||
|
return { detail: "fertig" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "zwei",
|
||||||
|
titel: "Zwei",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
await k.log("Zeile 2");
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
senke,
|
||||||
|
);
|
||||||
|
return { ...ergebnis, ergebnis: { ip: "10.0.0.5" } };
|
||||||
|
});
|
||||||
|
const auftrag = await auftragAnlegen(
|
||||||
|
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
|
||||||
|
{ benutzer, quelle: "WEB" },
|
||||||
|
null,
|
||||||
|
async () => {},
|
||||||
|
);
|
||||||
|
expect(auftrag.kurzId).toMatch(/^c-[2-9a-z]{5}$/);
|
||||||
|
|
||||||
|
const strom = await ereignisseGet(
|
||||||
|
anfrage("GET", `/api/v1/auftraege/${auftrag.kurzId}/ereignisse`, cookie),
|
||||||
|
kontext({ id: auftrag.kurzId }),
|
||||||
|
);
|
||||||
|
expect(strom.headers.get("content-type")).toContain("text/event-stream");
|
||||||
|
const ereignisse = await stromLesen(strom, () => auftragAusfuehren(auftrag.id));
|
||||||
|
|
||||||
|
const staende = ereignisse
|
||||||
|
.filter((e) => e.art === "stand")
|
||||||
|
.map((e) => (e.daten as { status: string }).status);
|
||||||
|
// Je nach Takt sieht der erste Stand den Auftrag wartend oder schon laufend – nie fertig.
|
||||||
|
expect(["WARTEND", "LAEUFT"]).toContain(staende[0]);
|
||||||
|
expect(staende.at(-1)).toBe("ERFOLGREICH");
|
||||||
|
const logs = ereignisse
|
||||||
|
.filter((e) => e.art === "log")
|
||||||
|
.flatMap((e) => (e.daten as Array<{ nachricht: string }>).map((z) => z.nachricht));
|
||||||
|
expect(logs).toEqual(["Zeile 1", "Zeile 2"]);
|
||||||
|
expect(ereignisse.at(-1)?.art).toBe("ende");
|
||||||
|
|
||||||
|
const fertig = await (
|
||||||
|
await auftragGet(
|
||||||
|
anfrage("GET", `/api/v1/auftraege/${auftrag.kurzId}`, cookie),
|
||||||
|
kontext({ id: auftrag.kurzId }),
|
||||||
|
)
|
||||||
|
).json();
|
||||||
|
expect(
|
||||||
|
fertig.phasen.map((p: { schluessel: string; status: string }) => `${p.schluessel}:${p.status}`),
|
||||||
|
).toEqual(["eins:ERLEDIGT", "zwei:ERLEDIGT"]);
|
||||||
|
expect(fertig.ergebnis).toEqual({ ip: "10.0.0.5" });
|
||||||
|
expect(await testPrisma.vmidReservierung.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Wiederverbinden mit Last-Event-ID liefert nur neue Log-Zeilen", async () => {
|
||||||
|
const server = await serverAnlegen();
|
||||||
|
const { benutzer, cookie } = await angemeldet("OPERATOR");
|
||||||
|
ablaufRegistrieren("AUFRAEUMEN", async ({ senke }) =>
|
||||||
|
phasenAusfuehren(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
schluessel: "a",
|
||||||
|
titel: "A",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
await k.log("alt");
|
||||||
|
await k.log("neu");
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
senke,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const auftrag = await auftragAnlegen(
|
||||||
|
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
|
||||||
|
{ benutzer, quelle: "WEB" },
|
||||||
|
null,
|
||||||
|
async () => {},
|
||||||
|
);
|
||||||
|
await auftragAusfuehren(auftrag.id);
|
||||||
|
const erste = await testPrisma.logeintrag.findFirstOrThrow({
|
||||||
|
where: { auftragId: auftrag.id, nachricht: "alt" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const strom = await ereignisseGet(
|
||||||
|
new Request(`http://localhost/api/v1/auftraege/${auftrag.kurzId}/ereignisse`, {
|
||||||
|
headers: { cookie, "last-event-id": erste.id.toString() },
|
||||||
|
}),
|
||||||
|
kontext({ id: auftrag.kurzId }),
|
||||||
|
);
|
||||||
|
const ereignisse = await stromLesen(strom);
|
||||||
|
const logs = ereignisse
|
||||||
|
.filter((e) => e.art === "log")
|
||||||
|
.flatMap((e) => (e.daten as Array<{ nachricht: string }>).map((z) => z.nachricht));
|
||||||
|
expect(logs).toEqual(["neu"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Abbruch: Operator fordert an, der Ablauf endet vor der nächsten Phase als ABGEBROCHEN", async () => {
|
||||||
|
const server = await serverAnlegen();
|
||||||
|
const { benutzer, cookie } = await angemeldet("OPERATOR");
|
||||||
|
let auftragKurz = "";
|
||||||
|
ablaufRegistrieren("AUFRAEUMEN", async ({ senke, abbruchGewuenscht }) =>
|
||||||
|
phasenAusfuehren(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
schluessel: "a",
|
||||||
|
titel: "A",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
await abbrechenPost(
|
||||||
|
anfrage("POST", `/api/v1/auftraege/${auftragKurz}/abbrechen`, cookie),
|
||||||
|
kontext({ id: auftragKurz }),
|
||||||
|
);
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ schluessel: "b", titel: "B", ausfuehren: async () => ({}) },
|
||||||
|
],
|
||||||
|
senke,
|
||||||
|
{ abbruchGewuenscht },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const auftrag = await auftragAnlegen(
|
||||||
|
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
|
||||||
|
{ benutzer, quelle: "WEB" },
|
||||||
|
null,
|
||||||
|
async () => {},
|
||||||
|
);
|
||||||
|
auftragKurz = auftrag.kurzId;
|
||||||
|
await auftragAusfuehren(auftrag.id);
|
||||||
|
const fertig = await testPrisma.auftrag.findUniqueOrThrow({
|
||||||
|
where: { id: auftrag.id },
|
||||||
|
include: { phasen: { orderBy: { reihenfolge: "asc" } } },
|
||||||
|
});
|
||||||
|
expect(fertig.status).toBe("ABGEBROCHEN");
|
||||||
|
expect(fertig.phasen.map((p) => p.status)).toEqual(["ERLEDIGT", "FEHLER"]);
|
||||||
|
expect(await testPrisma.protokoll.count({ where: { aktion: "Abbruch angefordert" } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ein doppelt gelieferter Job läuft nur einmal; verwaiste Aufträge werden beim Start beendet", async () => {
|
||||||
|
const server = await serverAnlegen();
|
||||||
|
const { benutzer } = await angemeldet("OPERATOR");
|
||||||
|
let laeufe = 0;
|
||||||
|
ablaufRegistrieren("AUFRAEUMEN", async () => {
|
||||||
|
laeufe += 1;
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
const auftrag = await auftragAnlegen(
|
||||||
|
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
|
||||||
|
{ benutzer, quelle: "WEB" },
|
||||||
|
null,
|
||||||
|
async () => {},
|
||||||
|
);
|
||||||
|
await Promise.all([auftragAusfuehren(auftrag.id), auftragAusfuehren(auftrag.id)]);
|
||||||
|
expect(laeufe).toBe(1);
|
||||||
|
|
||||||
|
const haengt = await auftragAnlegen(
|
||||||
|
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
|
||||||
|
{ benutzer, quelle: "WEB" },
|
||||||
|
null,
|
||||||
|
async () => {},
|
||||||
|
);
|
||||||
|
await testPrisma.auftrag.update({ where: { id: haengt.id }, data: { status: "LAEUFT" } });
|
||||||
|
expect(await verwaisteAuftraegeBeenden()).toBe(1);
|
||||||
|
expect((await testPrisma.auftrag.findUniqueOrThrow({ where: { id: haengt.id } })).status).toBe(
|
||||||
|
"FEHLGESCHLAGEN",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { POST as ausrollenPost } from "@/app/api/v1/auftraege/route";
|
||||||
|
import { verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { cliBefehl, AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||||
|
import { katalogSynchronisieren } from "@/server/services/katalog";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
import { angemeldet, anfrage } from "./helpers/anfragen";
|
||||||
|
|
||||||
|
const SCHLUESSEL =
|
||||||
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@test";
|
||||||
|
const gueltig = {
|
||||||
|
typ: "vm",
|
||||||
|
server: "rz1",
|
||||||
|
distribution: "debian-13",
|
||||||
|
name: "web01",
|
||||||
|
storage: "local-zfs",
|
||||||
|
netz: { bridge: "vmbr0" },
|
||||||
|
zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] },
|
||||||
|
profile: ["docker"],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CLI-Befehl aus der Anfrage", () => {
|
||||||
|
it("nennt nur Abweichungen vom Standard und entspricht der Vorgabe", () => {
|
||||||
|
const a = AusrollAnfrage.parse({
|
||||||
|
...gueltig,
|
||||||
|
profile: ["base", "docker"],
|
||||||
|
netz: { bridge: "vmbr0", vlan: 20, modus: "statisch", ip: "10.0.20.15/24", gateway: "10.0.20.1" },
|
||||||
|
cores: 2,
|
||||||
|
speicherMb: 4096,
|
||||||
|
diskGb: 40,
|
||||||
|
});
|
||||||
|
expect(cliBefehl(a)).toBe(
|
||||||
|
"proxdeploy vm web01 --server rz1 --distro debian-13 --profile base,docker --ip 10.0.20.15/24 --gw 10.0.20.1 --vlan 20 --memory 4096 --disk 40G --storage local-zfs --user florian",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quotet Variablenwerte mit Leerzeichen", () => {
|
||||||
|
const a = AusrollAnfrage.parse({ ...gueltig, variablen: { "zabbix-agent": { server: "a b" } } });
|
||||||
|
expect(cliBefehl(a)).toContain("--var zabbix-agent.server='a b'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("POST /api/v1/auftraege", () => {
|
||||||
|
let cookie: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
await katalogSynchronisieren();
|
||||||
|
cookie = (await angemeldet("OPERATOR")).cookie;
|
||||||
|
await testPrisma.server.create({
|
||||||
|
data: {
|
||||||
|
name: "rz1",
|
||||||
|
titel: "RZ 1",
|
||||||
|
url: "https://127.0.0.1:8006",
|
||||||
|
tokenId: "a@pve!b",
|
||||||
|
tokenGeheimnis: verschluesseln("x"),
|
||||||
|
fingerabdruck: "AA",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const senden = async (rumpf: unknown) => {
|
||||||
|
const antwort = await ausrollenPost(anfrage("POST", "/api/v1/auftraege", cookie, rumpf));
|
||||||
|
return { status: antwort.status, inhalt: await antwort.json() };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("meldet Feldfehler einzeln: Name, Schlüssel, root", async () => {
|
||||||
|
const { status, inhalt } = await senden({
|
||||||
|
...gueltig,
|
||||||
|
name: "Web_01",
|
||||||
|
zugang: { benutzer: "root", sshSchluessel: ["kein schlüssel"] },
|
||||||
|
});
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(Object.keys(inhalt.fehler.felder)).toEqual(
|
||||||
|
expect.arrayContaining(["name", "zugang.benutzer", "zugang.sshSchluessel.0"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fehlende Profil-Variable landet am richtigen Feld", async () => {
|
||||||
|
const { status, inhalt } = await senden({ ...gueltig, profile: ["zabbix-agent"] });
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(inhalt.fehler.felder).toEqual({ "variablen.zabbix-agent.server": "Zabbix-Server fehlt." });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deaktivierte Distribution und unbekannter Server werden abgewiesen", async () => {
|
||||||
|
await testPrisma.distribution.update({ where: { id: "debian-13" }, data: { aktiv: false } });
|
||||||
|
expect((await senden(gueltig)).status).toBe(400);
|
||||||
|
expect((await senden({ ...gueltig, server: "gibtsnicht" })).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Läuft im Container: systemctl-Stub, Skript ausführen, Ergebnis prüfen.
|
||||||
|
printf '#!/bin/sh\necho "[systemctl] $*" >> /tmp/systemctl.log\n' > /usr/local/bin/systemctl && chmod +x /usr/local/bin/systemctl
|
||||||
|
bash "$1" > /tmp/lauf.log 2>&1; code=$?
|
||||||
|
grep '::proxdeploy::' /tmp/lauf.log
|
||||||
|
echo "exit=$code"
|
||||||
|
[ $code -ne 0 ] && tail -25 /tmp/lauf.log
|
||||||
|
echo "--- Prüfung"
|
||||||
|
docker --version; docker compose version
|
||||||
|
zabbix_agent2 -V | head -1; cat /etc/zabbix/zabbix_agent2.d/proxdeploy.conf
|
||||||
|
cat /etc/timezone; readlink /etc/localtime; grep LANG /etc/default/locale; locale -a | grep -i de_de
|
||||||
|
grep XKBLAYOUT /etc/default/keyboard
|
||||||
|
id florian; cat /etc/sudoers.d/90-proxdeploy-florian; cat ~florian/.ssh/authorized_keys
|
||||||
|
cat /etc/apt/apt.conf.d/20auto-upgrades; dpkg -l fail2ban chrony qemu-guest-agent | grep ^ii | awk '{print $2}'
|
||||||
|
cat /tmp/systemctl.log | sort -u | tr '\n' ' '; echo
|
||||||
|
echo "--- Zweiter Lauf (Idempotenz)"; bash "$1" > /tmp/lauf2.log 2>&1; echo "exit2=$?"; grep -c florian ~florian/.ssh/authorized_keys
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Führt die Profile base, docker und zabbix-agent wirklich aus: je ein frischer Container pro
|
||||||
|
# Distribution, danach Prüfung des Ergebnisses und ein zweiter Lauf (Idempotenz).
|
||||||
|
# Dauert einige Minuten und braucht Docker und Netz – deshalb nicht im Commit-Tor.
|
||||||
|
# pnpm test:container # alle
|
||||||
|
# pnpm test:container debian-13 # eine
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||||
|
declare -A IMAGES=(
|
||||||
|
[debian-12]=debian:bookworm-slim [debian-13]=debian:trixie-slim
|
||||||
|
[ubuntu-22.04]=ubuntu:22.04 [ubuntu-24.04]=ubuntu:24.04 [ubuntu-26.04]=ubuntu:26.04
|
||||||
|
)
|
||||||
|
ziele=("${@:-debian-12 debian-13 ubuntu-22.04 ubuntu-24.04 ubuntu-26.04}")
|
||||||
|
arbeit="$(mktemp -d)"; trap 'rm -rf "$arbeit"' EXIT
|
||||||
|
cp tests/container/im-gast-pruefen.sh "$arbeit/"
|
||||||
|
fehler=0
|
||||||
|
for distro in ${ziele[*]}; do
|
||||||
|
pnpm -s exec tsx scripts/profil-rendern.ts "$distro" docker,zabbix-agent server=zbx.example fail2ban=true > "$arbeit/$distro.sh"
|
||||||
|
echo "=== $distro (${IMAGES[$distro]})"
|
||||||
|
if docker run --rm -v "$arbeit:/s:ro" "${IMAGES[$distro]}" bash /s/im-gast-pruefen.sh "/s/$distro.sh" > "$arbeit/$distro.log" 2>&1 \
|
||||||
|
&& grep -q '^exit=0$' "$arbeit/$distro.log" && grep -q '^exit2=0$' "$arbeit/$distro.log"; then
|
||||||
|
echo "✓ $distro"
|
||||||
|
else
|
||||||
|
echo "✗ $distro"; tail -30 "$arbeit/$distro.log"; fehler=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit $fehler
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||||
|
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||||
|
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||||
|
|
||||||
|
/** Eine Senke, die mitschreibt – so sieht ein Test genau, was Web und CLI zu sehen bekämen. */
|
||||||
|
function mitschreiber() {
|
||||||
|
const ereignisse: string[] = [];
|
||||||
|
const senke: EreignisSenke = {
|
||||||
|
phasenAnlegen: async (p) => void ereignisse.push(`anlegen ${p.map((x) => x.schluessel).join(",")}`),
|
||||||
|
phaseGestartet: async (s) => void ereignisse.push(`start ${s}`),
|
||||||
|
phaseDetail: async (s, d) => void ereignisse.push(`detail ${s}: ${d}`),
|
||||||
|
phaseBeendet: async (s, status, a) =>
|
||||||
|
void ereignisse.push(
|
||||||
|
`ende ${s} ${status}${a.detail ? `: ${a.detail}` : ""}${a.fehler ? ` – ${a.fehler.ursache} → ${a.fehler.hinweis}` : ""}`,
|
||||||
|
),
|
||||||
|
log: async (stufe, n) => void ereignisse.push(`log ${stufe} ${n}`),
|
||||||
|
};
|
||||||
|
return { senke, ereignisse };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Phasen-Runner", () => {
|
||||||
|
it("führt Phasen der Reihe nach aus und meldet Detail, Überspringen und Erfolg", async () => {
|
||||||
|
const { senke, ereignisse } = mitschreiber();
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
[
|
||||||
|
{ schluessel: "pruefen", titel: "Prüfungen", ausfuehren: async () => ({ detail: "VMID 120 frei" }) },
|
||||||
|
{
|
||||||
|
schluessel: "template",
|
||||||
|
titel: "Template",
|
||||||
|
ausfuehren: async () => ({ uebersprungen: true, detail: "9013 vorhanden" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "klonen",
|
||||||
|
titel: "Klonen",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
await k.detail("läuft …");
|
||||||
|
await k.log("clone gestartet");
|
||||||
|
return { detail: "full clone 9013 → 120" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
senke,
|
||||||
|
);
|
||||||
|
expect(ergebnis).toEqual({ ok: true });
|
||||||
|
expect(ereignisse).toEqual([
|
||||||
|
"anlegen pruefen,template,klonen",
|
||||||
|
"start pruefen",
|
||||||
|
"ende pruefen ERLEDIGT: VMID 120 frei",
|
||||||
|
"start template",
|
||||||
|
"ende template UEBERSPRUNGEN: 9013 vorhanden",
|
||||||
|
"start klonen",
|
||||||
|
"detail klonen: läuft …",
|
||||||
|
"log info clone gestartet",
|
||||||
|
"ende klonen ERLEDIGT: full clone 9013 → 120",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bricht bei einem Fehler ab: Ursache und Hinweis, folgende Phasen bleiben offen", async () => {
|
||||||
|
const { senke, ereignisse } = mitschreiber();
|
||||||
|
let danach = false;
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
schluessel: "klonen",
|
||||||
|
titel: "Klonen",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
throw new ProxmoxFehler(
|
||||||
|
"recht",
|
||||||
|
"Permission check failed (/vms/120, VM.Clone)",
|
||||||
|
"Der Rolle fehlt VM.Clone auf /vms/120.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ schluessel: "start", titel: "Start", ausfuehren: async () => void (danach = true) },
|
||||||
|
],
|
||||||
|
senke,
|
||||||
|
);
|
||||||
|
expect(ergebnis).toMatchObject({ ok: false, abgebrochen: false, phase: "klonen" });
|
||||||
|
expect(danach).toBe(false);
|
||||||
|
expect(ereignisse).toContain(
|
||||||
|
"ende klonen FEHLER – Permission check failed (/vms/120, VM.Clone) → Der Rolle fehlt VM.Clone auf /vms/120.",
|
||||||
|
);
|
||||||
|
expect(ereignisse.some((e) => e.startsWith("start start"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prüft vor jeder Phase auf Abbruch", async () => {
|
||||||
|
const { senke } = mitschreiber();
|
||||||
|
let abbrechen = false;
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
[
|
||||||
|
{ schluessel: "a", titel: "A", ausfuehren: async () => void (abbrechen = true) },
|
||||||
|
{ schluessel: "b", titel: "B", ausfuehren: async () => ({}) },
|
||||||
|
],
|
||||||
|
senke,
|
||||||
|
{ abbruchGewuenscht: async () => abbrechen },
|
||||||
|
);
|
||||||
|
expect(ergebnis).toMatchObject({ ok: false, abgebrochen: true, phase: "b" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("benennt unerwartete Fehler ehrlich", async () => {
|
||||||
|
const { senke } = mitschreiber();
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
schluessel: "x",
|
||||||
|
titel: "X",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
throw new TypeError("kaputt");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
senke,
|
||||||
|
);
|
||||||
|
expect(ergebnis.ok || ergebnis.fehler.ursache).toBe("Unerwarteter Fehler: kaputt");
|
||||||
|
const bewusst = await phasenAusfuehren(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
schluessel: "y",
|
||||||
|
titel: "Y",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
throw new AblaufFehler("U", "H");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
senke,
|
||||||
|
);
|
||||||
|
expect(bewusst.ok || bewusst.fehler).toEqual({ ursache: "U", hinweis: "H" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { adapterFuer, bekannteFamilien } from "@/engine/adapter";
|
||||||
|
import { AdapterFehler, debianAdapter } from "@/engine/adapter/debian";
|
||||||
|
import { katalogLaden } from "@/engine/katalog/laden";
|
||||||
|
import { ProfilSchema, type Profil } from "@/engine/katalog/schema";
|
||||||
|
import { profileAufloesen } from "@/engine/profile/aufloesen";
|
||||||
|
import { MARKE, profileRendern } from "@/engine/profile/rendern";
|
||||||
|
import { einsetzen, VorlagenFehler } from "@/engine/profile/vorlage";
|
||||||
|
import { q } from "@/engine/shell";
|
||||||
|
|
||||||
|
const kontext = {
|
||||||
|
var: { a: "eins", leer: "" },
|
||||||
|
os: { id: "debian", version_id: "13", codename: "trixie" },
|
||||||
|
guest: { name: "web01", user: "florian" },
|
||||||
|
};
|
||||||
|
|
||||||
|
async function katalogProfile(): Promise<Map<string, Profil>> {
|
||||||
|
const k = await katalogLaden("catalog");
|
||||||
|
return new Map(k.profile.map((p) => [p.eintrag.id, p.eintrag]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const profil = (roh: unknown) => ProfilSchema.parse(roh);
|
||||||
|
|
||||||
|
describe("Shell-Quoting", () => {
|
||||||
|
it("lässt Harmloses stehen und packt alles andere in einfache Anführungszeichen", () => {
|
||||||
|
expect(q("docker-ce")).toBe("docker-ce");
|
||||||
|
expect(q("a b")).toBe("'a b'");
|
||||||
|
expect(q("$(rm -rf /)")).toBe("'$(rm -rf /)'");
|
||||||
|
expect(q("it's")).toBe(`'it'"'"'s'`);
|
||||||
|
expect(q("")).toBe("''");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ein gequoteter Wert kommt in bash unverändert an", () => {
|
||||||
|
for (const wert of ["$(id)", "`id`", "a'b\"c", "x; rm -rf /", "ä ö ü", "\\n"]) {
|
||||||
|
expect(execFileSync("bash", ["-c", `printf '%s' ${q(wert)}`]).toString()).toBe(wert);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Platzhalter", () => {
|
||||||
|
it("setzt var, os und guest ein; ?? nimmt den ersten nicht leeren Wert", () => {
|
||||||
|
expect(einsetzen("{{ os.id }}{{ os.version_id }}-{{ guest.name }}", kontext)).toBe("debian13-web01");
|
||||||
|
expect(einsetzen("{{ var.leer ?? var.a }}", kontext)).toBe("eins");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft bei unbekannten Platzhaltern statt sie leer zu lassen", () => {
|
||||||
|
expect(() => einsetzen("{{ var.gibtsnicht }}", kontext)).toThrow(VorlagenFehler);
|
||||||
|
expect(() => einsetzen("{{ env.HOME }}", kontext)).toThrow(/Unbekannter Platzhalter/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("im Shell-Modus kann eine Variable nie Code werden", () => {
|
||||||
|
const boese = { ...kontext, var: { name: "x; touch /tmp/gehackt" } };
|
||||||
|
const befehl = einsetzen("echo {{ var.name }}", boese, "shell");
|
||||||
|
expect(befehl).toBe("echo 'x; touch /tmp/gehackt'");
|
||||||
|
expect(execFileSync("bash", ["-c", befehl]).toString().trim()).toBe("x; touch /tmp/gehackt");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Profile auflösen", () => {
|
||||||
|
it("zieht Voraussetzungen nach vorne und meldet sie als automatisch", async () => {
|
||||||
|
const ergebnis = profileAufloesen(["docker", "zabbix-agent"], await katalogProfile(), "debian", {
|
||||||
|
"zabbix-agent": { server: "zbx" },
|
||||||
|
});
|
||||||
|
expect(ergebnis.ok).toBe(true);
|
||||||
|
if (!ergebnis.ok) return;
|
||||||
|
expect(ergebnis.profile.map((p) => p.profil.id)).toEqual(["base", "docker", "zabbix-agent"]);
|
||||||
|
expect(ergebnis.automatisch).toEqual(["base"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt Standardwerte und prüft Pflichtvariablen", async () => {
|
||||||
|
const fehlt = profileAufloesen(["zabbix-agent"], await katalogProfile(), "debian");
|
||||||
|
expect(fehlt).toEqual({
|
||||||
|
ok: false,
|
||||||
|
fehler: [{ profil: "zabbix-agent", variable: "server", meldung: "Zabbix-Server fehlt." }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = profileAufloesen(["base"], await katalogProfile(), "debian");
|
||||||
|
expect(ok.ok && ok.profile[0]!.werte).toEqual({
|
||||||
|
zeitzone: "Europe/Berlin",
|
||||||
|
locale: "de_DE.UTF-8",
|
||||||
|
tastatur: "de",
|
||||||
|
fail2ban: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lehnt Zeilenumbrüche, falsche Typen, Zyklen und fehlende Familien ab", () => {
|
||||||
|
const verfuegbar = new Map<string, Profil>([
|
||||||
|
["a", profil({ id: "a", name: "A", requires: ["b"], families: { debian: [{ update: true }] } })],
|
||||||
|
["b", profil({ id: "b", name: "B", requires: ["a"], families: { debian: [{ update: true }] } })],
|
||||||
|
["nur-rhel", profil({ id: "nur-rhel", name: "R", families: { rhel: [{ update: true }] } })],
|
||||||
|
[
|
||||||
|
"v",
|
||||||
|
profil({
|
||||||
|
id: "v",
|
||||||
|
name: "V",
|
||||||
|
variables: [
|
||||||
|
{ name: "text", label: "Text" },
|
||||||
|
{ name: "zahl", label: "Zahl", type: "number" },
|
||||||
|
{ name: "wahl", label: "Wahl", type: "select", options: ["x", "y"] },
|
||||||
|
],
|
||||||
|
families: { debian: [{ update: true }] },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const ergebnis = profileAufloesen(["a", "nur-rhel", "v"], verfuegbar, "debian", {
|
||||||
|
v: { text: "a\nb", zahl: "abc", wahl: "z" },
|
||||||
|
});
|
||||||
|
expect(ergebnis.ok).toBe(false);
|
||||||
|
if (ergebnis.ok) return;
|
||||||
|
const meldungen = ergebnis.fehler.map(
|
||||||
|
(f) => `${f.profil}${f.variable ? "." + f.variable : ""}: ${f.meldung}`,
|
||||||
|
);
|
||||||
|
expect(meldungen).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringMatching(/Zyklische Voraussetzung/),
|
||||||
|
"nur-rhel: Keine Umsetzung für die Familie „debian“.",
|
||||||
|
"v.text: Text: keine Zeilenumbrüche.",
|
||||||
|
"v.zahl: Zahl: Zahl erwartet.",
|
||||||
|
"v.wahl: Wahl: erlaubt sind x, y.",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Rendern", () => {
|
||||||
|
const renderKontext = {
|
||||||
|
os: { id: "debian", version_id: "13", codename: "trixie" },
|
||||||
|
gast: { name: "web01", benutzer: "florian" },
|
||||||
|
};
|
||||||
|
|
||||||
|
async function rendern(auswahl: string[], eingaben = {}) {
|
||||||
|
const ergebnis = profileAufloesen(auswahl, await katalogProfile(), "debian", eingaben);
|
||||||
|
if (!ergebnis.ok) throw new Error(JSON.stringify(ergebnis.fehler));
|
||||||
|
return profileRendern(ergebnis.profile, debianAdapter, renderKontext);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("setzt Marken je Profil und die SSH-Härtung ganz ans Ende", async () => {
|
||||||
|
const { skript } = await rendern(["docker"]);
|
||||||
|
const marken = skript.split("\n").filter((z) => z.includes(MARKE));
|
||||||
|
expect(marken).toEqual([
|
||||||
|
`echo '${MARKE} profil base'`,
|
||||||
|
`echo '${MARKE} profil docker'`,
|
||||||
|
`echo '${MARKE} abschluss'`,
|
||||||
|
`echo '${MARKE} fertig'`,
|
||||||
|
]);
|
||||||
|
expect(skript.indexOf("10-proxdeploy.conf")).toBeGreaterThan(skript.indexOf("docker-compose-plugin"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt Distribution und Gast ein (Docker-Repo, Zabbix-Paket, Hostname)", async () => {
|
||||||
|
const { skript } = await rendern(["docker", "zabbix-agent"], {
|
||||||
|
"zabbix-agent": { server: "zbx.example" },
|
||||||
|
});
|
||||||
|
expect(skript).toContain("https://download.docker.com/linux/debian/gpg");
|
||||||
|
expect(skript).toContain("Suites: trixie");
|
||||||
|
expect(skript).toContain("zabbix-release_latest_7.0+debian13_all.deb");
|
||||||
|
expect(skript).toContain("usermod -aG docker florian");
|
||||||
|
// Der Inhalt der Konfigurationsdatei steht base64-kodiert im Skript.
|
||||||
|
const b64 = /printf '%s' (\S+) \| base64 -d > \/etc\/zabbix/.exec(skript)![1]!;
|
||||||
|
expect(Buffer.from(b64, "base64").toString()).toContain("Hostname=web01\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("überspringt Schritte, deren Bedingung nicht erfüllt ist", async () => {
|
||||||
|
expect((await rendern(["base"])).skript).not.toContain("fail2ban");
|
||||||
|
expect((await rendern(["base"], { base: { fail2ban: true } })).skript).toContain("fail2ban");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist ungültige Zeitzone, Locale und Tastatur schon beim Rendern ab", async () => {
|
||||||
|
await expect(rendern(["base"], { base: { zeitzone: "Europe/Berlin; rm" } })).rejects.toThrow(
|
||||||
|
AdapterFehler,
|
||||||
|
);
|
||||||
|
await expect(rendern(["base"], { base: { locale: "de" } })).rejects.toThrow(/Locale/);
|
||||||
|
await expect(rendern(["base"], { base: { tastatur: "de'" } })).rejects.toThrow(/Tastatur/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ist syntaktisch gültiges bash", async () => {
|
||||||
|
const { skript } = await rendern(["docker", "zabbix-agent"], {
|
||||||
|
"zabbix-agent": { server: "zbx" },
|
||||||
|
base: { fail2ban: true },
|
||||||
|
});
|
||||||
|
const datei = join(mkdtempSync(join(tmpdir(), "pd-skript-")), "s.sh");
|
||||||
|
writeFileSync(datei, skript);
|
||||||
|
expect(() => execFileSync("bash", ["-n", datei])).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Adapter-Registry", () => {
|
||||||
|
it("kennt debian; eine unbekannte Familie ergibt null statt Absturz", () => {
|
||||||
|
expect(bekannteFamilien()).toEqual(["debian"]);
|
||||||
|
expect(adapterFuer("debian")).toBe(debianAdapter);
|
||||||
|
expect(adapterFuer("rhel")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Benutzer: sudo-Datei wird vor dem Aktivieren mit visudo geprüft, Schlüssel ergänzt statt ersetzt", () => {
|
||||||
|
const skript = debianAdapter.benutzerAnlegen({
|
||||||
|
name: "florian",
|
||||||
|
sshSchluessel: ["ssh-ed25519 AAAA x"],
|
||||||
|
sudo: true,
|
||||||
|
});
|
||||||
|
expect(skript).toMatch(/visudo -cf .*\.neu >\/dev\/null && mv/);
|
||||||
|
expect(skript).toContain('>> "$pd_home/.ssh/authorized_keys"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { debianAdapter } from "@/engine/adapter/debian";
|
||||||
|
import { katalogLaden } from "@/engine/katalog/laden";
|
||||||
|
import { profileAufloesen } from "@/engine/profile/aufloesen";
|
||||||
|
import { profileRendern } from "@/engine/profile/rendern";
|
||||||
|
|
||||||
|
/** shellcheck über Docker – läuft nur, wenn Docker und das Image da sind, und sagt das sonst. */
|
||||||
|
function shellcheckVerfuegbar(): boolean {
|
||||||
|
try {
|
||||||
|
execFileSync("docker", ["image", "inspect", "koalaman/shellcheck:stable"], { stdio: "ignore" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
console.warn("shellcheck übersprungen: docker oder koalaman/shellcheck:stable fehlt");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.runIf(shellcheckVerfuegbar())("shellcheck", () => {
|
||||||
|
it("findet in den gerenderten Katalogprofilen nichts", async () => {
|
||||||
|
const katalog = await katalogLaden("catalog");
|
||||||
|
const profile = new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag]));
|
||||||
|
const ordner = mkdtempSync(join(tmpdir(), "pd-sc-"));
|
||||||
|
for (const { eintrag: distro } of katalog.distributionen) {
|
||||||
|
const aufgeloest = profileAufloesen(["docker", "zabbix-agent"], profile, "debian", {
|
||||||
|
base: { fail2ban: true },
|
||||||
|
"zabbix-agent": { server: "zbx" },
|
||||||
|
});
|
||||||
|
if (!aufgeloest.ok) throw new Error("Auflösung fehlgeschlagen");
|
||||||
|
const { skript } = profileRendern(
|
||||||
|
aufgeloest.profile,
|
||||||
|
debianAdapter,
|
||||||
|
{ os: distro.os, gast: { name: "g", benutzer: "u" } },
|
||||||
|
[debianAdapter.benutzerAnlegen({ name: "u", sshSchluessel: ["ssh-ed25519 AAAA u"], sudo: true })],
|
||||||
|
);
|
||||||
|
writeFileSync(join(ordner, `${distro.id}.sh`), skript);
|
||||||
|
}
|
||||||
|
const ausgabe = (() => {
|
||||||
|
try {
|
||||||
|
execFileSync(
|
||||||
|
"bash",
|
||||||
|
["-c", `docker run --rm -v ${ordner}:/s:ro koalaman/shellcheck:stable -s bash /s/*.sh`],
|
||||||
|
{ stdio: "pipe" },
|
||||||
|
);
|
||||||
|
return "";
|
||||||
|
} catch (fehler) {
|
||||||
|
return String((fehler as { stdout?: Buffer }).stdout ?? fehler);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
expect(ausgabe).toBe("");
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||||
|
import {
|
||||||
|
templateBauSchritte,
|
||||||
|
vorbereitungsSnippet,
|
||||||
|
type NodeBefehl,
|
||||||
|
type TemplateBauStand,
|
||||||
|
} from "@/engine/ablaeufe/template-bau";
|
||||||
|
import { katalogLaden } from "@/engine/katalog/laden";
|
||||||
|
import type { Distribution } from "@/engine/katalog/schema";
|
||||||
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock, type MockAnfrage } from "../proxmox-mock/mock";
|
||||||
|
import { szenarioEinrichten } from "../proxmox-mock/szenario";
|
||||||
|
|
||||||
|
const HASH = "f".repeat(128);
|
||||||
|
const UPID = (art: string) => `UPID:pve01:00001234:00ABCDEF:6710F00D:${art}:9000:proxdeploy@pve!test:`;
|
||||||
|
|
||||||
|
const stumm: EreignisSenke = {
|
||||||
|
phasenAnlegen: async () => {},
|
||||||
|
phaseGestartet: async () => {},
|
||||||
|
phaseDetail: async () => {},
|
||||||
|
phaseBeendet: async () => {},
|
||||||
|
log: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const aufraeumen: Array<() => Promise<void>> = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
while (aufraeumen.length) await aufraeumen.pop()!();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function debian13(): Promise<Distribution> {
|
||||||
|
return (await katalogLaden("catalog")).distributionen.find((d) => d.eintrag.id === "debian-13")!.eintrag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proxmox-Mock mit allem, was der Template-Bau anfasst. VM-Status: läuft zweimal, dann gestoppt. */
|
||||||
|
async function aufbauen(optionen: {
|
||||||
|
version: string;
|
||||||
|
importVorhanden?: boolean;
|
||||||
|
startScheitert?: boolean;
|
||||||
|
trocken?: boolean;
|
||||||
|
}) {
|
||||||
|
const mock = await new ProxmoxMock().starten();
|
||||||
|
szenarioEinrichten(mock, { version: optionen.version, importStorage: true });
|
||||||
|
let statusAbfragen = 0;
|
||||||
|
mock
|
||||||
|
.route("GET", "/cluster/resources", () => ({ data: [{ vmid: 100 }, { vmid: 9000, template: 1 }] }))
|
||||||
|
.route("GET", /^\/nodes\/pve01\/storage\/local\/content$/, () => ({
|
||||||
|
data: optionen.importVorhanden
|
||||||
|
? [{ volid: `local:import/proxdeploy-debian-13-${HASH.slice(0, 12)}.qcow2` }]
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
.route("POST", /\/download-url$/, () => ({ data: UPID("download") }))
|
||||||
|
.route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } }))
|
||||||
|
.route("GET", /\/tasks\/[^/]+\/log$/, () => ({ data: [{ n: 1, t: "downloaded 100%" }] }))
|
||||||
|
.route("POST", "/nodes/pve01/qemu", () => ({ data: UPID("qmcreate") }))
|
||||||
|
.route("PUT", /^\/nodes\/pve01\/qemu\/\d+\/config$/, () => ({ data: null }))
|
||||||
|
.route("POST", /^\/nodes\/pve01\/qemu\/\d+\/status\/start$/, () =>
|
||||||
|
optionen.startScheitert
|
||||||
|
? { status: 500, statusText: "start failed: OVMF not found" }
|
||||||
|
: { data: UPID("qmstart") },
|
||||||
|
)
|
||||||
|
.route("GET", /^\/nodes\/pve01\/qemu\/\d+\/status\/current$/, () => ({
|
||||||
|
data: { status: ++statusAbfragen > 2 ? "stopped" : "running" },
|
||||||
|
}))
|
||||||
|
.route("POST", /^\/nodes\/pve01\/qemu\/\d+\/template$/, () => ({ data: UPID("qmtemplate") }));
|
||||||
|
const client = new ProxmoxClient(
|
||||||
|
{
|
||||||
|
url: mock.url,
|
||||||
|
tokenId: MOCK_TOKEN_ID,
|
||||||
|
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
|
||||||
|
tlsPruefen: false,
|
||||||
|
fingerabdruck: mock.fingerabdruck,
|
||||||
|
},
|
||||||
|
{ trocken: optionen.trocken },
|
||||||
|
);
|
||||||
|
aufraeumen.push(async () => {
|
||||||
|
await client.schliessen();
|
||||||
|
await mock.stoppen();
|
||||||
|
});
|
||||||
|
return { mock, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sshAufzeichnung() {
|
||||||
|
const befehle: Array<{ befehl: string; eingabe?: string }> = [];
|
||||||
|
const ssh: NodeBefehl = async (befehl, optionen) => {
|
||||||
|
befehle.push({ befehl, eingabe: optionen?.eingabe });
|
||||||
|
return { code: 0, stdout: "", stderr: "" };
|
||||||
|
};
|
||||||
|
return { ssh, befehle };
|
||||||
|
}
|
||||||
|
|
||||||
|
const schreibend = (anfragen: MockAnfrage[]) =>
|
||||||
|
anfragen.filter((a) => a.methode !== "GET").map((a) => `${a.methode} ${a.pfad}`);
|
||||||
|
|
||||||
|
describe("Template-Bau", () => {
|
||||||
|
it("PVE 9.2 per API: download-url mit Prüfsumme, import-from per Volume-ID, UEFI, Agent per Snippet, Template", async () => {
|
||||||
|
const { mock, client } = await aufbauen({ version: "9.2.1" });
|
||||||
|
const { ssh, befehle } = sshAufzeichnung();
|
||||||
|
const stand: TemplateBauStand = {};
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
templateBauSchritte(
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
distribution: await debian13(),
|
||||||
|
knoten: "pve01",
|
||||||
|
storage: "local-zfs",
|
||||||
|
bridge: "vmbr0",
|
||||||
|
ssh,
|
||||||
|
auftragKurzId: "t-test1",
|
||||||
|
holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2\n`,
|
||||||
|
vmidReservieren: async (belegt) => (belegt.has(9000) ? 9001 : 9000),
|
||||||
|
warten: async () => {},
|
||||||
|
},
|
||||||
|
stand,
|
||||||
|
),
|
||||||
|
stumm,
|
||||||
|
);
|
||||||
|
expect(ergebnis).toEqual({ ok: true });
|
||||||
|
expect(stand).toMatchObject({ vmid: 9001, weg: "api", importStorage: "local", pruefsumme: HASH });
|
||||||
|
|
||||||
|
expect(schreibend(mock.anfragen)).toEqual([
|
||||||
|
"POST /nodes/pve01/storage/local/download-url",
|
||||||
|
"POST /nodes/pve01/qemu",
|
||||||
|
"PUT /nodes/pve01/qemu/9001/config",
|
||||||
|
"POST /nodes/pve01/qemu/9001/status/start",
|
||||||
|
"PUT /nodes/pve01/qemu/9001/config",
|
||||||
|
"POST /nodes/pve01/qemu/9001/template",
|
||||||
|
]);
|
||||||
|
const download = mock.anfragen.find((a) => a.pfad.endsWith("download-url"))!.rumpf;
|
||||||
|
expect(Object.fromEntries(download)).toMatchObject({
|
||||||
|
content: "import",
|
||||||
|
filename: `proxdeploy-debian-13-${HASH.slice(0, 12)}.qcow2`,
|
||||||
|
checksum: HASH,
|
||||||
|
"checksum-algorithm": "sha512",
|
||||||
|
"verify-certificates": "1",
|
||||||
|
});
|
||||||
|
const anlegen = Object.fromEntries(
|
||||||
|
mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/qemu")!.rumpf,
|
||||||
|
);
|
||||||
|
expect(anlegen).toMatchObject({
|
||||||
|
vmid: "9001",
|
||||||
|
machine: "q35",
|
||||||
|
bios: "ovmf",
|
||||||
|
efidisk0: "local-zfs:1,efitype=4m,pre-enrolled-keys=1",
|
||||||
|
scsihw: "virtio-scsi-single",
|
||||||
|
scsi0: `local-zfs:0,import-from=local:import/proxdeploy-debian-13-${HASH.slice(0, 12)}.qcow2,discard=on,ssd=1,iothread=1`,
|
||||||
|
cpu: "host",
|
||||||
|
serial0: "socket",
|
||||||
|
agent: "enabled=1,fstrim_cloned_disks=1",
|
||||||
|
ide2: "local-zfs:cloudinit",
|
||||||
|
tags: "proxdeploy-template;debian-13",
|
||||||
|
});
|
||||||
|
const configs = mock.anfragen.filter((a) => a.methode === "PUT").map((a) => Object.fromEntries(a.rumpf));
|
||||||
|
expect(configs).toEqual([
|
||||||
|
{ cicustom: "vendor=local:snippets/proxdeploy-vorbereitung-t-test1.yaml" },
|
||||||
|
{ delete: "cicustom" },
|
||||||
|
]);
|
||||||
|
expect(befehle[0]!.eingabe).toBe(vorbereitungsSnippet());
|
||||||
|
expect(befehle.at(-1)!.befehl).toMatch(/^rm -f/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("überspringt den Download, wenn das geprüfte Image schon liegt", async () => {
|
||||||
|
const { mock, client } = await aufbauen({ version: "9.2.1", importVorhanden: true });
|
||||||
|
const { ssh } = sshAufzeichnung();
|
||||||
|
await phasenAusfuehren(
|
||||||
|
templateBauSchritte({
|
||||||
|
client,
|
||||||
|
distribution: await debian13(),
|
||||||
|
knoten: "pve01",
|
||||||
|
storage: "local-zfs",
|
||||||
|
bridge: "vmbr0",
|
||||||
|
ssh,
|
||||||
|
auftragKurzId: "t",
|
||||||
|
holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`,
|
||||||
|
vmidReservieren: async () => 9000,
|
||||||
|
warten: async () => {},
|
||||||
|
}),
|
||||||
|
stumm,
|
||||||
|
);
|
||||||
|
expect(mock.anfragen.some((a) => a.pfad.endsWith("download-url"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fehlt die Datei in der Prüfsummenliste, bricht das Laden mit Hinweis ab", async () => {
|
||||||
|
const { client } = await aufbauen({ version: "9.2.1" });
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
templateBauSchritte({
|
||||||
|
client,
|
||||||
|
distribution: await debian13(),
|
||||||
|
knoten: "pve01",
|
||||||
|
storage: "local-zfs",
|
||||||
|
bridge: "vmbr0",
|
||||||
|
ssh: sshAufzeichnung().ssh,
|
||||||
|
auftragKurzId: "t",
|
||||||
|
holen: async () => `${HASH} andere-datei.qcow2`,
|
||||||
|
vmidReservieren: async () => 9000,
|
||||||
|
warten: async () => {},
|
||||||
|
}),
|
||||||
|
stumm,
|
||||||
|
);
|
||||||
|
expect(ergebnis).toMatchObject({ ok: false, phase: "image" });
|
||||||
|
if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/umbenannt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PVE 8.3 ohne SSH: schon die Prüfung sagt, was fehlt", async () => {
|
||||||
|
const { client } = await aufbauen({ version: "8.3.5" });
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
templateBauSchritte({
|
||||||
|
client,
|
||||||
|
distribution: await debian13(),
|
||||||
|
knoten: "pve01",
|
||||||
|
storage: "local-zfs",
|
||||||
|
bridge: "vmbr0",
|
||||||
|
ssh: null,
|
||||||
|
auftragKurzId: "t",
|
||||||
|
holen: async () => "",
|
||||||
|
vmidReservieren: async () => 9000,
|
||||||
|
}),
|
||||||
|
stumm,
|
||||||
|
);
|
||||||
|
expect(ergebnis).toMatchObject({ ok: false, phase: "pruefungen" });
|
||||||
|
if (!ergebnis.ok) expect(ergebnis.fehler.ursache).toMatch(/erst ab 8\.4/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PVE 8.3 mit SSH: Download und Prüfung auf dem Node, Import per qm set", async () => {
|
||||||
|
const { client } = await aufbauen({ version: "8.3.5" });
|
||||||
|
const { ssh, befehle } = sshAufzeichnung();
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
templateBauSchritte({
|
||||||
|
client,
|
||||||
|
distribution: await debian13(),
|
||||||
|
knoten: "pve01",
|
||||||
|
storage: "local-zfs",
|
||||||
|
bridge: "vmbr0",
|
||||||
|
ssh,
|
||||||
|
auftragKurzId: "t",
|
||||||
|
holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`,
|
||||||
|
vmidReservieren: async () => 9000,
|
||||||
|
warten: async () => {},
|
||||||
|
}),
|
||||||
|
stumm,
|
||||||
|
);
|
||||||
|
expect(ergebnis).toEqual({ ok: true });
|
||||||
|
expect(befehle[0]!.befehl).toContain("sha512sum -c --status");
|
||||||
|
expect(befehle[0]!.befehl).toContain("curl -fsSL");
|
||||||
|
expect(befehle[1]!.befehl).toMatch(
|
||||||
|
/^qm set 9000 --scsi0 .*import-from=\/var\/tmp\/proxdeploy\/proxdeploy-debian-13-/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("räumt das Snippet auch auf, wenn der Vorbereitungsstart scheitert", async () => {
|
||||||
|
const { mock, client } = await aufbauen({ version: "9.2.1", startScheitert: true });
|
||||||
|
const { ssh, befehle } = sshAufzeichnung();
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
templateBauSchritte({
|
||||||
|
client,
|
||||||
|
distribution: await debian13(),
|
||||||
|
knoten: "pve01",
|
||||||
|
storage: "local-zfs",
|
||||||
|
bridge: "vmbr0",
|
||||||
|
ssh,
|
||||||
|
auftragKurzId: "t",
|
||||||
|
holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`,
|
||||||
|
vmidReservieren: async () => 9000,
|
||||||
|
warten: async () => {},
|
||||||
|
}),
|
||||||
|
stumm,
|
||||||
|
);
|
||||||
|
expect(ergebnis).toMatchObject({ ok: false, phase: "agent" });
|
||||||
|
expect(
|
||||||
|
mock.anfragen.filter((a) => a.methode === "PUT").map((a) => Object.fromEntries(a.rumpf)),
|
||||||
|
).toContainEqual({ delete: "cicustom" });
|
||||||
|
expect(befehle.at(-1)!.befehl).toMatch(/^rm -f/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Trockenlauf: dieselben Phasen, nichts Schreibendes erreicht Proxmox, der Plan nennt jeden Aufruf", async () => {
|
||||||
|
const { mock, client } = await aufbauen({ version: "9.2.1", trocken: true });
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
templateBauSchritte({
|
||||||
|
client,
|
||||||
|
distribution: await debian13(),
|
||||||
|
knoten: "pve01",
|
||||||
|
storage: "local-zfs",
|
||||||
|
bridge: "vmbr0",
|
||||||
|
ssh: sshAufzeichnung().ssh,
|
||||||
|
auftragKurzId: "t",
|
||||||
|
holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`,
|
||||||
|
vmidReservieren: async () => 9000,
|
||||||
|
}),
|
||||||
|
stumm,
|
||||||
|
);
|
||||||
|
expect(ergebnis).toEqual({ ok: true });
|
||||||
|
expect(schreibend(mock.anfragen)).toEqual([]);
|
||||||
|
expect(client.geplant.map((a) => `${a.methode} ${a.pfad}`)).toEqual([
|
||||||
|
"POST /nodes/pve01/storage/local/download-url",
|
||||||
|
"POST /nodes/pve01/qemu",
|
||||||
|
"PUT /nodes/pve01/qemu/9000/config",
|
||||||
|
"POST /nodes/pve01/qemu/9000/status/start",
|
||||||
|
"PUT /nodes/pve01/qemu/9000/config",
|
||||||
|
"POST /nodes/pve01/qemu/9000/template",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||||
|
import {
|
||||||
|
ipconfig,
|
||||||
|
sshkeysKodieren,
|
||||||
|
vmAusrollSchritte,
|
||||||
|
type VmAusrollStand,
|
||||||
|
} from "@/engine/ablaeufe/vm-ausrollen";
|
||||||
|
import { katalogLaden } from "@/engine/katalog/laden";
|
||||||
|
import { profileAufloesen } from "@/engine/profile/aufloesen";
|
||||||
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||||
|
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
|
||||||
|
import { szenarioEinrichten } from "../proxmox-mock/szenario";
|
||||||
|
|
||||||
|
const UPID = (art: string) => `UPID:pve01:00001234:00ABCDEF:6710F00D:${art}:120:proxdeploy@pve!test:`;
|
||||||
|
const SCHLUESSEL =
|
||||||
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@test";
|
||||||
|
const stumm: EreignisSenke = {
|
||||||
|
phasenAnlegen: async () => {},
|
||||||
|
phaseGestartet: async () => {},
|
||||||
|
phaseDetail: async () => {},
|
||||||
|
phaseBeendet: async () => {},
|
||||||
|
log: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const aufraeumen: Array<() => Promise<void>> = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
while (aufraeumen.length) await aufraeumen.pop()!();
|
||||||
|
});
|
||||||
|
|
||||||
|
type MockOptionen = {
|
||||||
|
template?: boolean;
|
||||||
|
cloneBelegt?: boolean;
|
||||||
|
provisionierungCode?: number;
|
||||||
|
agentStumm?: boolean;
|
||||||
|
vlanBridge?: boolean;
|
||||||
|
trocken?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function aufbauen(o: MockOptionen = {}) {
|
||||||
|
const mock = await new ProxmoxMock().starten();
|
||||||
|
szenarioEinrichten(mock, { version: "9.2.1", importStorage: true });
|
||||||
|
let klonVersuche = 0;
|
||||||
|
const dateien = new Map<string, string>();
|
||||||
|
let execStatusAbfragen = 0;
|
||||||
|
mock
|
||||||
|
.route("GET", "/cluster/resources", () => ({
|
||||||
|
data: [
|
||||||
|
{ type: "node", node: "pve01", status: "online", maxmem: 64e9, mem: 8e9 },
|
||||||
|
...(o.template === false
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{ type: "qemu", vmid: 9000, node: "pve01", template: 1, tags: "proxdeploy-template;debian-13" },
|
||||||
|
]),
|
||||||
|
{ type: "qemu", vmid: 100, node: "pve01" },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
.route("GET", /^\/nodes\/pve01\/network$/, () => ({
|
||||||
|
data: [{ iface: "vmbr0", type: "bridge", bridge_vlan_aware: o.vlanBridge === false ? 0 : 1 }],
|
||||||
|
}))
|
||||||
|
.route("POST", "/nodes/pve01/qemu/9000/clone", () =>
|
||||||
|
o.cloneBelegt && ++klonVersuche === 1
|
||||||
|
? { status: 500, statusText: "unable to create VM 101 - VM 101 already exists on node 'pve02'" }
|
||||||
|
: { data: UPID("qmclone") },
|
||||||
|
)
|
||||||
|
.route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } }))
|
||||||
|
.route("PUT", /^\/nodes\/pve01\/qemu\/\d+\/(config|resize)$/, () => ({ data: null }))
|
||||||
|
.route("GET", /^\/nodes\/pve01\/qemu\/\d+\/config$/, () => ({
|
||||||
|
data: { scsi0: "local-zfs:vm-101-disk-1,size=3G" },
|
||||||
|
}))
|
||||||
|
.route("POST", /^\/nodes\/pve01\/qemu\/\d+\/status\/start$/, () => ({ data: UPID("qmstart") }))
|
||||||
|
.route("POST", /\/agent\/ping$/, () =>
|
||||||
|
o.agentStumm
|
||||||
|
? { status: 500, statusText: "QEMU guest agent is not running" }
|
||||||
|
: { data: { result: {} } },
|
||||||
|
)
|
||||||
|
.route("GET", /\/agent\/network-get-interfaces$/, () => ({
|
||||||
|
data: {
|
||||||
|
result: [
|
||||||
|
{ name: "lo", "ip-addresses": [{ "ip-address-type": "ipv4", "ip-address": "127.0.0.1" }] },
|
||||||
|
{ name: "eth0", "ip-addresses": [{ "ip-address-type": "ipv4", "ip-address": "10.0.10.50" }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.route("POST", /\/agent\/file-write$/, (a) => {
|
||||||
|
dateien.set(
|
||||||
|
a.rumpf.get("file")!,
|
||||||
|
Buffer.from(a.rumpf.get("content")!, a.rumpf.get("encode") === "0" ? "base64" : "utf8").toString(
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { data: null };
|
||||||
|
})
|
||||||
|
.route("POST", /\/agent\/exec$/, () => ({ data: { pid: 4711 } }))
|
||||||
|
.route("GET", /\/agent\/exec-status$/, () => ({
|
||||||
|
data: ++execStatusAbfragen < 2 ? { exited: 0 } : { exited: 1, exitcode: o.provisionierungCode ?? 0 },
|
||||||
|
}))
|
||||||
|
.route("GET", /\/agent\/file-read$/, () => ({
|
||||||
|
data: {
|
||||||
|
content: "::proxdeploy:: profil base\nReading package lists...\n::proxdeploy:: profil docker\n",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const client = new ProxmoxClient(
|
||||||
|
{
|
||||||
|
url: mock.url,
|
||||||
|
tokenId: MOCK_TOKEN_ID,
|
||||||
|
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
|
||||||
|
tlsPruefen: false,
|
||||||
|
fingerabdruck: mock.fingerabdruck,
|
||||||
|
},
|
||||||
|
{ trocken: o.trocken },
|
||||||
|
);
|
||||||
|
aufraeumen.push(async () => {
|
||||||
|
await client.schliessen();
|
||||||
|
await mock.stoppen();
|
||||||
|
});
|
||||||
|
return { mock, client, dateien };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eingabe(client: ProxmoxClient, anfrageTeil: Record<string, unknown> = {}) {
|
||||||
|
const katalog = await katalogLaden("catalog");
|
||||||
|
const distribution = katalog.distributionen.find((d) => d.eintrag.id === "debian-13")!.eintrag;
|
||||||
|
const anfrage = AusrollAnfrage.parse({
|
||||||
|
typ: "vm",
|
||||||
|
server: "rz1",
|
||||||
|
distribution: "debian-13",
|
||||||
|
name: "web01",
|
||||||
|
storage: "local-zfs",
|
||||||
|
diskGb: 40,
|
||||||
|
netz: {
|
||||||
|
bridge: "vmbr0",
|
||||||
|
vlan: 20,
|
||||||
|
modus: "statisch",
|
||||||
|
ip: "10.0.20.15/24",
|
||||||
|
gateway: "10.0.20.1",
|
||||||
|
dns: ["10.0.20.1"],
|
||||||
|
},
|
||||||
|
zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] },
|
||||||
|
profile: ["docker"],
|
||||||
|
...anfrageTeil,
|
||||||
|
});
|
||||||
|
const aufgeloest = profileAufloesen(
|
||||||
|
anfrage.profile,
|
||||||
|
new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])),
|
||||||
|
"debian",
|
||||||
|
);
|
||||||
|
if (!aufgeloest.ok) throw new Error("Profile");
|
||||||
|
const angelegt: Array<Record<string, unknown>> = [];
|
||||||
|
return {
|
||||||
|
angelegt,
|
||||||
|
e: {
|
||||||
|
client,
|
||||||
|
anfrage,
|
||||||
|
distribution,
|
||||||
|
profile: aufgeloest.profile,
|
||||||
|
vmidReservieren: async (belegt: Set<number>, wunsch?: number) =>
|
||||||
|
wunsch ?? (belegt.has(101) ? 102 : 101),
|
||||||
|
angelegt: async (x: Record<string, unknown>) => void angelegt.push(x),
|
||||||
|
auftragKurzId: "d-test1",
|
||||||
|
warten: async () => {},
|
||||||
|
agentMs: 50,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VM ausrollen", () => {
|
||||||
|
it("klont, konfiguriert, setzt cloud-init, startet, wartet auf den Agent und provisioniert", async () => {
|
||||||
|
const { mock, client, dateien } = await aufbauen();
|
||||||
|
const { e, angelegt } = await eingabe(client);
|
||||||
|
const stand: VmAusrollStand = {};
|
||||||
|
expect(await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
|
||||||
|
expect(stand).toMatchObject({
|
||||||
|
knoten: "pve01",
|
||||||
|
template: { vmid: 9000, knoten: "pve01" },
|
||||||
|
vmid: 101,
|
||||||
|
ip: "10.0.20.15",
|
||||||
|
});
|
||||||
|
expect(angelegt).toContainEqual({ vmid: 101 });
|
||||||
|
|
||||||
|
const klon = Object.fromEntries(mock.anfragen.find((a) => a.pfad.endsWith("/clone"))!.rumpf);
|
||||||
|
expect(klon).toEqual({ newid: "101", name: "web01", full: "1", target: "pve01", storage: "local-zfs" });
|
||||||
|
|
||||||
|
const configs = mock.anfragen
|
||||||
|
.filter((a) => a.methode === "PUT" && a.pfad.endsWith("/config"))
|
||||||
|
.map((a) => Object.fromEntries(a.rumpf));
|
||||||
|
expect(configs[0]).toMatchObject({
|
||||||
|
cores: "2",
|
||||||
|
memory: "2048",
|
||||||
|
cpu: "host",
|
||||||
|
net0: "virtio,bridge=vmbr0,tag=20",
|
||||||
|
tags: "proxdeploy;debian-13",
|
||||||
|
});
|
||||||
|
expect(configs[1]).toMatchObject({
|
||||||
|
ciuser: "florian",
|
||||||
|
ipconfig0: "ip=10.0.20.15/24,gw=10.0.20.1",
|
||||||
|
nameserver: "10.0.20.1",
|
||||||
|
ciupgrade: "0",
|
||||||
|
sshkeys: sshkeysKodieren([SCHLUESSEL]),
|
||||||
|
});
|
||||||
|
expect(Object.fromEntries(mock.anfragen.find((a) => a.pfad.endsWith("/resize"))!.rumpf)).toEqual({
|
||||||
|
disk: "scsi0",
|
||||||
|
size: "40G",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Das Skript kommt base64-kodiert an (Umlaute!) und enthält Benutzer und beide Profile.
|
||||||
|
const skript = [...dateien.values()].join("");
|
||||||
|
expect(skript).toContain("::proxdeploy:: profil base");
|
||||||
|
expect(skript).toContain("::proxdeploy:: profil docker");
|
||||||
|
expect(skript).toContain("useradd -m -s /bin/bash florian");
|
||||||
|
expect(skript).toContain("Frische Images");
|
||||||
|
const exec = mock.anfragen.find((a) => a.pfad.endsWith("/agent/exec"))!.rumpf.getAll("command");
|
||||||
|
expect(exec[0]).toBe("/bin/bash");
|
||||||
|
expect(exec[2]).toMatch(/> \/var\/log\/proxdeploy-provisionierung\.log 2>&1$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kodiert SSH-Schlüssel so, wie Proxmox sie erwartet (auch / + und Leerzeichen)", () => {
|
||||||
|
expect(sshkeysKodieren(["ssh-ed25519 AA/B+C= x (y)"])).toBe("ssh-ed25519%20AA%2FB%2BC%3D%20x%20%28y%29");
|
||||||
|
expect(ipconfig({ bridge: "vmbr0", modus: "dhcp", dns: [] })).toBe("ip=dhcp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ohne Template: klare Meldung – mit templateBauen würde gebaut", async () => {
|
||||||
|
const { client } = await aufbauen({ template: false });
|
||||||
|
const { e } = await eingabe(client);
|
||||||
|
const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm);
|
||||||
|
expect(ergebnis).toMatchObject({ ok: false, phase: "pruefungen" });
|
||||||
|
if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/Template vorher bauen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("VLAN auf einer nicht VLAN-fähigen Bridge wird vor dem Klonen abgelehnt", async () => {
|
||||||
|
const { mock, client } = await aufbauen({ vlanBridge: false });
|
||||||
|
const { e } = await eingabe(client);
|
||||||
|
const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm);
|
||||||
|
expect(ergebnis.ok || ergebnis.fehler.ursache).toMatch(/nicht VLAN-fähig/);
|
||||||
|
expect(mock.anfragen.some((a) => a.pfad.endsWith("/clone"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ist die VMID beim Klonen inzwischen belegt, wird einmal mit neuer ID geklont", async () => {
|
||||||
|
const { mock, client } = await aufbauen({ cloneBelegt: true });
|
||||||
|
const { e } = await eingabe(client);
|
||||||
|
const stand: VmAusrollStand = {};
|
||||||
|
let aufrufe = 0;
|
||||||
|
e.vmidReservieren = async () => (++aufrufe === 1 ? 101 : 102);
|
||||||
|
expect(await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
|
||||||
|
expect(stand.vmid).toBe(102);
|
||||||
|
expect(mock.anfragen.filter((a) => a.pfad.endsWith("/clone")).map((a) => a.rumpf.get("newid"))).toEqual([
|
||||||
|
"101",
|
||||||
|
"102",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ein scheiternder Profillauf nennt das Profil und den Exit-Code", async () => {
|
||||||
|
const { client } = await aufbauen({ provisionierungCode: 100 });
|
||||||
|
const { e } = await eingabe(client);
|
||||||
|
const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm);
|
||||||
|
expect(ergebnis).toMatchObject({ ok: false, phase: "provisionierung" });
|
||||||
|
if (!ergebnis.ok)
|
||||||
|
expect(ergebnis.fehler.ursache).toBe("Provisionierung im Profil docker mit Code 100 abgebrochen.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("antwortet der Agent nicht, sagt die Phase, woran es liegen kann", async () => {
|
||||||
|
const { client } = await aufbauen({ agentStumm: true });
|
||||||
|
const { e } = await eingabe(client);
|
||||||
|
const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm);
|
||||||
|
expect(ergebnis).toMatchObject({ ok: false, phase: "agent" });
|
||||||
|
if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/Konsole/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DHCP: die IP kommt vom Guest-Agent (ohne Loopback)", async () => {
|
||||||
|
const { client } = await aufbauen();
|
||||||
|
const { e } = await eingabe(client, { netz: { bridge: "vmbr0", modus: "dhcp" } });
|
||||||
|
const stand: VmAusrollStand = {};
|
||||||
|
await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm);
|
||||||
|
expect(stand.ip).toBe("10.0.10.50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Trockenlauf: nichts Schreibendes erreicht Proxmox, kein Agent-Aufruf", async () => {
|
||||||
|
const { mock, client } = await aufbauen({ trocken: true });
|
||||||
|
const { e } = await eingabe(client);
|
||||||
|
expect(await phasenAusfuehren(vmAusrollSchritte(e), stumm)).toEqual({ ok: true });
|
||||||
|
expect(mock.anfragen.filter((a) => a.methode !== "GET")).toEqual([]);
|
||||||
|
expect(mock.anfragen.some((a) => a.pfad.includes("/agent/"))).toBe(false);
|
||||||
|
expect(client.geplant.map((a) => `${a.methode} ${a.pfad}`)).toEqual([
|
||||||
|
"POST /nodes/pve01/qemu/9000/clone",
|
||||||
|
"PUT /nodes/pve01/qemu/101/config",
|
||||||
|
"PUT /nodes/pve01/qemu/101/resize",
|
||||||
|
"PUT /nodes/pve01/qemu/101/config",
|
||||||
|
"POST /nodes/pve01/qemu/101/status/start",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,3 +51,14 @@ describe("Zugangsprüfung", () => {
|
|||||||
expect(istOeffentlich("/server")).toBe(false);
|
expect(istOeffentlich("/server")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Client-Komponenten", () => {
|
||||||
|
const clientDateien = dateien(join(wurzel, "src"))
|
||||||
|
.filter((pfad) => /\.tsx?$/.test(pfad) && !pfad.includes("/generated/"))
|
||||||
|
.filter((pfad) => /^["']use client["']/m.test(readFileSync(pfad, "utf8")));
|
||||||
|
|
||||||
|
it.each(clientDateien.map((p) => relative(wurzel, p)))("%s importiert nichts aus src/server", (pfad) => {
|
||||||
|
// Auch reine Typimporte nicht: sonst wandert beim nächsten Umbau doch Servercode ins Browser-Bündel.
|
||||||
|
expect(readFileSync(join(wurzel, pfad), "utf8")).not.toMatch(/from ["']@\/server\//);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user