13 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5.5 1e195f9bca chore(release): v0.3.0
Etappe 3: LXC, Ubuntu, Profil-Editor, Vorlagen, Profile im Template, SSH-Schlüssel, Template-Bau ohne SSH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:39:08 +02:00
florian.wildeandClaude Opus 5.5 8bfa89fb3b docs(devlog): Sitzung 2 und Zeiten für Etappe 3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:37:19 +02:00
florian.wildeandClaude Opus 5.5 21edff2141 feat(templates): Template-Bau ohne SSH über eine Bau-Adresse
Ohne SSH zum Node kommt der Guest-Agent jetzt über eine feste
Bau-Adresse ins Template: cloud-init setzt Adresse und den
Bereitstellungsschlüssel für den Standardbenutzer, ProxDeploy meldet
sich per SSH in der VM an, installiert den Agent und entfernt danach
Schlüssel, Adresse und cloud-init-Angaben.

Dabei korrigiert: der Verbindungstest meldete „Template-Bau per API“
auch ohne Weg, den Agent einzubauen. Im Protokoll erscheinen
unveränderte JSON-Felder nicht mehr als geändert.

Refs #021

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:36:38 +02:00
florian.wildeandClaude Opus 5.5 426d3ec95a feat(schluessel): SSH-Schlüssel hinterlegen statt in jedes Formular
Benutzer hinterlegen ihre öffentlichen Schlüssel einmal, Admins
zusätzlich Team-Schlüssel. Sie kommen in jeden Gast: im Assistenten
vorausgewählt und abwählbar, per API und CLI automatisch, wenn die
Anfrage keine nennt. Die Schlüssel kommen über die Schnittstelle
SchluesselQuelle – dort lässt sich später eine externe Verwaltung
anbinden.

Vorgezogen aus #015 auf die Frage, ob Schlüssel ins Template gehören:
nein, in die Verwaltung.

Refs #015

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:28:45 +02:00
florian.wildeandClaude Opus 5.5 00712dc6ce feat(templates): Profile ins Template einbacken
Beim Template-Bau lassen sich Profile wählen (nur solche ohne
Pflichtvariablen). Nach dem Vorbereitungsstart laufen sie über den
Guest-Agent; danach werden Maschinen-ID, SSH-Host-Keys, Docker-Engine-ID
und apt-Cache entfernt. Ausrollen nimmt das Template mit den meisten
passenden Profilen und überspringt, was schon eingebacken ist.

Auf pve-demo: Provisionierung mit base+docker 141 s ohne, ~30 s mit
Template. Dabei gefunden: docker nimmt den Benutzer per guest.user in
die Gruppe auf – Profile mit guest.* laufen deshalb beim Deploy noch
einmal (idempotent, schnell).

Closes #022

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:20:32 +02:00
florian.wildeandClaude Opus 5.5 ebcf63100a feat(ubuntu): Ubuntu als VM und LXC, Fortschritt beim Klonen
Ubuntu 24.04 auf pve-demo als VM (Template im selben Auftrag gebaut)
und als Container ausgerollt; 22.04 und 26.04 laufen im Containertest.
Der Voll-Klon brauchte auf dem Testhost neun Minuten – die Phase zeigt
jetzt den Fortschritt aus dem Task-Log.

Closes #010

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:44:37 +02:00
florian.wildeandClaude Opus 5.5 1b1ee4b876 feat(vorlagen): Vorlagen (Presets) für Assistent, API und CLI
Gespeicherte Kombinationen aus Distribution, Ressourcen und Profilen.
Im Assistenten mit einem Klick vorbelegt oder dort gespeichert; die API
nimmt „vorlage“ entgegen (Grundlage für --preset der CLI). Eine Vorlage
liegt unter der Anfrage – was die Anfrage selbst sagt, gewinnt.

Beim ersten Start wird das Beispiel „Docker-Host klein“ aus der Vorgabe
angelegt, einmalig.

Closes #012

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:44:33 +02:00
florian.wildeandClaude Opus 5.5 e4306a6a53 feat(katalog): Profil-Editor mit Live-Prüfung und Skriptvorschau
Admins legen eigene Profile als YAML an; geprüft wird beim Tippen gegen
dasselbe Schema wie der Katalog, dazu eine Vorschau des erzeugten
Skripts. Katalogprofile bleiben schreibgeschützt und lassen sich als
Kopie bearbeiten. Ein Profil, das ein anderes voraussetzt, lässt sich
nicht löschen.

Closes #011

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:44:28 +02:00
florian.wildeandClaude Opus 5.5 d25ec54ef9 feat(lxc): LXC-Container ausrollen, mit oder ohne Node-SSH
Container aus dem Proxmox-Template (neueste passende Version aus
aplinfo), unprivilegiert, nesting automatisch für docker. Weil Proxmox
keine Exec-API für Container hat, läuft die Provisionierung per SSH:
bevorzugt pct exec über den Node, sonst direkt in den Container mit
einem Bereitstellungsschlüssel, der am Ende wieder entfernt wird.

Auf pve-demo beide Wege geprüft: Debian 13 mit docker per pct exec
(hello-world läuft mit nesting allein) und Ubuntu 24.04 per SSH in den
Container. Dabei gefunden: chrony scheitert ohne CAP_SYS_TIME (jetzt
if: vm), sshd -t auf Ubuntu 24.04 ohne /run/sshd.

Closes #009

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:19:13 +02:00
florian.wildeandClaude Opus 5.5 9310164bc2 feat(ausrollen): VM ausrollen mit Assistent, Dry-Run und CLI-Befehl
Eine VM aus dem ProxDeploy-Template ausrollen, vollständig per API:
Klonen, Hardware, cloud-init, Start, Guest-Agent und Provisionierung der
Profile über den Agent – ohne SSH. Der Fortschritt kommt aus der
Logdatei im Gast, je Profil eine Marke.

Der Dry-Run läuft durch dieselben Phasen mit einem Trocken-Client und
zeigt jeden geplanten Proxmox-Aufruf und den passenden CLI-Befehl. Der
Assistent hat 9 Schritte, den Zustand in der URL und bietet an, ein
fehlendes Template im selben Auftrag zu bauen.

Auf pve-demo abgenommen: pd-test01 (base, docker) per API, pd-test02
(base, zabbix-agent) über den Assistenten, beide per SSH geprüft. Dabei
gefunden: file-write scheitert an Umlauten – Skripte gehen jetzt
base64-kodiert hinüber.

Closes #008

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:34:15 +02:00
florian.wildeandClaude Opus 5.5 4285785a4c feat(templates): Template-Bau für VMs mit eingebautem Guest-Agent
Cloud-Image laden (download-url mit Prüfsumme, ab PVE 8.4 per API, davor
per SSH), VM mit q35/UEFI/virtio-scsi-single anlegen, Disk per
import-from importieren, Guest-Agent mit einem einmaligen
Vorbereitungsstart einbauen und als Template markieren.

Auf pve-demo (PVE 9.2.2) gebaut: Template 9000 aus Debian 13 in
5 min 26 s, Snippet und cicustom danach entfernt. Der Weg ohne SSH über
eine Bau-Adresse ist als #021 ausgegliedert.

Templates-Seite mit Liste (Proxmox ist maßgeblich, Tag
proxdeploy-template) und Formular; Abfrageschleifen erscheinen im
Detail-Log nur noch bei Fehlern.

Closes #007

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:57:11 +02:00
florian.wildeandClaude Opus 5.5 76bc0e4269 feat(auftraege): worker, Phasen und Live-Fortschritt per SSE
Aufträge laufen als pg-boss-Jobs im worker. Der Phasen-Runner der Engine
kennt weder Proxmox noch Datenbank; eine Senke schreibt Phasen und Log
in die DB und meldet per NOTIFY, die app streamt per SSE an Web und CLI
(Last-Event-ID, Herzschlag, Polling-Fallback im Browser).

VMIDs vergibt eine Advisory-Sperre je Server zusammen mit einer
Reservierungstabelle; zehn parallele Reservierungen im Test bekommen
zehn verschiedene IDs. Der Proxmox-Client hat einen Trockenmodus für
den Dry-Run: Lesendes echt, Schreibendes nur aufgezeichnet.

Live-Ansicht im Aufbau der CLI-Ausgabe. Im echten Betrieb aufgefallen:
ssh2 und pg sind CommonJS und brauchen im worker Default-Importe.

Closes #006

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:54:41 +02:00
florian.wildeandClaude Opus 5.5 01d22aac3a feat(engine): Adapter debian und Profil-Engine
Profile sind deklarative Schritte je Distributionsfamilie; der Adapter
übersetzt sie in ein idempotentes Shell-Skript. Ein neuer Adapter (rhel,
alpine) ist eine Datei und ein Eintrag in der Registry.

Variablen werden typisiert geprüft, in run-Schritten jeder Wert für die
Shell gequotet. SSH-Härtung läuft zuletzt, damit sich eine Provisionierung
per SSH nicht selbst aussperrt.

Geprüft mit shellcheck und echt in Containern (Debian 12/13, Ubuntu
22.04/24.04/26.04) inklusive zweitem Lauf. Dabei aufgefallen: frische
Images haben keine Paketlisten – der Vorspann macht jetzt apt-get update.

