13 Commits
Author SHA1 Message Date
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
florian.wildeandClaude Opus 5.5 acae1bc2a4 feat(katalog): Distributionen und Profile als YAML-Daten
Fünf Distributionen (Debian 12/13, Ubuntu 22.04/24.04/26.04) und drei
Profile liegen unter catalog/ und werden beim Start abgeglichen. Die
Datei ist maßgeblich für den Inhalt, die Datenbank nur für „aktiv“;
verschwundene Einträge werden markiert, nicht gelöscht.

Unbekannte Schlüssel sind Fehler, damit Tippfehler nicht still
verschwinden; eine kaputte Datei wird mit Pfad gemeldet und
übersprungen, statt den Start zu verhindern. Prüfsummen-Parser kennt
das Debian-, Ubuntu- und BSD-Format; bei mehreren LXC-Templates gewinnt
die höchste Version.

Closes #004

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:11:14 +02:00
florian.wildeandClaude Opus 5.5 69cf8eb784 docs: README mit Stand, Fähigkeiten und Entwicklung
Ein Zwischenstand bis zum vollständigen README mit Banner und
Screenshots in Etappe 6 (#018).

Refs #018

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:08:42 +02:00
146 changed files with 11913 additions and 123 deletions
+31
View File
@@ -8,15 +8,46 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
### Hinzugefügt ### 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)
### Geändert ### Geändert
### Behoben ### 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)
### Entfernt ### Entfernt
### Sicherheit ### Sicherheit
## [0.2.0] – 2026-10-12
### Hinzugefügt
- Katalog als Daten: Debian 12/13 und Ubuntu 22.04/24.04/26.04 sowie die Profile base, docker und zabbix-agent als YAML unter `catalog/`, beim Start in die Datenbank abgeglichen (#004)
- Katalogseite mit Aktiv-Schaltern für Distributionen und Profile; `GET /api/v1/distributionen?typ=vm|lxc`, `GET /api/v1/profile` (#004)
- README mit Stand, Fähigkeiten und Entwicklungsumgebung (#018)
- Provisionierungs-Adapter `debian` und Profil-Engine: Profile werden zu einem idempotenten Skript; geprüft mit shellcheck und echt in Containern für alle fünf Distributionen (`pnpm test:container`) (#005)
- `docs/katalog.md`: neue Distribution, neuer Adapter, eigenes Profil (#005)
- Aufträge im worker (pg-boss) mit Phasen, Detail-Log und Live-Fortschritt per Server-Sent Events; Live-Ansicht im Aufbau der CLI-Ausgabe, Verlaufsseite (#006)
- Template-Bau: Cloud-Image mit Prüfsumme laden, VM mit q35/UEFI anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren; Templates-Seite (#007)
- VM ausrollen: Klonen, Hardware, cloud-init, Start, Guest-Agent, Provisionierung der Profile über den Guest-Agent – ohne SSH (#008)
- Deploy-Assistent mit 9 Schritten, Dry-Run mit allen geplanten Proxmox-Aufrufen und passendem CLI-Befehl (#008)
### Behoben
- Provisionierungsskripte mit Umlauten scheiterten an Proxmox' file-write; sie werden jetzt base64-kodiert übertragen (#008)
### Sicherheit
- Profil-Variablen werden in Shell-Befehlen immer gequotet; Zeitzone, Locale und Tastatur gegen strenge Muster geprüft (#005)
- VMIDs werden unter einer Sperre je Server vergeben – parallele Aufträge bekommen nie dieselbe ID (#006)
## [0.1.0] – 2026-10-12 ## [0.1.0] – 2026-10-12
### Hinzugefügt ### Hinzugefügt
+20 -3
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 1 h 15 min (laufende Sitzung) **Gesamt:** 2 h 50 min (Sitzung 1)
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten. Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
@@ -11,16 +11,22 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
| #001 Projekt-Setup | 0 h 50 min | | #001 Projekt-Setup | 0 h 50 min |
| #002 Server-Verwaltung | 0 h 11 min | | #002 Server-Verwaltung | 0 h 11 min |
| #003 Verbindungstest | 0 h 25 min | | #003 Verbindungstest | 0 h 25 min |
| #004 Katalog | 0 h 09 min |
| #005 Adapter und Profile | 0 h 20 min |
| #006 Aufträge und Live-Fortschritt | 0 h 15 min |
| #007 Template-Bau | 0 h 12 min |
| #008 VM ausrollen und Assistent | 0 h 36 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
| v0.1.0 | 1 h 15 min | **abgeschlossen** | v0.1.0 | 1 h 15 min | **abgeschlossen**
| v0.2.0 | 1 h 32 min | **abgeschlossen**
--- ---
## 2026-10-12 · Sitzung 1 · 08:45–laufend ## 2026-10-12 · Sitzung 1 · 08:45–11:35
**Issue:** #001 **Issue:** #001
**Version:** 0.1.0-dev **Version:** 0.1.0, 0.2.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt: Reverse-Proxy (ja), OIDC (implementieren, vorerst nur lokal testen), Testumgebung (folgt), CLI (Go) - Auftrag gelesen, Rückfragen gestellt: Reverse-Proxy (ja), OIDC (implementieren, vorerst nur lokal testen), Testumgebung (folgt), CLI (Go)
@@ -32,6 +38,13 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
- #003 abgeschlossen: Verbindungstest mit Fähigkeitserkennung und SSH-Host-Key-Pinning; 127 Tests - #003 abgeschlossen: Verbindungstest mit Fähigkeitserkennung und SSH-Host-Key-Pinning; 127 Tests
- Feldtest auf dem Testhost `pve-demo` (PVE 9.2.2): Rolle/Token nach Doku per SSH angelegt, Fingerprint und Host-Key gegen den Node verglichen, Test mit und ohne SSH – alles wie erwartet - Feldtest auf dem Testhost `pve-demo` (PVE 9.2.2): Rolle/Token nach Doku per SSH angelegt, Fingerprint und Host-Key gegen den Node verglichen, Test mit und ohne SSH – alles wie erwartet
- Meilenstein v0.1.0 abgeschlossen, getaggt und nach Gitea gepusht - Meilenstein v0.1.0 abgeschlossen, getaggt und nach Gitea gepusht
- README als Zwischenstand angelegt (Florian: „readme im projekt fehlt“)
- #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 - #002 abgeschlossen: Server-Verwaltung mit Fingerprint-Pinning, Proxmox-Client, Proxmox-Mock (HTTPS, selbst signiert); 99 Tests, 10 Playwright-Abläufe
- Testhost 10.0.10.228 von Florian bereitgestellt (Installation läuft) - Testhost 10.0.10.228 von Florian bereitgestellt (Installation läuft)
- #001 abgeschlossen: Grundgerüst mit Anmeldung, Seitenleiste, beiden Themes; 60 Tests, 8 Playwright-Abläufe (Desktop + 390 px) - #001 abgeschlossen: Grundgerüst mit Anmeldung, Seitenleiste, beiden Themes; 60 Tests, 8 Playwright-Abläufe (Desktop + 390 px)
@@ -48,4 +61,8 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
- **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1. - **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1.
- **TLS-Pinning über einen eigenen undici-Connector** statt `rejectUnauthorized: false` + Nachprüfen: die Verbindung wird getrennt, bevor das Token gesendet wird. Sitzungs-Cache aus, sonst fehlt bei Wiederaufnahme das Zertifikat. - **TLS-Pinning über einen eigenen undici-Connector** statt `rejectUnauthorized: false` + Nachprüfen: die Verbindung wird getrennt, bevor das Token gesendet wird. Sitzungs-Cache aus, sonst fehlt bei Wiederaufnahme das Zertifikat.
- **Zugang zum Testhost über einen eigenen SSH-Schlüssel** (`~/.ssh/proxdeploy-testhost`), nicht über das root-Passwort: Claude Code verweigert das Anlegen von Zugängen mit Passwort, und ein Schlüssel nur für diesen Host ist ohnehin der sauberere Weg. - **Zugang zum Testhost über einen eigenen SSH-Schlüssel** (`~/.ssh/proxdeploy-testhost`), nicht über das root-Passwort: Claude Code verweigert das Anlegen von Zugängen mit Passwort, und ein Schlüssel nur für diesen Host ist ohnehin der sauberere Weg.
- **Trockenmodus im Proxmox-Client statt eigenem Planer:** Dry-Run und Ernstfall laufen durch dieselben Phasen; Lesendes geht echt an Proxmox, Schreibendes wird aufgezeichnet. So können Plan und Wirklichkeit nicht auseinanderlaufen.
- **Guest-Agent wird beim Template-Bau per Vorbereitungsstart eingebaut** (vendor-data-Snippet), nicht bei jedem Deployment – Deployments laufen danach ohne SSH.
- **Skripte base64 über file-write:** Proxmox' eigene Kodierung scheitert an Umlauten (Fund beim ersten echten Deploy).
- **Template-Bau ohne SSH (Bau-Adresse) ausgegliedert** als #021, damit Etappe 2 nicht wartet.
- **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe). - **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe).
+69
View File
@@ -0,0 +1,69 @@
# ProxDeploy
**VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.**
ProxDeploy ist eine selbst gehostete Webanwendung mit einer CLI (`proxdeploy`). Damit wählt man einen von
mehreren Proxmox-VE-Servern (8.x/9.x, Einzelhost oder Cluster) und rollt darauf vollständig unattended
eine VM oder einen LXC-Container mit Debian oder Ubuntu aus, inklusive Profilen wie `base`, `docker`
oder `zabbix-agent`. Weitere Distributionen und Profile sind YAML-Dateien im Katalog, kein Code.
> ProxDeploy ist ein unabhängiges Werkzeug und stammt nicht von Proxmox Server Solutions GmbH.
> „Proxmox“ ist eine Marke der Proxmox Server Solutions GmbH.
## Stand
In Entwicklung. Version und Fortschritt stehen im [CHANGELOG](CHANGELOG.md), der Plan in
[docs/architektur.md](docs/architektur.md), die Aufgaben in [docs/issues/](docs/issues/README.md).
| 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, Assistent | ✓ fertig, auf PVE 9.2 geprüft |
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen | ◐ als Nächstes |
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ○ geplant |
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ geplant |
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
## Was ProxDeploy auf einem Server kann
Der Verbindungstest prüft jeden Server und zeigt, welcher Vorgang per API, per SSH oder gar nicht geht.
Maßgeblich ist die Proxmox-API (geprüft gegen PVE 8.x und 9.2):
- **Template bauen:** per API ab PVE 8.4 (`download-url` + `import-from`), davor per SSH
- **VM provisionieren:** über den Guest-Agent, ohne SSH
- **LXC provisionieren:** braucht SSH (Proxmox hat keine Exec-API für Container)
- **cloud-init-Snippets:** nur per SSH (keine API)
Das API-Token braucht eine eigene Rolle; die Befehle stehen in
[docs/proxmox-rechte.md](docs/proxmox-rechte.md).
## Entwicklung
Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL.
```bash
pnpm install
cp .env.example .env # Schlüssel erzeugen: openssl rand -base64 32 / 48, ADMIN_PASSWORT setzen
pnpm db:up # PostgreSQL 17 auf 127.0.0.1:5437 (inkl. Testdatenbank)
pnpm db:migrate
pnpm build && PORT=3310 APP_URL=http://127.0.0.1:3310 pnpm start
```
Prüfen vor jedem Commit:
```bash
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test
pnpm test:e2e # Browser-Abläufe gegen den gebauten Stand (Desktop und 390 px)
```
Konventionen (deutsche Bezeichner, Schichtung, Versionen) stehen in [CLAUDE.md](CLAUDE.md).
## Betrieb
Docker Compose (app, worker, postgres) und ein Installer für einen Debian-LXC auf Proxmox folgen mit
Etappe 6. ProxDeploy ist für den Betrieb hinter einem Reverse-Proxy mit TLS gedacht
(`TRUST_PROXY=true`, `APP_URL` auf die öffentliche Adresse).
## Lizenz
Noch nicht festgelegt.
+22
View File
@@ -0,0 +1,22 @@
# Geprüft am 2026-10-12: Image, SHA512SUMS (Zeilenformat "<hash> <datei>") und LXC-Template
# existieren. Der Guest-Agent ist im Image nicht enthalten – er wird beim Template-Bau eingebaut.
id: debian-12
name: Debian 12 „Bookworm“
family: debian
os:
id: debian
version_id: "12"
codename: bookworm
arch: amd64
vm:
image_url: https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
checksum_url: https://cloud.debian.org/images/cloud/bookworm/latest/SHA512SUMS
checksum_type: sha512
format: qcow2
default_user: debian
guest_agent_preinstalled: false
bios: ovmf
min_disk_gb: 4
lxc:
template_pattern: "debian-12-standard_*_amd64.tar.zst" # aus pveam available
ostype: debian
+22
View File
@@ -0,0 +1,22 @@
# Geprüft am 2026-10-12: Image, SHA512SUMS (Zeilenformat "<hash> <datei>") und LXC-Template
# existieren. Der Guest-Agent ist im Image nicht enthalten – er wird beim Template-Bau eingebaut.
id: debian-13
name: Debian 13 „Trixie“
family: debian
os:
id: debian
version_id: "13"
codename: trixie
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
format: qcow2
default_user: debian
guest_agent_preinstalled: false
bios: ovmf
min_disk_gb: 4
lxc:
template_pattern: "debian-13-standard_*_amd64.tar.zst" # aus pveam available
ostype: debian
+22
View File
@@ -0,0 +1,22 @@
# Geprüft am 2026-10-12: Image (.img ist qcow2), SHA256SUMS (Zeilenformat "<hash> *<datei>")
# und LXC-Template existieren. Guest-Agent nicht im Image – wird beim Template-Bau eingebaut.
id: ubuntu-22.04
name: Ubuntu 22.04 LTS „Jammy Jellyfish“
family: debian
os:
id: ubuntu
version_id: "22.04"
codename: jammy
arch: amd64
vm:
image_url: https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img
checksum_url: https://cloud-images.ubuntu.com/jammy/current/SHA256SUMS
checksum_type: sha256
format: qcow2
default_user: ubuntu
guest_agent_preinstalled: false
bios: ovmf
min_disk_gb: 4
lxc:
template_pattern: "ubuntu-22.04-standard_*_amd64.tar.zst"
ostype: ubuntu
+22
View File
@@ -0,0 +1,22 @@
# Geprüft am 2026-10-12: Image (.img ist qcow2), SHA256SUMS (Zeilenformat "<hash> *<datei>")
# und LXC-Template existieren. Guest-Agent nicht im Image – wird beim Template-Bau eingebaut.
id: ubuntu-24.04
name: Ubuntu 24.04 LTS „Noble Numbat“
family: debian
os:
id: ubuntu
version_id: "24.04"
codename: noble
arch: amd64
vm:
image_url: https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
checksum_url: https://cloud-images.ubuntu.com/noble/current/SHA256SUMS
checksum_type: sha256
format: qcow2
default_user: ubuntu
guest_agent_preinstalled: false
bios: ovmf
min_disk_gb: 4
lxc:
template_pattern: "ubuntu-24.04-standard_*_amd64.tar.zst"
ostype: ubuntu
+22
View File
@@ -0,0 +1,22 @@
# Geprüft am 2026-10-12: Image (.img ist qcow2), SHA256SUMS (Zeilenformat "<hash> *<datei>")
# und LXC-Template existieren. Guest-Agent nicht im Image – wird beim Template-Bau eingebaut.
id: ubuntu-26.04
name: Ubuntu 26.04 LTS „Resolute Raccoon“
family: debian
os:
id: ubuntu
version_id: "26.04"
codename: resolute
arch: amd64
vm:
image_url: https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img
checksum_url: https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS
checksum_type: sha256
format: qcow2
default_user: ubuntu
guest_agent_preinstalled: false
bios: ovmf
min_disk_gb: 4
lxc:
template_pattern: "ubuntu-26.04-standard_*_amd64.tar.zst"
ostype: ubuntu
+31
View File
@@ -0,0 +1,31 @@
# 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
variables:
- { name: zeitzone, label: Zeitzone, default: Europe/Berlin }
- { name: locale, label: Locale, default: de_DE.UTF-8 }
- { name: tastatur, label: Tastaturbelegung, default: de }
- { name: fail2ban, label: fail2ban installieren, type: boolean, default: false, help: Sperrt Adressen nach wiederholten SSH-Fehlversuchen. }
families:
debian:
- update: true
- guest_agent: true
- timezone: "{{ var.zeitzone }}"
- locale: "{{ var.locale }}"
- keyboard: "{{ var.tastatur }}"
- 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]
if: fail2ban
- service: { name: fail2ban, enable: true, start: true }
if: fail2ban
+22
View File
@@ -0,0 +1,22 @@
# Docker CE aus dem offiziellen Repository nach docs.docker.com (geprüft 2026-10-12):
# Schlüssel unter /etc/apt/keyrings/docker.asc, Quelle im deb822-Format.
id: docker
name: Docker CE
description: Docker Engine mit compose- und buildx-Plugin aus dem offiziellen Repository
requires: [base]
variables:
- { name: benutzer_in_gruppe, label: Benutzer in die Gruppe docker aufnehmen, type: boolean, default: true, help: Erlaubt docker ohne sudo – entspricht root-Rechten. }
lxc:
features: { nesting: true }
families:
debian:
- repo:
name: docker
key_url: "https://download.docker.com/linux/{{ os.id }}/gpg"
uris: "https://download.docker.com/linux/{{ os.id }}"
suites: "{{ os.codename }}"
components: stable
- packages: [docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin, docker-compose-plugin]
- service: { name: docker, enable: true, start: true }
- run: usermod -aG docker {{ guest.user }}
if: benutzer_in_gruppe
+21
View File
@@ -0,0 +1,21 @@
# Zabbix Agent 2 aus dem offiziellen Repository, 7.0 LTS (8.0 hatte am 2026-10-12 noch keine
# Agent-Pakete). Das Release-Paket legt Repository und Schlüssel an.
id: zabbix-agent
name: Zabbix Agent 2
description: Zabbix Agent 2 (7.0 LTS), Server und ServerActive als Variablen
requires: [base]
variables:
- { name: server, label: Zabbix-Server, required: true, help: "Adresse(n) des Servers, kommagetrennt" }
- { name: server_active, label: ServerActive, help: Leer lassen = wie Zabbix-Server }
families:
debian:
- repo_package: "https://repo.zabbix.com/zabbix/7.0/{{ os.id }}/pool/main/z/zabbix-release/zabbix-release_latest_7.0+{{ os.id }}{{ os.version_id }}_all.deb"
- packages: [zabbix-agent2]
- file:
path: /etc/zabbix/zabbix_agent2.d/proxdeploy.conf
content: |
# Von ProxDeploy angelegt
Server={{ var.server }}
ServerActive={{ var.server_active ?? var.server }}
Hostname={{ guest.name }}
- service: { name: zabbix-agent2, enable: true, start: true, restart: true }
+14 -9
View File
@@ -1,24 +1,29 @@
# #004 Katalog: Distributionen und Profile als Daten # #004 Katalog: Distributionen und Profile als Daten
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature, katalog **Labels:** feature, katalog
**Geschätzt:** 5 h **Tatsächlich:** – **Geschätzt:** 5 h **Tatsächlich:** 0 h 09 min
## Ziel ## Ziel
YAML-Katalog laden, validieren, in die DB synchronisieren, in der GUI ansehen und deaktivieren. YAML-Katalog laden, validieren, in die DB synchronisieren, in der GUI ansehen und deaktivieren.
## Aufgaben ## Aufgaben
- [ ] zod-Schemas für `catalog/distros` und `catalog/profiles` - [x] zod-Schemas für `catalog/distros` und `catalog/profiles`
- [ ] Debian 12/13, Ubuntu 22.04/24.04 (URLs/Prüfsummen geprüft am 2026-10-12) - [x] Debian 12/13, Ubuntu 22.04/24.04/26.04 (URLs/Prüfsummen geprüft am 2026-10-12)
- [ ] Prüfsummen-Parser für beide Zeilenformate; LXC-Musterauflösung mit Versionssortierung - [x] Prüfsummen-Parser für beide Zeilenformate; LXC-Musterauflösung mit Versionssortierung
- [ ] Sync beim worker-Start (Upsert, Aktiv-Schalter bleibt), fehlerhafte Datei überspringen + melden - [x] Sync beim worker-Start (Upsert, Aktiv-Schalter bleibt), fehlerhafte Datei überspringen + melden
- [ ] Katalog-Seite (Distributionen, Profile, aktiv/inaktiv) - [x] Katalog-Seite (Distributionen, Profile, aktiv/inaktiv)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Unit-Tests für Schema, Sync und Muster - [x] Unit-Tests für Schema, Sync und Muster
## Abhängigkeiten ## Abhängigkeiten
#001 #001
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - Profile `base`, `docker`, `zabbix-agent` liegen als Daten schon im Katalog; ihre Umsetzung in Shell folgt mit dem Adapter (#005).
- Zusätzliche Felder gegenüber der Vorgabe: `os` (id, version_id, codename – für Repo-URLs in Profilen), `arch`, `vm.bios`, `vm.min_disk_gb`.
- Unbekannte YAML-Schlüssel sind ein Fehler; Dateiname und `id` müssen übereinstimmen.
- Verschwundene Einträge werden markiert (`entferntAm`), nicht gelöscht – Aufträge verweisen später darauf.
- Ein eigenes Profil mit gleicher id wie ein Katalogprofil gewinnt; der Abgleich meldet den Konflikt.
- Abgleich unter Advisory-Lock, weil app und worker beim Start gleichzeitig abgleichen.
+17 -9
View File
@@ -1,23 +1,31 @@
# #005 Adapter debian und Profil-Engine # #005 Adapter debian und Profil-Engine
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature, engine **Labels:** feature, engine
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min
## Ziel ## Ziel
Interface `FamilienAdapter`, Umsetzung `debian`, Profil `base` wird zu einem idempotenten Skript. Interface `FamilienAdapter`, Umsetzung `debian`, Profile werden zu einem idempotenten Skript.
## Aufgaben ## Aufgaben
- [ ] Interface + Registry; `debian`-Adapter (apt, systemd, deb822, locale, keyboard, sshd_config.d) - [x] Interface + Registry; `debian`-Adapter (apt mit Lock-Timeout, deb822, systemd, locale, keyboard, sshd_config.d, sudoers mit visudo-Prüfung)
- [ ] Profilauflösung (requires, Reihenfolge, Variablen, Shell-Quoting) - [x] Profilauflösung (requires transitiv, Zyklen, Variablen mit Typen und Standardwerten, Familien-Prüfung)
- [ ] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, sudo-User, SSH-Härtung, chrony, fail2ban optional) - [x] Platzhalter `{{ var.x }}`, `{{ os.* }}`, `{{ guest.* }}`, `a ?? b`; in `run` jeder Wert shell-gequotet
- [ ] Snapshot-Tests der erzeugten Skripte; shellcheck auf die Ausgabe - [x] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, chrony, SSH-Härtung, fail2ban optional)
- [x] Tests der erzeugten Skripte; shellcheck auf die Ausgabe aller Distributionen
- [x] `pnpm test:container`: Skripte wirklich ausführen (Debian 12/13, Ubuntu 22.04/24.04/26.04), Ergebnis prüfen, zweiter Lauf auf Idempotenz
- [x] `docs/katalog.md`: neue Distribution, neuer Adapter, eigenes Profil
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code - [x] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code
- [x] base + docker + zabbix-agent laufen auf allen fünf Distributionen durch und sind idempotent (Container, 2026-10-12)
## Abhängigkeiten ## Abhängigkeiten
#004 #004
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **Fund im Container:** frische Images haben keine Paketlisten – der Vorspann macht jetzt immer `apt-get update`, sonst scheiterte schon die Benutzeranlage an „Unable to locate package sudo“.
- **Zeitzone, Locale und Tastatur werden gegen strenge Muster geprüft**, bevor sie ins Skript kommen (shellcheck fand verschachteltes Quoting im sed-Ausdruck).
- **SSH-Härtung läuft immer zuletzt:** läuft die Provisionierung per SSH als root (LXC ohne Node-SSH), schlösse sie sich sonst selbst aus.
- **Marken `::proxdeploy:: profil <id>`** im Skript zeigen im Live-Log, welches Profil gerade läuft.
- Dateiinhalte gehen base64-kodiert ins Skript – beliebige Zeichen kommen unverändert an.
+17 -10
View File
@@ -1,25 +1,32 @@
# #006 Aufträge, Phasen, worker und Live-Fortschritt # #006 Aufträge, Phasen, worker und Live-Fortschritt
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature, engine **Labels:** feature, engine
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 15 min
## Ziel ## Ziel
Hintergrund-Jobs mit Phasen in der DB, live per SSE in der Web-GUI. Hintergrund-Jobs mit Phasen in der DB, live per SSE in der Web-GUI.
## Aufgaben ## Aufgaben
- [ ] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung` - [x] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung`
- [ ] Phasen-Runner mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis - [x] Phasen-Runner (frameworkfrei) mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis, Abbruch zwischen Phasen
- [ ] SSE-Endpunkt mit Last-Event-ID, Polling-Fallback - [x] SSE-Endpunkt mit Last-Event-ID, Herzschlag, `X-Accel-Buffering: no`; Polling-Fallback im Client
- [ ] VMID-Vergabe mit Advisory-Lock, Test mit parallelen Aufträgen - [x] VMID-Vergabe mit Advisory-Lock, Test mit zehn parallelen Reservierungen
- [ ] Live-Ansicht als Terminal-Karte, Detail-Log, Ergebnis-Karte - [x] Live-Ansicht als Terminal-Karte (Aufbau der CLI-Ausgabe), Detail-Log, Ergebnis-Box; Verlaufsseite
- [x] Verwaiste Aufträge nach worker-Neustart als fehlgeschlagen markieren; doppelt gelieferte Jobs laufen nur einmal
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test) - [x] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test)
- [ ] Neuladen der Seite mitten im Auftrag zeigt den vollständigen Stand - [x] Neuladen der Seite mitten im Auftrag zeigt den vollständigen Stand (Stand kommt immer ganz, Log ab Last-Event-ID)
- [x] Live geprüft mit einem echten Template-Bau auf pve-demo (#007)
## Abhängigkeiten ## Abhängigkeiten
#003 #003
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **ssh2 und pg sind CommonJS:** benannte Importe gehen im Next-Bundle, aber nicht im worker unter reinem Node-ESM – jetzt Default-Importe.
- **Der worker lädt `.env` selbst** (`process.loadEnvFile`, falls vorhanden); im Container kommt die Umgebung von außen.
- **Kein automatisches Wiederholen** (`retryLimit: 0`): halb angelegte Gäste soll ein Mensch ansehen.
- **Abfrageschleifen** (Task-Status, VM-Status) erscheinen im Detail-Log nur bei Fehlern.
- **Kopfzeile** (`Auftrag.kopf`) setzt die Engine – Web und CLI zeigen denselben Text.
- Client-Komponenten importieren nichts aus `src/server` (struktureller Test in `tests/zugang.test.ts`).
+15 -11
View File
@@ -1,26 +1,30 @@
# #007 Template-Bau für VMs # #007 Template-Bau für VMs
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature, proxmox **Labels:** feature, proxmox
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 12 min
## Ziel ## Ziel
Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren. Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren.
## Aufgaben ## Aufgaben
- [ ] Weg A (≥ 8.4): `download-url` (content=import, checksum) + `import-from` per Volume-ID - [x] Weg A (≥ 8.4): `download-url` (content=import, checksum) + `import-from` per Volume-ID
- [ ] Weg B (SSH): Download + Prüfung auf dem Node, Import per `qm importdisk`/`import-from` - [x] Weg B (SSH): Download + Prüfung auf dem Node, Import per `qm importdisk`/`import-from`
- [ ] VMID: nächste freie im Template-Bereich (Entscheidung 2026-10-12) - [x] VMID: nächste freie im Template-Bereich (Entscheidung 2026-10-12)
- [ ] Hardware: q35, OVMF+EFI-Disk (Standard) oder SeaBIOS, virtio-scsi-single, discard/ssd/iothread, CPU host, serielle Konsole, agent=1, cloud-init-Laufwerk - [x] Hardware: q35, OVMF+EFI-Disk (Standard) oder SeaBIOS, virtio-scsi-single, discard/ssd/iothread, CPU host, serielle Konsole, agent=1, cloud-init-Laufwerk
- [ ] Guest-Agent einbauen: Snippet (SSH) oder Bau-Adresse + SSH in die VM - [x] Guest-Agent einbauen per Snippet (SSH); Weg über Bau-Adresse ohne SSH → #021
- [ ] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar - [x] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar
- [ ] Singleton je Server+Distribution - [x] Singleton je Server+Distribution
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Debian-13-Template auf echtem Proxmox gebaut (8.x und 9.x, sobald verfügbar) - [x] Debian-13-Template auf pve-demo (PVE 9.2.2) gebaut, Auftrag t-8zca3, 5 min 26 s; 8.x folgt im Feldtest (#020)
## Abhängigkeiten ## Abhängigkeiten
#005, #006 #005, #006
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **Image-Dateiname mit Prüfsummen-Präfix** (`proxdeploy-debian-13-f46f0671a6e5.qcow2`): ein neues „latest“-Image wird neu geladen, ein gleiches wiederverwendet.
- **Proxmox prüft die Prüfsumme selbst** (`download-url` mit `checksum`); ProxDeploy holt sie vorher aus SHA512SUMS/SHA256SUMS.
- **Vorbereitungsstart mit vendor-data:** qemu-guest-agent, Maschinen-ID leeren (sonst teilen sich Klone DHCP-Client-ID), `cloud-init clean`, poweroff. Snippet und `cicustom` werden auch nach einem Fehler entfernt.
- **UEFI mit `pre-enrolled-keys=1`** (Secure Boot an) – Debian 13 bootet damit; Proxmox setzt selbst `ms-cert=2023k`.
- Auf dem Testhost dauerte der Vorbereitungsstart ~70 s.
+16 -9
View File
@@ -1,24 +1,31 @@
# #008 Deploy-Assistent und VM ausrollen # #008 Deploy-Assistent und VM ausrollen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature **Labels:** feature
**Geschätzt:** 10 h **Tatsächlich:** – **Geschätzt:** 10 h **Tatsächlich:** 0 h 36 min
## Ziel ## Ziel
Debian-13-VM aus Template vollständig unattended, Assistent mit Dry-Run und CLI-Befehl. Debian-13-VM aus Template vollständig unattended, Assistent mit Dry-Run und CLI-Befehl.
## Aufgaben ## Aufgaben
- [ ] Planer VM (Phasen + geplante API-Aufrufe), `POST /auftraege/plan` - [x] Anfrageschema für Web, CLI, Batch und Vorlagen (`src/lib/schemas/ausrollen.ts`), CLI-Befehl daraus
- [ ] Ausführung: Klonen, Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Agent, Provisionierung per Agent - [x] Ablauf VM: Prüfungen (Node, Template, Storage, Bridge/VLAN, VMID), Klonen (mit einem Wiederholversuch bei belegter VMID), Hardware + Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Guest-Agent (ping, IP), Provisionierung per Agent mit Live-Fortschritt
- [ ] Fehlendes Template → automatisch bauen anbieten - [x] Dry-Run über denselben Ablauf mit Trocken-Client: `POST /api/v1/auftraege/plan`
- [ ] Assistent mit 9 Schritten, URL-Zustand, Handy-tauglich - [x] Fehlendes Template → „Template vorher bauen“ im Assistenten, läuft als Phase im selben Auftrag
- [ ] Aufräumen nach Bestätigung - [x] Assistent mit 9 Schritten, Zustand in der URL, jederzeit zurückspringbar, Handy-tauglich
- [ ] Aufräumen nach Bestätigung → folgt mit #014 (Verlauf); bis dahin werden angelegte VMIDs je Auftrag festgehalten
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] VM mit base-Profil per SSH erreichbar; Fehlerfall zeigt Ursache + Hinweis - [x] VM mit base-Profil per SSH erreichbar: pd-test01 (base, docker) per API, pd-test02 (base, zabbix-agent) über den Assistenten auf pve-demo, 2026-10-12
- [x] Fehlerfall zeigt Ursache + Hinweis (Tests: VLAN auf nicht VLAN-fähiger Bridge, Agent antwortet nicht, Profil scheitert mit Code)
## Abhängigkeiten ## Abhängigkeiten
#007 #007
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **Fund beim ersten echten Deploy:** `agent/file-write` scheitert an Nicht-ASCII („Wide character in subroutine entry“). ProxDeploy kodiert die Skriptstücke jetzt selbst als base64 (`encode=0`) und teilt nach Bytes.
- **Die Agent-Endpunkte antworten unterschiedlich** (exec → `{pid}`, exec-status/file-read direkt, ping/network-get-interfaces → `{result}`).
- **Fortschritt der Provisionierung** aus der Logdatei im Gast (`/var/log/proxdeploy-provisionierung.log`, per file-read), Marken je Profil.
- **Klon auf anderen Node** nur bei geteiltem Storage; sonst sagt die Prüfung, welchen Node man wählen soll.
- Zwei Felder hießen „Server“ (Umschalter und Assistent) – der Umschalter heißt jetzt „Aktiver Server“.
- Testgäste auf pve-demo: VM 100 `pd-test01`, VM 101 `pd-test02` (Tag `proxdeploy-test`).
+11 -6
View File
@@ -1,21 +1,26 @@
# #009 LXC-Container ausrollen # #009 LXC-Container ausrollen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature, proxmox **Labels:** feature, proxmox
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 30 min
## Ziel ## Ziel
LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container. LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container.
## Aufgaben ## Aufgaben
- [ ] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH) - [x] 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] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen)
## Akzeptanzkriterien ## 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 ## Abhängigkeiten
#008 #008
## Notizen / Entscheidungen ## 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 # #010 Ubuntu 22.04, 24.04 und 26.04
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature, katalog **Labels:** feature, katalog
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 05 min
## Ziel ## Ziel
Ubuntu als VM und LXC über den debian-Adapter. Ubuntu als VM und LXC über den debian-Adapter.
## Aufgaben ## Aufgaben
- [ ] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades) - [x] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades)
- [ ] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12) - [x] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12)
## Akzeptanzkriterien ## 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 ## Abhängigkeiten
#009 #009
## Notizen / Entscheidungen ## 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 # #011 Profile docker und zabbix-agent, Variablen, Editor
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature **Labels:** feature
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 0 h 10 min
## Ziel ## Ziel
Kombinierbare Profile mit Variablen; eigene Profile im YAML-Editor. Kombinierbare Profile mit Variablen; eigene Profile im YAML-Editor.
## Aufgaben ## Aufgaben
- [ ] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive) - [x] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive)
- [ ] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie - [x] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie
- [ ] YAML-Editor mit Validierung und Fehlerstelle - [x] YAML-Editor mit Validierung und Fehlerstelle
## Akzeptanzkriterien ## 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 ## Abhängigkeiten
#005 #005
## Notizen / Entscheidungen ## 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) # #012 Vorlagen (Presets)
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature **Labels:** feature
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 11 min
## Ziel ## Ziel
Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar. Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar.
## Aufgaben ## 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 ## Akzeptanzkriterien
- [ ] `--preset` in API und Assistent identisch aufgelöst - [x] `--preset` in API und Assistent identisch aufgelöst
## Abhängigkeiten ## Abhängigkeiten
#008 #008
## Notizen / Entscheidungen ## 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 ## Aufgaben
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test) - [ ] 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 - [ ] Audit-Log-Ansicht mit Filter
## Akzeptanzkriterien ## Akzeptanzkriterien
@@ -19,4 +20,4 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
#001 #001
## Notizen / Entscheidungen ## 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 25 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 35 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).
+11 -9
View File
@@ -7,15 +7,15 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#001](001-projekt-setup.md) | Projekt-Setup | erledigt | v0.1.0 | 4 h | 0 h 50 min | | [#001](001-projekt-setup.md) | Projekt-Setup | erledigt | v0.1.0 | 4 h | 0 h 50 min |
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min | | [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min |
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min | | [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min |
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | offen | v0.2.0 | 5 h | – | | [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | erledigt | v0.2.0 | 5 h | 0 h 09 min |
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | offen | v0.2.0 | 6 h | – | | [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | erledigt | v0.2.0 | 6 h | 0 h 20 min |
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | offen | v0.2.0 | 8 h | – | | [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min |
| [#007](007-template-bau.md) | Template-Bau für VMs | offen | v0.2.0 | 8 h | – | | [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min |
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | offen | v0.2.0 | 10 h | – | | [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min |
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – | | [#009](009-lxc.md) | LXC-Container ausrollen | erledigt | v0.3.0 | 8 h | 0 h 30 min |
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – | | [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | erledigt | v0.3.0 | 3 h | 0 h 05 min |
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – | | [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | erledigt | v0.3.0 | 6 h | 0 h 10 min |
| [#012](012-vorlagen.md) | Vorlagen (Presets) | offen | v0.3.0 | 3 h | – | | [#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 | – | | [#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 | – | | [#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 | – | | [#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 | – | | [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – | | [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – | | [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | umgesetzt, Abnahme offen | v0.3.0 | 3 h | 0 h 25 min |
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 35 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
```
+6 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxdeploy", "name": "proxdeploy",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI", "description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
@@ -24,7 +24,9 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"typecheck": "tsc --noEmit" "test:container": "tests/container/profile-pruefen.sh",
"typecheck": "tsc --noEmit",
"worker": "tsx src/worker/main.ts"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0",
@@ -37,11 +39,13 @@
"lucide-react": "1.47.0", "lucide-react": "1.47.0",
"next": "16.4.0", "next": "16.4.0",
"pg": "^8.23.0", "pg": "^8.23.0",
"pg-boss": "12.37.1",
"pino": "10.4.0", "pino": "10.4.0",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
"ssh2": "1.17.0", "ssh2": "1.17.0",
"undici": "8.11.2", "undici": "8.11.2",
"yaml": "2.9.1",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
+91 -6
View File
@@ -192,10 +192,13 @@ importers:
version: 1.47.0(react@19.3.0) version: 1.47.0(react@19.3.0)
next: next:
specifier: 16.4.0 specifier: 16.4.0
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
pg: pg:
specifier: ^8.23.0 specifier: ^8.23.0
version: 8.23.1 version: 8.23.1
pg-boss:
specifier: 12.37.1
version: 12.37.1(@opentelemetry/api@1.9.1)
pino: pino:
specifier: 10.4.0 specifier: 10.4.0
version: 10.4.0 version: 10.4.0
@@ -211,6 +214,9 @@ importers:
undici: undici:
specifier: 8.11.2 specifier: 8.11.2
version: 8.11.2 version: 8.11.2
yaml:
specifier: 2.9.1
version: 2.9.1
zod: zod:
specifier: ^4.6.5 specifier: ^4.6.5
version: 4.6.5 version: 4.6.5
@@ -283,7 +289,7 @@ importers:
version: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) version: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
vitest: vitest:
specifier: 5.0.1 specifier: 5.0.1
version: 5.0.1(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
packages: packages:
@@ -1196,6 +1202,10 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} engines: {node: '>=12.4.0'}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@oxc-project/types@0.153.0': '@oxc-project/types@0.153.0':
resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
@@ -2106,6 +2116,10 @@ packages:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
cron-parser@5.10.2:
resolution: {integrity: sha512-JfAYMY0LZaWQTuict8PhtF/ksSnq8xefSDmQUnnoqvkPYkU/y3UP0DTWzJKFBz0P1IXsDG8q0vY3zHkoFZOhvw==}
engines: {node: '>=18'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3077,6 +3091,10 @@ packages:
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -3161,6 +3179,10 @@ packages:
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
engines: {node: '>=18'} engines: {node: '>=18'}
non-error@0.1.0:
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
engines: {node: '>=20'}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3245,6 +3267,13 @@ packages:
perfect-debounce@2.1.0: perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
pg-boss@12.37.1:
resolution: {integrity: sha512-LghWeOIbMCiLcTY3+8MInXaJ0wk0AOkbeUZ8oE+cGO0uoheGSW3GK57eDqEbrDGpwTIpX9BVphvdvWEDYTcJ9A==}
engines: {node: '>=22.12.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.9.0
pg-cloudflare@1.4.1: pg-cloudflare@1.4.1:
resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==}
@@ -3466,6 +3495,9 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
rrule-temporal@2.2.8:
resolution: {integrity: sha512-+wWUe0J02oMh1UWKIt1RTu2H0Cz+gP11O/iweK0MN2UP6JLGeepTUkbSBa90Hxq0W+ywSxM9IlJNouIUdMOwhg==}
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -3507,6 +3539,10 @@ packages:
seq-queue@0.0.5: seq-queue@0.0.5:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
serialize-error@13.0.2:
resolution: {integrity: sha512-G+YKwp9I6IvWMe5RlwqxdmTPIcq/G8HK/sU51gdV1XAJznYkRSTB0ph+zB9AfSxG0fx6xrqYA6i9HeIcHFTXow==}
engines: {node: '>=20'}
set-function-length@1.2.2: set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3665,6 +3701,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwindcss@4.3.3: tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
@@ -3672,6 +3712,9 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'} engines: {node: '>=6'}
temporal-spec@1.0.1:
resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==}
thread-stream@4.2.0: thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -3720,6 +3763,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
type-fest@5.10.0:
resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==}
engines: {node: '>=20'}
typed-array-buffer@1.0.3: typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4660,6 +4707,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {} '@nolyfill/is-core-module@1.0.39': {}
'@opentelemetry/api@1.9.1': {}
'@oxc-project/types@0.153.0': {} '@oxc-project/types@0.153.0': {}
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
@@ -5554,6 +5603,10 @@ snapshots:
nan: 2.29.0 nan: 2.29.0
optional: true optional: true
cron-parser@5.10.2:
dependencies:
luxon: 3.7.2
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -6637,6 +6690,8 @@ snapshots:
dependencies: dependencies:
react: 19.3.0 react: 19.3.0
luxon@3.7.2: {}
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/sourcemap-codec': 1.6.0
@@ -6691,7 +6746,7 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies: dependencies:
'@next/env': 16.4.0 '@next/env': 16.4.0
'@swc/helpers': 0.5.23 '@swc/helpers': 0.5.23
@@ -6710,6 +6765,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.4.0 '@next/swc-linux-x64-musl': 16.4.0
'@next/swc-win32-arm64-msvc': 16.4.0 '@next/swc-win32-arm64-msvc': 16.4.0
'@next/swc-win32-x64-msvc': 16.4.0 '@next/swc-win32-x64-msvc': 16.4.0
'@opentelemetry/api': 1.9.1
'@playwright/test': 1.63.0 '@playwright/test': 1.63.0
sharp: 0.35.5(@types/node@24.19.2) sharp: 0.35.5(@types/node@24.19.2)
transitivePeerDependencies: transitivePeerDependencies:
@@ -6726,6 +6782,8 @@ snapshots:
node-releases@2.0.58: {} node-releases@2.0.58: {}
non-error@0.1.0: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
@@ -6819,6 +6877,16 @@ snapshots:
perfect-debounce@2.1.0: {} perfect-debounce@2.1.0: {}
pg-boss@12.37.1(@opentelemetry/api@1.9.1):
dependencies:
'@opentelemetry/api': 1.9.1
cron-parser: 5.10.2
pg: 8.23.1
rrule-temporal: 2.2.8
serialize-error: 13.0.2
transitivePeerDependencies:
- pg-native
pg-cloudflare@1.4.1: pg-cloudflare@1.4.1:
optional: true optional: true
@@ -7049,6 +7117,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.13 '@rolldown/binding-win32-arm64-msvc': 1.2.13
'@rolldown/binding-win32-x64-msvc': 1.2.13 '@rolldown/binding-win32-x64-msvc': 1.2.13
rrule-temporal@2.2.8:
dependencies:
temporal-spec: 1.0.1
run-parallel@1.2.0: run-parallel@1.2.0:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
@@ -7088,6 +7160,11 @@ snapshots:
seq-queue@0.0.5: {} seq-queue@0.0.5: {}
serialize-error@13.0.2:
dependencies:
non-error: 0.1.0
type-fest: 5.10.0
set-function-length@1.2.2: set-function-length@1.2.2:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@@ -7300,10 +7377,14 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
tagged-tag@1.0.0: {}
tailwindcss@4.3.3: {} tailwindcss@4.3.3: {}
tapable@2.3.3: {} tapable@2.3.3: {}
temporal-spec@1.0.1: {}
thread-stream@4.2.0: thread-stream@4.2.0:
dependencies: dependencies:
real-require: 1.0.0 real-require: 1.0.0
@@ -7348,6 +7429,10 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-fest@5.10.0:
dependencies:
tagged-tag: 1.0.0
typed-array-buffer@1.0.3: typed-array-buffer@1.0.3:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -7464,7 +7549,7 @@ snapshots:
tsx: 4.23.15 tsx: 4.23.15
yaml: 2.9.1 yaml: 2.9.1
vitest@5.0.1(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): vitest@5.0.1(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
dependencies: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
@@ -7481,6 +7566,7 @@ snapshots:
vite: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) vite: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.19.2 '@types/node': 24.19.2
transitivePeerDependencies: transitivePeerDependencies:
- msw - msw
@@ -7549,8 +7635,7 @@ snapshots:
yallist@3.1.1: {} yallist@3.1.1: {}
yaml@2.9.1: yaml@2.9.1: {}
optional: true
yargs-parser@22.0.0: {} yargs-parser@22.0.0: {}
@@ -0,0 +1,37 @@
-- CreateEnum
CREATE TYPE "Herkunft" AS ENUM ('KATALOG', 'EIGEN');
-- CreateTable
CREATE TABLE "Distribution" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"familie" TEXT NOT NULL,
"definition" JSONB NOT NULL,
"quelltext" TEXT NOT NULL,
"dateiHash" TEXT NOT NULL,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"entferntAm" TIMESTAMP(3),
"synchronisiertAm" TIMESTAMP(3) NOT NULL,
"geaendertVonId" TEXT,
CONSTRAINT "Distribution_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Profil" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"beschreibung" TEXT,
"familien" TEXT[],
"herkunft" "Herkunft" NOT NULL,
"definition" JSONB NOT NULL,
"quelltext" TEXT NOT NULL,
"dateiHash" TEXT,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"entferntAm" TIMESTAMP(3),
"synchronisiertAm" TIMESTAMP(3),
"geaendertVonId" TEXT,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Profil_pkey" PRIMARY KEY ("id")
);
@@ -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;
+209
View File
@@ -38,6 +38,8 @@ model Benutzer {
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
protokoll Protokoll[] protokoll Protokoll[]
auftraege Auftrag[]
schluessel SshSchluessel[]
} }
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE. /// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
@@ -99,4 +101,211 @@ model Server {
geprueftAm DateTime? geprueftAm DateTime?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
auftraege Auftrag[]
templates Template[]
}
enum Herkunft {
KATALOG
EIGEN
}
/// Spiegel von catalog/distros/*.yaml. Die Datei ist maßgeblich; nur `aktiv` gehört der Datenbank.
model Distribution {
id String @id
name String
familie String
/// Validierter Inhalt der YAML-Datei.
definition Json
quelltext String
dateiHash String
aktiv Boolean @default(true)
/// Gesetzt, wenn die Datei aus dem Katalog verschwunden ist – die Zeile bleibt für den Verlauf.
entferntAm DateTime?
synchronisiertAm DateTime
geaendertVonId String?
}
/// Profile aus catalog/profiles/*.yaml (KATALOG, schreibgeschützt) und eigene aus der GUI (EIGEN).
model Profil {
id String @id
name String
beschreibung String?
familien String[]
herkunft Herkunft
definition Json
/// YAML wie eingegeben – Kommentare bleiben erhalten.
quelltext String
dateiHash String?
aktiv Boolean @default(true)
entferntAm DateTime?
synchronisiertAm DateTime?
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>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
/** Aktiv/inaktiv als echter Schalter (role=switch) mit sichtbarem Zustandstext. */
export function AktivSchalter({
art,
id,
aktiv,
name,
}: {
art: "distributionen" | "profile";
id: string;
aktiv: boolean;
name: string;
}) {
const router = useRouter();
const [wert, setWert] = useState(aktiv);
const [laeuft, setLaeuft] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
async function umschalten() {
setLaeuft(true);
setFehler(null);
try {
await apiAufruf("PATCH", `/api/v1/${art}/${id}`, { aktiv: !wert });
setWert(!wert);
router.refresh();
} catch (ursache) {
setFehler(ursache instanceof ApiFehler ? ursache.message : "Fehler");
} finally {
setLaeuft(false);
}
}
return (
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={wert}
aria-label={`${name} ${wert ? "deaktivieren" : "aktivieren"}`}
disabled={laeuft}
onClick={umschalten}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
wert ? "bg-akzent border-akzent" : "bg-panel-hoch border-linie"
}`}
>
<span
aria-hidden="true"
className={`inline-block h-4 w-4 rounded-full transition ${wert ? "bg-akzent-text translate-x-6" : "bg-text translate-x-1"}`}
/>
</button>
<span className="text-muted w-14 text-sm">{fehler ?? (wert ? "aktiv" : "inaktiv")}</span>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
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 { distributionenListe, profileListe } from "@/server/services/katalog";
import { AktivSchalter } from "./aktiv-schalter";
export const metadata: Metadata = { title: "Katalog" };
export default async function KatalogSeite() {
const benutzer = await seiteSchuetzen("lesen", "/katalog");
const verwalten = darf(benutzer.rolle, "katalogVerwalten");
const [distributionen, profile] = await Promise.all([
distributionenListe({ nurAktive: !verwalten }),
profileListe({ nurAktive: !verwalten }),
]);
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Katalog</h1>
<p className="text-muted text-sm">
Distributionen und Profile kommen aus <span className="font-mono">catalog/*.yaml</span> und werden
beim Start abgeglichen.{" "}
{verwalten ? "Deaktivierte Einträge erscheinen nicht im Assistenten." : null}
</p>
</header>
<Karte titel="Distributionen">
<ul className="divide-linie flex flex-col divide-y">
{distributionen.map((d) => (
<li
key={d.id}
className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center"
>
<div className="min-w-0 flex-1">
<p className="font-medium">{d.name}</p>
<p className="text-muted font-mono text-xs">
{d.id} · Familie {d.familie}
{d.definition.vm
? ` · Image ${d.definition.vm.format}, Benutzer ${d.definition.vm.default_user}`
: ""}
</p>
</div>
<div className="flex items-center gap-4 text-sm">
<Status art={d.vm ? "erledigt" : "offen"}>VM</Status>
<Status art={d.lxc ? "erledigt" : "offen"}>LXC</Status>
{verwalten ? (
<AktivSchalter art="distributionen" id={d.id} aktiv={d.aktiv} name={d.name} />
) : null}
</div>
</li>
))}
</ul>
</Karte>
<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
key={p.id}
className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center"
>
<div className="min-w-0 flex-1">
<p className="font-medium">
<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>
</p>
{p.beschreibung ? <p className="text-muted text-sm">{p.beschreibung}</p> : null}
<p className="text-muted mt-1 text-xs">
Familien: <span className="font-mono">{p.familien.join(", ")}</span>
{p.definition.requires.length > 0 ? (
<>
{" "}
· setzt voraus: <span className="font-mono">{p.definition.requires.join(", ")}</span>
</>
) : null}
{p.definition.variables.length > 0 ? (
<>
{" "}
· Variablen:{" "}
<span className="font-mono">
{p.definition.variables.map((v) => v.name).join(", ")}
</span>
</>
) : null}
</p>
</div>
{verwalten ? <AktivSchalter art="profile" id={p.id} aktiv={p.aktiv} name={p.name} /> : null}
</li>
))}
</ul>
</Karte>
</div>
);
}
@@ -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; templateVmidVon: string;
templateVmidBis: string; templateVmidBis: string;
gastVmidVon: string; gastVmidVon: string;
bauIp: string;
bauGateway: string;
bauDns: string;
}; };
function anfangswerte(server?: ServerSicht): Werte { function anfangswerte(server?: ServerSicht): Werte {
@@ -60,6 +63,9 @@ function anfangswerte(server?: ServerSicht): Werte {
templateVmidVon: String(server?.templateVmidVon ?? 9000), templateVmidVon: String(server?.templateVmidVon ?? 9000),
templateVmidBis: String(server?.templateVmidBis ?? 9999), templateVmidBis: String(server?.templateVmidBis ?? 9999),
gastVmidVon: String(server?.gastVmidVon ?? 100), 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); setSpeichert(true);
setFelder({}); setFelder({});
setMeldung(null); setMeldung(null);
const { bauIp, bauGateway, bauDns, ...ohneBau } = werte;
const rumpf = { const rumpf = {
...werte, ...ohneBau,
sshPort: Number(werte.sshPort), sshPort: Number(werte.sshPort),
templateVmidVon: Number(werte.templateVmidVon), templateVmidVon: Number(werte.templateVmidVon),
templateVmidBis: Number(werte.templateVmidBis), templateVmidBis: Number(werte.templateVmidBis),
gastVmidVon: Number(werte.gastVmidVon), gastVmidVon: Number(werte.gastVmidVon),
bauNetz: bauIp.trim()
? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) }
: null,
}; };
try { try {
const gespeichert = bearbeiten const gespeichert = bearbeiten
@@ -361,6 +371,44 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
</Feld> </Feld>
</div> </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 <Schalter
id="sshAktiv" id="sshAktiv"
label="SSH zu den Nodes nutzen" 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 });
});
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { distributionAktivSetzen } from "@/server/services/katalog";
type Kontext = { params: Promise<{ id: string }> };
/** Nur `aktiv` ist änderbar – der Inhalt gehört der YAML-Datei im Katalog. */
export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
const { aktiv } = z.object({ aktiv: z.boolean() }).parse(await jsonLesen(anfrage));
await distributionAktivSetzen((await params).id, aktiv, aufrufer, aufruferAdresse(anfrage.headers));
return NextResponse.json({ ok: true });
});
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { darf } from "@/server/auth/rechte";
import { rechtErforderlich } from "@/server/auth/zugang";
import { distributionenListe } from "@/server/services/katalog";
/**
* `?typ=vm|lxc` filtert auf Distributionen, die den Typ können. Deaktivierte sehen nur Admins
* (mit `?alle=1`), alle anderen bekommen nur, was ausgerollt werden darf.
*/
export const GET = api(async (anfrage: Request) => {
const { benutzer } = await rechtErforderlich(anfrage, "lesen");
const abfrage = new URL(anfrage.url).searchParams;
const typ = abfrage.get("typ");
const alle = abfrage.get("alle") === "1" && darf(benutzer.rolle, "katalogVerwalten");
return NextResponse.json(
await distributionenListe({ typ: typ === "vm" || typ === "lxc" ? typ : undefined, nurAktive: !alle }),
);
});
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { profilAktivSetzen, profilLoeschen, profilSpeichern } from "@/server/services/katalog";
type Kontext = { params: Promise<{ id: string }> };
export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
const { aktiv } = z.object({ aktiv: z.boolean() }).parse(await jsonLesen(anfrage));
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));
});
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
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, profilSpeichern } from "@/server/services/katalog";
export const GET = api(async (anfrage: Request) => {
const { benutzer } = await rechtErforderlich(anfrage, "lesen");
const alle =
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 ( return (
<div className="flex flex-col gap-1.5 px-2"> <div className="flex flex-col gap-1.5 px-2">
<label htmlFor="server-umschalter" className="text-muted text-xs font-medium tracking-wide uppercase"> <label htmlFor="server-umschalter" className="text-muted text-xs font-medium tracking-wide uppercase">
Server Aktiver Server
</label> </label>
<select <select
id="server-umschalter" id="server-umschalter"
+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;
}
+102
View File
@@ -0,0 +1,102 @@
import { createHash } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { parse, YAMLParseError } from "yaml";
import type { ZodType } from "@/lib/validierung";
import { DistributionSchema, ProfilSchema, type Distribution, type Profil } from "./schema";
/**
* Liest `catalog/distros` und `catalog/profiles`. Eine fehlerhafte Datei wird mit Dateiname und
* Pfad gemeldet und übersprungen – ein Tippfehler in einem Profil soll nicht den Start verhindern.
*/
export type KatalogFehler = { datei: string; meldung: string };
export type Geladen<T> = { eintrag: T; datei: string; quelltext: string; hash: string };
export type Katalog = {
distributionen: Geladen<Distribution>[];
profile: Geladen<Profil>[];
fehler: KatalogFehler[];
};
/** Prüft YAML-Text gegen ein Schema; liefert Eintrag oder lesbare Fehlerliste. */
export function yamlPruefen<T>(
quelltext: string,
schema: ZodType<T>,
): { ok: true; eintrag: T } | { ok: false; fehler: string[] } {
let roh: unknown;
try {
roh = parse(quelltext, { prettyErrors: true });
} catch (fehler) {
const text = fehler instanceof YAMLParseError ? fehler.message.split("\n")[0]! : String(fehler);
return { ok: false, fehler: [`YAML: ${text}`] };
}
const ergebnis = schema.safeParse(roh);
if (ergebnis.success) return { ok: true, eintrag: ergebnis.data };
return {
ok: false,
fehler: ergebnis.error.issues.map(
(p) => `${p.path.length > 0 ? p.path.join(".") : "(Wurzel)"}: ${p.message}`,
),
};
}
async function verzeichnisLesen<T extends { id: string }>(
verzeichnis: string,
schema: ZodType<T>,
fehler: KatalogFehler[],
): Promise<Geladen<T>[]> {
let dateien: string[];
try {
dateien = (await readdir(verzeichnis)).filter((d) => /\.ya?ml$/.test(d)).sort();
} catch {
fehler.push({ datei: verzeichnis, meldung: "Verzeichnis fehlt." });
return [];
}
const ergebnis: Geladen<T>[] = [];
const gesehen = new Set<string>();
for (const datei of dateien) {
const quelltext = await readFile(join(verzeichnis, datei), "utf8");
const geprueft = yamlPruefen(quelltext, schema);
if (!geprueft.ok) {
fehler.push({ datei, meldung: geprueft.fehler.join("; ") });
continue;
}
// Dateiname und id sollen übereinstimmen – sonst sucht man ewig, woher ein Eintrag kommt.
if (datei.replace(/\.ya?ml$/, "") !== geprueft.eintrag.id) {
fehler.push({ datei, meldung: `id „${geprueft.eintrag.id}“ passt nicht zum Dateinamen.` });
continue;
}
if (gesehen.has(geprueft.eintrag.id)) {
fehler.push({ datei, meldung: `id „${geprueft.eintrag.id}“ gibt es doppelt.` });
continue;
}
gesehen.add(geprueft.eintrag.id);
ergebnis.push({
eintrag: geprueft.eintrag,
datei,
quelltext,
hash: createHash("sha256").update(quelltext).digest("hex"),
});
}
return ergebnis;
}
export async function katalogLaden(wurzel: string): Promise<Katalog> {
const fehler: KatalogFehler[] = [];
const distributionen = await verzeichnisLesen(join(wurzel, "distros"), DistributionSchema, fehler);
const profile = await verzeichnisLesen(join(wurzel, "profiles"), ProfilSchema, fehler);
// Verweise prüfen: requires auf unbekannte Profile ist ein Fehler des verweisenden Profils.
const bekannt = new Set(profile.map((p) => p.eintrag.id));
const gueltig = profile.filter((p) => {
const fehlt = p.eintrag.requires.filter((r) => !bekannt.has(r));
if (fehlt.length > 0)
fehler.push({
datei: p.datei,
meldung: `requires verweist auf unbekannte Profile: ${fehlt.join(", ")}`,
});
return fehlt.length === 0;
});
return { distributionen, profile: gueltig, fehler };
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Wählt aus der Liste verfügbarer Container-Templates (pveam available bzw. GET aplinfo) das
* passende zum Muster des Katalogs. Bei mehreren Treffern gewinnt die höchste Version – für
* Debian 13 gab es am 2026-10-12 sowohl 13.1-2 als auch 13.6-1.
*/
export function musterZuRegex(muster: string): RegExp {
const maskiert = muster.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
return new RegExp(`^${maskiert}$`);
}
/** Vergleicht wie `sort -V`: Zahlenfolgen numerisch, Rest lexikalisch. */
export function versionVergleichen(a: string, b: string): number {
const teile = (s: string) => s.match(/\d+|\D+/g) ?? [];
const x = teile(a);
const y = teile(b);
for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
const p = x[i];
const q = y[i];
if (p === undefined) return -1;
if (q === undefined) return 1;
const zahlen = /^\d+$/.test(p) && /^\d+$/.test(q);
const unterschied = zahlen ? Number(p) - Number(q) : p.localeCompare(q);
if (unterschied !== 0) return unterschied;
}
return 0;
}
export function templateWaehlen(muster: string, verfuegbar: readonly string[]): string | null {
const regex = musterZuRegex(muster);
const treffer = verfuegbar.filter((name) => regex.test(name));
if (treffer.length === 0) return null;
return [...treffer].sort(versionVergleichen).at(-1)!;
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Prüfsummen-Dateien der Cloud-Images. Zwei Zeilenformate kommen vor (geprüft 2026-10-12):
* Debian `<hash>␣␣<datei>`, Ubuntu `<hash>␣*<datei>` (Binärmodus). Dazu das BSD-Format
* `SHA256 (<datei>) = <hash>`, das manche Spiegel liefern.
*/
export function pruefsummeFinden(inhalt: string, datei: string, typ: "sha256" | "sha512"): string | null {
const laenge = typ === "sha256" ? 64 : 128;
for (const zeile of inhalt.split(/\r?\n/)) {
const gnu = /^([0-9a-fA-F]+)\s+\*?(.+)$/.exec(zeile.trim());
if (gnu && gnu[2] === datei && gnu[1]!.length === laenge) return gnu[1]!.toLowerCase();
const bsd = /^(SHA256|SHA512) \((.+)\) = ([0-9a-fA-F]+)$/.exec(zeile.trim());
if (bsd && bsd[2] === datei && bsd[3]!.length === laenge) return bsd[3]!.toLowerCase();
}
return null;
}
/** Der Dateiname am Ende einer Image-URL. */
export function dateiAusUrl(url: string): string {
return decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
}
+177
View File
@@ -0,0 +1,177 @@
import { z } from "@/lib/validierung";
/**
* Der Katalog ist Daten, kein Code: jede Distribution und jedes Profil ist eine YAML-Datei unter
* `catalog/`. Die Schlüssel bleiben englisch wie in der Vorgabe – es ist ein Austauschformat.
* Unbekannte Schlüssel sind ein Fehler (`strict`), damit ein Tippfehler nicht still ignoriert wird.
*/
const KENNUNG = /^[a-z0-9][a-z0-9.-]{0,47}$/;
const Kennung = z.string().regex(KENNUNG, "Nur Kleinbuchstaben, Ziffern, Punkt und Bindestrich.");
// ── Distributionen ──────────────────────────────────────────────────────────────────────────────
export const DistributionSchema = z
.object({
id: Kennung,
name: z.string().min(1),
family: z.string().regex(/^[a-z][a-z0-9]*$/, "Familie: Kleinbuchstaben, z. B. debian, rhel, alpine."),
os: z
.object({
id: z.string().min(1),
version_id: z.string().min(1),
codename: z.string().optional(),
})
.strict(),
arch: z.enum(["amd64", "arm64"]).default("amd64"),
vm: z
.object({
image_url: z.url({ protocol: /^https$/, message: "Nur HTTPS-Quellen." }),
checksum_url: z.url({ protocol: /^https$/, message: "Nur HTTPS-Quellen." }),
checksum_type: z.enum(["sha256", "sha512"]),
format: z.enum(["qcow2", "raw", "vmdk"]),
default_user: z.string().min(1),
guest_agent_preinstalled: z.boolean().default(false),
bios: z.enum(["ovmf", "seabios"]).default("ovmf"),
min_disk_gb: z.number().int().min(1).default(4),
})
.strict()
.optional(),
lxc: z
.object({
template_pattern: z
.string()
.regex(/^[\w.*-]+\.tar\.(zst|gz|xz)$/, "Muster wie debian-13-standard_*_amd64.tar.zst"),
ostype: z.string().min(1),
})
.strict()
.optional(),
})
.strict()
.refine((d) => d.vm || d.lxc, { message: "Mindestens vm oder lxc angeben." });
export type Distribution = z.infer<typeof DistributionSchema>;
// ── Profile ─────────────────────────────────────────────────────────────────────────────────────
export const VariableSchema = z
.object({
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),
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
options: z.array(z.string()).optional(),
help: z.string().optional(),
})
.strict()
.refine((v) => v.type !== "select" || (v.options && v.options.length > 0), {
message: "Eine Auswahl braucht options.",
});
export type ProfilVariable = z.infer<typeof VariableSchema>;
/** Optional an jedem Schritt: nur ausführen, wenn die Variable wahr ist (`if: fail2ban`). */
const Bedingung = {
if: z
.string()
.regex(/^!?[a-z][a-z0-9_]*$/)
.optional(),
};
/**
* Ein Schritt ist deklarativ; der Adapter der Familie übersetzt ihn in Shell. Genau ein
* Aktionsschlüssel je Schritt.
*/
export const SchrittSchema = z.union([
z.object({ update: z.literal(true), ...Bedingung }).strict(),
z.object({ packages: z.array(z.string().min(1)).min(1), ...Bedingung }).strict(),
z
.object({
service: z
.object({
name: z.string().min(1),
enable: z.boolean().default(true),
start: z.boolean().default(true),
restart: z.boolean().default(false),
})
.strict(),
...Bedingung,
})
.strict(),
z
.object({
repo: z
.object({
name: z.string().regex(/^[a-z0-9-]+$/),
key_url: z.url({ protocol: /^https$/ }),
uris: z.string().min(1),
suites: z.string().min(1),
components: z.string().min(1),
})
.strict(),
...Bedingung,
})
.strict(),
z.object({ repo_package: z.string().min(1), ...Bedingung }).strict(),
z
.object({
file: z
.object({
path: z.string().regex(/^\/[^\0]+$/, "Absoluter Pfad."),
content: z.string(),
mode: z
.string()
.regex(/^[0-7]{3,4}$/)
.default("0644"),
})
.strict(),
...Bedingung,
})
.strict(),
z.object({ timezone: z.string().min(1), ...Bedingung }).strict(),
z.object({ locale: z.string().min(1), ...Bedingung }).strict(),
z.object({ keyboard: z.string().min(1), ...Bedingung }).strict(),
z
.object({
ssh_harden: z
.object({ password_auth: z.boolean().default(false), root_login: z.boolean().default(false) })
.strict(),
...Bedingung,
})
.strict(),
z.object({ guest_agent: z.literal(true), ...Bedingung }).strict(),
z.object({ unattended_upgrades: z.boolean(), ...Bedingung }).strict(),
z.object({ run: z.string().min(1), ...Bedingung }).strict(),
]);
export type ProfilSchritt = z.infer<typeof SchrittSchema>;
export const ProfilSchema = z
.object({
id: Kennung,
name: z.string().min(1),
description: z.string().optional(),
requires: z.array(Kennung).default([]),
variables: z.array(VariableSchema).default([]),
lxc: z
.object({
features: z
.object({ nesting: z.boolean().optional(), keyctl: z.boolean().optional() })
.strict()
.optional(),
})
.strict()
.default({}),
families: z.record(z.string().regex(/^[a-z][a-z0-9]*$/), z.array(SchrittSchema).min(1)),
})
.strict()
.refine((p) => new Set(p.variables.map((v) => v.name)).size === p.variables.length, {
message: "Variablennamen müssen eindeutig sein.",
path: ["variables"],
});
export type Profil = z.infer<typeof ProfilSchema>;
+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"; import { ProxmoxFehler } from "./fehler";
/** /**
@@ -10,6 +10,7 @@ type LogZeile = { n: number; t: string };
/** UPID:node:pid:pstart:starttime:type:id:user: – der Node steht an zweiter Stelle. */ /** UPID:node:pid:pstart:starttime:type:id:user: – der Node steht an zweiter Stelle. */
export function nodeAusUpid(upid: string): string { export function nodeAusUpid(upid: string): string {
if (istTrockenUpid(upid)) return upid.split(":")[2] ?? "node";
const teile = upid.split(":"); const teile = upid.split(":");
if (teile[0] !== "UPID" || !teile[1]) { if (teile[0] !== "UPID" || !teile[1]) {
throw new ProxmoxFehler("api", `„${upid}“ ist keine UPID.`, "Antwort von Proxmox prüfen."); throw new ProxmoxFehler("api", `„${upid}“ ist keine UPID.`, "Antwort von Proxmox prüfen.");
@@ -32,6 +33,7 @@ export async function aufgabeAbwarten(
upid: string, upid: string,
optionen: AbwartenOptionen = {}, optionen: AbwartenOptionen = {},
): Promise<void> { ): Promise<void> {
if (istTrockenUpid(upid)) return;
const node = nodeAusUpid(upid); const node = nodeAusUpid(upid);
const intervall = optionen.intervallMs ?? 1000; const intervall = optionen.intervallMs ?? 1000;
const zeitlimit = optionen.zeitlimitMs ?? 30 * 60 * 1000; const zeitlimit = optionen.zeitlimitMs ?? 30 * 60 * 1000;
+29 -1
View File
@@ -26,6 +26,17 @@ export type AufrufBeobachter = (
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler }, ergebnis: { dauerMs: number; fehler?: ProxmoxFehler },
) => void; ) => void;
/**
* Trockenlauf (Dry-Run): lesende Aufrufe gehen wirklich an Proxmox, schreibende werden nur
* aufgezeichnet und mit einer Platzhalter-UPID beantwortet. Dieselben Phasen laufen so im
* Trockenlauf und im Ernstfall – der angezeigte Plan kann nicht vom tatsächlichen Ablauf abweichen.
*/
export const TROCKEN_UPID_PRAEFIX = "UPID:trocken:";
export function istTrockenUpid(upid: unknown): boolean {
return typeof upid === "string" && upid.startsWith(TROCKEN_UPID_PRAEFIX);
}
const STANDARD_ZEITLIMIT_MS = 30_000; const STANDARD_ZEITLIMIT_MS = 30_000;
/** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */ /** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */
@@ -60,7 +71,11 @@ export class ProxmoxClient {
constructor( constructor(
private readonly verbindung: ProxmoxVerbindung, private readonly verbindung: ProxmoxVerbindung,
private readonly optionen: { zeitlimitMs?: number; beobachter?: AufrufBeobachter } = {}, private readonly optionen: {
zeitlimitMs?: number;
beobachter?: AufrufBeobachter;
trocken?: boolean;
} = {},
) { ) {
if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) { if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) {
throw new ProxmoxFehler( throw new ProxmoxFehler(
@@ -90,7 +105,20 @@ export class ProxmoxClient {
return await this.aufrufen<T>({ methode: "DELETE", pfad, parameter }); return await this.aufrufen<T>({ methode: "DELETE", pfad, parameter });
} }
/** Im Trockenlauf aufgezeichnete schreibende Aufrufe, in Reihenfolge. */
readonly geplant: Aufruf[] = [];
get trocken(): boolean {
return Boolean(this.optionen.trocken);
}
async aufrufen<T>(aufruf: Aufruf): Promise<T> { async aufrufen<T>(aufruf: Aufruf): Promise<T> {
if (this.optionen.trocken && aufruf.methode !== "GET") {
this.geplant.push(aufruf);
this.optionen.beobachter?.(aufruf, { dauerMs: 0 });
const knoten = /^\/nodes\/([^/]+)/.exec(aufruf.pfad)?.[1] ?? "node";
return `${TROCKEN_UPID_PRAEFIX}${knoten}:${this.geplant.length}` as T;
}
const beginn = Date.now(); const beginn = Date.now();
try { try {
const ergebnis = await this.senden<T>(aufruf); const ergebnis = await this.senden<T>(aufruf);
+2 -1
View File
@@ -119,7 +119,7 @@ async function versuchen<T>(warnungen: string[], text: string, aufruf: () => Pro
export async function erkunden( export async function erkunden(
client: ProxmoxClient, client: ProxmoxClient,
optionen: { serverHost: string; ssh: SshZugang | null; jetzt?: () => Date }, optionen: { serverHost: string; ssh: SshZugang | null; bauNetz?: boolean; jetzt?: () => Date },
): Promise<VerbindungsBericht> { ): Promise<VerbindungsBericht> {
const warnungen: string[] = []; const warnungen: string[] = [];
const version = await client.get<{ version: string; release: string }>("/version"); const version = await client.get<{ version: string; release: string }>("/version");
@@ -313,6 +313,7 @@ export async function erkunden(
storages: alleStorages, storages: alleStorages,
ssh: sshGesamt, ssh: sshGesamt,
rechteFehlen: fehlendeRechte.some((r) => r.pflicht), rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
bauNetz: optionen.bauNetz,
}), }),
warnungen, warnungen,
}; };
+13 -2
View File
@@ -230,6 +230,8 @@ export function faehigkeitenAbleiten(eingabe: {
storages: StorageInfo[]; storages: StorageInfo[];
ssh: SshZustand; ssh: SshZustand;
rechteFehlen: boolean; rechteFehlen: boolean;
/** Bau-Adresse gesetzt (#021) – Guest-Agent einbauen geht dann auch ohne SSH zum Node. */
bauNetz?: boolean;
}): Faehigkeiten { }): Faehigkeiten {
const { version, storages, ssh } = eingabe; const { version, storages, ssh } = eingabe;
const sshOk = ssh === "ok"; const sshOk = ssh === "ok";
@@ -237,10 +239,19 @@ export function faehigkeitenAbleiten(eingabe: {
const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets")); const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets"));
let templateBau: Strategie; 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 = { templateBau = {
weg: "api", 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) { } else if (sshOk) {
templateBau = { 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 { createHash } from "node:crypto";
import { Client } from "ssh2"; // ssh2 ist CommonJS: unter reinem Node-ESM (worker) gibt es keine benannten Exporte.
import ssh2 from "ssh2";
const { Client } = ssh2;
/** /**
* SSH zu Proxmox-Nodes oder Gästen. Der Host-Key wird wie das TLS-Zertifikat gepinnt: Ist kein * SSH zu Proxmox-Nodes oder Gästen. Der Host-Key wird wie das TLS-Zertifikat gepinnt: Ist kein
@@ -37,7 +40,13 @@ export function hostSchluesselFingerabdruck(schluessel: Buffer): string {
export function sshAusfuehren( export function sshAusfuehren(
ziel: SshZiel, ziel: SshZiel,
befehl: string, 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> { ): Promise<SshErgebnis> {
if (!ziel.hostSchluessel && !optionen.erkunden) { if (!ziel.hostSchluessel && !optionen.erkunden) {
return Promise.reject( return Promise.reject(
@@ -92,8 +101,16 @@ export function sshAusfuehren(
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
let code: number | null = null; let code: number | null = null;
kanal.on("data", (teil: Buffer) => (stdout += teil.toString("utf8"))); kanal.on("data", (teil: Buffer) => {
kanal.stderr.on("data", (teil: Buffer) => (stderr += teil.toString("utf8"))); 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("exit", (exitCode: number | null) => (code = exitCode));
kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen })); kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen }));
if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe); if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe);
+9 -1
View File
@@ -1,15 +1,23 @@
/** /**
* Läuft einmal beim Start des Next-Servers. Prüft die Konfiguration früh und legt den ersten * Läuft einmal beim Start des Next-Servers. Prüft die Konfiguration früh und legt den ersten
* Administrator an – statt beim ersten Seitenaufruf mit einer unklaren Meldung zu scheitern. * Administrator an, gleicht den Katalog ab – statt beim ersten Seitenaufruf mit einer unklaren Meldung zu scheitern.
*/ */
export async function register(): Promise<void> { export async function register(): Promise<void> {
if (process.env["NEXT_RUNTIME"] !== "nodejs") return; if (process.env["NEXT_RUNTIME"] !== "nodejs") return;
const { schluesselPruefen } = await import("@/lib/geheimnisse"); const { schluesselPruefen } = await import("@/lib/geheimnisse");
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung"); const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
const { logger } = await import("@/lib/logger"); const { logger } = await import("@/lib/logger");
const { katalogSynchronisieren } = await import("@/server/services/katalog");
const { beispielVorlageAnlegen } = await import("@/server/services/vorlagen");
try { try {
schluesselPruefen(); schluesselPruefen();
await ersterAdminFallsLeer(); await ersterAdminFallsLeer();
const sync = await katalogSynchronisieren();
await beispielVorlageAnlegen();
logger.info(
{ distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length },
"Katalog abgeglichen",
);
} catch (fehler) { } catch (fehler) {
logger.error({ err: fehler }, "Start-Prüfung fehlgeschlagen"); logger.error({ err: fehler }, "Start-Prüfung fehlgeschlagen");
} }
+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",
};
+5 -5
View File
@@ -22,11 +22,11 @@ export type NavigationsEintrag = {
export const NAVIGATION: readonly NavigationsEintrag[] = [ export const NAVIGATION: readonly NavigationsEintrag[] = [
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" }, { pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" }, { pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen", ab: "#006" }, { pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" }, { pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" },
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" }, { pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen", ab: "#004" }, { pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" }, { pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
{ {
pfad: "/einstellungen", pfad: "/einstellungen",
+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}`; 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 Vmid = z.coerce.number().int("Ganze Zahl erwartet.").min(100, "Mindestens 100.").max(999_999_999);
const Basis = z.object({ const Basis = z.object({
@@ -52,6 +81,7 @@ const Basis = z.object({
templateVmidVon: Vmid.default(9000), templateVmidVon: Vmid.default(9000),
templateVmidBis: Vmid.default(9999), templateVmidBis: Vmid.default(9999),
gastVmidVon: Vmid.default(100), gastVmidVon: Vmid.default(100),
bauNetz: BauNetz,
}); });
function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) { function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) {
@@ -112,6 +142,7 @@ export type ServerSicht = {
templateVmidVon: number; templateVmidVon: number;
templateVmidBis: number; templateVmidBis: number;
gastVmidVon: number; gastVmidVon: number;
bauNetz: BauNetz | null;
erreichbar: boolean | null; erreichbar: boolean | null;
pveVersion: string | null; pveVersion: string | null;
geprueftAm: 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;
}

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