Closes #005

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:53:38 +02:00
128 changed files with 10540 additions and 125 deletions
+37 -3
View File
@@ -8,9 +8,6 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
### 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
@@ -20,6 +17,43 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit
## [0.3.0] – 2026-10-12
### Hinzugefügt
- Template-Bau ohne SSH zum Node: über eine Bau-Adresse installiert ProxDeploy den Guest-Agent per SSH in der Vorbereitungs-VM (#021)
- SSH-Schlüssel in den Einstellungen hinterlegen (persönlich und, für Admins, Team-Schlüssel); sie kommen automatisch in jeden Gast – auch per API/CLI ohne Angabe – und sind im Assistenten abwählbar (#015)
- Templates mit eingebackenen Profilen: beim Template-Bau Profile wählen; Ausrollen nimmt das passende Template und überspringt, was schon drin ist (#022)
- Profil-Editor: eigene Profile als YAML mit Live-Prüfung und Skriptvorschau, Katalogprofile als Kopie bearbeiten (#011)
- Vorlagen (Presets): im Assistenten wählen und speichern, Vorlagenseite, `vorlage` in der API; Beispiel „Docker-Host klein“ (#012)
- Ubuntu 22.04/24.04/26.04 als VM und LXC; Fortschritt beim Klonen aus dem Task-Log (#010)
- LXC-Container ausrollen: Container-Template prüfen und laden, anlegen (unprivilegiert, nesting für docker), Netz, Provisionierung per `pct exec` über Node-SSH oder per SSH in den Container mit einem Bereitstellungsschlüssel (#009)
- Profile kennen die festen Bedingungen `if: vm` und `if: lxc` (#009)
### Behoben
- Verbindungstest meldete „Template-Bau per API“ auch dann, wenn der Guest-Agent mangels SSH gar nicht ins Template käme (#021)
- chrony wird in Containern nicht mehr installiert – ohne CAP_SYS_TIME scheiterte dort das Profil base (#009)
- SSH-Härtung auf Ubuntu 24.04 scheiterte an fehlendem /run/sshd (#009)
## [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
### Hinzugefügt
+49 -4
View File
@@ -2,7 +2,7 @@
## Zeitübersicht
**Gesamt:** 1 h 24 min (laufende Sitzung)
**Gesamt:** 4 h 38 min (Sitzung 1 und 2)
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
@@ -12,17 +12,53 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
| #002 Server-Verwaltung | 0 h 11 min |
| #003 Verbindungstest | 0 h 25 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 |
| #009 LXC | 0 h 31 min |
| #010 Ubuntu | 0 h 05 min |
| #011 Profil-Editor | 0 h 10 min |
| #012 Vorlagen | 0 h 11 min |
| #022 Profile im Template | 0 h 22 min |
| #015 (Teil) SSH-Schlüssel | 0 h 10 min |
| #021 Template-Bau ohne SSH | 0 h 19 min |
| Meilenstein | Zeit |
|---|---|
| v0.1.0 | 1 h 15 min | **abgeschlossen**
| v0.2.0 | – | in Arbeit
| v0.2.0 | 1 h 32 min | **abgeschlossen**
| v0.3.0 | 1 h 48 min | **abgeschlossen** (Abnahme #021 offen)
---
## 2026-10-12 · Sitzung 1 · 08:45–laufend
## 2026-10-12 · Sitzung 2 · 12:48–laufend
**Issue:** #009–#012, #015 (Teil), #021, #022
**Version:** 0.3.0-dev
### Erledigt
- #009: LXC per pct exec (pd-ct01, Debian 13, docker im unprivilegierten CT) und per SSH in den Container (pd-ct02, Ubuntu 24.04) auf pve-demo
- #010: Ubuntu 24.04 als VM (pd-ubu01, Template 9001 im selben Auftrag gebaut)
- #011: Profil-Editor mit Live-Prüfung und Skriptvorschau
- #012: Vorlagen inkl. „Docker-Host klein“, im Assistenten und per API
- #022 (Wunsch Florian): Profile ins Template einbacken – Template 9002 tpl-debian-13-docker; Provisionierung 141 s → ~30 s
- #015 vorgezogen: SSH-Schlüssel hinterlegen (persönlich/Team), automatisch in jedem Gast
- #021: Template-Bau ohne SSH über Bau-Adresse (Mock-getestet, Abnahme auf pve-demo braucht eine freie IP)
- Florians Fragen beantwortet: was am Template anpassbar ist; Schlüssel/Anwendungen ins Template
- 253 Tests, 18 Playwright-Abläufe; main gepusht
### Entscheidungen
- **chrony nur in VMs** (`if: vm`): unprivilegierte Container dürfen die Uhr nicht stellen.
- **Profile mit `{{ guest.* }}` laufen beim Deploy erneut**, auch wenn eingebacken – sonst fehlt z. B. der Benutzer in der docker-Gruppe (gefunden mit pd-test03).
- **SSH-Schlüssel nicht ins Template**, sondern in die Verwaltung (`SchluesselQuelle`) – beim Schlüsselwechsel bleibt es eine Stelle.
- **Fähigkeitserkennung korrigiert:** Template-Bau per API nur, wenn der Guest-Agent auch hineinkommt.
- **Voll-Klon bleibt Standard**; der Testhost kopiert zurzeit nur ~3–8 MB/s (Klon 8 min) – Linked Clones als spätere Option notiert.
---
## 2026-10-12 · Sitzung 1 · 08:45–11:35
**Issue:** #001
**Version:** 0.1.0-dev
**Version:** 0.1.0, 0.2.0
### Erledigt
- Auftrag gelesen, Rückfragen gestellt: Reverse-Proxy (ja), OIDC (implementieren, vorerst nur lokal testen), Testumgebung (folgt), CLI (Go)
@@ -35,6 +71,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
- Meilenstein v0.1.0 abgeschlossen, getaggt und nach Gitea gepusht
- 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
- #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)
@@ -52,4 +93,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.
- **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.
- **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).
+3 -3
View File
@@ -18,9 +18,9 @@ In Entwicklung. Version und Fortschritt stehen im [CHANGELOG](CHANGELOG.md), der
| Etappe | Inhalt | Stand |
|---|---|---|
| 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 |
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen | ○ geplant |
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ○ geplant |
| 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, Profile im Template, SSH-Schlüssel | ✓ fertig, auf PVE 9.2 geprüft (Bau-Adresse #021: Abnahme offen) |
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ◐ als Nächstes |
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ geplant |
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
+6 -1
View File
@@ -1,6 +1,7 @@
# Grundausstattung für jeden Gast. Platzhalter: {{ var.<name> }} aus den Variablen unten,
# {{ os.id }}, {{ os.version_id }}, {{ os.codename }} aus der Distribution,
# {{ guest.name }}, {{ guest.user }} aus dem Deployment. `a ?? b` nimmt b, wenn a leer ist.
# Bedingungen: `if: <variable>`, dazu fest `if: vm` / `if: lxc`.
id: base
name: Basis
description: Updates, Guest-Agent, Zeitzone, Locale, Tastatur, automatische Sicherheitsupdates, Zeitsync, SSH-Härtung
@@ -16,8 +17,12 @@ families:
- timezone: "{{ var.zeitzone }}"
- locale: "{{ var.locale }}"
- keyboard: "{{ var.tastatur }}"
- packages: [chrony, sudo, curl, ca-certificates]
- packages: [sudo, curl, ca-certificates]
# Zeitsync nur in VMs: ein unprivilegierter Container darf die Uhr nicht stellen, er nimmt die des Hosts.
- packages: [chrony]
if: vm
- service: { name: chrony, enable: true, start: true }
if: vm
- unattended_upgrades: true
- ssh_harden: { password_auth: false, root_login: false }
- packages: [fail2ban]
+17 -9
View File
@@ -1,23 +1,31 @@
# #005 Adapter debian und Profil-Engine
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** feature, engine
**Geschätzt:** 6 h **Tatsächlich:** –
**Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min
## Ziel
Interface `FamilienAdapter`, Umsetzung `debian`, Profil `base` wird zu einem idempotenten Skript.
Interface `FamilienAdapter`, Umsetzung `debian`, Profile werden zu einem idempotenten Skript.
## Aufgaben
- [ ] Interface + Registry; `debian`-Adapter (apt, systemd, deb822, locale, keyboard, sshd_config.d)
- [ ] Profilauflösung (requires, Reihenfolge, Variablen, Shell-Quoting)
- [ ] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, sudo-User, SSH-Härtung, chrony, fail2ban optional)
- [ ] Snapshot-Tests der erzeugten Skripte; shellcheck auf die Ausgabe
- [x] Interface + Registry; `debian`-Adapter (apt mit Lock-Timeout, deb822, systemd, locale, keyboard, sshd_config.d, sudoers mit visudo-Prüfung)
- [x] Profilauflösung (requires transitiv, Zyklen, Variablen mit Typen und Standardwerten, Familien-Prüfung)
- [x] Platzhalter `{{ var.x }}`, `{{ os.* }}`, `{{ guest.* }}`, `a ?? b`; in `run` jeder Wert shell-gequotet
- [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
- [ ] 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
#004
## 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.
+17 -10
View File
@@ -1,25 +1,32 @@
# #006 Aufträge, Phasen, worker und Live-Fortschritt
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** feature, engine
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 0 h 15 min
## Ziel
Hintergrund-Jobs mit Phasen in der DB, live per SSE in der Web-GUI.
## Aufgaben
- [ ] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung`
- [ ] Phasen-Runner mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis
- [ ] SSE-Endpunkt mit Last-Event-ID, Polling-Fallback
- [ ] VMID-Vergabe mit Advisory-Lock, Test mit parallelen Aufträgen
- [ ] Live-Ansicht als Terminal-Karte, Detail-Log, Ergebnis-Karte
- [x] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung`
- [x] Phasen-Runner (frameworkfrei) mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis, Abbruch zwischen Phasen
- [x] SSE-Endpunkt mit Last-Event-ID, Herzschlag, `X-Accel-Buffering: no`; Polling-Fallback im Client
- [x] VMID-Vergabe mit Advisory-Lock, Test mit zehn parallelen Reservierungen
- [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
- [ ] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test)
- [ ] Neuladen der Seite mitten im Auftrag zeigt den vollständigen Stand
- [x] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test)
- [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
#003
## 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`).
+15 -11
View File
@@ -1,26 +1,30 @@
# #007 Template-Bau für VMs
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** feature, proxmox
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 0 h 12 min
## Ziel
Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren.
## Aufgaben
- [ ] 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`
- [ ] 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
- [ ] Guest-Agent einbauen: Snippet (SSH) oder Bau-Adresse + SSH in die VM
- [ ] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar
- [ ] Singleton je Server+Distribution
- [x] Weg A (≥ 8.4): `download-url` (content=import, checksum) + `import-from` per Volume-ID
- [x] Weg B (SSH): Download + Prüfung auf dem Node, Import per `qm importdisk`/`import-from`
- [x] VMID: nächste freie im Template-Bereich (Entscheidung 2026-10-12)
- [x] Hardware: q35, OVMF+EFI-Disk (Standard) oder SeaBIOS, virtio-scsi-single, discard/ssd/iothread, CPU host, serielle Konsole, agent=1, cloud-init-Laufwerk
- [x] Guest-Agent einbauen per Snippet (SSH); Weg über Bau-Adresse ohne SSH → #021
- [x] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar
- [x] Singleton je Server+Distribution
## 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
#005, #006
## 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.
+16 -9
View File
@@ -1,24 +1,31 @@
# #008 Deploy-Assistent und VM ausrollen
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** feature
**Geschätzt:** 10 h **Tatsächlich:** –
**Geschätzt:** 10 h **Tatsächlich:** 0 h 36 min
## Ziel
Debian-13-VM aus Template vollständig unattended, Assistent mit Dry-Run und CLI-Befehl.
## Aufgaben
- [ ] Planer VM (Phasen + geplante API-Aufrufe), `POST /auftraege/plan`
- [ ] Ausführung: Klonen, Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Agent, Provisionierung per Agent
- [ ] Fehlendes Template → automatisch bauen anbieten
- [ ] Assistent mit 9 Schritten, URL-Zustand, Handy-tauglich
- [ ] Aufräumen nach Bestätigung
- [x] Anfrageschema für Web, CLI, Batch und Vorlagen (`src/lib/schemas/ausrollen.ts`), CLI-Befehl daraus
- [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
- [x] Dry-Run über denselben Ablauf mit Trocken-Client: `POST /api/v1/auftraege/plan`
- [x] Fehlendes Template → „Template vorher bauen“ im Assistenten, läuft als Phase im selben Auftrag
- [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
- [ ] 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
#007
## 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`).
+11 -6
View File
@@ -1,21 +1,26 @@
# #009 LXC-Container ausrollen
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.3.0
**Labels:** feature, proxmox
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 0 h 30 min
## Ziel
LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container.
## Aufgaben
- [ ] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH)
- [ ] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen)
- [x] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH)
- [x] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen)
## Akzeptanzkriterien
- [ ] LXC mit base auf echtem Proxmox, einmal mit und einmal ohne Node-SSH
- [x] Auf pve-demo: pd-ct01 (Debian 13, base+docker, pct exec, docker hello-world im unprivilegierten CT mit nesting) und pd-ct02 (Ubuntu 24.04, base, SSH in den Container, Bereitstellungsschlüssel danach entfernt)
## Abhängigkeiten
#008
## Notizen / Entscheidungen
–
- **Fund:** chrony läuft in unprivilegierten Containern nicht (keine CAP_SYS_TIME). Profile kennen jetzt die festen Bedingungen `if: vm` / `if: lxc`; base installiert chrony nur in VMs.
- **Fund:** Ubuntu 24.04 startet sshd per Socket, `/run/sshd` fehlt bis zur ersten Verbindung – `sshd -t` scheiterte. Der Adapter legt das Verzeichnis vorher an.
- **Docker im unprivilegierten Container braucht nur nesting**, kein keyctl (geprüft auf PVE 9.2.2 mit Debian 13).
- **keyctl** darf per Token nicht gesetzt werden; mit Node-SSH setzt ProxDeploy es per `pct set`, ohne lehnt die Prüfung ab.
- **Bereitstellungsschlüssel** (eigener ed25519, verschlüsselt in den Einstellungen) nur für den Weg ohne Node-SSH; die Schlüssel des Benutzers gehen nie an root.
- Bei mehreren passenden Container-Templates wird das neueste aus `aplinfo` geladen, wenn lokal nur ein älteres liegt.
+8 -6
View File
@@ -1,21 +1,23 @@
# #010 Ubuntu 22.04, 24.04 und 26.04
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.3.0
**Labels:** feature, katalog
**Geschätzt:** 3 h **Tatsächlich:** –
**Geschätzt:** 3 h **Tatsächlich:** 0 h 05 min
## Ziel
Ubuntu als VM und LXC über den debian-Adapter.
## Aufgaben
- [ ] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades)
- [ ] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12)
- [x] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades)
- [x] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12)
## Akzeptanzkriterien
- [ ] Je eine Ubuntu-VM und ein Ubuntu-LXC ausgerollt
- [x] Auf pve-demo: pd-ubu01 (Ubuntu 24.04 VM, Template 9001 im selben Auftrag gebaut) und pd-ct02 (Ubuntu 24.04 LXC)
## Abhängigkeiten
#009
## Notizen / Entscheidungen
–
- Der Adapter brauchte für Ubuntu nur eine Änderung: `/run/sshd` anlegen (sshd per Socket, #009). Locale-Erzeugung unterscheidet Debian/Ubuntu.
- Ubuntu 22.04 und 26.04 laufen im Containertest (`pnpm test:container`); auf dem Testhost geprüft ist 24.04.
- Der Voll-Klon dauerte auf pve-demo ~9 min (3,5 GB, Storage ~3 MB/s) – die Phase „Klonen“ zeigt jetzt den Fortschritt aus dem Task-Log.
+10 -7
View File
@@ -1,22 +1,25 @@
# #011 Profile docker und zabbix-agent, Variablen, Editor
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.3.0
**Labels:** feature
**Geschätzt:** 6 h **Tatsächlich:** –
**Geschätzt:** 6 h **Tatsächlich:** 0 h 10 min
## Ziel
Kombinierbare Profile mit Variablen; eigene Profile im YAML-Editor.
## Aufgaben
- [ ] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive)
- [ ] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie
- [ ] YAML-Editor mit Validierung und Fehlerstelle
- [x] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive)
- [x] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie
- [x] YAML-Editor mit Validierung und Fehlerstelle
## Akzeptanzkriterien
- [ ] docker run hello-world im ausgerollten Gast erfolgreich
- [x] docker hello-world in pd-test01 (VM) und pd-ct01 (LXC); zabbix-agent2 aktiv in pd-test02
## Abhängigkeiten
#005
## Notizen / Entscheidungen
–
- Editor prüft beim Tippen (400 ms Pause) gegen dasselbe Schema wie der Katalog und zeigt das erzeugte Skript.
- Katalogprofile sind schreibgeschützt; „Als Kopie bearbeiten“ legt `<id>-eigen` an. Eigene Profile mit Katalog-id werden abgewiesen.
- Profile anlegen dürfen nur Admins – sie führen Befehle als root aus.
- Löschen verweigert, solange ein anderes Profil es per `requires` braucht.
+8 -5
View File
@@ -1,20 +1,23 @@
# #012 Vorlagen (Presets)
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.3.0
**Labels:** feature
**Geschätzt:** 3 h **Tatsächlich:** –
**Geschätzt:** 3 h **Tatsächlich:** 0 h 11 min
## Ziel
Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar.
## Aufgaben
- [ ] CRUD, „aus diesem Auftrag als Vorlage speichern“, Beispiel „Docker-Host klein“
- [x] CRUD, „Als Vorlage speichern“ im Assistenten, Beispiel „Docker-Host klein“ (einmalig angelegt)
- [x] Vorlagen-Auswahl im Assistenten, `?vorlage=` von der Vorlagenseite, `vorlage` in der API (für `--preset` der CLI)
## Akzeptanzkriterien
- [ ] `--preset` in API und Assistent identisch aufgelöst
- [x] `--preset` in API und Assistent identisch aufgelöst
## Abhängigkeiten
#008
## Notizen / Entscheidungen
–
- Eine Vorlage liegt **unter** der Anfrage: was die Anfrage sagt, gewinnt; netz/features/variablen feldweise, Listen ersetzt.
- Name, VMID, IP und SSH-Schlüssel gehören nicht in eine Vorlage (Schema `strict`).
- Operatoren dürfen Vorlagen pflegen (Recht `vorlagenPflegen`).
+3 -2
View File
@@ -9,7 +9,8 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
## Aufgaben
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test)
- [ ] Benutzerverwaltung, CLI-Tokens, SSH-Keys über `SchluesselQuelle`
- [ ] Benutzerverwaltung, CLI-Tokens
- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar
- [ ] Audit-Log-Ansicht mit Filter
## Akzeptanzkriterien
@@ -19,4 +20,4 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
#001
## Notizen / Entscheidungen
–
- SSH-Schlüssel auf Florians Frage nach Schlüsseln im Template vorgezogen (2026-10-12): Schlüssel gehören in die Verwaltung, nicht ins Template. `SchluesselQuelle` ist die Andockstelle für eine externe Verwaltung (z. B. sshkeyvault).
+27
View File
@@ -0,0 +1,27 @@
# #021 Template-Bau ohne SSH (Bau-Adresse)
**Status:** umgesetzt, Abnahme auf pve-demo ausstehend
**Meilenstein:** v0.3.0
**Labels:** feature, proxmox
**Geschätzt:** 3 h **Tatsächlich:** 0 h 19 min
## 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
- [x] Server-Einstellung `bauNetz`: statische IP/CIDR, Gateway, DNS (Bridge/VLAN wie beim Template-Bau gewählt)
- [x] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys`
- [x] 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
- [x] 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
- **Fähigkeitserkennung war zu optimistisch:** „Template-Bau per API“ stand auch ohne Node-SSH da, obwohl der Guest-Agent dann nicht hineinkam. Jetzt: per API nur mit Snippet-Weg oder Bau-Adresse.
- Der Bereitstellungsschlüssel kommt per cloud-init an den Standardbenutzer des Images; danach werden alle authorized_keys, ciuser, sshkeys und die feste Adresse entfernt.
- Host-Key der frischen VM ist vorher unbekannt (TOFU, nur für diese eine Verbindung).
- Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH.
+30
View File
@@ -0,0 +1,30 @@
# #022 Templates mit eingebackenen Profilen
**Status:** erledigt
**Meilenstein:** v0.3.0
**Labels:** feature, proxmox
**Geschätzt:** 4 h **Tatsächlich:** 0 h 22 min
## Ziel
Beim Template-Bau Profile (z. B. base, docker) gleich mit installieren, damit Deployments schneller
fertig sind. Wunsch von Florian am 2026-10-12.
## Aufgaben
- [x] Template-Bau-Anfrage um `profile` erweitern (nur Profile ohne Pflichtvariablen bzw. mit Standardwerten)
- [x] Nach dem Vorbereitungsstart: VM erneut starten, Profile über den Guest-Agent ausführen (gleicher Code wie beim Deployment)
- [x] Aufräumen vor dem Template: Maschinen-ID leeren, `cloud-init clean`, SSH-Host-Keys löschen, `apt-get clean`, Logdatei entfernen
- [x] Name/Tags: `tpl-debian-13-docker`, Tags `proxdeploy-template;debian-13;profil-docker`
- [x] Ausrollen: passendes Template bevorzugen (eingebackene Profile ⊆ gewählte); eingebackene Profile beim Deploy überspringen, wenn ihre Variablen den Standardwerten entsprechen
- [ ] Optional: zusätzlicher Admin-/Notfall-Schlüssel im Template – zurückgestellt; Standard-Schlüssel je Benutzer/Team kommen stattdessen (#015)
## Akzeptanzkriterien
- [x] Template 9002 `tpl-debian-13-docker` auf pve-demo (8 min). Provisionierung beim Deploy: 141 s ohne, 29–32 s mit eingebackenem base+docker
## Abhängigkeiten
#007, #008
## Notizen / Entscheidungen
- SSH-Schlüssel gehören grundsätzlich nicht ins Template: beim Wechsel müsste jedes Template und jeder Klon angefasst werden. Dafür kommen Standard-Schlüssel je Benutzer/Team (aus #015 vorgezogen).
- **Fund:** Profile mit `{{ guest.* }}` hängen vom einzelnen Gast ab (docker nimmt den Benutzer in die Gruppe auf). Sie laufen beim Deploy trotzdem noch einmal – idempotent und schnell. Übersprungen wird nur, was gastunabhängig ist und Standardwerte hat.
- Vor dem Template: Maschinen-ID leeren, SSH-Host-Keys und Docker-engine-id löschen, apt-Cache leeren, `cloud-init clean`; geprüft: Klone haben eigene Maschinen-ID und eigene Host-Keys.
- Ausrollen wählt das Template mit den meisten eingebackenen Profilen, deren Profile alle gewählt sind – nie eins mit ungewählter Software.
- Gemeinsames Modul `gastagent.ts` für Provisionierung über den Guest-Agent (Deploy und Template).
+10 -8
View File
@@ -8,14 +8,14 @@ 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 |
| [#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 |
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | offen | v0.2.0 | 6 h | – |
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | offen | v0.2.0 | 8 h | – |
| [#007](007-template-bau.md) | Template-Bau für VMs | offen | v0.2.0 | 8 h | – |
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | offen | v0.2.0 | 10 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 | – |
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – |
| [#012](012-vorlagen.md) | Vorlagen (Presets) | offen | v0.3.0 | 3 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 | erledigt | v0.2.0 | 8 h | 0 h 15 min |
| [#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 | erledigt | v0.2.0 | 10 h | 0 h 36 min |
| [#009](009-lxc.md) | LXC-Container ausrollen | erledigt | v0.3.0 | 8 h | 0 h 30 min |
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | erledigt | v0.3.0 | 3 h | 0 h 05 min |
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | erledigt | v0.3.0 | 6 h | 0 h 10 min |
| [#012](012-vorlagen.md) | Vorlagen (Presets) | erledigt | v0.3.0 | 3 h | 0 h 11 min |
| [#013](013-cli.md) | CLI proxdeploy (Go) | offen | v0.4.0 | 12 h | – |
| [#014](014-verlauf.md) | Verlauf und Wiederholen | offen | v0.5.0 | 4 h | – |
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | offen | v0.5.0 | 6 h | – |
@@ -24,3 +24,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#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 | – |
| [#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) | umgesetzt, Abnahme offen | v0.3.0 | 3 h | 0 h 19 min |
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 22 min |
+124
View File
@@ -0,0 +1,124 @@
# 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. Fest eingebaut sind `if: vm` und `if: lxc` – etwa für Zeitsync, die
in einem unprivilegierten Container nicht laufen kann.
### 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
View File
@@ -1,6 +1,6 @@
{
"name": "proxdeploy",
"version": "0.1.0",
"version": "0.3.0",
"private": true,
"type": "module",
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
@@ -24,7 +24,9 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit"
"test:container": "tests/container/profile-pruefen.sh",
"typecheck": "tsc --noEmit",
"worker": "tsx src/worker/main.ts"
},
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.3.0",
@@ -37,6 +39,7 @@
"lucide-react": "1.47.0",
"next": "16.4.0",
"pg": "^8.23.0",
"pg-boss": "12.37.1",
"pino": "10.4.0",
"react": "19.3.0",
"react-dom": "19.3.0",
+87 -4
View File
@@ -192,10 +192,13 @@ importers:
version: 1.47.0(react@19.3.0)
next:
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:
specifier: ^8.23.0
version: 8.23.1
pg-boss:
specifier: 12.37.1
version: 12.37.1(@opentelemetry/api@1.9.1)
pino:
specifier: 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)
vitest:
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:
@@ -1199,6 +1202,10 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
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':
resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
@@ -2109,6 +2116,10 @@ packages:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'}
cron-parser@5.10.2:
resolution: {integrity: sha512-JfAYMY0LZaWQTuict8PhtF/ksSnq8xefSDmQUnnoqvkPYkU/y3UP0DTWzJKFBz0P1IXsDG8q0vY3zHkoFZOhvw==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -3080,6 +3091,10 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -3164,6 +3179,10 @@ packages:
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
engines: {node: '>=18'}
non-error@0.1.0:
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
engines: {node: '>=20'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -3248,6 +3267,13 @@ packages:
perfect-debounce@2.1.0:
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:
resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==}
@@ -3469,6 +3495,9 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
rrule-temporal@2.2.8:
resolution: {integrity: sha512-+wWUe0J02oMh1UWKIt1RTu2H0Cz+gP11O/iweK0MN2UP6JLGeepTUkbSBa90Hxq0W+ywSxM9IlJNouIUdMOwhg==}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -3510,6 +3539,10 @@ packages:
seq-queue@0.0.5:
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:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -3668,6 +3701,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
@@ -3675,6 +3712,9 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
temporal-spec@1.0.1:
resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==}
thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'}
@@ -3723,6 +3763,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
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:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -4663,6 +4707,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@opentelemetry/api@1.9.1': {}
'@oxc-project/types@0.153.0': {}
'@pinojs/redact@0.4.0': {}
@@ -5557,6 +5603,10 @@ snapshots:
nan: 2.29.0
optional: true
cron-parser@5.10.2:
dependencies:
luxon: 3.7.2
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -6640,6 +6690,8 @@ snapshots:
dependencies:
react: 19.3.0
luxon@3.7.2: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@@ -6694,7 +6746,7 @@ snapshots:
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:
'@next/env': 16.4.0
'@swc/helpers': 0.5.23
@@ -6713,6 +6765,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.4.0
'@next/swc-win32-arm64-msvc': 16.4.0
'@next/swc-win32-x64-msvc': 16.4.0
'@opentelemetry/api': 1.9.1
'@playwright/test': 1.63.0
sharp: 0.35.5(@types/node@24.19.2)
transitivePeerDependencies:
@@ -6729,6 +6782,8 @@ snapshots:
node-releases@2.0.58: {}
non-error@0.1.0: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -6822,6 +6877,16 @@ snapshots:
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:
optional: true
@@ -7052,6 +7117,10 @@ snapshots:
'@rolldown/binding-win32-arm64-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:
dependencies:
queue-microtask: 1.2.3
@@ -7091,6 +7160,11 @@ snapshots:
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:
dependencies:
define-data-property: 1.1.4
@@ -7303,10 +7377,14 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
tagged-tag@1.0.0: {}
tailwindcss@4.3.3: {}
tapable@2.3.3: {}
temporal-spec@1.0.1: {}
thread-stream@4.2.0:
dependencies:
real-require: 1.0.0
@@ -7351,6 +7429,10 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
type-fest@5.10.0:
dependencies:
tagged-tag: 1.0.0
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -7467,7 +7549,7 @@ snapshots:
tsx: 4.23.15
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:
'@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))
@@ -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)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.19.2
transitivePeerDependencies:
- 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;
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "Vorlage" (
"name" TEXT NOT NULL,
"titel" TEXT NOT NULL,
"beschreibung" TEXT,
"parameter" JSONB NOT NULL,
"erstelltVonId" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Vorlage_pkey" PRIMARY KEY ("name")
);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "profile" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "SshSchluessel" (
"id" TEXT NOT NULL,
"benutzerId" TEXT,
"name" TEXT NOT NULL,
"oeffentlich" TEXT NOT NULL,
"fingerabdruck" TEXT NOT NULL,
"quelle" TEXT NOT NULL DEFAULT 'lokal',
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SshSchluessel_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SshSchluessel_benutzerId_fingerabdruck_key" ON "SshSchluessel"("benutzerId", "fingerabdruck");
-- AddForeignKey
ALTER TABLE "SshSchluessel" ADD CONSTRAINT "SshSchluessel_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+170
View File
@@ -38,6 +38,8 @@ model Benutzer {
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
protokoll Protokoll[]
auftraege Auftrag[]
schluessel SshSchluessel[]
}
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
@@ -99,6 +101,8 @@ model Server {
geprueftAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
auftraege Auftrag[]
templates Template[]
}
enum Herkunft {
@@ -139,3 +143,169 @@ model Profil {
geaendertVonId String?
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
/// Eingebackene Profile (#022), aufgelöst inkl. Voraussetzungen.
profile String[] @default([])
gebautVonAuftragId String?
gebautAm DateTime @default(now())
@@unique([serverId, vmid])
@@index([serverId, distributionId])
}
/// Vorlage (Preset): eine gespeicherte Kombination, die Assistent, CLI (--preset) und API vorbelegen.
model Vorlage {
/// Kurzname für die CLI: „docker-host-klein“
name String @id
titel String
beschreibung String?
/// Teilmenge der Ausroll-Anfrage (typ, distribution, cores, speicherMb, diskGb, profile, variablen, tags …).
parameter Json
erstelltVonId String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
}
/// Öffentliche SSH-Schlüssel, die in Gäste kommen. benutzerId leer = Team-Schlüssel (in jeden Gast).
model SshSchluessel {
id String @id @default(cuid())
benutzerId String?
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
name String
oeffentlich String
/// SHA256:… wie ssh-keygen -lf
fingerabdruck String
/// Kennung der SchluesselQuelle („lokal“; später z. B. eine externe Schlüsselverwaltung).
quelle String @default("lokal")
erstelltAm DateTime @default(now())
@@unique([benutzerId, fingerabdruck])
}
+42
View File
@@ -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>
);
}
+34
View File
@@ -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")}
/>
);
}
+55
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
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 { vorlagenListe } from "@/server/services/vorlagen";
import { schluesselFuer } from "@/server/services/schluessel";
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(),
vorlagen: await vorlagenListe(),
schluessel: (await schluesselFuer(benutzer)).map((x) => ({
name: x.name,
oeffentlich: x.oeffentlich,
fingerabdruck: x.fingerabdruck,
bereich: x.bereich,
})),
};
const vorauswahl = aktiverServer(
daten.server.map((s) => s.name),
(await cookies()).get(SERVER_COOKIE)?.value,
);
return <Assistent daten={daten} vorauswahl={vorauswahl} />;
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { schluesselFuer } from "@/server/services/schluessel";
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
export const metadata: Metadata = { title: "Einstellungen" };
/** Einstellungen. Heute: SSH-Schlüssel. Benutzer, CLI-Tokens und Standardwerte folgen mit #015. */
export default async function Einstellungen() {
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
<p className="text-muted text-sm">Angemeldet als {benutzer.benutzername}</p>
</header>
<SchluesselVerwaltung
anfang={await schluesselFuer(benutzer)}
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
/>
</div>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { EINGABE_KLASSE, Feld, Schalter } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
type Schluessel = {
id: string;
name: string;
fingerabdruck: string;
bereich: "persoenlich" | "team";
quelle: string;
oeffentlich: string;
};
/**
* Öffentliche SSH-Schlüssel, die automatisch in jeden Gast kommen – eigene und (von Admins
* gepflegte) Team-Schlüssel. Im Assistenten lassen sie sich je Gast abwählen.
*/
export function SchluesselVerwaltung({
anfang,
teamErlaubt,
}: {
anfang: Schluessel[];
teamErlaubt: boolean;
}) {
const router = useRouter();
const [neu, setNeu] = useState({ name: "", oeffentlich: "", team: false });
const [meldung, setMeldung] = useState<{
art: "erledigt" | "fehler";
text: string;
felder?: Record<string, string>;
} | null>(null);
async function hinzufuegen(ereignis: React.FormEvent) {
ereignis.preventDefault();
try {
await apiAufruf("POST", "/api/v1/schluessel", neu);
setNeu({ name: "", oeffentlich: "", team: false });
setMeldung({ art: "erledigt", text: "Schlüssel hinzugefügt." });
router.refresh();
} catch (fehler) {
setMeldung({
art: "fehler",
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
});
}
}
async function entfernen(s: Schluessel) {
if (!window.confirm(`Schlüssel „${s.name}“ entfernen? Bereits ausgerollte Gäste behalten ihn.`)) return;
try {
await apiAufruf("DELETE", `/api/v1/schluessel/${s.id}`);
router.refresh();
} catch (fehler) {
setMeldung({ art: "fehler", text: fehler instanceof ApiFehler ? fehler.message : "Fehler." });
}
}
return (
<Karte titel="SSH-Schlüssel">
<div className="flex flex-col gap-5">
<p className="text-muted text-sm">
Diese öffentlichen Schlüssel kommen in jeden Gast, den du ausrollst – auch per CLI, wenn dort keine
angegeben sind.
</p>
{anfang.length === 0 ? (
<Status art="offen">Noch keine Schlüssel hinterlegt.</Status>
) : (
<ul className="divide-linie flex flex-col divide-y">
{anfang.map((s) => (
<li key={s.id} className="flex flex-col gap-2 py-3 first:pt-0 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<p className="font-medium">
{s.name}{" "}
<span className="text-muted text-sm">
· {s.bereich === "team" ? "Team" : "persönlich"}
</span>
</p>
<p className="text-muted font-mono text-xs break-all">{s.fingerabdruck}</p>
</div>
{s.bereich === "persoenlich" || teamErlaubt ? (
<Knopf
variante="gefahr"
onClick={() => void entfernen(s)}
aria-label={`Schlüssel ${s.name} entfernen`}
>
Entfernen
</Knopf>
) : null}
</li>
))}
</ul>
)}
<form onSubmit={hinzufuegen} className="border-linie flex flex-col gap-4 border-t pt-5">
<Feld id="schluessel-name" label="Name" fehler={meldung?.felder?.["name"]}>
<input
id="schluessel-name"
className={`${EINGABE_KLASSE} sm:max-w-sm`}
value={neu.name}
onChange={(e) => setNeu({ ...neu, name: e.target.value })}
placeholder="Laptop"
/>
</Feld>
<Feld
id="schluessel-text"
label="Öffentlicher Schlüssel"
hilfe="Inhalt von ~/.ssh/id_ed25519.pub"
fehler={meldung?.felder?.["oeffentlich"]}
>
<textarea
id="schluessel-text"
rows={3}
className={`${EINGABE_KLASSE} h-auto py-2 font-mono text-xs`}
value={neu.oeffentlich}
onChange={(e) => setNeu({ ...neu, oeffentlich: e.target.value })}
placeholder="ssh-ed25519 AAAA… name@rechner"
spellCheck={false}
/>
</Feld>
{teamErlaubt ? (
<Schalter
id="schluessel-team"
label="Team-Schlüssel"
hilfe="Kommt in jeden Gast, egal wer ausrollt."
checked={neu.team}
onChange={(an) => setNeu({ ...neu, team: an })}
/>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Knopf type="submit" disabled={!neu.name || !neu.oeffentlich}>
Hinzufügen
</Knopf>
<div aria-live="polite" className="text-sm">
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
</div>
</div>
</form>
</div>
</Karte>
);
}
+14 -2
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { darf } from "@/server/auth/rechte";
@@ -55,7 +56,16 @@ export default async function KatalogSeite() {
</ul>
</Karte>
<Karte titel="Profile">
<Karte
titel="Profile"
aktion={
verwalten ? (
<Link href="/katalog/profile/neu" className="text-akzent text-sm font-semibold">
Profil anlegen
</Link>
) : null
}
>
<ul className="divide-linie flex flex-col divide-y">
{profile.map((p) => (
<li
@@ -64,7 +74,9 @@ export default async function KatalogSeite() {
>
<div className="min-w-0 flex-1">
<p className="font-medium">
{p.name}{" "}
<Link href={`/katalog/profile/${p.id}`} className="hover:underline">
{p.name}
</Link>{" "}
<span className="text-muted font-mono text-xs">
{p.id} · {p.herkunft === "KATALOG" ? "Katalog" : "eigenes"}
</span>
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { ProfilEditor } from "../profil-editor";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
return { title: `Profil ${(await params).id}` };
}
/** Katalogprofile nur ansehen (bearbeiten als Kopie), eigene bearbeiten. */
export default async function ProfilSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const benutzer = await seiteSchuetzen("lesen", `/katalog/profile/${id}`);
const profil = await prisma.profil.findUnique({ where: { id } });
if (!profil || profil.entferntAm) notFound();
const bearbeitbar = profil.herkunft === "EIGEN" && darf(benutzer.rolle, "katalogVerwalten");
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-muted font-mono text-sm">
{profil.id} · {profil.herkunft === "KATALOG" ? "Katalog (schreibgeschützt)" : "eigenes Profil"}
</p>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{profil.name}</h1>
</div>
{profil.herkunft === "KATALOG" && darf(benutzer.rolle, "katalogVerwalten") ? (
<Link
href={`/katalog/profile/neu?von=${profil.id}`}
className="border-linie bg-panel-hoch hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
>
Als Kopie bearbeiten
</Link>
) : null}
</header>
<ProfilEditor
id={bearbeitbar ? profil.id : undefined}
anfang={profil.quelltext}
schreibgeschuetzt={!bearbeitbar}
/>
</div>
);
}
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { ProfilEditor } from "../profil-editor";
export const metadata: Metadata = { title: "Profil anlegen" };
const VORLAGE = `# Eigenes Profil – Format und Schritte: docs/katalog.md
id: mein-profil
name: Mein Profil
description: Was das Profil tut
requires: [base]
variables:
- { name: beispiel, label: Beispielwert, default: hallo }
families:
debian:
- packages: [htop]
- file:
path: /etc/motd
content: |
{{ guest.name }} – {{ var.beispiel }}
`;
/** Neues Profil, leer oder als Kopie (`?von=<id>`). */
export default async function ProfilNeu({ searchParams }: { searchParams: Promise<{ von?: string }> }) {
await seiteSchuetzen("katalogVerwalten", "/katalog/profile/neu");
const von = (await searchParams).von;
const quelle = von ? await prisma.profil.findUnique({ where: { id: von } }) : null;
const anfang = quelle
? quelle.quelltext
.replace(/^id:\s*.+$/m, `id: ${quelle.id}-eigen`)
.replace(/^name:\s*(.+)$/m, "name: $1 (eigen)")
: VORLAGE;
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
{quelle ? `${quelle.name} als Kopie` : "Profil anlegen"}
</h1>
<ProfilEditor anfang={anfang} schreibgeschuetzt={false} />
</div>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { Knopf } from "@/components/knopf";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
type Pruefung = { ok: true; vorschau: string | null } | { ok: false; fehler: string[] };
/**
* YAML-Editor für Profile mit Prüfung beim Tippen (nach kurzer Pause) und Vorschau des Skripts.
* Gespeichert wird nur, was der Server für gültig hält.
*/
export function ProfilEditor({
id,
anfang,
schreibgeschuetzt,
}: {
id?: string;
anfang: string;
schreibgeschuetzt: boolean;
}) {
const router = useRouter();
const [text, setText] = useState(anfang);
const [pruefung, setPruefung] = useState<Pruefung | null>(null);
const [meldung, setMeldung] = useState<{ text: string; hinweis?: string } | null>(null);
const [speichert, setSpeichert] = useState(false);
const zaehler = useRef(0);
useEffect(() => {
const nummer = ++zaehler.current;
const uhr = setTimeout(async () => {
try {
const ergebnis = await apiAufruf<Pruefung>("POST", "/api/v1/profile/pruefen", { quelltext: text });
// Eine ältere Antwort darf eine neuere nicht überschreiben.
if (nummer === zaehler.current) setPruefung(ergebnis);
} catch {
// Prüfung ist Komfort – beim Speichern prüft der Server ohnehin.
}
}, 400);
return () => clearTimeout(uhr);
}, [text]);
async function speichern() {
setSpeichert(true);
setMeldung(null);
try {
const gespeichert = id
? await apiAufruf<{ id: string }>("PUT", `/api/v1/profile/${id}`, { quelltext: text })
: await apiAufruf<{ id: string }>("POST", "/api/v1/profile", { quelltext: text });
router.push(`/katalog/profile/${gespeichert.id}`);
router.refresh();
} catch (fehler) {
setMeldung(
fehler instanceof ApiFehler
? { text: fehler.message, hinweis: fehler.hinweis }
: { text: "Unerwarteter Fehler." },
);
} finally {
setSpeichert(false);
}
}
async function loeschen() {
if (!id || !window.confirm(`Profil „${id}“ wirklich löschen?`)) return;
try {
await apiAufruf("DELETE", `/api/v1/profile/${id}`);
router.push("/katalog");
router.refresh();
} catch (fehler) {
setMeldung(
fehler instanceof ApiFehler
? { text: fehler.message, hinweis: fehler.hinweis }
: { text: "Unerwarteter Fehler." },
);
}
}
return (
<div className="flex flex-col gap-6">
<Karte titel="YAML">
<label htmlFor="quelltext" className="sr-only">
Profil als YAML
</label>
<textarea
id="quelltext"
value={text}
onChange={(e) => setText(e.target.value)}
readOnly={schreibgeschuetzt}
spellCheck={false}
rows={Math.min(40, Math.max(14, text.split("\n").length + 2))}
className="bg-grund border-linie text-text w-full rounded-lg border p-3 font-mono text-xs leading-5 sm:text-sm"
aria-describedby="pruefung"
/>
<div id="pruefung" aria-live="polite" className="mt-3 text-sm">
{pruefung === null ? (
<Status art="laeuft">Prüft …</Status>
) : pruefung.ok ? (
<Status art="erledigt">Gültig</Status>
) : (
<ul className="flex flex-col gap-1">
{pruefung.fehler.map((f) => (
<li key={f}>
<Status art="fehler">{f}</Status>
</li>
))}
</ul>
)}
</div>
</Karte>
{pruefung?.ok && pruefung.vorschau ? (
<details className="bg-panel border-linie rounded-karte border">
<summary className="cursor-pointer px-5 py-3.5 font-semibold">Vorschau: erzeugtes Skript</summary>
<pre className="border-linie max-h-[50vh] overflow-auto border-t p-5 font-mono text-xs">
{pruefung.vorschau}
</pre>
</details>
) : null}
{!schreibgeschuetzt ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<div aria-live="polite" className="text-sm">
{meldung ? (
<div>
<Status art="fehler">{meldung.text}</Status>
{meldung.hinweis ? <p className="text-muted">{meldung.hinweis}</p> : null}
</div>
) : null}
</div>
<div className="flex gap-3">
{id ? (
<Knopf variante="gefahr" onClick={loeschen}>
Löschen
</Knopf>
) : null}
<Knopf onClick={speichern} disabled={speichert || !pruefung?.ok}>
{speichert ? "Speichert …" : "Speichern"}
</Knopf>
</div>
</div>
) : null}
</div>
);
}
+49 -1
View File
@@ -43,6 +43,9 @@ type Werte = {
templateVmidVon: string;
templateVmidBis: string;
gastVmidVon: string;
bauIp: string;
bauGateway: string;
bauDns: string;
};
function anfangswerte(server?: ServerSicht): Werte {
@@ -60,6 +63,9 @@ function anfangswerte(server?: ServerSicht): Werte {
templateVmidVon: String(server?.templateVmidVon ?? 9000),
templateVmidBis: String(server?.templateVmidBis ?? 9999),
gastVmidVon: String(server?.gastVmidVon ?? 100),
bauIp: server?.bauNetz?.ip ?? "",
bauGateway: server?.bauNetz?.gateway ?? "",
bauDns: server?.bauNetz?.dns.join(", ") ?? "",
};
}
@@ -117,12 +123,16 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
setSpeichert(true);
setFelder({});
setMeldung(null);
const { bauIp, bauGateway, bauDns, ...ohneBau } = werte;
const rumpf = {
...werte,
...ohneBau,
sshPort: Number(werte.sshPort),
templateVmidVon: Number(werte.templateVmidVon),
templateVmidBis: Number(werte.templateVmidBis),
gastVmidVon: Number(werte.gastVmidVon),
bauNetz: bauIp.trim()
? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) }
: null,
};
try {
const gespeichert = bearbeiten
@@ -361,6 +371,44 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
</Feld>
</div>
<fieldset className="flex flex-col gap-3">
<legend className="text-sm font-medium">Bau-Adresse (optional)</legend>
<p className="text-muted text-sm">
Feste Adresse für den Vorbereitungsstart beim Template-Bau. Nur nötig ohne SSH zum Node:
ProxDeploy meldet sich darüber kurz in der VM an und installiert den Guest-Agent. Braucht
Internetzugang.
</p>
<div className="grid gap-4 sm:grid-cols-3">
<Feld id="bauIp" label="IP/Präfix" fehler={felder["bauNetz.ip"]}>
<input
id="bauIp"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.bauIp}
onChange={(e) => setze("bauIp", e.target.value)}
placeholder="10.0.10.250/24"
/>
</Feld>
<Feld id="bauGateway" label="Gateway" fehler={felder["bauNetz.gateway"]}>
<input
id="bauGateway"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.bauGateway}
onChange={(e) => setze("bauGateway", e.target.value)}
placeholder="10.0.10.1"
/>
</Feld>
<Feld id="bauDns" label="DNS" fehler={felder["bauNetz.dns"]}>
<input
id="bauDns"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.bauDns}
onChange={(e) => setze("bauDns", e.target.value)}
placeholder="10.0.10.1"
/>
</Feld>
</div>
</fieldset>
<Schalter
id="sshAktiv"
label="SSH zu den Nodes nutzen"
+139
View File
@@ -0,0 +1,139 @@
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, profileListe } 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,
familie: d.familie,
}));
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}` : ""}
{t.profile.length > 0 ? ` · mit ${t.profile.join(", ")}` : ""}
</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}
profile={(await profileListe({ nurAktive: true }))
.filter((p) => !p.definition.variables.some((v) => v.required && v.default === undefined))
.map((p) => ({
id: p.id,
name: p.name,
requires: p.definition.requires,
familien: p.familien,
}))}
/>
</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>
);
}
+180
View File
@@ -0,0 +1,180 @@
"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,
profile,
}: {
server: string;
distributionen: Array<{ id: string; name: string; familie: string }>;
profile: Array<{ id: string; name: string; requires: string[]; familien: 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] ?? ""),
profile: [] as string[],
});
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>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Profile einbacken (optional)</legend>
<p className="text-muted text-sm">
Was hier gewählt ist, ist in jedem Klon schon installiert – Deployments mit diesen Profilen sind
schneller fertig. Profile mit Pflichtangaben (z. B. Zabbix-Server) laufen beim Ausrollen.
</p>
<div className="flex flex-wrap gap-x-6 gap-y-2">
{profile
.filter((p) =>
p.familien.includes(distributionen.find((d) => d.id === werte.distribution)?.familie ?? ""),
)
.map((p) => {
const benoetigt = profile.some(
(x) => werte.profile.includes(x.id) && x.requires.includes(p.id),
);
return (
<label key={p.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="accent-akzent h-4 w-4"
checked={werte.profile.includes(p.id) || benoetigt}
disabled={benoetigt && !werte.profile.includes(p.id)}
onChange={(e) =>
setWerte({
...werte,
profile: e.target.checked
? [...werte.profile, p.id]
: werte.profile.filter((x) => x !== p.id),
})
}
/>
{p.name}
</label>
);
})}
</div>
</fieldset>
<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>
);
}
+68
View File
@@ -0,0 +1,68 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { vorlagenListe } from "@/server/services/vorlagen";
import { VorlageLoeschen } from "./vorlage-loeschen";
export const metadata: Metadata = { title: "Vorlagen" };
export default async function Vorlagen() {
const benutzer = await seiteSchuetzen("lesen", "/vorlagen");
const liste = await vorlagenListe();
const pflegen = darf(benutzer.rolle, "vorlagenPflegen");
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Vorlagen</h1>
<p className="text-muted text-sm">
Gespeicherte Kombinationen für den Assistenten und die CLI (
<span className="font-mono">--preset</span>). Anlegen: im Assistenten auf der Zusammenfassung „Als
Vorlage speichern“.
</p>
</header>
{liste.length === 0 ? (
<Karte>
<Status art="offen">Noch keine Vorlage.</Status>
</Karte>
) : (
<ul className="grid gap-4 md:grid-cols-2">
{liste.map((v) => {
const p = v.parameter;
const eckdaten = [
p.typ?.toUpperCase(),
p.distribution,
p.cores ? `${p.cores} CPU` : null,
p.speicherMb ? `${p.speicherMb / 1024} GB RAM` : null,
p.diskGb ? `${p.diskGb} GB Disk` : null,
].filter(Boolean);
return (
<li key={v.name} className="bg-panel border-linie rounded-karte flex flex-col gap-3 border p-5">
<div>
<p className="font-semibold">{v.titel}</p>
<p className="text-muted font-mono text-xs">--preset {v.name}</p>
</div>
{v.beschreibung ? <p className="text-muted text-sm">{v.beschreibung}</p> : null}
<p className="font-mono text-xs">{eckdaten.join(" · ")}</p>
{p.profile && p.profile.length > 0 ? (
<p className="text-muted text-xs">Profile: {p.profile.join(", ")}</p>
) : null}
<div className="mt-auto flex flex-wrap items-center gap-3 pt-2">
<Link
href={`/ausrollen?vorlage=${v.name}`}
className="bg-akzent text-akzent-text inline-flex h-11 items-center rounded-lg px-4 text-sm font-semibold sm:h-10"
>
Ausrollen mit dieser Vorlage
</Link>
{pflegen ? <VorlageLoeschen name={v.name} /> : null}
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -0,0 +1,21 @@
"use client";
import { useRouter } from "next/navigation";
import { Knopf } from "@/components/knopf";
import { apiAufruf } from "@/lib/api-client";
export function VorlageLoeschen({ name }: { name: string }) {
const router = useRouter();
return (
<Knopf
variante="gefahr"
onClick={async () => {
if (!window.confirm(`Vorlage „${name}“ löschen?`)) return;
await apiAufruf("DELETE", `/api/v1/vorlagen/${name}`);
router.refresh();
}}
>
Löschen
</Knopf>
);
}
@@ -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")));
});
+11
View File
@@ -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));
});
+10
View File
@@ -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) => {
const aufrufer = await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage), aufrufer));
});
+23
View File
@@ -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 });
});
+17 -1
View File
@@ -3,7 +3,7 @@ import { aufruferAdresse } from "@/lib/adressen";
import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { profilAktivSetzen } from "@/server/services/katalog";
import { profilAktivSetzen, profilLoeschen, profilSpeichern } from "@/server/services/katalog";
type Kontext = { params: Promise<{ id: string }> };
@@ -13,3 +13,19 @@ export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
await profilAktivSetzen((await params).id, aktiv, aufrufer, aufruferAdresse(anfrage.headers));
return NextResponse.json({ ok: true });
});
export const PUT = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
const { quelltext } = z
.object({ quelltext: z.string().min(1).max(200_000) })
.parse(await jsonLesen(anfrage));
return NextResponse.json(
await profilSpeichern(quelltext, aufrufer, aufruferAdresse(anfrage.headers), (await params).id),
);
});
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
await profilLoeschen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return new NextResponse(null, { status: 204 });
});
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { profilPruefen } from "@/server/services/katalog";
/** Live-Prüfung im Editor: Fehlerliste oder Vorschau des Skripts. Speichert nichts. */
export const POST = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "katalogVerwalten");
const { quelltext } = z.object({ quelltext: z.string().max(200_000) }).parse(await jsonLesen(anfrage));
return NextResponse.json(await profilPruefen(quelltext));
});
+15 -2
View File
@@ -1,8 +1,10 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { aufruferAdresse } from "@/lib/adressen";
import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api";
import { darf } from "@/server/auth/rechte";
import { rechtErforderlich } from "@/server/auth/zugang";
import { profileListe } from "@/server/services/katalog";
import { profileListe, profilSpeichern } from "@/server/services/katalog";
export const GET = api(async (anfrage: Request) => {
const { benutzer } = await rechtErforderlich(anfrage, "lesen");
@@ -10,3 +12,14 @@ export const GET = api(async (anfrage: Request) => {
new URL(anfrage.url).searchParams.get("alle") === "1" && darf(benutzer.rolle, "katalogVerwalten");
return NextResponse.json(await profileListe({ nurAktive: !alle }));
});
/** Eigenes Profil anlegen (YAML im Feld `quelltext`). Nur Admins – Profile führen Befehle als root aus. */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
const { quelltext } = z
.object({ quelltext: z.string().min(1).max(200_000) })
.parse(await jsonLesen(anfrage));
return NextResponse.json(await profilSpeichern(quelltext, aufrufer, aufruferAdresse(anfrage.headers)), {
status: 201,
});
});
+13
View File
@@ -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 { schluesselLoeschen } from "@/server/services/schluessel";
type Kontext = { params: Promise<{ id: string }> };
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
await schluesselLoeschen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return new NextResponse(null, { status: 204 });
});
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { schluesselAnlegen, schluesselFuer } from "@/server/services/schluessel";
/** Die eigenen und die Team-Schlüssel – das, was ohne Angabe in jeden Gast kommt. */
export const GET = api(async (anfrage: Request) => {
const { benutzer } = await rechtErforderlich(anfrage, "eigeneZugaenge");
return NextResponse.json(await schluesselFuer(benutzer));
});
/** `{ name, oeffentlich, team? }` – Team-Schlüssel nur für Admins. */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
return NextResponse.json(
await schluesselAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
{ status: 201 },
);
});
@@ -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 });
});
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { vorlageHolen, vorlageLoeschen, vorlageSpeichern } from "@/server/services/vorlagen";
type Kontext = { params: Promise<{ name: string }> };
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await vorlageHolen((await params).name));
});
export const PUT = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen");
return NextResponse.json(
await vorlageSpeichern(
await jsonLesen(anfrage),
aufrufer,
aufruferAdresse(anfrage.headers),
(await params).name,
),
);
});
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen");
await vorlageLoeschen((await params).name, aufrufer, aufruferAdresse(anfrage.headers));
return new NextResponse(null, { status: 204 });
});
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { vorlageSpeichern, vorlagenListe } from "@/server/services/vorlagen";
export const GET = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await vorlagenListe());
});
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen");
return NextResponse.json(
await vorlageSpeichern(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
{ status: 201 },
);
});
+1 -1
View File
@@ -20,7 +20,7 @@ export function ServerUmschalter({
return (
<div className="flex flex-col gap-1.5 px-2">
<label htmlFor="server-umschalter" className="text-muted text-xs font-medium tracking-wide uppercase">
Server
Aktiver Server
</label>
<select
id="server-umschalter"
+179
View File
@@ -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>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { AblaufFehler } from "@/engine/ablauf/fehler";
import type { PhasenKontext } from "@/engine/ablauf/ablauf";
import { MARKE } from "@/engine/profile/rendern";
import type { Parameter, ProxmoxClient } from "@/engine/proxmox/client";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
/**
* Provisionierung über den QEMU-Guest-Agent – gemeinsam für Deployments (#008) und Templates mit
* eingebackenen Profilen (#022). Kein SSH nötig.
*
* Die Agent-Endpunkte antworten unterschiedlich: exec → {pid}, exec-status und file-read direkt,
* ping und network-get-interfaces → {result}.
*/
export const SKRIPT = "/root/proxdeploy-provisionierung.sh";
export 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 ExecStatus = { exited: number | boolean; exitcode?: number };
export function agent(client: ProxmoxClient, vmPfad: string) {
return async <T>(befehl: string, methode: "GET" | "POST" = "POST", parameter?: Parameter) =>
await client.aufrufen<T>({ methode, pfad: `${vmPfad}/agent/${befehl}`, parameter });
}
/** Wartet, bis der Agent antwortet; liefert die Wartezeit in ms. */
export async function agentAbwarten(
client: ProxmoxClient,
vmPfad: string,
k: PhasenKontext,
optionen: { grenzeMs: number; warten: (ms: number) => Promise<void> },
): Promise<number> {
const rufen = agent(client, vmPfad);
const beginn = Date.now();
for (;;) {
await k.pruefeAbbruch();
try {
await rufen("ping");
return Date.now() - beginn;
} catch (fehler) {
if (fehler instanceof ProxmoxFehler && fehler.art === "recht") throw fehler;
if (Date.now() - beginn > optionen.grenzeMs) {
throw new AblaufFehler(
`Der Guest-Agent antwortet nach ${Math.round(optionen.grenzeMs / 1000)} s nicht.`,
"Bootet die VM? Konsole ansehen. Wurde das Template von ProxDeploy gebaut (Agent eingebaut)?",
);
}
}
await k.detail(`wartet … ${Math.round((Date.now() - beginn) / 1000)}s`);
await optionen.warten(3000);
}
}
/**
* Überträgt ein Skript in Stücken (file-write ersetzt, hängt nicht an), führt es aus und zeigt den
* Fortschritt aus der Logdatei im Gast (Marken `::proxdeploy:: profil <id>`).
*/
export async function skriptAusfuehren(
client: ProxmoxClient,
vmPfad: string,
skript: string,
k: PhasenKontext,
optionen: { grenzeMs: number; warten: (ms: number) => Promise<void> },
): Promise<void> {
const rufen = agent(client, vmPfad);
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 rufen("file-write", "POST", {
file: `${SKRIPT}.${nr}`,
content: inhalt.toString("base64"),
encode: "0",
});
}
const teile = stuecke.map((_, nr) => `${SKRIPT}.${nr}`).join(" ");
const { pid } = await rufen<{ pid: number }>("exec", "POST", {
command: [
"/bin/bash",
"-c",
`cat ${teile} > ${SKRIPT} && rm -f ${teile} && chmod 700 ${SKRIPT} && ${SKRIPT} > ${LOG} 2>&1`,
],
});
const beginn = Date.now();
let gelesen = 0;
let profil = "";
for (;;) {
await optionen.warten(3000);
const status = await rufen<ExecStatus>("exec-status", "GET", { pid: String(pid) });
try {
const datei = await rufen<{ content: string }>("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.
}
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 > optionen.grenzeMs) {
throw new AblaufFehler(
`Provisionierung läuft länger als ${Math.round(optionen.grenzeMs / 60000)} min.`,
`${LOG} in der VM ansehen.`,
);
}
const s = Math.round((Date.now() - beginn) / 1000);
await k.detail(
`${profil ? `Profil ${profil}` : "Vorbereitung"} läuft … ${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`,
);
}
await rufen("exec", "POST", { command: ["rm", "-f", SKRIPT] }).catch(() => undefined);
}
+444
View File
@@ -0,0 +1,444 @@
import { AblaufFehler } from "@/engine/ablauf/fehler";
import type { PhasenKontext, PhasenSchritt } from "@/engine/ablauf/ablauf";
import { adapterFuer } from "@/engine/adapter";
import { templateWaehlen } from "@/engine/katalog/lxc-template";
import type { Distribution } from "@/engine/katalog/schema";
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { MARKE, profileRendern } from "@/engine/profile/rendern";
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
import { q } from "@/engine/shell";
import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
import type { AusrollAnfrage } from "@/lib/schemas/ausrollen";
import type { NodeBefehl } from "./template-bau";
/**
* Einen LXC-Container ausrollen. Anlegen, Netz und Start gehen per API; Befehle im Container
* nicht – Proxmox hat dafür keine API (geprüft gegen das Schema von PVE 9.2). Provisioniert wird
* deshalb per SSH, auf einem von zwei Wegen:
*
* - **Node**: `pct exec <ctid> -- bash -s` über SSH zum Node. Braucht keinen Netzweg zum Container.
* - **Gast**: SSH als root direkt in den Container mit dem ProxDeploy-Bereitstellungsschlüssel, den
* das Anlegen per `ssh-public-keys` hinterlegt; am Ende wird er wieder entfernt.
*/
export type Bereitstellungsschluessel = { privat: string; oeffentlich: string };
export type LxcAusrollEingabe = {
client: ProxmoxClient;
anfrage: AusrollAnfrage;
distribution: Distribution;
profile: AufgeloestesProfil[];
/** Container-Features, die Profile verlangen (z. B. docker → nesting). */
profilFeatures: { nesting: boolean; keyctl: boolean };
vmidReservieren: (belegt: Set<number>, wunsch?: number) => Promise<number>;
sshFuer: (knoten: string) => NodeBefehl | null;
bereitstellung: () => Promise<Bereitstellungsschluessel>;
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
auftragKurzId: string;
warten?: (ms: number) => Promise<void>;
netzMs?: number;
/** Nur für Tests: SSH in den Gast ersetzen. */
gastSsh?: (
ziel: { host: string; schluessel: Bereitstellungsschluessel },
skript: string,
beiAusgabe: (t: string) => void,
warten: (ms: number) => Promise<void>,
) => Promise<number | null>;
};
export type LxcAusrollStand = {
knoten?: string;
vmid?: number;
ostemplate?: string;
vztmplStorage?: string;
ip?: string;
weg?: "node" | "gast";
};
type Ressource = {
vmid?: number;
node?: string;
type?: string;
status?: string;
maxmem?: number;
mem?: number;
};
type StorageEintrag = { storage: string; content: string; active?: number; enabled?: number };
type Aplinfo = { template: string; section?: string; package?: string };
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
/** Markenzeilen der Provisionierung in Detail und Log übersetzen – für beide Wege gleich. */
export function ausgabeVerfolgen(k: PhasenKontext) {
let rest = "";
let profil = "";
const beginn = Date.now();
return {
get profil() {
return profil;
},
weiter(text: string) {
rest += text;
const zeilen = rest.split("\n");
rest = zeilen.pop() ?? "";
for (const zeile of zeilen) {
const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile);
if (marke) {
profil = marke[1]!;
const s = Math.round((Date.now() - beginn) / 1000);
void k.detail(
`Profil ${profil} läuft … ${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`,
);
}
void k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug");
}
},
};
}
export function lxcNetz(netz: AusrollAnfrage["netz"]): string {
return [
"name=eth0",
`bridge=${netz.bridge}`,
...(netz.vlan ? [`tag=${netz.vlan}`] : []),
netz.modus === "dhcp" ? "ip=dhcp" : `ip=${netz.ip}`,
...(netz.modus === "statisch" && netz.gateway ? [`gw=${netz.gateway}`] : []),
"type=veth",
].join(",");
}
export function lxcAusrollSchritte(e: LxcAusrollEingabe, stand: LxcAusrollStand = {}): PhasenSchritt[] {
const { client, anfrage: a, distribution: d } = e;
const warten = e.warten ?? schlafen;
const lxc = d.lxc;
const ctPfad = () => pfad`/nodes/${stand.knoten!}/lxc/${stand.vmid!}`;
const features = {
nesting: a.features.nesting || e.profilFeatures.nesting,
keyctl: a.features.keyctl || e.profilFeatures.keyctl,
};
let bereitstellung: Bereitstellungsschluessel | undefined;
return [
{
schluessel: "pruefungen",
titel: "Prüfungen",
ausfuehren: async () => {
if (!lxc)
throw new AblaufFehler(
`${d.name} gibt es nicht als LXC-Container.`,
"Eine andere Distribution wählen.",
);
const ressourcen = await client.get<Ressource[]>("/cluster/resources");
const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online");
stand.knoten =
a.knoten ??
[...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.");
}
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${stand.knoten}/storage`);
const aktiv = (s: StorageEintrag) => s.active === 1 && s.enabled !== 0;
const ziel = storages.find((s) => s.storage === a.storage);
if (!ziel || !aktiv(ziel) || !ziel.content.split(",").includes("rootdir")) {
throw new AblaufFehler(
`Storage ${a.storage} nimmt auf ${stand.knoten} keine Container auf.`,
`Geeignet sind: ${
storages
.filter((s) => aktiv(s) && s.content.includes("rootdir"))
.map((s) => s.storage)
.join(", ") || "keiner"
}.`,
);
}
stand.vztmplStorage = storages.find(
(s) => aktiv(s) && s.content.split(",").includes("vztmpl"),
)?.storage;
if (!stand.vztmplStorage)
throw new AblaufFehler(
"Kein Storage mit Inhaltstyp „Container-Templates“.",
"Einem Dir-Storage den Inhaltstyp vztmpl geben.",
);
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 aware“ an der Bridge einschalten oder ohne VLAN ausrollen.",
);
}
// Provisionierung braucht SSH: lieber zum Node (kein Netzweg zum Container nötig).
stand.weg = e.sshFuer(stand.knoten) ? "node" : "gast";
if (features.keyctl && stand.weg !== "node") {
throw new AblaufFehler(
"Das Feature keyctl darf per API-Token nicht gesetzt werden.",
"SSH zum Node einrichten (dann setzt ProxDeploy es per pct set) oder ohne keyctl ausrollen.",
);
}
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: "LXC" });
return {
detail: `CTID ${stand.vmid} frei · ${a.storage} · ${a.netz.bridge}${a.netz.vlan ? ` VLAN ${a.netz.vlan}` : ""} · Provisionierung über ${stand.weg === "node" ? "pct exec" : "SSH in den Container"}`,
};
},
},
{
schluessel: "lxc-template",
titel: "LXC-Template",
ausfuehren: async () => {
const vorhanden = await client.get<Array<{ volid: string }>>(
pfad`/nodes/${stand.knoten!}/storage/${stand.vztmplStorage!}/content`,
{
content: "vztmpl",
},
);
const dateien = vorhanden.map((v) => v.volid.split("/").pop()!);
const lokal = templateWaehlen(lxc!.template_pattern, dateien);
// Was es zum Herunterladen gibt – ist dort eine neuere Version, wird sie geladen.
const verfuegbar = (await client.get<Aplinfo[]>(pfad`/nodes/${stand.knoten!}/aplinfo`)).map(
(x) => x.template,
);
const neueste = templateWaehlen(lxc!.template_pattern, verfuegbar);
if (lokal && (!neueste || lokal === neueste)) {
stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${lokal}`;
return { uebersprungen: true, detail: `${lokal} vorhanden` };
}
if (!neueste) {
if (lokal) {
stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${lokal}`;
return { uebersprungen: true, detail: `${lokal} vorhanden` };
}
throw new AblaufFehler(
`Kein Container-Template passt zu „${lxc!.template_pattern}“.`,
"Mit „pveam update“ die Liste auf dem Node auffrischen oder das Muster im Katalog prüfen.",
);
}
await aufgabeAbwarten(
client,
await client.post<string>(pfad`/nodes/${stand.knoten!}/aplinfo`, {
storage: stand.vztmplStorage!,
template: neueste,
}),
{ zeitlimitMs: 30 * 60 * 1000 },
);
stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${neueste}`;
return { detail: `${neueste} geladen` };
},
},
{
schluessel: "anlegen",
titel: "Anlegen",
ausfuehren: async () => {
if (stand.weg === "gast") bereitstellung = await e.bereitstellung();
const featureListe = [features.nesting ? "nesting=1" : null].filter(Boolean).join(",");
await aufgabeAbwarten(
client,
await client.post<string>(pfad`/nodes/${stand.knoten!}/lxc`, {
vmid: stand.vmid!,
hostname: a.name,
ostemplate: stand.ostemplate!,
ostype: lxc!.ostype,
rootfs: `${a.storage}:${a.diskGb}`,
cores: a.cores,
memory: a.speicherMb,
swap: a.swapMb,
unprivileged: a.unprivilegiert,
...(featureListe ? { features: featureListe } : {}),
net0: lxcNetz(a.netz),
...(a.netz.dns.length > 0 ? { nameserver: a.netz.dns.join(" ") } : {}),
...(a.netz.suchdomain ? { searchdomain: a.netz.suchdomain } : {}),
// Nur der Bereitstellungsschlüssel kommt an root – der Benutzer bekommt seine eigenen Schlüssel.
...(bereitstellung ? { "ssh-public-keys": bereitstellung.oeffentlich } : {}),
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(", ") || "–"}`,
}),
{ zeitlimitMs: 15 * 60 * 1000 },
);
await e.angelegt({ vmid: stand.vmid });
if (features.keyctl) {
const ssh = e.sshFuer(stand.knoten!)!;
if (!client.trocken) {
const ergebnis = await ssh(
`pct set ${stand.vmid} --features ${q(`nesting=${features.nesting ? 1 : 0},keyctl=1`)}`,
);
if (ergebnis.code !== 0)
throw new AblaufFehler(
`keyctl ließ sich nicht setzen: ${ergebnis.stderr.trim()}`,
"Rechte des SSH-Benutzers prüfen.",
);
}
}
return {
detail: `${a.unprivilegiert ? "unprivilegiert" : "privilegiert"} · ${a.cores} CPU · ${a.speicherMb} MB · ${a.diskGb}G${
features.nesting || features.keyctl
? ` · ${[features.nesting && "nesting", features.keyctl && "keyctl"].filter(Boolean).join(", ")}`
: ""
}`,
};
},
},
{
schluessel: "start",
titel: "Start",
ausfuehren: async () => {
await aufgabeAbwarten(client, await client.post<string>(`${ctPfad()}/status/start`), {
zeitlimitMs: 5 * 60 * 1000,
});
return { detail: `CT ${stand.vmid} läuft` };
},
},
{
schluessel: "netzwerk",
titel: "Netzwerk",
ausfuehren: async (k) => {
if (a.netz.modus === "statisch") {
stand.ip = a.netz.ip!.split("/")[0];
return { detail: `IP ${stand.ip}` };
}
if (client.trocken) return { detail: "IP per DHCP" };
const beginn = Date.now();
const grenze = e.netzMs ?? 2 * 60 * 1000;
while (!stand.ip) {
await k.pruefeAbbruch();
const schnittstellen = await client.get<Array<{ name: string; inet?: string }>>(
`${ctPfad()}/interfaces`,
);
stand.ip = schnittstellen.find((s) => s.name === "eth0" && s.inet)?.inet?.split("/")[0];
if (stand.ip) break;
if (Date.now() - beginn > grenze) {
throw new AblaufFehler(
"Der Container hat per DHCP keine Adresse bekommen.",
"DHCP auf der Bridge bzw. im VLAN prüfen oder eine statische Adresse wählen.",
);
}
await k.detail(`wartet auf DHCP … ${Math.round((Date.now() - beginn) / 1000)}s`);
await warten(2000);
}
return { detail: `IP ${stand.ip} per DHCP` };
},
},
{
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 nachspann =
stand.weg === "gast"
? [
`echo '${MARKE} bereitstellung-entfernen'\nsed -i '/proxdeploy-bereitstellung/d' /root/.ssh/authorized_keys\n`,
]
: [];
const { skript } = profileRendern(
e.profile,
adapter,
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "lxc" } },
[
adapter.benutzerAnlegen({
name: a.zugang.benutzer,
sshSchluessel: a.zugang.sshSchluessel,
sudo: true,
}),
],
);
const gesamt = skript + nachspann.join("");
if (client.trocken) {
await k.log(gesamt, "info");
return {
detail: `über ${stand.weg === "node" ? "pct exec" : "SSH"}${e.profile.length > 0 ? ` · Profile ${e.profile.map((p) => p.profil.id).join(", ")}` : ""}`,
};
}
const verfolgen = ausgabeVerfolgen(k);
let code: number | null;
if (stand.weg === "node") {
const ssh = e.sshFuer(stand.knoten!)!;
code = (
await ssh(`pct exec ${stand.vmid} -- bash -s`, {
eingabe: gesamt,
zeitlimitMs: 60 * 60 * 1000,
beiAusgabe: (t) => verfolgen.weiter(t),
})
).code;
} else {
const ziel = { host: stand.ip!, schluessel: bereitstellung! };
code = await (e.gastSsh ?? gastSshStandard)(ziel, gesamt, (t) => verfolgen.weiter(t), warten);
}
verfolgen.weiter("\n");
if (code !== 0) {
throw new AblaufFehler(
`Provisionierung ${verfolgen.profil ? `im Profil ${verfolgen.profil} ` : ""}mit Code ${code} abgebrochen.`,
"Detail-Log ansehen.",
);
}
return {
detail:
e.profile.length > 0
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet`
: "Benutzer eingerichtet",
};
},
},
];
}
/**
* SSH als root in den frischen Container. Seinen Host-Key kann ProxDeploy vorher nicht kennen –
* er entsteht beim ersten Start. Vertraut wird deshalb dem ersten Schlüssel (TOFU), und das nur für
* diese eine Verbindung; der Bereitstellungsschlüssel wird am Ende des Skripts wieder entfernt.
*/
async function gastSshStandard(
ziel: { host: string; schluessel: Bereitstellungsschluessel },
skript: string,
beiAusgabe: (t: string) => void,
warten: (ms: number) => Promise<void> = schlafen,
): Promise<number | null> {
const beginn = Date.now();
for (;;) {
try {
const ergebnis = await sshAusfuehren(
{
host: ziel.host,
port: 22,
benutzer: "root",
privatSchluessel: ziel.schluessel.privat,
hostSchluessel: null,
},
"bash -s",
{ erkunden: true, eingabe: skript, zeitlimitMs: 60 * 60 * 1000, beiAusgabe },
);
return ergebnis.code;
} catch (fehler) {
// sshd braucht nach dem Start ein paar Sekunden.
if (fehler instanceof SshFehler && fehler.art === "verbindung" && Date.now() - beginn < 90_000) {
await warten(3000);
continue;
}
if (fehler instanceof SshFehler) {
throw new AblaufFehler(
`SSH in den Container ${ziel.host} fehlgeschlagen: ${fehler.ursache}`,
"Ist der Container aus dem Netz von ProxDeploy erreichbar? Sonst SSH zum Node einrichten (pct exec).",
);
}
throw fehler;
}
}
}
+609
View File
@@ -0,0 +1,609 @@
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";
import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
import type { BauNetz } from "@/lib/schemas/server";
import type { Bereitstellungsschluessel } from "./lxc-ausrollen";
import { adapterFuer } from "@/engine/adapter";
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { profileRendern } from "@/engine/profile/rendern";
import { agentAbwarten, skriptAusfuehren } from "./gastagent";
/**
* 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; beiAusgabe?: (text: string) => void },
) => Promise<{ code: number | null; stdout: string; stderr: string }>;
export type TemplateBauEingabe = {
client: ProxmoxClient;
distribution: Distribution;
/** Eingebackene Profile (#022), aufgelöst mit Standardwerten; leer = nur Guest-Agent. */
profile?: AufgeloestesProfil[];
/** Bau-Adresse (#021): Guest-Agent per SSH in die VM einbauen, wenn es kein Node-SSH gibt. */
bauNetz?: BauNetz | null;
bereitstellung?: () => Promise<Bereitstellungsschluessel>;
/** Nur für Tests: SSH in die Vorbereitungs-VM ersetzen. */
gastSsh?: (
ziel: { host: string; benutzer: string; schluessel: Bereitstellungsschluessel },
skript: string,
) => Promise<number | null>;
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;
/** Wie der Guest-Agent hineinkommt: Snippet (Node-SSH) oder SSH in die VM über die Bau-Adresse. */
agentWeg?: "snippet" | "baunetz";
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, profile: string[] = []): string {
return ["tpl", distribution.id, ...profile.filter((p) => p !== "base")]
.join("-")
.replace(/[^a-z0-9-]/g, "-");
}
/**
* Aufräumen vor dem Template, wenn Profile eingebacken wurden – alles, was je Klon eindeutig sein
* muss oder nur Platz kostet. SSH-Host-Keys erzeugt cloud-init bei jedem Klon neu.
*/
export const TEMPLATE_AUFRAEUMEN = [
"set -e",
"truncate -s 0 /etc/machine-id",
"rm -f /var/lib/dbus/machine-id",
"rm -f /etc/ssh/ssh_host_*",
"rm -f /var/lib/docker/engine-id",
"apt-get clean || true",
"rm -f /var/log/proxdeploy-provisionierung.log",
"cloud-init clean --logs",
"sync",
].join("; ");
/**
* 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
`;
}
/**
* Guest-Agent ohne Node-SSH (#021): Vorbereitungsstart mit fester Bau-Adresse und dem
* Bereitstellungsschlüssel für den Standardbenutzer des Images, dann SSH in die VM. Danach wird
* der Schlüssel entfernt und die cloud-init-Angaben zurückgesetzt – im Template bleibt nichts davon.
*/
export const BAUNETZ_SKRIPT = `set -e
export DEBIAN_FRONTEND=noninteractive
cloud-init status --wait >/dev/null 2>&1 || true
apt-get -q update
apt-get -y -q -o DPkg::Lock::Timeout=600 install qemu-guest-agent
systemctl enable qemu-guest-agent
rm -f /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys
truncate -s 0 /etc/machine-id
rm -f /var/lib/dbus/machine-id
cloud-init clean --logs
sync
`;
async function agentUeberBauNetz(
e: TemplateBauEingabe,
vmPfad: string,
benutzer: string,
k: PhasenKontext,
warten: (ms: number) => Promise<void>,
): Promise<{ detail: string }> {
const { client } = e;
const netz = e.bauNetz!;
const schluessel = await e.bereitstellung!();
const host = netz.ip.split("/")[0]!;
await client.put(`${vmPfad}/config`, {
ciuser: benutzer,
sshkeys: encodeURIComponent(schluessel.oeffentlich).replace(
/[!'()*]/g,
(z) => `%${z.charCodeAt(0).toString(16).toUpperCase()}`,
),
ipconfig0: `ip=${netz.ip},gw=${netz.gateway}`,
...(netz.dns.length > 0 ? { nameserver: netz.dns.join(" ") } : {}),
});
try {
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/start`), {
zeitlimitMs: 5 * 60 * 1000,
});
if (client.trocken) return { detail: `über Bau-Adresse ${host}` };
await k.detail(`Vorbereitungsstart über Bau-Adresse ${host} …`);
const code = await (e.gastSsh ?? gastSshBau)(
{ host, benutzer, schluessel },
BAUNETZ_SKRIPT,
warten,
(z) => void k.log(z, "debug"),
);
if (code !== 0) {
throw new AblaufFehler(
`Einbau des Guest-Agents über ${host} mit Code ${code} abgebrochen.`,
"Hat die Bau-Adresse Internetzugang (Gateway, DNS)? Detail-Log ansehen.",
);
}
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/shutdown`, { timeout: 180 }), {
zeitlimitMs: 5 * 60 * 1000,
});
for (let i = 0; i < 60; i += 1) {
if ((await client.get<{ status: string }>(`${vmPfad}/status/current`)).status === "stopped") break;
await warten(3000);
}
} finally {
// Bau-Adresse und Schlüssel gehören nicht ins Template.
await client
.put(`${vmPfad}/config`, { ipconfig0: "ip=dhcp", delete: "sshkeys,ciuser,nameserver" })
.catch(() => undefined);
}
return { detail: `qemu-guest-agent über Bau-Adresse ${host} installiert · Maschinen-ID geleert` };
}
async function gastSshBau(
ziel: { host: string; benutzer: string; schluessel: Bereitstellungsschluessel },
skript: string,
warten: (ms: number) => Promise<void>,
beiZeile: (z: string) => void,
): Promise<number | null> {
// Den Host-Key der frischen VM kann niemand vorher kennen (TOFU, nur für diese Verbindung).
const beginn = Date.now();
for (;;) {
try {
const ergebnis = await sshAusfuehren(
{
host: ziel.host,
port: 22,
benutzer: ziel.benutzer,
privatSchluessel: ziel.schluessel.privat,
hostSchluessel: null,
},
"sudo -n bash -s",
{
erkunden: true,
eingabe: skript,
zeitlimitMs: 20 * 60 * 1000,
beiAusgabe: (t) => t.split("\n").filter(Boolean).forEach(beiZeile),
},
);
return ergebnis.code;
} catch (fehler) {
if (
fehler instanceof SshFehler &&
(fehler.art === "verbindung" || fehler.art === "anmeldung" || fehler.art === "zeitueberschreitung") &&
Date.now() - beginn < 4 * 60 * 1000
) {
// Bootet noch oder cloud-init hat den Schlüssel noch nicht eingetragen.
await warten(5000);
continue;
}
if (fehler instanceof SshFehler) {
throw new AblaufFehler(
`SSH in die Vorbereitungs-VM (${ziel.host}) fehlgeschlagen: ${fehler.ursache}`,
"Ist die Bau-Adresse aus dem Netz von ProxDeploy erreichbar? Stimmen Bridge/VLAN?",
);
}
throw fehler;
}
}
}
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 profilIds = (e.profile ?? []).map((p) => p.profil.id);
const name = templateName(d, profilIds);
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) {
if (e.ssh && stand.snippetStorage) stand.agentWeg = "snippet";
else if (e.bauNetz && e.bereitstellung) stand.agentWeg = "baunetz";
else {
throw new AblaufFehler(
"Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node (mit einem Snippets-Storage) oder eine Bau-Adresse.",
e.ssh
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben oder in den Server-Einstellungen eine Bau-Adresse eintragen."
: "SSH in den Server-Einstellungen einrichten oder dort eine Bau-Adresse (feste IP für die Vorbereitungs-VM) eintragen.",
);
}
}
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,
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, ...profilIds.map((p) => `profil-${p}`)].join(";"),
description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}${
profilIds.length > 0 ? `\nEingebackene Profile: ${profilIds.join(", ")}` : ""
}\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} (${name}) · 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}`;
if (stand.agentWeg === "baunetz")
return await agentUeberBauNetz(e, vmPfad, vm.default_user, k, warten);
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: "profile",
titel: "Profile",
ausfuehren: async (k) => {
if (profilIds.length === 0) return { uebersprungen: true, detail: "keine eingebacken" };
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 vmPfad = pfad`/nodes/${knoten}/qemu/${stand.vmid!}`;
const { skript } = profileRendern(e.profile!, adapter, {
os: d.os,
gast: { name, benutzer: vm.default_user, typ: "vm" },
});
if (client.trocken) {
await k.log(skript, "info");
return { detail: `${profilIds.join(", ")} würden eingebacken` };
}
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/start`), {
zeitlimitMs: 5 * 60 * 1000,
});
await agentAbwarten(client, vmPfad, k, { grenzeMs: 5 * 60 * 1000, warten });
await skriptAusfuehren(client, vmPfad, skript, k, { grenzeMs: 60 * 60 * 1000, warten });
await k.detail("aufräumen und herunterfahren …");
// Über denselben Weg wie die Profile: wartet aufs Ende, statt eine Pause zu raten.
await skriptAusfuehren(client, vmPfad, `#!/bin/bash\n${TEMPLATE_AUFRAEUMEN}\n`, k, {
grenzeMs: 5 * 60 * 1000,
warten,
});
await aufgabeAbwarten(
client,
await client.post<string>(`${vmPfad}/status/shutdown`, { timeout: 180 }),
{ zeitlimitMs: 5 * 60 * 1000 },
);
for (let i = 0; i < 60; i += 1) {
if ((await client.get<{ status: string }>(`${vmPfad}/status/current`)).status === "stopped") break;
await warten(3000);
}
return { detail: `${profilIds.join(", ")} eingebacken · Maschinen-ID und Host-Keys entfernt` };
},
},
{
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 ${name}` };
},
},
];
}
+417
View File
@@ -0,0 +1,417 @@
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 } from "@/engine/profile/rendern";
import { agent, agentAbwarten, skriptAusfuehren } from "./gastagent";
import { gastunabhaengig, mitStandardwerten, 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; eingebacken: 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}` : ""}`;
}
type AgentAntwort<T> = { result: T };
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!}`;
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 gewaehlt = new Set(e.profile.map((p) => p.profil.id));
const tags = (r: Ressource) => (r.tags ?? "").split(/[;, ]/);
// Nur Templates, deren eingebackene Profile alle gewählt sind (#022) – sonst hätte der Gast
// Software, die niemand bestellt hat.
const templates = ressourcen
.filter(
(r) =>
r.type === "qemu" &&
r.template === 1 &&
tags(r).includes("proxdeploy-template") &&
tags(r).includes(d.id),
)
.map((r) => ({
r,
eingebacken: tags(r)
.filter((t) => t.startsWith("profil-"))
.map((t) => t.slice(7)),
}))
.filter((t) => t.eingebacken.every((p) => gewaehlt.has(p)))
// Am meisten eingebacken zuerst, bei Gleichstand die kleinere VMID.
.sort((x, y) => y.eingebacken.length - x.eingebacken.length || (x.r.vmid ?? 0) - (y.r.vmid ?? 0));
// Node: gewünscht, sonst der mit dem besten Template, sonst der mit dem meisten freien RAM.
stand.knoten =
a.knoten ??
templates.find((t) => nodes.some((n) => n.node === t.r.node))?.r.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 besten = templates.find((t) => t.r.node === stand.knoten) ?? templates[0];
const tpl = besten?.r;
if (besten)
stand.template = { vmid: besten.r.vmid!, knoten: besten.r.node!, eingebacken: besten.eingebacken };
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) {
const t = stand.template;
return {
uebersprungen: true,
detail: `${t.vmid} auf ${t.knoten}${t.eingebacken.length > 0 ? ` · ${t.eingebacken.join(", ")} eingebacken` : ""}`,
};
}
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!, eingebacken: [] };
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: 60 * 60 * 1000,
intervallMs: 2000,
// „transferred 1.2 GiB of 3.5 GiB (35.16%)“ – auf langsamen Storages dauert ein Voll-Klon.
beiLog: (zeilen) => {
const letzte = zeilen
.map((z) => /transferred (.+) of (.+) \((\d+(?:\.\d+)?)%\)/.exec(z))
.filter(Boolean)
.at(-1);
if (letzte)
void k.detail(`${letzte[1]} von ${letzte[2]} (${Math.round(Number(letzte[3]))} %)`);
},
},
);
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)" };
stand.agentNachMs = await agentAbwarten(client, vmPfad(), k, {
grenzeMs: e.agentMs ?? 5 * 60 * 1000,
warten,
});
// 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(client, vmPfad())<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).",
);
// Eingebacken und mit Standardwerten → schon im Template, nicht noch einmal (#022).
const eingebacken = new Set(stand.template?.eingebacken ?? []);
const uebersprungen = e.profile
.filter((p) => eingebacken.has(p.profil.id) && mitStandardwerten(p) && gastunabhaengig(p.profil))
.map((p) => p.profil.id);
const auszufuehren = e.profile.filter((p) => !uebersprungen.includes(p.profil.id));
if (uebersprungen.length > 0)
await k.log(`Im Template eingebacken, wird übersprungen: ${uebersprungen.join(", ")}`);
const { skript } = profileRendern(
auszufuehren,
adapter,
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "vm" } },
[
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",
);
const zusatz = uebersprungen.length > 0 ? ` (${uebersprungen.join(", ")} aus dem Template)` : "";
const liste = auszufuehren.map((p) => p.profil.id).join(", ");
if (client.trocken) {
await k.log(skript, "info");
return { detail: `${liste ? `Profile ${liste}` : "nur Benutzer"}${zusatz}` };
}
await skriptAusfuehren(client, vmPfad(), skript, k, {
grenzeMs: e.provisionierungMs ?? 60 * 60 * 1000,
warten,
});
return { detail: `${liste ? `Profile ${liste} angewendet` : "Benutzer eingerichtet"}${zusatz}` };
},
},
];
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;
}
+86
View File
@@ -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 };
}
+46
View File
@@ -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.",
};
}
+174
View File
@@ -0,0 +1,174 @@
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
# Ubuntu 24.04 startet sshd per Socket – bis zur ersten Verbindung fehlt /run/sshd, und sshd -t bricht ab.
install -d -m 0755 /run/sshd
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:-}"
`,
};
+15
View File
@@ -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);
}
+47
View File
@@ -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;
}
+4 -1
View File
@@ -56,7 +56,10 @@ export type Distribution = z.infer<typeof DistributionSchema>;
export const VariableSchema = z
.object({
name: z.string().regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich."),
name: z
.string()
.regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich.")
.refine((n) => n !== "vm" && n !== "lxc", "vm und lxc sind feste Bedingungen, keine Variablennamen."),
label: z.string().min(1),
type: z.enum(["string", "number", "boolean", "select"]).default("string"),
required: z.boolean().default(false),
+135
View File
@@ -0,0 +1,135 @@
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)) };
}
/** Werte eines Profils aus der Eingabe, mit Standardwerten und Typprüfung. */
export function variablenWerte(
profil: Profil,
eingabe: Record<string, unknown>,
): { werte: Record<string, string | number | boolean>; fehler: ProfilFehler[] } {
const werte: Record<string, string | number | boolean> = {};
const fehler: ProfilFehler[] = [];
for (const variable of profil.variables) {
const ergebnis = wertPruefen(variable, eingabe[variable.name]);
if ("fehler" in ergebnis)
fehler.push({ profil: profil.id, variable: variable.name, meldung: ergebnis.fehler });
else werte[variable.name] = ergebnis.wert;
}
return { werte, fehler };
}
/**
* Ein ins Template eingebackenes Profil muss beim Deploy nur dann nicht noch einmal laufen, wenn
* seine Variablen genau die Standardwerte haben – mit denen wurde es eingebacken.
*/
export function mitStandardwerten(eintrag: AufgeloestesProfil): boolean {
const standard = variablenWerte(eintrag.profil, {}).werte;
return JSON.stringify(standard) === JSON.stringify(eintrag.werte);
}
/**
* Verweist ein Profil auf `{{ guest.* }}` (Name, Benutzer), hängt es vom einzelnen Gast ab – im
* Template stand dort etwas anderes. Solche Profile laufen beim Deploy trotzdem noch einmal; dank
* Idempotenz ist das schnell (Fund: docker nahm sonst den Benutzer nicht in die Gruppe auf).
*/
export function gastunabhaengig(profil: Profil): boolean {
return !/\{\{[^}]*\bguest\./.test(JSON.stringify(profil.families));
}
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 };
}
}
}
+118
View File
@@ -0,0 +1,118 @@
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; typ?: "vm" | "lxc" };
};
/** Feste Bedingungen neben den Profil-Variablen: `if: vm` / `if: lxc` (bzw. `!vm`, `!lxc`). */
export const FESTE_BEDINGUNGEN = ["vm", "lxc"] as const;
export type Abschnitt = { profil: string; skript: string };
export type Gerendert = { skript: string; abschnitte: Abschnitt[] };
function bedingungErfuellt(
schritt: ProfilSchritt,
werte: Record<string, unknown>,
typ: "vm" | "lxc" | undefined,
): boolean {
if (!schritt.if) return true;
const verneint = schritt.if.startsWith("!");
const name = schritt.if.replace(/^!/, "");
// Ohne bekannten Typ (Rendern zum Ansehen) laufen typabhängige Schritte mit.
const wert = name === "vm" || name === "lxc" ? (typ ? typ === name : !verneint) : Boolean(werte[name]);
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, kontext.gast.typ)) 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 };
}
+53
View File
@@ -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;
}
+3 -1
View File
@@ -1,4 +1,4 @@
import { pfad, type ProxmoxClient } from "./client";
import { istTrockenUpid, pfad, type ProxmoxClient } from "./client";
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. */
export function nodeAusUpid(upid: string): string {
if (istTrockenUpid(upid)) return upid.split(":")[2] ?? "node";
const teile = upid.split(":");
if (teile[0] !== "UPID" || !teile[1]) {
throw new ProxmoxFehler("api", `„${upid}“ ist keine UPID.`, "Antwort von Proxmox prüfen.");
@@ -32,6 +33,7 @@ export async function aufgabeAbwarten(
upid: string,
optionen: AbwartenOptionen = {},
): Promise<void> {
if (istTrockenUpid(upid)) return;
const node = nodeAusUpid(upid);
const intervall = optionen.intervallMs ?? 1000;
const zeitlimit = optionen.zeitlimitMs ?? 30 * 60 * 1000;
+29 -1
View File
@@ -26,6 +26,17 @@ export type AufrufBeobachter = (
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler },
) => 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;
/** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */
@@ -60,7 +71,11 @@ export class ProxmoxClient {
constructor(
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)) {
throw new ProxmoxFehler(
@@ -90,7 +105,20 @@ export class ProxmoxClient {
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> {
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();
try {
const ergebnis = await this.senden<T>(aufruf);
+2 -1
View File
@@ -119,7 +119,7 @@ async function versuchen<T>(warnungen: string[], text: string, aufruf: () => Pro
export async function erkunden(
client: ProxmoxClient,
optionen: { serverHost: string; ssh: SshZugang | null; jetzt?: () => Date },
optionen: { serverHost: string; ssh: SshZugang | null; bauNetz?: boolean; jetzt?: () => Date },
): Promise<VerbindungsBericht> {
const warnungen: string[] = [];
const version = await client.get<{ version: string; release: string }>("/version");
@@ -313,6 +313,7 @@ export async function erkunden(
storages: alleStorages,
ssh: sshGesamt,
rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
bauNetz: optionen.bauNetz,
}),
warnungen,
};
+13 -2
View File
@@ -230,6 +230,8 @@ export function faehigkeitenAbleiten(eingabe: {
storages: StorageInfo[];
ssh: SshZustand;
rechteFehlen: boolean;
/** Bau-Adresse gesetzt (#021) – Guest-Agent einbauen geht dann auch ohne SSH zum Node. */
bauNetz?: boolean;
}): Faehigkeiten {
const { version, storages, ssh } = eingabe;
const sshOk = ssh === "ok";
@@ -237,10 +239,19 @@ export function faehigkeitenAbleiten(eingabe: {
const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets"));
let templateBau: Strategie;
if (mindestens(version, 8, 4) && importSpeicher.length > 0) {
const agentEinbauen = (sshOk && snippetSpeicher.length > 0) || Boolean(eingabe.bauNetz);
if (mindestens(version, 8, 4) && importSpeicher.length > 0 && !agentEinbauen) {
templateBau = {
weg: "nicht_moeglich",
grund:
"Image-Import ginge per API, aber der Guest-Agent muss ins Template: SSH zum Node (mit Snippets-Storage) oder eine Bau-Adresse in den Server-Einstellungen.",
};
} else if (mindestens(version, 8, 4) && importSpeicher.length > 0) {
templateBau = {
weg: "api",
grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}`,
grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}${
sshOk && snippetSpeicher.length > 0 ? "" : "; Guest-Agent über die Bau-Adresse"
}`,
};
} else if (sshOk) {
templateBau = {
+14
View File
@@ -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");
}
+21 -4
View File
@@ -1,5 +1,8 @@
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
@@ -37,7 +40,13 @@ export function hostSchluesselFingerabdruck(schluessel: Buffer): string {
export function sshAusfuehren(
ziel: SshZiel,
befehl: string,
optionen: { zeitlimitMs?: number; eingabe?: string; erkunden?: boolean } = {},
optionen: {
zeitlimitMs?: number;
eingabe?: string;
erkunden?: boolean;
/** Laufende Ausgabe (stdout und stderr), z. B. für Live-Fortschritt einer Provisionierung. */
beiAusgabe?: (text: string) => void;
} = {},
): Promise<SshErgebnis> {
if (!ziel.hostSchluessel && !optionen.erkunden) {
return Promise.reject(
@@ -92,8 +101,16 @@ export function sshAusfuehren(
let stdout = "";
let stderr = "";
let code: number | null = null;
kanal.on("data", (teil: Buffer) => (stdout += teil.toString("utf8")));
kanal.stderr.on("data", (teil: Buffer) => (stderr += teil.toString("utf8")));
kanal.on("data", (teil: Buffer) => {
const text = teil.toString("utf8");
stdout += text;
optionen.beiAusgabe?.(text);
});
kanal.stderr.on("data", (teil: Buffer) => {
const text = teil.toString("utf8");
stderr += text;
optionen.beiAusgabe?.(text);
});
kanal.on("exit", (exitCode: number | null) => (code = exitCode));
kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen }));
if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe);
+2
View File
@@ -8,10 +8,12 @@ export async function register(): Promise<void> {
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
const { logger } = await import("@/lib/logger");
const { katalogSynchronisieren } = await import("@/server/services/katalog");
const { beispielVorlageAnlegen } = await import("@/server/services/vorlagen");
try {
schluesselPruefen();
await ersterAdminFallsLeer();
const sync = await katalogSynchronisieren();
await beispielVorlageAnlegen();
logger.info(
{ distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length },
"Katalog abgeglichen",
+18
View File
@@ -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",
};
+4 -4
View File
@@ -22,10 +22,10 @@ export type NavigationsEintrag = {
export const NAVIGATION: readonly NavigationsEintrag[] = [
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen", ab: "#006" },
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" },
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
{
+35
View File
@@ -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 };
+163
View File
@@ -0,0 +1,163 @@
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."),
/** Leer = die hinterlegten Schlüssel des Benutzers und die Team-Schlüssel (beim Anlegen ergänzt). */
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. In den Einstellungen hinterlegen oder hier angeben.",
),
});
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),
/** Name der Vorlage, die angewendet wurde (nur zur Nachverfolgung; angewendet wird vorher). */
vorlage: z.string().optional(),
})
.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.vorlage) teile.push(`--preset ${a.vorlage}`);
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(" ");
}
+31
View File
@@ -30,6 +30,35 @@ export const ServerUrl = z
return `${url.protocol}//${url.host}`;
});
/**
* Bau-Adresse (#021): eine feste Adresse für den Vorbereitungsstart beim Template-Bau, wenn es
* kein SSH zum Node gibt. ProxDeploy meldet sich darüber per SSH in der VM an.
*/
export const BauNetz = z
.object({
ip: z
.string()
.trim()
.regex(/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, "Format: 10.0.10.250/24"),
gateway: z
.string()
.trim()
.regex(/^(\d{1,3}\.){3}\d{1,3}$/, "Format: 10.0.10.1"),
dns: z
.array(
z
.string()
.trim()
.regex(/^(\d{1,3}\.){3}\d{1,3}$/),
)
.max(3)
.default([]),
})
.nullable()
.default(null);
export type BauNetz = NonNullable<z.infer<typeof BauNetz>>;
const Vmid = z.coerce.number().int("Ganze Zahl erwartet.").min(100, "Mindestens 100.").max(999_999_999);
const Basis = z.object({
@@ -52,6 +81,7 @@ const Basis = z.object({
templateVmidVon: Vmid.default(9000),
templateVmidBis: Vmid.default(9999),
gastVmidVon: Vmid.default(100),
bauNetz: BauNetz,
});
function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) {
@@ -112,6 +142,7 @@ export type ServerSicht = {
templateVmidVon: number;
templateVmidBis: number;
gastVmidVon: number;
bauNetz: BauNetz | null;
erreichbar: boolean | null;
pveVersion: string | null;
geprueftAm: string | null;
+14
View File
@@ -0,0 +1,14 @@
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(),
/** Profile, die ins Template eingebacken werden (#022) – nur solche ohne Pflichtvariablen. */
profile: z.array(z.string()).default([]),
});
export type TemplateBauAnfrage = z.infer<typeof TemplateBauAnfrage>;
+78
View File
@@ -0,0 +1,78 @@
import { z } from "@/lib/validierung";
/**
* Was eine Vorlage festlegen darf. Bewusst ohne Name, VMID, IP und Schlüssel – das ist je Gast
* verschieden. Server und Storage dürfen drin stehen, sind aber optional.
*/
export const VorlagenParameter = z
.object({
typ: z.enum(["vm", "lxc"]).optional(),
server: z.string().trim().optional(),
distribution: z.string().trim().optional(),
cores: z.coerce.number().int().min(1).max(128).optional(),
speicherMb: z.coerce.number().int().min(256).optional(),
diskGb: z.coerce.number().int().min(1).optional(),
storage: z.string().trim().optional(),
swapMb: z.coerce.number().int().min(0).optional(),
unprivilegiert: z.boolean().optional(),
features: z.object({ nesting: z.boolean(), keyctl: z.boolean() }).partial().optional(),
netz: z
.object({ bridge: z.string().trim(), vlan: z.coerce.number().int().min(1).max(4094) })
.partial()
.optional(),
profile: z.array(z.string()).optional(),
variablen: z
.record(z.string(), z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])))
.optional(),
tags: z.array(z.string()).optional(),
})
.strict();
export type VorlagenParameter = z.infer<typeof VorlagenParameter>;
export const VorlageEingabe = z.object({
name: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9][a-z0-9-]{0,47}$/, "Kurzname: Kleinbuchstaben, Ziffern und Bindestrich."),
titel: z.string().trim().min(1, "Titel fehlt.").max(80),
beschreibung: z.string().trim().max(500).optional(),
parameter: VorlagenParameter,
});
/**
* Legt eine Vorlage unter eine Anfrage: was die Anfrage selbst angibt, gewinnt. Objekte (netz,
* features, variablen) werden feldweise gemischt, Listen ersetzt.
*/
export function vorlageAnwenden(
vorlage: VorlagenParameter,
anfrage: Record<string, unknown>,
): Record<string, unknown> {
const ergebnis: Record<string, unknown> = { ...vorlage };
for (const [schluessel, wert] of Object.entries(anfrage)) {
if (wert === undefined) continue;
const basis = ergebnis[schluessel];
if (
basis &&
typeof basis === "object" &&
!Array.isArray(basis) &&
wert &&
typeof wert === "object" &&
!Array.isArray(wert)
) {
ergebnis[schluessel] =
schluessel === "variablen"
? Object.fromEntries(
[...new Set([...Object.keys(basis), ...Object.keys(wert)])].map((p) => [
p,
{ ...(basis as Record<string, object>)[p], ...(wert as Record<string, object>)[p] },
]),
)
: { ...basis, ...wert };
} else {
ergebnis[schluessel] = wert;
}
}
return ergebnis;
}
+1 -1
View File
@@ -2,7 +2,7 @@
* 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).
*/
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.1.0";
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.3.0";
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
export function versionText(): string {
+1
View File
@@ -19,6 +19,7 @@ export function fehlerAntwort(fehler: unknown): NextResponse<FehlerAntwort> {
code: fehler.code,
nachricht: fehler.message,
...(fehler.hinweis ? { hinweis: fehler.hinweis } : {}),
...(fehler.felder ? { felder: fehler.felder } : {}),
},
},
{ status: fehler.status },
+49
View File
@@ -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);
}
+83
View File
@@ -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);
}
}
+2
View File
@@ -10,6 +10,8 @@ export class AnwendungsFehler extends Error {
readonly code: string,
nachricht: 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);
this.name = "AnwendungsFehler";
+21
View File
@@ -0,0 +1,21 @@
import { prisma } from "@/lib/db";
import type { GastSchluessel, SchluesselQuelle } from "./quelle";
/** Schlüssel aus der eigenen Datenbank: die des Benutzers und die Team-Schlüssel. */
export const lokaleQuelle: SchluesselQuelle = {
name: "lokal",
async schluesselFuer(benutzer) {
const zeilen = await prisma.sshSchluessel.findMany({
where: { OR: [{ benutzerId: benutzer.id }, { benutzerId: null }] },
orderBy: [{ benutzerId: "asc" }, { name: "asc" }],
});
return zeilen.map((z): GastSchluessel => ({
id: z.id,
name: z.name,
oeffentlich: z.oeffentlich,
fingerabdruck: z.fingerabdruck,
bereich: z.benutzerId ? "persoenlich" : "team",
quelle: z.quelle,
}));
},
};
+33
View File
@@ -0,0 +1,33 @@
import { createHash } from "node:crypto";
import { SSH_SCHLUESSEL } from "@/lib/schemas/ausrollen";
/**
* Woher die SSH-Schlüssel eines Benutzers kommen. Heute aus der eigenen Datenbank („lokal“); eine
* externe Schlüsselverwaltung ist eine weitere Umsetzung dieses Interfaces, die in
* `schluesselQuellen()` eingetragen wird – Assistent, API und CLI merken davon nichts.
*/
export type GastSchluessel = {
id: string;
name: string;
oeffentlich: string;
fingerabdruck: string;
/** Persönlich oder für alle (Team). */
bereich: "persoenlich" | "team";
quelle: string;
};
export interface SchluesselQuelle {
readonly name: string;
/** Schlüssel, die standardmäßig in Gäste dieses Benutzers kommen. */
schluesselFuer(benutzer: { id: string; benutzername: string }): Promise<GastSchluessel[]>;
}
/** Fingerprint wie `ssh-keygen -lf`: SHA256 über die Base64-dekodierten Schlüsseldaten. */
export function fingerabdruck(oeffentlich: string): string {
const daten = oeffentlich.trim().split(/\s+/)[1] ?? "";
return `SHA256:${createHash("sha256").update(Buffer.from(daten, "base64")).digest("base64").replace(/=+$/, "")}`;
}
export function istOeffentlicherSchluessel(text: string): boolean {
return SSH_SCHLUESSEL.test(text.trim());
}
+191
View File
@@ -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);
}
+272
View File
@@ -0,0 +1,272 @@
import type { Server } from "@/generated/prisma/client";
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
import { lxcAusrollSchritte, type LxcAusrollStand } from "@/engine/ablaeufe/lxc-ausrollen";
import { vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen";
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
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 { BauNetz } from "@/lib/schemas/server";
import { vorlageAnwenden } from "@/lib/schemas/vorlage";
import { vorlageHolen } from "@/server/services/vorlagen";
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 { bereitstellungsschluessel } from "@/server/services/bereitstellung";
import { schluesselFuer } from "@/server/services/schluessel";
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> {
// Eine Vorlage (Preset) liegt unter der Anfrage: was die Anfrage selbst sagt, gewinnt.
const vorlageName =
roh && typeof roh === "object" && "vorlage" in roh ? (roh as { vorlage?: unknown }).vorlage : undefined;
const mitVorlage =
typeof vorlageName === "string" && vorlageName
? vorlageAnwenden((await vorlageHolen(vorlageName)).parameter, roh as Record<string, unknown>)
: roh;
const anfrage = AusrollAnfrage.parse(mitVorlage);
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"}.`,
);
}
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 };
}
/**
* Gibt die Anfrage keine Schlüssel an, kommen die hinterlegten des Benutzers und die Team-Schlüssel
* hinein. Das passiert beim Anlegen, nicht im worker: dort ist bekannt, wer ausrollt, und die
* Anfrage im Verlauf zeigt, welche Schlüssel tatsächlich im Gast gelandet sind.
*/
export async function schluesselErgaenzen(roh: unknown, aufrufer: Aufrufer): Promise<unknown> {
if (!roh || typeof roh !== "object") return roh;
const anfrage = roh as { zugang?: { benutzer?: unknown; sshSchluessel?: unknown } };
const angegeben = Array.isArray(anfrage.zugang?.sshSchluessel) ? anfrage.zugang.sshSchluessel : [];
if (angegeben.length > 0) return roh;
const hinterlegt = (await schluesselFuer(aufrufer.benutzer)).map((s) => s.oeffentlich);
return { ...anfrage, zugang: { ...anfrage.zugang, sshSchluessel: hinterlegt } };
}
export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: string | null) {
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
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 AusrollStand = VmAusrollStand & LxcAusrollStand;
/** Container-Features, die die gewählten Profile verlangen (z. B. docker → nesting). */
export function profilFeatures(profile: AufgeloestesProfil[]): { nesting: boolean; keyctl: boolean } {
return {
nesting: profile.some((p) => p.profil.lxc.features?.nesting),
keyctl: profile.some((p) => p.profil.lxc.features?.keyctl),
};
}
/**
* Die Phasen für VM oder LXC – dieselben im Trockenlauf und im worker. Was sich unterscheidet
* (reservieren oder nur vorschlagen, Auftrags-ID), kommt von außen.
*/
export function ausrollSchritte(
v: Vorbereitet,
o: {
client: ProxmoxClient;
vmidReservieren: (belegt: Set<number>, wunsch?: number) => Promise<number>;
templateVmidReservieren: (belegt: Set<number>) => Promise<number>;
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
auftragKurzId: string;
},
stand: AusrollStand,
): PhasenSchritt[] {
const sshFuer = (knoten: string) => nodeBefehlFuer(v.server, knoten);
if (v.anfrage.typ === "lxc") {
return lxcAusrollSchritte(
{
client: o.client,
anfrage: v.anfrage,
distribution: v.distribution,
profile: v.profile,
profilFeatures: profilFeatures(v.profile),
vmidReservieren: o.vmidReservieren,
sshFuer,
bereitstellung: bereitstellungsschluessel,
angelegt: o.angelegt,
auftragKurzId: o.auftragKurzId,
},
stand,
);
}
return vmAusrollSchritte(
{
client: o.client,
anfrage: v.anfrage,
distribution: v.distribution,
profile: v.profile,
vmidReservieren: o.vmidReservieren,
templateBau: v.anfrage.templateBauen
? {
sshFuer,
auftragKurzId: o.auftragKurzId,
vmidReservieren: o.templateVmidReservieren,
bauNetz: (v.server.bauNetz as BauNetz | null) ?? null,
bereitstellung: bereitstellungsschluessel,
}
: undefined,
angelegt: o.angelegt,
auftragKurzId: o.auftragKurzId,
},
stand,
);
}
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, aufrufer: Aufrufer): Promise<Plan> {
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
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: AusrollStand = {};
try {
const ergebnis = await phasenAusfuehren(
ausrollSchritte(
v,
{
client,
vmidReservieren: (belegt, wunsch) =>
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.gastVmidVon, wunsch }),
templateVmidReservieren: (belegt) =>
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }),
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" | "anfrage">,
stand: AusrollStand,
): string {
return [
`Server ${v.server.name}`,
stand.knoten ? `Node ${stand.knoten}` : null,
v.anfrage.typ === "lxc"
? `${v.distribution.name} (LXC)`
: `${v.distribution.name}${stand.template ? ` (Template ${stand.template.vmid})` : ""}`,
]
.filter(Boolean)
.join(" · ");
}
+30
View File
@@ -0,0 +1,30 @@
import ssh2 from "ssh2";
import type { Bereitstellungsschluessel } from "@/engine/ablaeufe/lxc-ausrollen";
import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
/**
* Der Bereitstellungsschlüssel: damit meldet sich ProxDeploy als root in frisch angelegten
* Containern an, wenn es keinen SSH-Zugang zum Node hat. Er steht nur während der
* Provisionierung in /root/.ssh/authorized_keys und wird am Ende des Skripts entfernt (am
* Kommentar `proxdeploy-bereitstellung` erkennbar). Einmal erzeugt, privat nur verschlüsselt.
*/
const SCHLUESSEL = "bereitstellungsschluessel";
export const KOMMENTAR = "proxdeploy-bereitstellung";
export async function bereitstellungsschluessel(): Promise<Bereitstellungsschluessel> {
const vorhanden = await prisma.einstellung.findUnique({ where: { schluessel: SCHLUESSEL } });
if (vorhanden) {
const wert = vorhanden.wert as { privat: string; oeffentlich: string };
return { privat: entschluesseln(wert.privat), oeffentlich: wert.oeffentlich };
}
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: KOMMENTAR });
const neu = { privat: verschluesseln(paar.private), oeffentlich: paar.public.trim() };
// Zwei gleichzeitige Aufträge: der erste gewinnt, der zweite liest dessen Schlüssel.
await prisma.einstellung.upsert({
where: { schluessel: SCHLUESSEL },
create: { schluessel: SCHLUESSEL, wert: neu },
update: {},
});
return await bereitstellungsschluessel();
}
+163 -2
View File
@@ -1,11 +1,15 @@
import { join } from "node:path";
import type { Prisma } from "@/generated/prisma/client";
import { katalogLaden, type KatalogFehler } from "@/engine/katalog/laden";
import { adapterFuer, bekannteFamilien } from "@/engine/adapter";
import { katalogLaden, yamlPruefen, type KatalogFehler } from "@/engine/katalog/laden";
import { ProfilSchema } from "@/engine/katalog/schema";
import { profileAufloesen } from "@/engine/profile/aufloesen";
import { profileRendern } from "@/engine/profile/rendern";
import type { Distribution, Profil } from "@/engine/katalog/schema";
import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
import type { Aufrufer } from "@/server/auth/zugang";
import { nichtGefunden } from "@/server/fehler";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll";
/** Wo der Katalog liegt: im Repo und im Image unter ./catalog, überschreibbar für Tests. */
@@ -209,3 +213,160 @@ export async function profilAktivSetzen(id: string, aktiv: boolean, aufrufer: Au
);
});
}
// ── Eigene Profile (#011) ───────────────────────────────────────────────────────────────────────
export type ProfilPruefung =
{ ok: true; profil: Profil; vorschau: string | null } | { ok: false; fehler: string[] };
/**
* Prüft ein Profil aus dem Editor: YAML, Schema, Voraussetzungen. Mit Vorschau des erzeugten
* Skripts für die erste Distribution der Familie, damit man sieht, was im Gast ankommt.
*/
export async function profilPruefen(quelltext: string): Promise<ProfilPruefung> {
const geprueft = yamlPruefen(quelltext, ProfilSchema);
if (!geprueft.ok) return { ok: false, fehler: geprueft.fehler };
const profil = geprueft.eintrag;
const alle = new Map(
(await prisma.profil.findMany({ where: { entferntAm: null } })).map((p) => [
p.id,
p.definition as Profil,
]),
);
alle.set(profil.id, profil);
const fehler: string[] = [];
for (const familie of Object.keys(profil.families)) {
if (!adapterFuer(familie))
fehler.push(
`families.${familie}: Für diese Familie gibt es keinen Adapter (bekannt: ${bekannteFamilien().join(", ")}).`,
);
}
const familie = Object.keys(profil.families).find((f) => adapterFuer(f));
let vorschau: string | null = null;
if (familie) {
const aufgeloest = profileAufloesen([profil.id], alle, familie, {
// Für die Vorschau Pflichtvariablen mit einem Platzhalter füllen.
[profil.id]: Object.fromEntries(
profil.variables.filter((v) => v.required).map((v) => [v.name, `<${v.name}>`]),
),
});
if (!aufgeloest.ok) {
fehler.push(...aufgeloest.fehler.map((f) => `${f.profil}: ${f.meldung}`));
} else {
const distro = (await distributionenListe({ nurAktive: true })).find((d) => d.familie === familie);
try {
vorschau =
profileRendern(
aufgeloest.profile.filter((p) => p.profil.id === profil.id),
adapterFuer(familie)!,
{
os: distro?.definition.os ?? { id: familie, version_id: "0" },
gast: { name: "beispiel", benutzer: "benutzer", typ: "vm" },
},
).abschnitte[0]?.skript ?? null;
} catch (ursache) {
fehler.push(ursache instanceof Error ? ursache.message : String(ursache));
}
}
}
return fehler.length > 0 ? { ok: false, fehler } : { ok: true, profil, vorschau };
}
export async function profilSpeichern(
quelltext: string,
aufrufer: Aufrufer,
ip: string | null,
bisherigeId?: string,
): Promise<ProfilSicht> {
const pruefung = await profilPruefen(quelltext);
if (!pruefung.ok)
throw new AnwendungsFehler(
400,
"ungueltig",
pruefung.fehler.join(" "),
"Fehlerstellen im Editor beheben.",
);
const { profil } = pruefung;
const vorhanden = await prisma.profil.findUnique({ where: { id: profil.id } });
if (bisherigeId && bisherigeId !== profil.id) {
throw new AnwendungsFehler(
400,
"ungueltig",
"Die id eines Profils lässt sich nicht ändern.",
"Als neues Profil mit anderer id speichern.",
);
}
if (vorhanden && (vorhanden.herkunft === "KATALOG" || !bisherigeId) && !vorhanden.entferntAm) {
throw new AnwendungsFehler(
409,
"vergeben",
vorhanden.herkunft === "KATALOG"
? `„${profil.id}“ ist ein Katalogprofil.`
: `Ein Profil „${profil.id}“ gibt es schon.`,
vorhanden.herkunft === "KATALOG"
? "Für eine eigene Fassung eine andere id wählen, z. B. docker-eigen."
: undefined,
);
}
const daten = {
name: profil.name,
beschreibung: profil.description ?? null,
familien: Object.keys(profil.families),
herkunft: "EIGEN" as const,
definition: profil as unknown as Prisma.InputJsonValue,
quelltext,
entferntAm: null,
geaendertVonId: aufrufer.benutzer.id,
};
await prisma.$transaction(async (tx) => {
await tx.profil.upsert({ where: { id: profil.id }, create: { id: profil.id, ...daten }, update: daten });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: bisherigeId ? "Profil geändert" : "Profil angelegt",
ziel: `profil:${profil.id}`,
ip,
},
tx,
);
});
return (await profileListe()).find((p) => p.id === profil.id)!;
}
export async function profilLoeschen(id: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
const profil = await prisma.profil.findUnique({ where: { id } });
if (!profil || profil.entferntAm) throw nichtGefunden(`Das Profil „${id}“`);
if (profil.herkunft === "KATALOG") {
throw new AnwendungsFehler(
409,
"katalog",
"Katalogprofile lassen sich nicht löschen.",
"Deaktivieren oder die YAML-Datei aus catalog/profiles entfernen.",
);
}
const abhaengig = (await prisma.profil.findMany({ where: { entferntAm: null } })).filter((p) =>
(p.definition as Profil).requires?.includes(id),
);
if (abhaengig.length > 0) {
throw new AnwendungsFehler(
409,
"benutzt",
`${abhaengig.map((p) => p.id).join(", ")} setzt „${id}“ voraus.`,
);
}
await prisma.$transaction(async (tx) => {
// Aufträge nennen Profile nur beim Namen – löschen ist sicher, der Verlauf bleibt lesbar.
await tx.profil.delete({ where: { id } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Profil gelöscht",
ziel: `profil:${id}`,
ip,
},
tx,
);
});
}
+100
View File
@@ -0,0 +1,100 @@
import { prisma } from "@/lib/db";
import { z } from "@/lib/validierung";
import type { Aufrufer } from "@/server/auth/zugang";
import { darf } from "@/server/auth/rechte";
import { AnwendungsFehler, keinRecht, nichtGefunden } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll";
import { lokaleQuelle } from "@/server/schluessel/lokal";
import {
fingerabdruck,
istOeffentlicherSchluessel,
type GastSchluessel,
type SchluesselQuelle,
} from "@/server/schluessel/quelle";
/** Alle angebundenen Quellen. Eine externe Verwaltung kommt hier dazu. */
export function schluesselQuellen(): SchluesselQuelle[] {
return [lokaleQuelle];
}
export async function schluesselFuer(benutzer: {
id: string;
benutzername: string;
}): Promise<GastSchluessel[]> {
const listen = await Promise.all(schluesselQuellen().map((q) => q.schluesselFuer(benutzer)));
// Derselbe Schlüssel aus zwei Quellen nur einmal.
const gesehen = new Set<string>();
return listen.flat().filter((s) => !gesehen.has(s.fingerabdruck) && gesehen.add(s.fingerabdruck));
}
const Eingabe = z.object({
name: z.string().trim().min(1, "Name fehlt.").max(80),
oeffentlich: z
.string()
.trim()
.refine(
istOeffentlicherSchluessel,
"Kein öffentlicher SSH-Schlüssel (ssh-ed25519 …, ssh-rsa …, ecdsa-sha2-… ). Den .pub-Inhalt einfügen, nie den privaten Schlüssel.",
),
team: z.boolean().default(false),
});
export async function schluesselAnlegen(
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<GastSchluessel> {
const eingabe = Eingabe.parse(roh);
if (eingabe.team && !darf(aufrufer.benutzer.rolle, "benutzerVerwalten")) throw keinRecht();
const abdruck = fingerabdruck(eingabe.oeffentlich);
const benutzerId = eingabe.team ? null : aufrufer.benutzer.id;
if (await prisma.sshSchluessel.findFirst({ where: { benutzerId, fingerabdruck: abdruck } })) {
throw new AnwendungsFehler(409, "vergeben", "Diesen Schlüssel gibt es hier schon.");
}
const zeile = await prisma.$transaction(async (tx) => {
const neu = await tx.sshSchluessel.create({
data: { benutzerId, name: eingabe.name, oeffentlich: eingabe.oeffentlich, fingerabdruck: abdruck },
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: eingabe.team ? "Team-Schlüssel hinzugefügt" : "SSH-Schlüssel hinzugefügt",
ziel: `schluessel:${abdruck}`,
details: { name: eingabe.name },
ip,
},
tx,
);
return neu;
});
return {
id: zeile.id,
name: zeile.name,
oeffentlich: zeile.oeffentlich,
fingerabdruck: abdruck,
bereich: benutzerId ? "persoenlich" : "team",
quelle: "lokal",
};
}
export async function schluesselLoeschen(id: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
const zeile = await prisma.sshSchluessel.findUnique({ where: { id } });
if (!zeile) throw nichtGefunden("Den Schlüssel");
const eigener = zeile.benutzerId === aufrufer.benutzer.id;
if (!eigener && !(zeile.benutzerId === null && darf(aufrufer.benutzer.rolle, "benutzerVerwalten")))
throw keinRecht();
await prisma.$transaction(async (tx) => {
await tx.sshSchluessel.delete({ where: { id } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: zeile.benutzerId ? "SSH-Schlüssel entfernt" : "Team-Schlüssel entfernt",
ziel: `schluessel:${zeile.fingerabdruck}`,
ip,
},
tx,
);
});
}
+15 -4
View File
@@ -1,5 +1,5 @@
import { utils as sshUtils } from "ssh2";
import type { Server } from "@/generated/prisma/client";
import ssh2 from "ssh2";
import { Prisma, type Server } from "@/generated/prisma/client";
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
@@ -11,6 +11,7 @@ import {
ServerAendern,
ServerAnlegen,
ServerPruefen,
type BauNetz,
type ServerSicht,
} from "@/lib/schemas/server";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
@@ -32,6 +33,7 @@ export function serverSicht(server: Server): ServerSicht {
templateVmidVon: server.templateVmidVon,
templateVmidBis: server.templateVmidBis,
gastVmidVon: server.gastVmidVon,
bauNetz: (server.bauNetz as BauNetz | null) ?? null,
erreichbar: server.erreichbar,
pveVersion: server.pveVersion,
geprueftAm: server.geprueftAm?.toISOString() ?? null,
@@ -83,6 +85,7 @@ export async function serverAnlegen(
const angelegt = await tx.server.create({
data: {
...eingabe,
bauNetz: eingabe.bauNetz ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis),
},
@@ -117,12 +120,19 @@ export async function serverAendern(
const { tokenGeheimnis, ...rest } = eingabe;
const daten = {
...rest,
bauNetz: rest.bauNetz ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}),
};
// Welche Felder sich geändert haben, steht im Protokoll – die Werte von Geheimnissen nie.
// JSON-Felder (bauNetz) vergleichen nach Inhalt; Prisma.JsonNull steht für „leer“ wie null.
const vergleichbar = (wert: unknown) => JSON.stringify(wert === Prisma.JsonNull ? null : (wert ?? null));
const geaendert = Object.entries(daten)
.filter(([feld, wert]) => feld === "tokenGeheimnis" || (bisher as Record<string, unknown>)[feld] !== wert)
.filter(
([feld, wert]) =>
feld === "tokenGeheimnis" ||
vergleichbar((bisher as Record<string, unknown>)[feld]) !== vergleichbar(wert),
)
.map(([feld]) => feld);
const server = await prisma.$transaction(async (tx) => {
@@ -170,7 +180,7 @@ export async function sshSchluesselErzeugen(
ip: string | null,
): Promise<ServerSicht> {
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 aktualisiert = await tx.server.update({
where: { id: server.id },
@@ -287,6 +297,7 @@ export async function serverTesten(
try {
const bericht = await erkunden(client, {
serverHost: new URL(server.url).hostname,
bauNetz: Boolean(server.bauNetz),
ssh: server.sshAktiv
? {
benutzer: server.sshBenutzer,
+34
View File
@@ -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, beiAusgabe: optionen.beiAusgabe },
);
}
+124
View File
@@ -0,0 +1,124 @@
import type { Distribution, Profil } from "@/engine/katalog/schema";
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
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.`);
}
await templateProfileAufloesen(anfrage.profile, (distribution.definition as Distribution).family);
return await auftragAnlegen(
{
art: "TEMPLATE_BAUEN",
serverId: server.id,
knoten: anfrage.knoten,
gastTyp: "VM",
distributionId: distribution.id,
name: `Template ${distribution.name}`,
anfrage,
},
aufrufer,
ip,
);
}
/**
* Profile fürs Template: aufgelöst mit Standardwerten. Ein Profil mit Pflichtvariable (z. B. der
* Zabbix-Server) ist gerätespezifisch und gehört nicht ins Template.
*/
export async function templateProfileAufloesen(
auswahl: string[],
familie: string,
): Promise<AufgeloestesProfil[]> {
if (auswahl.length === 0) return [];
const verfuegbar = new Map(
(await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [
p.id,
p.definition as Profil,
]),
);
const aufgeloest = profileAufloesen(auswahl, verfuegbar, familie);
if (!aufgeloest.ok) {
throw new AnwendungsFehler(
400,
"ungueltig",
aufgeloest.fehler.map((f) => `${f.profil}: ${f.meldung}`).join(" "),
"Profile mit Pflichtvariablen gehören nicht ins Template – sie laufen beim Ausrollen.",
);
}
return aufgeloest.profile;
}
export type TemplateSicht = {
vmid: number;
name: string;
knoten: string;
distributionId: string | null;
gebautAm: string | null;
imageDatei: string | null;
profile: string[];
};
/**
* 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" && !t.startsWith("profil-")) ??
null,
gebautAm: db?.gebautAm.toISOString() ?? null,
imageDatei: db?.imageDatei ?? null,
profile: tags.filter((t) => t.startsWith("profil-")).map((t) => t.slice("profil-".length)),
};
})
.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();
}
}

Some files were not shown because too many files have changed in this diff Show More