6 Commits
Author SHA1 Message Date
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
105 changed files with 8403 additions and 76 deletions
+20
View File
@@ -17,6 +17,26 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### 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
### Hinzugefügt
+20 -3
View File
@@ -2,7 +2,7 @@
## 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.
@@ -11,16 +11,22 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
| #001 Projekt-Setup | 0 h 50 min |
| #002 Server-Verwaltung | 0 h 11 min |
| #003 Verbindungstest | 0 h 25 min |
| #004 Katalog | 0 h 09 min |
| #005 Adapter und Profile | 0 h 20 min |
| #006 Aufträge und Live-Fortschritt | 0 h 15 min |
| #007 Template-Bau | 0 h 12 min |
| #008 VM ausrollen und Assistent | 0 h 36 min |
| Meilenstein | Zeit |
|---|---|
| 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
**Version:** 0.1.0-dev
**Version:** 0.1.0, 0.2.0
### Erledigt
- Auftrag gelesen, Rückfragen gestellt: Reverse-Proxy (ja), OIDC (implementieren, vorerst nur lokal testen), Testumgebung (folgt), CLI (Go)
@@ -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
- Feldtest auf dem Testhost `pve-demo` (PVE 9.2.2): Rolle/Token nach Doku per SSH angelegt, Fingerprint und Host-Key gegen den Node verglichen, Test mit und ohne SSH – alles wie erwartet
- Meilenstein v0.1.0 abgeschlossen, getaggt und nach Gitea gepusht
- README als Zwischenstand angelegt (Florian: „readme im projekt fehlt“)
- #005 abgeschlossen: Adapter debian, Profil-Engine; base+docker+zabbix-agent echt in Containern auf Debian 12/13 und Ubuntu 22.04/24.04/26.04 inkl. Idempotenz
- #006 abgeschlossen: worker, Phasen, SSE, Live-Ansicht, VMID-Sperre
- #007 abgeschlossen: erstes echtes Template (9000, Debian 13) auf pve-demo in 5 min 26 s
- #008 abgeschlossen: erste echte VMs auf pve-demo – pd-test01 per API (base, docker), pd-test02 über den Assistenten (base, zabbix-agent), per SSH geprüft; 219 Tests, 12 Playwright-Abläufe
- Meilenstein v0.2.0 abgeschlossen, getaggt und gepusht
- #004 abgeschlossen: Katalog mit fünf Distributionen und drei Profilen, Abgleich, Katalogseite; 150 Tests, 12 Playwright-Abläufe
- #002 abgeschlossen: Server-Verwaltung mit Fingerprint-Pinning, Proxmox-Client, Proxmox-Mock (HTTPS, selbst signiert); 99 Tests, 10 Playwright-Abläufe
- Testhost 10.0.10.228 von Florian bereitgestellt (Installation läuft)
- #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.
- **TLS-Pinning über einen eigenen undici-Connector** statt `rejectUnauthorized: false` + Nachprüfen: die Verbindung wird getrennt, bevor das Token gesendet wird. Sitzungs-Cache aus, sonst fehlt bei Wiederaufnahme das Zertifikat.
- **Zugang zum Testhost über einen eigenen SSH-Schlüssel** (`~/.ssh/proxdeploy-testhost`), nicht über das root-Passwort: Claude Code verweigert das Anlegen von Zugängen mit Passwort, und ein Schlüssel nur für diesen Host ist ohnehin der sauberere Weg.
- **Trockenmodus im Proxmox-Client statt eigenem Planer:** Dry-Run und Ernstfall laufen durch dieselben Phasen; Lesendes geht echt an Proxmox, Schreibendes wird aufgezeichnet. So können Plan und Wirklichkeit nicht auseinanderlaufen.
- **Guest-Agent wird beim Template-Bau per Vorbereitungsstart eingebaut** (vendor-data-Snippet), nicht bei jedem Deployment – Deployments laufen danach ohne SSH.
- **Skripte base64 über file-write:** Proxmox' eigene Kodierung scheitert an Umlauten (Fund beim ersten echten Deploy).
- **Template-Bau ohne SSH (Bau-Adresse) ausgegliedert** als #021, damit Etappe 2 nicht wartet.
- **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe).
+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
+26
View File
@@ -0,0 +1,26 @@
# 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.
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: [chrony, sudo, curl, ca-certificates]
- service: { name: chrony, enable: true, start: true }
- 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
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** feature, katalog
**Geschätzt:** 5 h **Tatsächlich:** –
**Geschätzt:** 5 h **Tatsächlich:** 0 h 09 min
## Ziel
YAML-Katalog laden, validieren, in die DB synchronisieren, in der GUI ansehen und deaktivieren.
## Aufgaben
- [ ] 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)
- [ ] Prüfsummen-Parser für beide Zeilenformate; LXC-Musterauflösung mit Versionssortierung
- [ ] Sync beim worker-Start (Upsert, Aktiv-Schalter bleibt), fehlerhafte Datei überspringen + melden
- [ ] Katalog-Seite (Distributionen, Profile, aktiv/inaktiv)
- [x] zod-Schemas für `catalog/distros` und `catalog/profiles`
- [x] Debian 12/13, Ubuntu 22.04/24.04/26.04 (URLs/Prüfsummen geprüft am 2026-10-12)
- [x] Prüfsummen-Parser für beide Zeilenformate; LXC-Musterauflösung mit Versionssortierung
- [x] Sync beim worker-Start (Upsert, Aktiv-Schalter bleibt), fehlerhafte Datei überspringen + melden
- [x] Katalog-Seite (Distributionen, Profile, aktiv/inaktiv)
## Akzeptanzkriterien
- [ ] Unit-Tests für Schema, Sync und Muster
- [x] Unit-Tests für Schema, Sync und Muster
## Abhängigkeiten
#001
## 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
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** feature, engine
**Geschätzt:** 6 h **Tatsächlich:** –
**Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min
## Ziel
Interface `FamilienAdapter`, Umsetzung `debian`, Profil `base` wird zu einem idempotenten Skript.
Interface `FamilienAdapter`, Umsetzung `debian`, Profile werden zu einem idempotenten Skript.
## Aufgaben
- [ ] Interface + Registry; `debian`-Adapter (apt, systemd, deb822, locale, keyboard, sshd_config.d)
- [ ] Profilauflösung (requires, Reihenfolge, Variablen, Shell-Quoting)
- [ ] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, sudo-User, SSH-Härtung, chrony, fail2ban optional)
- [ ] Snapshot-Tests der erzeugten Skripte; shellcheck auf die Ausgabe
- [x] Interface + Registry; `debian`-Adapter (apt mit Lock-Timeout, deb822, systemd, locale, keyboard, sshd_config.d, sudoers mit visudo-Prüfung)
- [x] Profilauflösung (requires transitiv, Zyklen, Variablen mit Typen und Standardwerten, Familien-Prüfung)
- [x] Platzhalter `{{ var.x }}`, `{{ os.* }}`, `{{ guest.* }}`, `a ?? b`; in `run` jeder Wert shell-gequotet
- [x] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, chrony, SSH-Härtung, fail2ban optional)
- [x] Tests der erzeugten Skripte; shellcheck auf die Ausgabe aller Distributionen
- [x] `pnpm test:container`: Skripte wirklich ausführen (Debian 12/13, Ubuntu 22.04/24.04/26.04), Ergebnis prüfen, zweiter Lauf auf Idempotenz
- [x] `docs/katalog.md`: neue Distribution, neuer Adapter, eigenes Profil
## Akzeptanzkriterien
- [ ] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code
- [x] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code
- [x] base + docker + zabbix-agent laufen auf allen fünf Distributionen durch und sind idempotent (Container, 2026-10-12)
## Abhängigkeiten
#004
## Notizen / Entscheidungen
–
- **Fund im Container:** frische Images haben keine Paketlisten – der Vorspann macht jetzt immer `apt-get update`, sonst scheiterte schon die Benutzeranlage an „Unable to locate package sudo“.
- **Zeitzone, Locale und Tastatur werden gegen strenge Muster geprüft**, bevor sie ins Skript kommen (shellcheck fand verschachteltes Quoting im sed-Ausdruck).
- **SSH-Härtung läuft immer zuletzt:** läuft die Provisionierung per SSH als root (LXC ohne Node-SSH), schlösse sie sich sonst selbst aus.
- **Marken `::proxdeploy:: profil <id>`** im Skript zeigen im Live-Log, welches Profil gerade läuft.
- Dateiinhalte gehen base64-kodiert ins Skript – beliebige Zeichen kommen unverändert an.
+17 -10
View File
@@ -1,25 +1,32 @@
# #006 Aufträge, Phasen, worker und Live-Fortschritt
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** feature, engine
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 0 h 15 min
## Ziel
Hintergrund-Jobs mit Phasen in der DB, live per SSE in der Web-GUI.
## Aufgaben
- [ ] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung`
- [ ] Phasen-Runner mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis
- [ ] SSE-Endpunkt mit Last-Event-ID, Polling-Fallback
- [ ] VMID-Vergabe mit Advisory-Lock, Test mit parallelen Aufträgen
- [ ] Live-Ansicht als Terminal-Karte, Detail-Log, Ergebnis-Karte
- [x] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung`
- [x] Phasen-Runner (frameworkfrei) mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis, Abbruch zwischen Phasen
- [x] SSE-Endpunkt mit Last-Event-ID, Herzschlag, `X-Accel-Buffering: no`; Polling-Fallback im Client
- [x] VMID-Vergabe mit Advisory-Lock, Test mit zehn parallelen Reservierungen
- [x] Live-Ansicht als Terminal-Karte (Aufbau der CLI-Ausgabe), Detail-Log, Ergebnis-Box; Verlaufsseite
- [x] Verwaiste Aufträge nach worker-Neustart als fehlgeschlagen markieren; doppelt gelieferte Jobs laufen nur einmal
## Akzeptanzkriterien
- [ ] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test)
- [ ] Neuladen der Seite mitten im Auftrag zeigt den vollständigen Stand
- [x] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test)
- [x] Neuladen der Seite mitten im Auftrag zeigt den vollständigen Stand (Stand kommt immer ganz, Log ab Last-Event-ID)
- [x] Live geprüft mit einem echten Template-Bau auf pve-demo (#007)
## Abhängigkeiten
#003
## Notizen / Entscheidungen
–
- **ssh2 und pg sind CommonJS:** benannte Importe gehen im Next-Bundle, aber nicht im worker unter reinem Node-ESM – jetzt Default-Importe.
- **Der worker lädt `.env` selbst** (`process.loadEnvFile`, falls vorhanden); im Container kommt die Umgebung von außen.
- **Kein automatisches Wiederholen** (`retryLimit: 0`): halb angelegte Gäste soll ein Mensch ansehen.
- **Abfrageschleifen** (Task-Status, VM-Status) erscheinen im Detail-Log nur bei Fehlern.
- **Kopfzeile** (`Auftrag.kopf`) setzt die Engine – Web und CLI zeigen denselben Text.
- Client-Komponenten importieren nichts aus `src/server` (struktureller Test in `tests/zugang.test.ts`).
+15 -11
View File
@@ -1,26 +1,30 @@
# #007 Template-Bau für VMs
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** feature, proxmox
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 0 h 12 min
## Ziel
Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren.
## Aufgaben
- [ ] Weg A (≥ 8.4): `download-url` (content=import, checksum) + `import-from` per Volume-ID
- [ ] Weg B (SSH): Download + Prüfung auf dem Node, Import per `qm importdisk`/`import-from`
- [ ] VMID: nächste freie im Template-Bereich (Entscheidung 2026-10-12)
- [ ] Hardware: q35, OVMF+EFI-Disk (Standard) oder SeaBIOS, virtio-scsi-single, discard/ssd/iothread, CPU host, serielle Konsole, agent=1, cloud-init-Laufwerk
- [ ] Guest-Agent einbauen: Snippet (SSH) oder Bau-Adresse + SSH in die VM
- [ ] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar
- [ ] Singleton je Server+Distribution
- [x] Weg A (≥ 8.4): `download-url` (content=import, checksum) + `import-from` per Volume-ID
- [x] Weg B (SSH): Download + Prüfung auf dem Node, Import per `qm importdisk`/`import-from`
- [x] VMID: nächste freie im Template-Bereich (Entscheidung 2026-10-12)
- [x] Hardware: q35, OVMF+EFI-Disk (Standard) oder SeaBIOS, virtio-scsi-single, discard/ssd/iothread, CPU host, serielle Konsole, agent=1, cloud-init-Laufwerk
- [x] Guest-Agent einbauen per Snippet (SSH); Weg über Bau-Adresse ohne SSH → #021
- [x] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar
- [x] Singleton je Server+Distribution
## Akzeptanzkriterien
- [ ] Debian-13-Template auf echtem Proxmox gebaut (8.x und 9.x, sobald verfügbar)
- [x] Debian-13-Template auf pve-demo (PVE 9.2.2) gebaut, Auftrag t-8zca3, 5 min 26 s; 8.x folgt im Feldtest (#020)
## Abhängigkeiten
#005, #006
## Notizen / Entscheidungen
–
- **Image-Dateiname mit Prüfsummen-Präfix** (`proxdeploy-debian-13-f46f0671a6e5.qcow2`): ein neues „latest“-Image wird neu geladen, ein gleiches wiederverwendet.
- **Proxmox prüft die Prüfsumme selbst** (`download-url` mit `checksum`); ProxDeploy holt sie vorher aus SHA512SUMS/SHA256SUMS.
- **Vorbereitungsstart mit vendor-data:** qemu-guest-agent, Maschinen-ID leeren (sonst teilen sich Klone DHCP-Client-ID), `cloud-init clean`, poweroff. Snippet und `cicustom` werden auch nach einem Fehler entfernt.
- **UEFI mit `pre-enrolled-keys=1`** (Secure Boot an) – Debian 13 bootet damit; Proxmox setzt selbst `ms-cert=2023k`.
- Auf dem Testhost dauerte der Vorbereitungsstart ~70 s.
+16 -9
View File
@@ -1,24 +1,31 @@
# #008 Deploy-Assistent und VM ausrollen
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** feature
**Geschätzt:** 10 h **Tatsächlich:** –
**Geschätzt:** 10 h **Tatsächlich:** 0 h 36 min
## Ziel
Debian-13-VM aus Template vollständig unattended, Assistent mit Dry-Run und CLI-Befehl.
## Aufgaben
- [ ] Planer VM (Phasen + geplante API-Aufrufe), `POST /auftraege/plan`
- [ ] Ausführung: Klonen, Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Agent, Provisionierung per Agent
- [ ] Fehlendes Template → automatisch bauen anbieten
- [ ] Assistent mit 9 Schritten, URL-Zustand, Handy-tauglich
- [ ] Aufräumen nach Bestätigung
- [x] Anfrageschema für Web, CLI, Batch und Vorlagen (`src/lib/schemas/ausrollen.ts`), CLI-Befehl daraus
- [x] Ablauf VM: Prüfungen (Node, Template, Storage, Bridge/VLAN, VMID), Klonen (mit einem Wiederholversuch bei belegter VMID), Hardware + Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Guest-Agent (ping, IP), Provisionierung per Agent mit Live-Fortschritt
- [x] Dry-Run über denselben Ablauf mit Trocken-Client: `POST /api/v1/auftraege/plan`
- [x] Fehlendes Template → „Template vorher bauen“ im Assistenten, läuft als Phase im selben Auftrag
- [x] Assistent mit 9 Schritten, Zustand in der URL, jederzeit zurückspringbar, Handy-tauglich
- [ ] Aufräumen nach Bestätigung → folgt mit #014 (Verlauf); bis dahin werden angelegte VMIDs je Auftrag festgehalten
## Akzeptanzkriterien
- [ ] VM mit base-Profil per SSH erreichbar; Fehlerfall zeigt Ursache + Hinweis
- [x] VM mit base-Profil per SSH erreichbar: pd-test01 (base, docker) per API, pd-test02 (base, zabbix-agent) über den Assistenten auf pve-demo, 2026-10-12
- [x] Fehlerfall zeigt Ursache + Hinweis (Tests: VLAN auf nicht VLAN-fähiger Bridge, Agent antwortet nicht, Profil scheitert mit Code)
## Abhängigkeiten
#007
## Notizen / Entscheidungen
–
- **Fund beim ersten echten Deploy:** `agent/file-write` scheitert an Nicht-ASCII („Wide character in subroutine entry“). ProxDeploy kodiert die Skriptstücke jetzt selbst als base64 (`encode=0`) und teilt nach Bytes.
- **Die Agent-Endpunkte antworten unterschiedlich** (exec → `{pid}`, exec-status/file-read direkt, ping/network-get-interfaces → `{result}`).
- **Fortschritt der Provisionierung** aus der Logdatei im Gast (`/var/log/proxdeploy-provisionierung.log`, per file-read), Marken je Profil.
- **Klon auf anderen Node** nur bei geteiltem Storage; sonst sagt die Prüfung, welchen Node man wählen soll.
- Zwei Felder hießen „Server“ (Umschalter und Assistent) – der Umschalter heißt jetzt „Aktiver Server“.
- Testgäste auf pve-demo: VM 100 `pd-test01`, VM 101 `pd-test02` (Tag `proxdeploy-test`).
+24
View File
@@ -0,0 +1,24 @@
# #021 Template-Bau ohne SSH (Bau-Adresse)
**Status:** offen
**Meilenstein:** v0.3.0
**Labels:** feature, proxmox
**Geschätzt:** 3 h **Tatsächlich:** –
## Ziel
Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet,
sondern über SSH **in die Vorbereitungs-VM** eingebaut.
## Aufgaben
- [ ] Server-Einstellung `bauNetz`: Bridge, VLAN, statische IP/CIDR, Gateway, DNS
- [ ] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys`
- [ ] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten
- [ ] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
## Akzeptanzkriterien
- [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH
## Abhängigkeiten
#007
## Notizen / Entscheidungen
- Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH.
+6 -5
View File
@@ -7,11 +7,11 @@ 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 |
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min |
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min |
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | offen | v0.2.0 | 5 h | – |
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | offen | v0.2.0 | 6 h | – |
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | offen | v0.2.0 | 8 h | – |
| [#007](007-template-bau.md) | Template-Bau für VMs | offen | v0.2.0 | 8 h | – |
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | offen | v0.2.0 | 10 h | – |
| [#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 | erledigt | v0.2.0 | 6 h | 0 h 20 min |
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min |
| [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min |
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min |
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – |
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – |
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – |
@@ -24,3 +24,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | offen | v0.3.0 | 3 h | – |
+123
View File
@@ -0,0 +1,123 @@
# Katalog: Distributionen und Profile
Distributionen und Profile sind Daten, kein Code. Jede liegt als YAML-Datei unter `catalog/` und wird
beim Start von ProxDeploy in die Datenbank abgeglichen. Die Datei ist maßgeblich für den Inhalt; in der
Oberfläche (*Katalog*) lassen sich Einträge nur aktivieren und deaktivieren. Eine fehlerhafte Datei
wird mit Dateiname und Fehlerstelle im Log gemeldet und übersprungen.
Regeln für beide Arten:
- Dateiname = `id` + `.yaml` (`catalog/distros/debian-13.yaml` → `id: debian-13`)
- unbekannte Schlüssel sind ein Fehler – ein Tippfehler fällt so sofort auf
- nur HTTPS-Quellen
## Neue Distribution hinzufügen
Beispiel Debian 13:
```yaml
id: debian-13
name: Debian 13 „Trixie“
family: debian # wählt den Provisionierungs-Adapter
os:
id: debian # für Platzhalter in Profilen: {{ os.id }}
version_id: "13" # {{ os.version_id }} – in Anführungszeichen, sonst wird 22.04 zur Zahl
codename: trixie # {{ os.codename }}
arch: amd64
vm:
image_url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
checksum_url: https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS
checksum_type: sha512 # sha256 | sha512
format: qcow2 # qcow2 | raw | vmdk
default_user: debian
guest_agent_preinstalled: false # sonst baut ProxDeploy ihn beim Template-Bau ein
bios: ovmf # ovmf (UEFI, Standard) | seabios
min_disk_gb: 4
lxc:
template_pattern: "debian-13-standard_*_amd64.tar.zst" # wie in `pveam available`
ostype: debian
```
`vm` oder `lxc` darf fehlen, wenn die Distribution nur eins davon kann. Bei mehreren passenden
LXC-Templates gewinnt die höchste Version. Die Prüfsummen-Datei darf im GNU-Format (`<hash> <datei>`
oder `<hash> *<datei>`) oder im BSD-Format (`SHA256 (<datei>) = <hash>`) vorliegen.
Gehört die Distribution zu einer Familie, für die es noch keinen Adapter gibt (etwa `rhel` für Rocky
oder Alma, `alpine`), braucht es zusätzlich einen Adapter:
1. `src/engine/adapter/<familie>.ts` anlegen, das Interface `FamilienAdapter` aus
`src/engine/adapter/schnittstelle.ts` umsetzen (Pakete, Dienste, Paketquellen, Zeitzone, Locale,
Tastatur, Benutzer, SSH-Härtung, Guest-Agent, automatische Updates)
2. in `src/engine/adapter/index.ts` eintragen
3. Tests nach dem Muster von `tests/engine/profile.test.ts`, dazu ein Image in
`tests/container/profile-pruefen.sh`
4. in den Profilen unter `families:` eine Umsetzung für die neue Familie ergänzen – Profile ohne
Umsetzung erscheinen im Assistenten ausgegraut
## Eigenes Profil anlegen
Ein Profil ist eine Liste von Schritten je Distributionsfamilie. Der Adapter übersetzt sie in ein
Shell-Skript, das bei VMs über den Guest-Agent und bei Containern per SSH läuft.
```yaml
id: nginx
name: nginx
description: Webserver mit Startseite
requires: [base] # wird automatisch davor ausgeführt
variables:
- { name: titel, label: Seitentitel, default: Willkommen }
- { name: tls, label: Zertifikat per certbot, type: boolean, default: false }
lxc:
features: { nesting: false } # Container-Features, die das Profil braucht
families:
debian:
- packages: [nginx]
- file:
path: /var/www/html/index.html
content: |
<h1>{{ var.titel }}</h1>
<p>{{ guest.name }} · {{ os.id }} {{ os.version_id }}</p>
- service: { name: nginx, enable: true, start: true }
- packages: [certbot, python3-certbot-nginx]
if: tls
```
### Schritte
| Schritt | Wirkung |
|---|---|
| `update: true` | Paketlisten aktualisieren und alle Pakete aktualisieren |
| `packages: [a, b]` | Pakete installieren |
| `service: { name, enable, start, restart }` | Dienst aktivieren, starten oder neu starten |
| `repo: { name, key_url, uris, suites, components }` | Paketquelle mit Schlüssel einrichten (deb822) |
| `repo_package: <url>` | ein Paket installieren, das selbst eine Quelle einrichtet (z. B. zabbix-release) |
| `file: { path, content, mode }` | Datei schreiben (Inhalt wird unverändert übertragen) |
| `timezone`, `locale`, `keyboard` | Zeitzone, Sprache, Tastaturbelegung |
| `ssh_harden: { password_auth, root_login }` | SSH absichern – läuft immer ganz am Ende |
| `guest_agent: true` | QEMU-Guest-Agent (in Containern übersprungen) |
| `unattended_upgrades: true` | automatische Sicherheitsupdates |
| `run: <befehl>` | freier Shell-Befehl – nicht portabel, nur wenn nichts anderes passt |
Jeder Schritt kann `if: <variable>` (oder `if: "!<variable>"`) tragen und läuft dann nur, wenn die
Variable wahr bzw. falsch ist.
### Platzhalter
`{{ var.<name> }}` (Profil-Variable), `{{ os.id }}`, `{{ os.version_id }}`, `{{ os.codename }}`,
`{{ guest.name }}`, `{{ guest.user }}`. `{{ var.a ?? var.b }}` nimmt `b`, wenn `a` leer ist. Ein
unbekannter Platzhalter ist ein Fehler, kein leerer Text.
In `run`-Schritten wird jeder eingesetzte Wert für die Shell gequotet – eine Eingabe im Formular kann
so nie zu Code werden. Variablen dürfen keine Zeilenumbrüche enthalten.
### Variablentypen
`string` (Standard), `number`, `boolean`, `select` (mit `options: [...]`). `required: true` macht
das Feld im Assistenten zur Pflicht; `default` füllt es vor; `help` erscheint darunter.
### Prüfen
```bash
pnpm exec tsx scripts/profil-rendern.ts debian-13 nginx titel=Hallo # Skript ansehen
pnpm test:container debian-13 # wirklich ausführen
```
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "proxdeploy",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
@@ -24,7 +24,9 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit"
"test:container": "tests/container/profile-pruefen.sh",
"typecheck": "tsc --noEmit",
"worker": "tsx src/worker/main.ts"
},
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.3.0",
@@ -37,11 +39,13 @@
"lucide-react": "1.47.0",
"next": "16.4.0",
"pg": "^8.23.0",
"pg-boss": "12.37.1",
"pino": "10.4.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"ssh2": "1.17.0",
"undici": "8.11.2",
"yaml": "2.9.1",
"zod": "^4.6.5"
},
"devDependencies": {
+91 -6
View File
@@ -192,10 +192,13 @@ importers:
version: 1.47.0(react@19.3.0)
next:
specifier: 16.4.0
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
pg:
specifier: ^8.23.0
version: 8.23.1
pg-boss:
specifier: 12.37.1
version: 12.37.1(@opentelemetry/api@1.9.1)
pino:
specifier: 10.4.0
version: 10.4.0
@@ -211,6 +214,9 @@ importers:
undici:
specifier: 8.11.2
version: 8.11.2
yaml:
specifier: 2.9.1
version: 2.9.1
zod:
specifier: ^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)
vitest:
specifier: 5.0.1
version: 5.0.1(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
packages:
@@ -1196,6 +1202,10 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@oxc-project/types@0.153.0':
resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
@@ -2106,6 +2116,10 @@ packages:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'}
cron-parser@5.10.2:
resolution: {integrity: sha512-JfAYMY0LZaWQTuict8PhtF/ksSnq8xefSDmQUnnoqvkPYkU/y3UP0DTWzJKFBz0P1IXsDG8q0vY3zHkoFZOhvw==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -3077,6 +3091,10 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -3161,6 +3179,10 @@ packages:
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
engines: {node: '>=18'}
non-error@0.1.0:
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
engines: {node: '>=20'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -3245,6 +3267,13 @@ packages:
perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
pg-boss@12.37.1:
resolution: {integrity: sha512-LghWeOIbMCiLcTY3+8MInXaJ0wk0AOkbeUZ8oE+cGO0uoheGSW3GK57eDqEbrDGpwTIpX9BVphvdvWEDYTcJ9A==}
engines: {node: '>=22.12.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.9.0
pg-cloudflare@1.4.1:
resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==}
@@ -3466,6 +3495,9 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
rrule-temporal@2.2.8:
resolution: {integrity: sha512-+wWUe0J02oMh1UWKIt1RTu2H0Cz+gP11O/iweK0MN2UP6JLGeepTUkbSBa90Hxq0W+ywSxM9IlJNouIUdMOwhg==}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -3507,6 +3539,10 @@ packages:
seq-queue@0.0.5:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
serialize-error@13.0.2:
resolution: {integrity: sha512-G+YKwp9I6IvWMe5RlwqxdmTPIcq/G8HK/sU51gdV1XAJznYkRSTB0ph+zB9AfSxG0fx6xrqYA6i9HeIcHFTXow==}
engines: {node: '>=20'}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -3665,6 +3701,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
@@ -3672,6 +3712,9 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
temporal-spec@1.0.1:
resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==}
thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'}
@@ -3720,6 +3763,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
type-fest@5.10.0:
resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==}
engines: {node: '>=20'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -4660,6 +4707,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@opentelemetry/api@1.9.1': {}
'@oxc-project/types@0.153.0': {}
'@pinojs/redact@0.4.0': {}
@@ -5554,6 +5603,10 @@ snapshots:
nan: 2.29.0
optional: true
cron-parser@5.10.2:
dependencies:
luxon: 3.7.2
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -6637,6 +6690,8 @@ snapshots:
dependencies:
react: 19.3.0
luxon@3.7.2: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@@ -6691,7 +6746,7 @@ snapshots:
natural-compare@1.4.0: {}
next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
'@next/env': 16.4.0
'@swc/helpers': 0.5.23
@@ -6710,6 +6765,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.4.0
'@next/swc-win32-arm64-msvc': 16.4.0
'@next/swc-win32-x64-msvc': 16.4.0
'@opentelemetry/api': 1.9.1
'@playwright/test': 1.63.0
sharp: 0.35.5(@types/node@24.19.2)
transitivePeerDependencies:
@@ -6726,6 +6782,8 @@ snapshots:
node-releases@2.0.58: {}
non-error@0.1.0: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -6819,6 +6877,16 @@ snapshots:
perfect-debounce@2.1.0: {}
pg-boss@12.37.1(@opentelemetry/api@1.9.1):
dependencies:
'@opentelemetry/api': 1.9.1
cron-parser: 5.10.2
pg: 8.23.1
rrule-temporal: 2.2.8
serialize-error: 13.0.2
transitivePeerDependencies:
- pg-native
pg-cloudflare@1.4.1:
optional: true
@@ -7049,6 +7117,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.13
'@rolldown/binding-win32-x64-msvc': 1.2.13
rrule-temporal@2.2.8:
dependencies:
temporal-spec: 1.0.1
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -7088,6 +7160,11 @@ snapshots:
seq-queue@0.0.5: {}
serialize-error@13.0.2:
dependencies:
non-error: 0.1.0
type-fest: 5.10.0
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -7300,10 +7377,14 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
tagged-tag@1.0.0: {}
tailwindcss@4.3.3: {}
tapable@2.3.3: {}
temporal-spec@1.0.1: {}
thread-stream@4.2.0:
dependencies:
real-require: 1.0.0
@@ -7348,6 +7429,10 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
type-fest@5.10.0:
dependencies:
tagged-tag: 1.0.0
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -7464,7 +7549,7 @@ snapshots:
tsx: 4.23.15
yaml: 2.9.1
vitest@5.0.1(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
vitest@5.0.1(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
@@ -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)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.19.2
transitivePeerDependencies:
- msw
@@ -7549,8 +7635,7 @@ snapshots:
yallist@3.1.1: {}
yaml@2.9.1:
optional: true
yaml@2.9.1: {}
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;
+177
View File
@@ -38,6 +38,7 @@ model Benutzer {
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
protokoll Protokoll[]
auftraege Auftrag[]
}
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
@@ -99,4 +100,180 @@ model Server {
geprueftAm DateTime?
erstelltAm DateTime @default(now())
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
gebautVonAuftragId String?
gebautAm DateTime @default(now())
@@unique([serverId, vmid])
@@index([serverId, distributionId])
}
+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>
);
}
+982
View File
@@ -0,0 +1,982 @@
"use client";
import { clsx } from "clsx";
import { ArrowLeft, ArrowRight, Copy } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Code, EINGABE_KLASSE, Feld, Schalter } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen";
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
type ProfilVariable = {
name: string;
label: string;
type: "string" | "number" | "boolean" | "select";
required: boolean;
default?: string | number | boolean;
options?: string[];
help?: string;
};
export type AssistentDaten = {
server: Array<{
name: string;
titel: string;
knoten: Array<{
name: string;
speicherFrei: number;
storages: Array<{ name: string; vm: boolean; lxc: boolean; frei: number | null }>;
bridges: Array<{ name: string; vlan: boolean }>;
}>;
templates: Array<{ distribution: string; knoten: string; vmid: number }>;
}>;
distributionen: Array<{ id: string; name: string; familie: string; vm: boolean; lxc: boolean }>;
profile: Array<{
id: string;
name: string;
beschreibung: string | null;
familien: string[];
requires: string[];
variablen: ProfilVariable[];
}>;
benutzer: string;
version: string;
};
type Plan = {
kopf: string;
phasen: Array<{
schluessel: string;
titel: string;
status: string;
detail: string | null;
ursache: string | null;
hinweis: string | null;
}>;
aufrufe: Array<{ methode: string; pfad: string; parameter?: Record<string, unknown> }>;
skript: string | null;
cliBefehl: string;
automatisch: string[];
ok: boolean;
};
// ── Zustand ─────────────────────────────────────────────────────────────────────────────────────
type Werte = {
server: string;
knoten: string;
typ: "vm" | "lxc";
distribution: string;
name: string;
vmid: string;
tags: string;
cores: string;
speicherMb: string;
diskGb: string;
storage: string;
swapMb: string;
unprivilegiert: boolean;
nesting: boolean;
bridge: string;
vlan: string;
modus: "dhcp" | "statisch";
ip: string;
gateway: string;
dns: string;
suchdomain: string;
benutzer: string;
schluessel: string;
profile: string[];
variablen: Record<string, Record<string, string | boolean>>;
templateBauen: boolean;
};
const SCHRITTE = [
"Server",
"Typ",
"Distribution",
"Name",
"Ressourcen",
"Netzwerk",
"Zugang",
"Profile",
"Zusammenfassung",
] as const;
function kodieren(werte: Werte): string {
return btoa(unescape(encodeURIComponent(JSON.stringify(werte))))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function dekodieren(text: string | null): Partial<Werte> {
if (!text) return {};
try {
return JSON.parse(decodeURIComponent(escape(atob(text.replace(/-/g, "+").replace(/_/g, "/")))));
} catch {
return {};
}
}
const SCHLUESSEL_SPEICHER = "proxdeploy.ssh-schluessel";
function anfrageAus(w: Werte): AusrollEingabe {
const zahl = (text: string) => (text.trim() === "" ? undefined : Number(text));
return {
typ: w.typ,
server: w.server,
knoten: w.knoten || undefined,
distribution: w.distribution,
name: w.name,
vmid: zahl(w.vmid),
tags: w.tags.split(/[\s,;]+/).filter(Boolean),
cores: Number(w.cores),
speicherMb: Number(w.speicherMb),
diskGb: Number(w.diskGb),
storage: w.storage,
swapMb: Number(w.swapMb),
unprivilegiert: w.unprivilegiert,
features: { nesting: w.nesting, keyctl: false },
netz: {
bridge: w.bridge,
vlan: zahl(w.vlan),
modus: w.modus,
ip: w.modus === "statisch" ? w.ip : undefined,
gateway: w.modus === "statisch" && w.gateway ? w.gateway : undefined,
dns: w.dns.split(/[\s,;]+/).filter(Boolean),
suchdomain: w.suchdomain || undefined,
},
zugang: {
benutzer: w.benutzer,
sshSchluessel: w.schluessel
.split("\n")
.map((z) => z.trim())
.filter(Boolean),
},
profile: w.profile,
variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))),
templateBauen: w.templateBauen,
};
}
// ── Komponente ──────────────────────────────────────────────────────────────────────────────────
/**
* Der Deploy-Assistent. Der Zustand steht in der URL (`?s=<schritt>&d=<werte>`): Zurückspringen,
* Neuladen und Weitergeben funktionieren damit ohne Server-Zwischenspeicher. Geprüft wird
* endgültig auf dem Server – hier nur so viel, dass niemand ins Leere klickt.
*/
export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; vorauswahl: string | null }) {
const router = useRouter();
const pfad = usePathname();
const parameter = useSearchParams();
const schritt = Math.min(Math.max(Number(parameter.get("s") ?? 0), 0), SCHRITTE.length - 1);
const anfang = useMemo<Werte>(() => {
const server = daten.server.find((s) => s.name === vorauswahl) ?? daten.server[0]!;
const knoten = server.knoten[0];
return {
server: server.name,
knoten: knoten?.name ?? "",
typ: "vm",
distribution:
daten.distributionen.find((d) => d.id === "debian-13")?.id ?? daten.distributionen[0]?.id ?? "",
name: "",
vmid: "",
tags: "",
cores: "2",
speicherMb: "2048",
diskGb: "20",
storage: knoten?.storages.find((s) => s.vm)?.name ?? "",
swapMb: "512",
unprivilegiert: true,
nesting: false,
bridge: knoten?.bridges.find((b) => b.name === "vmbr0")?.name ?? knoten?.bridges[0]?.name ?? "",
vlan: "",
modus: "dhcp",
ip: "",
gateway: "",
dns: "",
suchdomain: "",
benutzer: daten.benutzer.replace(/[^a-z0-9_-]/g, "") || "admin",
schluessel: "",
profile: ["base"],
variablen: {},
templateBauen: false,
};
}, [daten, vorauswahl]);
const [werte, setWerte] = useState<Werte>(() => ({ ...anfang, ...dekodieren(parameter.get("d")) }));
const [plan, setPlan] = useState<Plan | null>(null);
const [fehler, setFehler] = useState<{
text: string;
hinweis?: string;
felder?: Record<string, string>;
} | null>(null);
const [laeuft, setLaeuft] = useState(false);
const [kopiert, setKopiert] = useState(false);
const server = daten.server.find((s) => s.name === werte.server)!;
const knoten = server.knoten.find((k) => k.name === werte.knoten) ?? server.knoten[0];
const distribution = daten.distributionen.find((d) => d.id === werte.distribution);
const template = server.templates.find(
(t) => t.distribution === werte.distribution && (!werte.knoten || t.knoten === werte.knoten),
);
const bridge = knoten?.bridges.find((b) => b.name === werte.bridge);
const setze = (aenderung: Partial<Werte>) => {
setWerte((alt) => ({ ...alt, ...aenderung }));
setPlan(null);
};
const gehe = (ziel: number) => {
let naechste = werte;
// Gespeicherte Schlüssel als Bequemlichkeit vorbelegen (bis die Schlüsselverwaltung kommt, #015).
if (SCHRITTE[ziel] === "Zugang" && !werte.schluessel) {
try {
const gemerkt = localStorage.getItem(SCHLUESSEL_SPEICHER);
if (gemerkt) {
naechste = { ...werte, schluessel: gemerkt };
setWerte(naechste);
}
} catch {
// kein Speicher verfügbar – dann eben nicht
}
}
if (SCHRITTE[ziel] === "Zusammenfassung") void planen(naechste);
const neu = new URLSearchParams({ s: String(ziel), d: kodieren(naechste) });
router.replace(`${pfad}?${neu.toString()}`, { scroll: false });
setFehler(null);
window.scrollTo({ top: 0 });
};
// Was einen Schritt blockiert – als Text, damit „Weiter“ sagt, warum es nicht geht.
const sperre = (nr: number): string | null => {
switch (SCHRITTE[nr]) {
case "Server":
return !werte.server ? "Server wählen." : null;
case "Distribution":
if (!distribution) return "Distribution wählen.";
if (!distribution[werte.typ])
return `${distribution.name} gibt es nicht als ${werte.typ.toUpperCase()}.`;
if (werte.typ === "vm" && !template && !werte.templateBauen)
return "Template fehlt – „Template vorher bauen“ wählen.";
return null;
case "Name":
if (!GAST_NAME.test(werte.name)) return "Name: Kleinbuchstaben, Ziffern und Bindestrich.";
if (werte.vmid && (!/^\d+$/.test(werte.vmid) || Number(werte.vmid) < 100))
return "VMID ab 100 oder leer lassen.";
return null;
case "Ressourcen":
return !werte.storage ? "Storage wählen." : null;
case "Netzwerk":
if (!werte.bridge) return "Bridge wählen.";
if (werte.vlan && bridge && !bridge.vlan) return `Bridge ${bridge.name} ist nicht VLAN-fähig.`;
if (werte.modus === "statisch" && !/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(werte.ip))
return "IP mit Präfix angeben, z. B. 10.0.20.15/24.";
return null;
case "Zugang": {
const schluessel = werte.schluessel
.split("\n")
.map((z) => z.trim())
.filter(Boolean);
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(werte.benutzer) || werte.benutzer === "root")
return "Gültigen Benutzernamen angeben (nicht root).";
if (schluessel.length === 0) return "Mindestens einen öffentlichen SSH-Schlüssel angeben.";
if (schluessel.some((z) => !SSH_SCHLUESSEL.test(z)))
return "Eine Zeile ist kein öffentlicher SSH-Schlüssel.";
return null;
}
default:
return null;
}
};
const ersteSperre = SCHRITTE.findIndex((_, nr) => nr < schritt && sperre(nr) !== null);
async function planen(stand: Werte = werte) {
setLaeuft(true);
setFehler(null);
try {
setPlan(await apiAufruf<Plan>("POST", "/api/v1/auftraege/plan", anfrageAus(stand)));
} catch (ursache) {
setFehler(
ursache instanceof ApiFehler
? { text: ursache.message, hinweis: ursache.hinweis, felder: ursache.felder }
: { text: "Unerwarteter Fehler." },
);
} finally {
setLaeuft(false);
}
}
async function ausrollen() {
setLaeuft(true);
setFehler(null);
try {
try {
localStorage.setItem(SCHLUESSEL_SPEICHER, werte.schluessel);
} catch {
// egal
}
const { kurzId } = await apiAufruf<{ kurzId: string }>("POST", "/api/v1/auftraege", anfrageAus(werte));
router.push(`/auftraege/${kurzId}`);
} catch (ursache) {
setFehler(
ursache instanceof ApiFehler
? { text: ursache.message, hinweis: ursache.hinweis, felder: ursache.felder }
: { text: "Unerwarteter Fehler." },
);
setLaeuft(false);
}
}
// Direkt geöffneter Link auf die Zusammenfassung: nach dem ersten Rendern planen.
useEffect(() => {
if (SCHRITTE[schritt] !== "Zusammenfassung" || ersteSperre >= 0) return;
const uhr = setTimeout(() => void planen(), 0);
return () => clearTimeout(uhr);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const aktuelleSperre = sperre(schritt);
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Ausrollen</h1>
<p className="text-muted text-sm">
Schritt {schritt + 1} von {SCHRITTE.length} · {SCHRITTE[schritt]}
</p>
</header>
<div className="grid gap-6 lg:grid-cols-[14rem_1fr]">
{/* Schrittleiste: jederzeit zurückspringbar, nach vorne nur bis zum ersten offenen Schritt. */}
<nav aria-label="Schritte" className="-mx-4 overflow-x-auto px-4 lg:mx-0 lg:px-0">
<ol className="flex gap-2 lg:flex-col lg:gap-1">
{SCHRITTE.map((titel, nr) => {
const gesperrt = SCHRITTE.slice(0, nr).some((_, vorher) => sperre(vorher) !== null);
return (
<li key={titel} className="shrink-0">
<button
type="button"
disabled={gesperrt}
onClick={() => gehe(nr)}
aria-current={nr === schritt ? "step" : undefined}
className={clsx(
"flex h-10 items-center gap-2 rounded-lg px-3 text-sm whitespace-nowrap transition disabled:opacity-40 lg:w-full",
nr === schritt ? "bg-panel-hoch text-text font-semibold" : "text-muted hover:text-text",
)}
>
<span className={clsx("font-mono text-xs", nr === schritt && "text-akzent")}>
{String(nr + 1).padStart(2, "0")}
</span>
{titel}
</button>
</li>
);
})}
</ol>
</nav>
<div className="flex min-w-0 flex-col gap-6">
{SCHRITTE[schritt] === "Server" ? (
<Karte titel="Server und Node">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="server" label="Server">
<select
id="server"
className={EINGABE_KLASSE}
value={werte.server}
onChange={(e) => {
const s = daten.server.find((x) => x.name === e.target.value)!;
const k = s.knoten[0];
setze({
server: s.name,
knoten: k?.name ?? "",
storage: k?.storages.find((x) => x.vm)?.name ?? "",
bridge: k?.bridges[0]?.name ?? "",
});
}}
>
{daten.server.map((s) => (
<option key={s.name} value={s.name}>
{s.name} · {s.titel}
</option>
))}
</select>
</Feld>
<Feld
id="knoten"
label="Node"
hilfe="Leer = ProxDeploy wählt (Node mit dem Template, sonst der mit dem meisten freien RAM)."
>
<select
id="knoten"
className={EINGABE_KLASSE}
value={werte.knoten}
onChange={(e) => {
const k = server.knoten.find((x) => x.name === e.target.value);
setze({
knoten: e.target.value,
storage: k?.storages.find((x) => x.vm)?.name ?? werte.storage,
bridge: k?.bridges[0]?.name ?? werte.bridge,
});
}}
>
<option value="">automatisch</option>
{server.knoten.map((k) => (
<option key={k.name} value={k.name}>
{k.name} · {Math.round(k.speicherFrei / 1024 ** 3)} GiB RAM frei
</option>
))}
</select>
</Feld>
</div>
</Karte>
) : null}
{SCHRITTE[schritt] === "Typ" ? (
<Karte titel="VM oder Container">
<fieldset className="grid gap-3 sm:grid-cols-2">
<legend className="sr-only">Typ</legend>
{(
[
[
"vm",
"VM",
"Eigener Kernel, aus Cloud-Image-Template, Provisionierung über den Guest-Agent.",
],
[
"lxc",
"LXC-Container",
"Leichtgewichtig, aus Proxmox-Container-Template. Folgt mit #009.",
],
] as const
).map(([wert, titel, text]) => (
<label
key={wert}
className={clsx(
"border-linie flex cursor-pointer flex-col gap-1 rounded-lg border p-4",
werte.typ === wert && "border-akzent",
wert === "lxc" && "cursor-not-allowed opacity-50",
)}
>
<span className="flex items-center gap-2 font-semibold">
<input
type="radio"
name="typ"
value={wert}
checked={werte.typ === wert}
disabled={wert === "lxc"}
onChange={() => setze({ typ: wert })}
className="accent-akzent h-4 w-4"
/>
{titel}
</span>
<span className="text-muted text-sm">{text}</span>
</label>
))}
</fieldset>
</Karte>
) : null}
{SCHRITTE[schritt] === "Distribution" ? (
<Karte titel="Distribution">
<div className="flex flex-col gap-4">
<fieldset className="grid gap-2 sm:grid-cols-2">
<legend className="sr-only">Distribution</legend>
{daten.distributionen
.filter((d) => d[werte.typ])
.map((d) => {
const hatTemplate = server.templates.some((t) => t.distribution === d.id);
return (
<label
key={d.id}
className={clsx(
"border-linie flex cursor-pointer items-start gap-3 rounded-lg border p-3",
werte.distribution === d.id && "border-akzent",
)}
>
<input
type="radio"
name="distribution"
checked={werte.distribution === d.id}
onChange={() => setze({ distribution: d.id, templateBauen: false })}
className="accent-akzent mt-1 h-4 w-4"
/>
<span className="flex flex-col">
<span className="font-medium">{d.name}</span>
{werte.typ === "vm" ? (
<Status art={hatTemplate ? "erledigt" : "offen"} className="text-xs">
{hatTemplate ? "Template vorhanden" : "noch kein Template"}
</Status>
) : null}
</span>
</label>
);
})}
</fieldset>
{werte.typ === "vm" && distribution && !template ? (
<Schalter
id="templateBauen"
label="Template vorher bauen"
hilfe={`Für ${distribution.name} gibt es auf ${server.name} noch kein Template. Der Bau dauert einige Minuten und läuft als Teil dieses Auftrags.`}
checked={werte.templateBauen}
onChange={(an) => setze({ templateBauen: an })}
/>
) : template ? (
<Status art="erledigt">
Template {template.vmid} auf {template.knoten}
</Status>
) : null}
</div>
</Karte>
) : null}
{SCHRITTE[schritt] === "Name" ? (
<Karte titel="Name, ID und Tags">
<div className="grid gap-4 sm:grid-cols-3">
<Feld id="name" label="Name (Hostname)" fehler={fehler?.felder?.["name"]}>
<input
id="name"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.name}
onChange={(e) => setze({ name: e.target.value.toLowerCase() })}
placeholder="web01"
autoComplete="off"
spellCheck={false}
autoFocus
/>
</Feld>
<Feld id="vmid" label="VMID" hilfe="Leer = nächste freie">
<input
id="vmid"
inputMode="numeric"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.vmid}
onChange={(e) => setze({ vmid: e.target.value })}
placeholder="automatisch"
/>
</Feld>
<Feld id="tags" label="Tags" hilfe="Kommagetrennt">
<input
id="tags"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.tags}
onChange={(e) => setze({ tags: e.target.value })}
placeholder="web,produktion"
/>
</Feld>
</div>
</Karte>
) : null}
{SCHRITTE[schritt] === "Ressourcen" ? (
<Karte titel="Ressourcen">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Feld id="cores" label="CPU-Kerne">
<input
id="cores"
type="number"
min={1}
max={128}
className={EINGABE_KLASSE}
value={werte.cores}
onChange={(e) => setze({ cores: e.target.value })}
/>
</Feld>
<Feld id="speicher" label="RAM (MB)">
<input
id="speicher"
type="number"
min={256}
step={256}
className={EINGABE_KLASSE}
value={werte.speicherMb}
onChange={(e) => setze({ speicherMb: e.target.value })}
/>
</Feld>
<Feld id="disk" label="Disk (GB)">
<input
id="disk"
type="number"
min={4}
className={EINGABE_KLASSE}
value={werte.diskGb}
onChange={(e) => setze({ diskGb: e.target.value })}
/>
</Feld>
<Feld id="storage" label="Storage">
<select
id="storage"
className={EINGABE_KLASSE}
value={werte.storage}
onChange={(e) => setze({ storage: e.target.value })}
>
{(knoten?.storages ?? [])
.filter((s) => (werte.typ === "vm" ? s.vm : s.lxc))
.map((s) => (
<option key={s.name} value={s.name}>
{s.name}
{s.frei !== null ? ` · ${Math.round(s.frei / 1024 ** 3)} GiB frei` : ""}
</option>
))}
</select>
</Feld>
</div>
</Karte>
) : null}
{SCHRITTE[schritt] === "Netzwerk" ? (
<Karte titel="Netzwerk">
<div className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="bridge" label="Bridge">
<select
id="bridge"
className={EINGABE_KLASSE}
value={werte.bridge}
onChange={(e) => setze({ bridge: e.target.value })}
>
{(knoten?.bridges ?? []).map((b) => (
<option key={b.name} value={b.name}>
{b.name}
{b.vlan ? " · VLAN-fähig" : ""}
</option>
))}
</select>
</Feld>
<Feld
id="vlan"
label="VLAN"
hilfe={bridge && !bridge.vlan ? "Diese Bridge ist nicht VLAN-fähig." : "Leer = ohne Tag"}
>
<input
id="vlan"
inputMode="numeric"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.vlan}
onChange={(e) => setze({ vlan: e.target.value })}
placeholder="–"
disabled={Boolean(bridge && !bridge.vlan)}
/>
</Feld>
</div>
<fieldset className="flex gap-6">
<legend className="mb-2 text-sm font-medium">Adresse</legend>
{(["dhcp", "statisch"] as const).map((m) => (
<label key={m} className="flex items-center gap-2 text-sm">
<input
type="radio"
name="modus"
checked={werte.modus === m}
onChange={() => setze({ modus: m })}
className="accent-akzent h-4 w-4"
/>
{m === "dhcp" ? "DHCP" : "Statisch"}
</label>
))}
</fieldset>
{werte.modus === "statisch" ? (
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="ip" label="IP/Präfix">
<input
id="ip"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.ip}
onChange={(e) => setze({ ip: e.target.value })}
placeholder="10.0.20.15/24"
/>
</Feld>
<Feld id="gateway" label="Gateway">
<input
id="gateway"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.gateway}
onChange={(e) => setze({ gateway: e.target.value })}
placeholder="10.0.20.1"
/>
</Feld>
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="dns" label="DNS-Server" hilfe="Leer = vom Host übernehmen">
<input
id="dns"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.dns}
onChange={(e) => setze({ dns: e.target.value })}
placeholder="10.0.20.1"
/>
</Feld>
<Feld id="suchdomain" label="Suchdomain">
<input
id="suchdomain"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.suchdomain}
onChange={(e) => setze({ suchdomain: e.target.value })}
placeholder="example.lan"
/>
</Feld>
</div>
</div>
</Karte>
) : null}
{SCHRITTE[schritt] === "Zugang" ? (
<Karte titel="Zugang">
<div className="flex flex-col gap-4">
<Feld id="benutzer" label="Benutzer" hilfe="Wird mit sudo angelegt. Passwort-Login ist aus.">
<input
id="benutzer"
className={`${EINGABE_KLASSE} font-mono sm:max-w-xs`}
value={werte.benutzer}
onChange={(e) => setze({ benutzer: e.target.value })}
autoComplete="off"
/>
</Feld>
<Feld
id="schluessel"
label="Öffentliche SSH-Schlüssel"
hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Gespeicherte Schlüssel folgen mit #015."
>
<textarea
id="schluessel"
rows={4}
className={`${EINGABE_KLASSE} h-auto py-2 font-mono text-xs`}
value={werte.schluessel}
onChange={(e) => setze({ schluessel: e.target.value })}
placeholder="ssh-ed25519 AAAA… name@rechner"
spellCheck={false}
/>
</Feld>
</div>
</Karte>
) : null}
{SCHRITTE[schritt] === "Profile" ? (
<Karte titel="Profile">
<ul className="flex flex-col gap-4">
{daten.profile.map((p) => {
const umsetzbar = distribution ? p.familien.includes(distribution.familie) : false;
const gewaehlt = werte.profile.includes(p.id);
const benoetigtVon = daten.profile
.filter((x) => werte.profile.includes(x.id) && x.requires.includes(p.id))
.map((x) => x.name);
return (
<li key={p.id} className="flex flex-col gap-3">
<Schalter
id={`profil-${p.id}`}
label={p.name}
hilfe={
!umsetzbar
? `Keine Umsetzung für ${distribution?.name ?? "diese Distribution"} (Familie ${distribution?.familie}).`
: benoetigtVon.length > 0 && !gewaehlt
? `Wird automatisch mit ausgeführt (Voraussetzung für ${benoetigtVon.join(", ")}).`
: (p.beschreibung ?? undefined)
}
checked={gewaehlt || benoetigtVon.length > 0}
disabled={!umsetzbar || (benoetigtVon.length > 0 && !gewaehlt)}
onChange={(an) =>
setze({
profile: an ? [...werte.profile, p.id] : werte.profile.filter((x) => x !== p.id),
})
}
/>
{(gewaehlt || benoetigtVon.length > 0) && p.variablen.length > 0 ? (
<div className="border-linie ml-8 grid gap-4 border-l pl-4 sm:grid-cols-2">
{p.variablen.map((v) => {
const id = `var-${p.id}-${v.name}`;
const wert =
werte.variablen[p.id]?.[v.name] ??
v.default ??
(v.type === "boolean" ? false : "");
const setzeWert = (neu: string | boolean) =>
setze({
variablen: {
...werte.variablen,
[p.id]: { ...werte.variablen[p.id], [v.name]: neu },
},
});
return v.type === "boolean" ? (
<Schalter
key={v.name}
id={id}
label={v.label}
hilfe={v.help}
checked={Boolean(wert)}
onChange={setzeWert}
/>
) : (
<Feld
key={v.name}
id={id}
label={`${v.label}${v.required ? " *" : ""}`}
hilfe={v.help}
fehler={fehler?.felder?.[`variablen.${p.id}.${v.name}`]}
>
{v.type === "select" ? (
<select
id={id}
className={EINGABE_KLASSE}
value={String(wert)}
onChange={(e) => setzeWert(e.target.value)}
>
{v.options!.map((o) => (
<option key={o}>{o}</option>
))}
</select>
) : (
<input
id={id}
inputMode={v.type === "number" ? "numeric" : undefined}
className={`${EINGABE_KLASSE} font-mono`}
value={String(wert)}
onChange={(e) => setzeWert(e.target.value)}
/>
)}
</Feld>
);
})}
</div>
) : null}
</li>
);
})}
</ul>
</Karte>
) : null}
{SCHRITTE[schritt] === "Zusammenfassung" ? (
<div className="flex flex-col gap-6">
{ersteSperre >= 0 ? (
<Karte>
<Status art="fehler">
Schritt {ersteSperre + 1} ({SCHRITTE[ersteSperre]}) ist unvollständig:{" "}
{sperre(ersteSperre)}
</Status>
</Karte>
) : laeuft && !plan ? (
<Karte>
<Status art="laeuft">
Plane – prüft Template, VMID, Storage und Netz gegen den Server …
</Status>
</Karte>
) : plan ? (
<>
<TerminalKarte
version={daten.version}
kopf={plan.kopf}
trockenlauf
phasen={plan.phasen.map((p): TerminalPhase => ({
...p,
// Im Trockenlauf ist nichts erledigt – geplante Phasen erscheinen als offen.
status:
p.status === "FEHLER"
? "FEHLER"
: p.status === "UEBERSPRUNGEN"
? "UEBERSPRUNGEN"
: "OFFEN",
gestartetAm: null,
}))}
ergebnis={
plan.ok
? null
: { ok: false, titel: `${werte.name} würde fehlschlagen`, dauer: null, zeilen: [] }
}
/>
<Karte titel="Geplante Proxmox-Aufrufe">
<ol className="flex flex-col gap-1 font-mono text-xs sm:text-sm">
{plan.aufrufe.map((aufruf, nr) => (
<li key={nr} className="break-all">
<span className="text-laeuft">→ </span>
{aufruf.methode} {aufruf.pfad}
{aufruf.parameter && Object.keys(aufruf.parameter).length > 0 ? (
<span className="text-muted">
{" "}
{Object.entries(aufruf.parameter)
.map(
([k, v]) =>
`${k}=${String(v).length > 60 ? `${String(v).slice(0, 57)}…` : String(v)}`,
)
.join(" ")}
</span>
) : null}
</li>
))}
<li className="text-muted">
→ Guest-Agent: Provisionierungsskript übertragen und ausführen
</li>
</ol>
</Karte>
{plan.skript ? (
<details className="bg-panel border-linie rounded-karte border">
<summary className="cursor-pointer px-5 py-3.5 font-semibold">
Provisionierungsskript
</summary>
<pre className="border-linie max-h-[50vh] overflow-auto border-t p-5 font-mono text-xs">
{plan.skript}
</pre>
</details>
) : null}
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Dasselbe per CLI</span>
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-start">
<Code className="flex-1">{plan.cliBefehl}</Code>
<Knopf
variante="sekundaer"
aria-label="CLI-Befehl kopieren"
onClick={async () => {
await navigator.clipboard.writeText(plan.cliBefehl);
setKopiert(true);
setTimeout(() => setKopiert(false), 2000);
}}
>
<Copy size={16} aria-hidden="true" />
{kopiert ? "Kopiert" : "Kopieren"}
</Knopf>
</div>
</div>
</>
) : null}
</div>
) : null}
<div aria-live="polite">
{fehler ? (
<div>
<Status art="fehler">{fehler.text}</Status>
{fehler.hinweis ? <p className="text-muted text-sm">{fehler.hinweis}</p> : null}
</div>
) : null}
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<Knopf variante="sekundaer" onClick={() => gehe(schritt - 1)} disabled={schritt === 0}>
<ArrowLeft size={16} aria-hidden="true" /> Zurück
</Knopf>
{SCHRITTE[schritt] === "Zusammenfassung" ? (
<Knopf onClick={ausrollen} disabled={laeuft || !plan?.ok}>
{laeuft && plan ? "Startet …" : "Ausrollen"}
</Knopf>
) : (
<div className="flex items-center gap-3">
{aktuelleSperre ? <span className="text-muted text-sm">{aktuelleSperre}</span> : null}
<Knopf onClick={() => gehe(schritt + 1)} disabled={aktuelleSperre !== null}>
Weiter <ArrowRight size={16} aria-hidden="true" />
</Knopf>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { prisma } from "@/lib/db";
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
import { versionText } from "@/lib/version";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { distributionenListe, profileListe } from "@/server/services/katalog";
import { Assistent, type AssistentDaten } from "./assistent";
export const metadata: Metadata = { title: "Ausrollen" };
/** Der Deploy-Assistent. Alle Daten für die Auswahlfelder kommen hier einmal vom Server. */
export default async function Ausrollen() {
const benutzer = await seiteSchuetzen("ausrollen", "/ausrollen");
const server = await prisma.server.findMany({ orderBy: { name: "asc" } });
const mitTest = server.filter((s) => s.letzterTest);
if (mitTest.length === 0) {
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Ausrollen</h1>
<Karte>
<Status art="offen">Erst einen Server anlegen und die Verbindung testen.</Status>
</Karte>
</div>
);
}
const templates = await prisma.template.findMany({
select: { serverId: true, distributionId: true, knoten: true, vmid: true },
});
const daten: AssistentDaten = {
server: mitTest.map((s) => {
const bericht = s.letzterTest as VerbindungsBericht;
return {
name: s.name,
titel: s.titel,
knoten: bericht.knoten
.filter((k) => k.online)
.map((k) => ({
name: k.name,
speicherFrei: (k.speicherGesamt ?? 0) - (k.speicherBelegt ?? 0),
storages: k.storages
.filter((st) => st.aktiv && (st.inhalte.includes("images") || st.inhalte.includes("rootdir")))
.map((st) => ({
name: st.storage,
vm: st.inhalte.includes("images"),
lxc: st.inhalte.includes("rootdir"),
frei: st.frei,
})),
bridges: k.bridges.map((b) => ({ name: b.name, vlan: b.vlanBewusst })),
})),
templates: templates
.filter((t) => t.serverId === s.id)
.map((t) => ({ distribution: t.distributionId, knoten: t.knoten, vmid: t.vmid })),
};
}),
distributionen: (await distributionenListe({ nurAktive: true })).map((d) => ({
id: d.id,
name: d.name,
familie: d.familie,
vm: d.vm,
lxc: d.lxc,
})),
profile: (await profileListe({ nurAktive: true })).map((p) => ({
id: p.id,
name: p.name,
beschreibung: p.beschreibung,
familien: p.familien,
requires: p.definition.requires,
variablen: p.definition.variables,
})),
benutzer: benutzer.benutzername,
version: versionText(),
};
const vorauswahl = aktiverServer(
daten.server.map((s) => s.name),
(await cookies()).get(SERVER_COOKIE)?.value,
);
return <Assistent daten={daten} vorauswahl={vorauswahl} />;
}
+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>
);
}
+99
View File
@@ -0,0 +1,99 @@
import type { Metadata } from "next";
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">
<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">
{p.name}{" "}
<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>
);
}
+129
View File
@@ -0,0 +1,129 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { prisma } from "@/lib/db";
import { zeitpunkt } from "@/lib/formate";
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { AnwendungsFehler } from "@/server/fehler";
import { distributionenListe } from "@/server/services/katalog";
import { templatesListe, type TemplateSicht } from "@/server/services/templates";
import { TemplateBauen } from "./template-bauen";
export const metadata: Metadata = { title: "Templates" };
export default async function TemplatesSeite() {
const benutzer = await seiteSchuetzen("lesen", "/templates");
const alle = await prisma.server.findMany({ orderBy: { name: "asc" } });
const name = aktiverServer(
alle.map((s) => s.name),
(await cookies()).get(SERVER_COOKIE)?.value,
);
const server = alle.find((s) => s.name === name);
if (!server) {
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Templates</h1>
<Karte>
<Status art="offen">Noch kein Server angelegt.</Status>
</Karte>
</div>
);
}
let templates: TemplateSicht[] = [];
let fehler: { ursache: string; hinweis?: string } | null = null;
try {
templates = await templatesListe(server.name);
} catch (ursache) {
if (!(ursache instanceof AnwendungsFehler)) throw ursache;
fehler = { ursache: ursache.message, hinweis: ursache.hinweis };
}
const bericht = server.letzterTest as VerbindungsBericht | null;
const distributionen = (await distributionenListe({ typ: "vm", nurAktive: true })).map((d) => ({
id: d.id,
name: d.name,
}));
return (
<div className="flex flex-col gap-6">
<header>
<p className="text-muted font-mono text-sm">{server.name}</p>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Templates</h1>
<p className="text-muted text-sm">
VM-Templates mit eingebautem Guest-Agent, VMID-Bereich {server.templateVmidVon}–
{server.templateVmidBis}.
</p>
</header>
<Karte titel="Vorhanden">
{fehler ? (
<div>
<Status art="fehler">{fehler.ursache}</Status>
{fehler.hinweis ? <p className="text-muted mt-1 text-sm">{fehler.hinweis}</p> : null}
</div>
) : templates.length === 0 ? (
<Status art="offen">Noch kein ProxDeploy-Template auf diesem Server.</Status>
) : (
<ul className="divide-linie flex flex-col divide-y">
{templates.map((t) => (
<li
key={t.vmid}
className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:gap-4"
>
<span className="font-mono sm:w-16">{t.vmid}</span>
<span className="min-w-0 flex-1">
<span className="font-medium">{t.name}</span>
<span className="text-muted text-sm">
{" "}
· {t.knoten}
{t.distributionId ? ` · ${t.distributionId}` : ""}
</span>
</span>
<span className="text-muted text-sm">
{t.gebautAm ? `gebaut ${zeitpunkt(t.gebautAm)}` : "nicht von hier gebaut"}
</span>
</li>
))}
</ul>
)}
</Karte>
{darf(benutzer.rolle, "ausrollen") ? (
bericht ? (
<Karte titel="Template bauen">
<TemplateBauen
server={server.name}
distributionen={distributionen}
knoten={bericht.knoten
.filter((k) => k.online)
.map((k) => ({
name: k.name,
storages: k.storages
.filter((s) => s.aktiv && s.inhalte.includes("images"))
.map((s) => s.storage),
bridges: k.bridges.map((b) => b.name),
}))}
faehigkeit={bericht.faehigkeiten.templateBau}
/>
</Karte>
) : (
<Karte titel="Template bauen">
<Status art="offen">
Erst die Verbindung testen –{" "}
<Link href={`/server/${server.name}`} className="underline">
zur Serverseite
</Link>
.
</Status>
</Karte>
)
) : null}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { EINGABE_KLASSE, Feld } from "@/components/formular";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
type Knoten = { name: string; storages: string[]; bridges: string[] };
export function TemplateBauen({
server,
distributionen,
knoten,
faehigkeit,
}: {
server: string;
distributionen: Array<{ id: string; name: string }>;
knoten: Knoten[];
faehigkeit: { weg: "api" | "ssh" | "nicht_moeglich"; grund: string };
}) {
const router = useRouter();
const [werte, setWerte] = useState({
distribution: distributionen.find((d) => d.id === "debian-13")?.id ?? distributionen[0]?.id ?? "",
knoten: knoten[0]?.name ?? "",
storage: knoten[0]?.storages[0] ?? "",
bridge: knoten[0]?.bridges.includes("vmbr0") ? "vmbr0" : (knoten[0]?.bridges[0] ?? ""),
});
const [fehler, setFehler] = useState<{ text: string; hinweis?: string } | null>(null);
const [sendet, setSendet] = useState(false);
const aktuell = knoten.find((k) => k.name === werte.knoten);
async function bauen(ereignis: React.FormEvent) {
ereignis.preventDefault();
setSendet(true);
setFehler(null);
try {
const { kurzId } = await apiAufruf<{ kurzId: string }>(
"POST",
`/api/v1/server/${server}/templates`,
werte,
);
router.push(`/auftraege/${kurzId}`);
} catch (ursache) {
setFehler(
ursache instanceof ApiFehler
? { text: ursache.message, hinweis: ursache.hinweis }
: { text: "Unerwarteter Fehler." },
);
setSendet(false);
}
}
return (
<form onSubmit={bauen} className="flex flex-col gap-4">
<Status art={faehigkeit.weg === "api" ? "erledigt" : faehigkeit.weg === "ssh" ? "laeuft" : "fehler"}>
{faehigkeit.weg === "api" ? "Per API" : faehigkeit.weg === "ssh" ? "Per SSH" : "Nicht möglich"} –{" "}
{faehigkeit.grund}
</Status>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Feld id="distribution" label="Distribution">
<select
id="distribution"
className={EINGABE_KLASSE}
value={werte.distribution}
onChange={(e) => setWerte({ ...werte, distribution: e.target.value })}
>
{distributionen.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</Feld>
<Feld id="knoten" label="Node">
<select
id="knoten"
className={EINGABE_KLASSE}
value={werte.knoten}
onChange={(e) => {
const k = knoten.find((x) => x.name === e.target.value);
setWerte({
...werte,
knoten: e.target.value,
storage: k?.storages[0] ?? "",
bridge: k?.bridges[0] ?? "",
});
}}
>
{knoten.map((k) => (
<option key={k.name}>{k.name}</option>
))}
</select>
</Feld>
<Feld id="storage" label="Storage">
<select
id="storage"
className={EINGABE_KLASSE}
value={werte.storage}
onChange={(e) => setWerte({ ...werte, storage: e.target.value })}
>
{aktuell?.storages.map((s) => (
<option key={s}>{s}</option>
))}
</select>
</Feld>
<Feld id="bridge" label="Bridge (Vorbereitungsstart)">
<select
id="bridge"
className={EINGABE_KLASSE}
value={werte.bridge}
onChange={(e) => setWerte({ ...werte, bridge: e.target.value })}
>
{aktuell?.bridges.map((b) => (
<option key={b}>{b}</option>
))}
</select>
</Feld>
</div>
<p className="text-muted text-sm">
Der Vorbereitungsstart braucht DHCP und Internetzugang auf der Bridge, um qemu-guest-agent zu
installieren.
</p>
<div className="flex flex-wrap items-center gap-3">
<Knopf type="submit" disabled={sendet || faehigkeit.weg === "nicht_moeglich" || !werte.distribution}>
{sendet ? "Startet …" : "Template bauen"}
</Knopf>
<div aria-live="polite" className="text-sm">
{fehler ? (
<div>
<Status art="fehler">{fehler.text}</Status>
{fehler.hinweis ? <p className="text-muted">{fehler.hinweis}</p> : null}
</div>
) : null}
</div>
</div>
</form>
);
}
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftragAbbrechen } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
await auftragAbbrechen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return NextResponse.json({ ok: true });
});
@@ -0,0 +1,101 @@
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { abonnieren } from "@/server/ereignisse/hoeren";
import { auftragHolen, ENDZUSTAENDE, logAb } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
export const dynamic = "force-dynamic";
/**
* Live-Fortschritt als Server-Sent Events – für Web-GUI und CLI derselbe Strom.
*
* - `stand`: der vollständige Auftrag mit allen Phasen (klein, deshalb immer ganz)
* - `log`: neue Detail-Log-Zeilen; ihre ID ist die Event-ID, damit ein Wiederverbinden mit
* `Last-Event-ID` (oder `?ab=`) genau dort weitermacht
* - `ende`: der Auftrag ist fertig, der Strom schließt
*/
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
const anfangsStand = await auftragHolen((await params).id);
const auftragId = anfangsStand.id;
const letzte = anfrage.headers.get("last-event-id") ?? new URL(anfrage.url).searchParams.get("ab") ?? "0";
let logBis = BigInt(/^\d+$/.test(letzte) ? letzte : "0");
const kodierer = new TextEncoder();
let aufraeumen = () => {};
const strom = new ReadableStream<Uint8Array>({
async start(steuerung) {
let offen = true;
const senden = (text: string) => {
if (offen) steuerung.enqueue(kodierer.encode(text));
};
const ereignis = (art: string, daten: unknown, id?: string) =>
senden(`${id ? `id: ${id}\n` : ""}event: ${art}\ndata: ${JSON.stringify(daten)}\n\n`);
let laeuft = false;
let nochmal = false;
const aktualisieren = async () => {
// Signale können dicht kommen; zusammenfassen statt parallel abzufragen.
if (laeuft) {
nochmal = true;
return;
}
laeuft = true;
try {
do {
nochmal = false;
const stand = await auftragHolen(auftragId);
const zeilen = await logAb(auftragId, logBis);
if (zeilen.length > 0) {
logBis = BigInt(zeilen.at(-1)!.id);
ereignis("log", zeilen, zeilen.at(-1)!.id);
}
ereignis("stand", stand);
if (ENDZUSTAENDE.has(stand.status)) {
ereignis("ende", { status: stand.status });
schliessen();
return;
}
} while (nochmal);
} finally {
laeuft = false;
}
};
const abmelden = await abonnieren(auftragId, () => void aktualisieren());
// Herzschlag: hält Proxys davon ab, die Verbindung als tot zu schließen.
const herz = setInterval(() => senden(": ping\n\n"), 15_000);
const schliessen = () => {
if (!offen) return;
offen = false;
clearInterval(herz);
abmelden();
try {
steuerung.close();
} catch {
// schon geschlossen
}
};
aufraeumen = schliessen;
anfrage.signal.addEventListener("abort", schliessen);
senden("retry: 3000\n\n");
await aktualisieren();
},
cancel() {
aufraeumen();
},
});
return new Response(strom, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
// nginx puffert sonst und der Fortschritt käme in Brocken an.
"x-accel-buffering": "no",
},
});
});
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftragHolen, logAb } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
/** Detail-Log ab einer Zeilen-ID (`?ab=`), für Polling und `proxdeploy logs`. */
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
const auftrag = await auftragHolen((await params).id);
const ab = new URL(anfrage.url).searchParams.get("ab");
return NextResponse.json(await logAb(auftrag.id, BigInt(/^\d+$/.test(ab ?? "") ? ab! : "0")));
});
+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) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage)));
});
+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 }),
);
});
+15
View File
@@ -0,0 +1,15 @@
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 } 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 });
});
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { darf } from "@/server/auth/rechte";
import { rechtErforderlich } from "@/server/auth/zugang";
import { profileListe } 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 }));
});
@@ -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 });
});
+1 -1
View File
@@ -20,7 +20,7 @@ export function ServerUmschalter({
return (
<div className="flex flex-col gap-1.5 px-2">
<label htmlFor="server-umschalter" className="text-muted text-xs font-medium tracking-wide uppercase">
Server
Aktiver Server
</label>
<select
id="server-umschalter"
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { clsx } from "clsx";
import { useEffect, useState } from "react";
import { StatusSymbol, type StatusArt } from "@/components/status";
import { dauer } from "@/lib/formate";
/**
* Die Live-Ansicht im Aufbau der CLI-Ausgabe: Kopfzeile, eine Zeile je Phase (Symbol, Name in
* fester Breite, Detail grau), am Ende die Ergebnis-Box. Alle Texte kommen aus der Engine.
*/
export type TerminalPhase = {
schluessel: string;
titel: string;
status: "OFFEN" | "LAEUFT" | "ERLEDIGT" | "FEHLER" | "UEBERSPRUNGEN";
detail: string | null;
ursache: string | null;
hinweis: string | null;
gestartetAm: string | null;
};
export type TerminalErgebnis = {
ok: boolean;
titel: string;
dauer: string | null;
zeilen: Array<[string, string]>;
};
const ART: Record<TerminalPhase["status"], StatusArt> = {
OFFEN: "offen",
LAEUFT: "laeuft",
ERLEDIGT: "erledigt",
FEHLER: "fehler",
UEBERSPRUNGEN: "offen",
};
const FARBE: Record<StatusArt, string> = {
erledigt: "text-akzent",
laeuft: "text-laeuft",
fehler: "text-fehler",
offen: "text-muted",
};
function Laufzeit({ seit }: { seit: string }) {
const [jetzt, setJetzt] = useState(() => Date.now());
useEffect(() => {
const uhr = setInterval(() => setJetzt(Date.now()), 1000);
return () => clearInterval(uhr);
}, []);
return <span>{dauer(jetzt - new Date(seit).getTime())}</span>;
}
export function TerminalKarte({
version,
kopf,
trockenlauf = false,
phasen,
ergebnis,
}: {
version: string;
kopf: string;
trockenlauf?: boolean;
phasen: TerminalPhase[];
ergebnis: TerminalErgebnis | null;
}) {
const breite = Math.max(12, ...phasen.map((p) => p.titel.length)) + 2;
return (
<div className="bg-panel border-linie rounded-karte overflow-hidden border font-mono text-[13px] leading-6 sm:text-sm">
<div
className="flex items-center gap-1.5 border-b border-[var(--farbe-linie)] px-4 py-2"
aria-hidden="true"
>
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
</div>
<div className="flex flex-col gap-4 p-4 sm:p-5">
<p className="break-words">
<span className="text-muted">⬡ </span>
<span className="text-text">Prox</span>
<span className="text-akzent font-bold">Deploy</span>
<span className="text-muted">
{" "}
{version} · {kopf}
</span>
{trockenlauf ? <span className="text-laeuft font-bold"> · DRY-RUN</span> : null}
</p>
<ol className="flex flex-col" aria-label="Phasen">
{phasen.map((phase) => {
const art = ART[phase.status];
return (
<li key={phase.schluessel} className="flex flex-col sm:flex-row sm:gap-2">
<span
className={clsx("inline-flex shrink-0 items-baseline gap-2", FARBE[art])}
style={{ minWidth: `${breite + 2}ch` }}
>
<StatusSymbol
art={art}
className={clsx(
"translate-y-[0.15em]",
phase.status === "LAEUFT" && "motion-safe:animate-spin",
)}
/>
<span
className={
phase.status === "OFFEN" || phase.status === "UEBERSPRUNGEN"
? "text-muted"
: "text-text"
}
>
{phase.titel}
</span>
<span className="sr-only">
{" "}
–{" "}
{
{
OFFEN: "offen",
LAEUFT: "läuft",
ERLEDIGT: "erledigt",
FEHLER: "Fehler",
UEBERSPRUNGEN: "übersprungen",
}[phase.status]
}
</span>
</span>
<span className="text-muted min-w-0 pl-6 break-words sm:pl-0">
{phase.status === "FEHLER" ? (
<>
<span className="text-fehler">{phase.ursache}</span>
{phase.hinweis ? <span className="block">→ {phase.hinweis}</span> : null}
</>
) : (
<>
{phase.detail}
{phase.status === "LAEUFT" && phase.gestartetAm ? (
<>
{phase.detail ? " … " : ""}
<Laufzeit seit={phase.gestartetAm} />
</>
) : null}
</>
)}
</span>
</li>
);
})}
</ol>
{ergebnis ? (
<div
className={clsx(
"max-w-xl rounded-lg border px-4 py-3",
ergebnis.ok ? "border-akzent" : "border-fehler",
)}
>
<p className={ergebnis.ok ? "text-akzent" : "text-fehler"}>
{ergebnis.ok ? "● " : "✗ "}
<span className="text-text font-bold">{ergebnis.titel}</span>
{ergebnis.dauer ? <span className="text-muted"> · {ergebnis.dauer}</span> : null}
</p>
{ergebnis.zeilen.length > 0 ? (
<dl className="mt-1 grid grid-cols-[9ch_1fr] gap-x-2">
{ergebnis.zeilen.map(([name, wert]) => (
<div key={name} className="contents">
<dt className="text-muted">{name}</dt>
<dd className="text-text break-all">{wert}</dd>
</div>
))}
</dl>
) : null}
</div>
) : null}
</div>
</div>
);
}
+399
View File
@@ -0,0 +1,399 @@
import { AblaufFehler } from "@/engine/ablauf/fehler";
import type { PhasenKontext, PhasenSchritt } from "@/engine/ablauf/ablauf";
import type { Distribution } from "@/engine/katalog/schema";
import { dateiAusUrl, pruefsummeFinden } from "@/engine/katalog/pruefsumme";
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten";
import { q } from "@/engine/shell";
/**
* Template-Bau für VMs: Cloud-Image laden und prüfen, VM anlegen, Disk importieren, Guest-Agent
* einbauen, als Template markieren. Geprüft gegen die Proxmox-API (docs/architektur.md, Abschnitt 3):
*
* - **per API** ab PVE 8.4 mit einem Storage vom Inhaltstyp `import`: `download-url` (mit
* Prüfsumme, Proxmox prüft selbst) und `import-from=<storage>:import/<datei>`
* - **per SSH** davor oder ohne import-Storage: Download und Prüfung auf dem Node,
* `qm set --scsi0 …,import-from=<pfad>` als root
*
* Der Guest-Agent ist in keinem der mitgelieferten Images enthalten. Er wird mit einem einmaligen
* Vorbereitungsstart eingebaut (cloud-init vendor-data als Snippet, braucht SSH), damit jeder Klon
* sofort einen Agent hat – ohne ihn gäbe es bei DHCP weder IP noch Provisionierung.
*/
/** Führt einen Befehl als root auf einem Node aus (ssh2, gepinnter Host-Key). */
export type NodeBefehl = (
befehl: string,
optionen?: { eingabe?: string; zeitlimitMs?: number },
) => Promise<{ code: number | null; stdout: string; stderr: string }>;
export type TemplateBauEingabe = {
client: ProxmoxClient;
distribution: Distribution;
knoten: string;
/** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */
storage: string;
bridge: string;
vlan?: number;
/** null, wenn SSH nicht eingerichtet oder der Host-Key nicht bestätigt ist. */
ssh: NodeBefehl | null;
/** VMID im Template-Bereich reservieren (nach Prüfung, wer schon was belegt). */
vmidReservieren: (belegt: Set<number>) => Promise<number>;
/** Prüfsummen-Datei holen – im Test ersetzbar. */
holen?: (url: string) => Promise<string>;
auftragKurzId: string;
jetzt?: () => Date;
/** Zeitlimit für den Vorbereitungsstart (Paketinstallation im Gast). */
vorbereitungMs?: number;
warten?: (ms: number) => Promise<void>;
};
export type TemplateBauStand = {
vmid?: number;
weg?: "api" | "ssh";
importStorage?: string;
snippetStorage?: string;
imageDatei?: string;
pruefsumme?: string;
volid?: string;
nodePfad?: string;
};
type StorageEintrag = {
storage: string;
type: string;
content: string;
active?: number;
enabled?: number;
shared?: number;
};
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
async function standardHolen(url: string): Promise<string> {
const antwort = await fetch(url, { signal: AbortSignal.timeout(30_000) });
if (!antwort.ok)
throw new AblaufFehler(
`Prüfsummen-Datei nicht abrufbar (HTTP ${antwort.status}): ${url}`,
"Hat ProxDeploy Internetzugang? Stimmt die URL im Katalog?",
);
return await antwort.text();
}
export function templateName(distribution: Distribution): string {
return `tpl-${distribution.id.replace(/[^a-z0-9-]/g, "-")}`;
}
/**
* vendor-data für den Vorbereitungsstart: Guest-Agent installieren, Maschinen-ID leeren (sonst
* teilen sich alle Klone DHCP-Client-ID und journald-ID), cloud-init zurücksetzen, ausschalten.
* SSH-Host-Keys erzeugt cloud-init bei jedem Klon ohnehin neu (neue instance-id).
*/
export function vorbereitungsSnippet(): string {
return `#cloud-config
# Von ProxDeploy: einmaliger Vorbereitungsstart beim Template-Bau
package_update: true
packages:
- qemu-guest-agent
runcmd:
- [systemctl, enable, qemu-guest-agent]
- [sh, -c, "truncate -s 0 /etc/machine-id && rm -f /var/lib/dbus/machine-id && cloud-init clean --logs"]
power_state:
mode: poweroff
delay: now
message: ProxDeploy-Vorbereitung abgeschlossen
timeout: 300
condition: true
`;
}
export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauStand = {}): PhasenSchritt[] {
const { client, distribution: d, knoten } = e;
const vm = d.vm;
if (!vm)
throw new AblaufFehler(
`${d.name} bietet kein VM-Image.`,
"Im Katalog vm: ergänzen oder eine andere Distribution wählen.",
);
const holen = e.holen ?? standardHolen;
const warten = e.warten ?? schlafen;
const snippetDatei = `proxdeploy-vorbereitung-${e.auftragKurzId}.yaml`;
const ssh = async (
befehl: string,
k: PhasenKontext,
optionen?: { eingabe?: string; zeitlimitMs?: number },
) => {
if (!e.ssh)
throw new AblaufFehler(
"Dafür ist SSH zum Node nötig.",
"SSH in den Server-Einstellungen einrichten und den Host-Key bestätigen.",
);
if (client.trocken) {
await k.log(`[ssh ${knoten}] ${befehl}`, "info");
return { code: 0, stdout: "", stderr: "" };
}
await k.log(`[ssh ${knoten}] ${befehl.split("\n")[0]}`, "debug");
const ergebnis = await e.ssh(befehl, optionen);
if (ergebnis.code !== 0) {
throw new AblaufFehler(
`Befehl auf ${knoten} fehlgeschlagen (Code ${ergebnis.code}): ${(ergebnis.stderr || ergebnis.stdout).trim().split("\n").slice(-3).join(" | ")}`,
"Detail-Log prüfen.",
);
}
return ergebnis;
};
return [
{
schluessel: "pruefungen",
titel: "Prüfungen",
ausfuehren: async () => {
const version = await client.get<{ version: string }>(pfad`/nodes/${knoten}/version`).catch(() => {
throw new AblaufFehler(
`Node ${knoten} ist nicht erreichbar oder unbekannt.`,
"Node-Namen und Cluster-Status prüfen.",
);
});
const pve = versionLesen(version.version)!;
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${knoten}/storage`);
const aktiv = (s: StorageEintrag) => s.active === 1 && s.enabled !== 0;
const ziel = storages.find((s) => s.storage === e.storage);
if (!ziel || !aktiv(ziel) || !ziel.content.split(",").includes("images")) {
throw new AblaufFehler(
`Storage ${e.storage} ist auf ${knoten} nicht aktiv oder nimmt keine VM-Disks auf.`,
`Geeignet sind: ${
storages
.filter((s) => aktiv(s) && s.content.includes("images"))
.map((s) => s.storage)
.join(", ") || "keiner"
}.`,
);
}
const importSpeicher = storages.find((s) => aktiv(s) && s.content.split(",").includes("import"));
stand.snippetStorage = storages.find(
(s) => aktiv(s) && s.content.split(",").includes("snippets"),
)?.storage;
if (mindestens(pve, 8, 4) && importSpeicher) {
stand.weg = "api";
stand.importStorage = importSpeicher.storage;
} else if (e.ssh) {
stand.weg = "ssh";
} else {
throw new AblaufFehler(
mindestens(pve, 8, 4)
? `Auf ${knoten} gibt es keinen Storage mit Inhaltstyp „Import“.`
: `PVE ${pve.text} kann Disk-Images erst ab 8.4 per API laden.`,
mindestens(pve, 8, 4)
? "Einem Dir-Storage den Inhaltstyp „Import“ geben (Datacenter → Storage) oder SSH einrichten."
: "SSH zum Node einrichten oder auf PVE 8.4+ aktualisieren.",
);
}
if (!vm.guest_agent_preinstalled && !(e.ssh && stand.snippetStorage)) {
throw new AblaufFehler(
"Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node und einen Storage mit Inhaltstyp „Snippets“.",
e.ssh
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben."
: "SSH in den Server-Einstellungen einrichten.",
);
}
const netze = await client.get<Array<{ iface: string; type: string }>>(
pfad`/nodes/${knoten}/network`,
);
if (!netze.some((n) => n.iface === e.bridge && (n.type === "bridge" || n.type === "OVSBridge"))) {
throw new AblaufFehler(
`Bridge ${e.bridge} gibt es auf ${knoten} nicht.`,
"Bridge in den Server-Standards oder im Formular korrigieren.",
);
}
const belegt = new Set(
(await client.get<Array<{ vmid?: number }>>("/cluster/resources", { type: "vm" }))
.map((r) => r.vmid!)
.filter(Boolean),
);
stand.vmid = await e.vmidReservieren(belegt);
return {
detail: `VMID ${stand.vmid} frei · ${e.storage} · ${stand.weg === "api" ? `Import über ${stand.importStorage}` : "Import per SSH"} · ${e.bridge}${e.vlan ? ` VLAN ${e.vlan}` : ""}`,
};
},
},
{
schluessel: "image",
titel: "Image laden",
ausfuehren: async (k) => {
const quelle = dateiAusUrl(vm.image_url);
await k.detail("Prüfsumme holen …");
const summen = await holen(vm.checksum_url);
const hash = pruefsummeFinden(summen, quelle, vm.checksum_type);
if (!hash) {
throw new AblaufFehler(
`${quelle} steht nicht in ${vm.checksum_url}.`,
"Image- und Prüfsummen-URL im Katalog prüfen – der Anbieter hat die Dateien vielleicht umbenannt.",
);
}
stand.pruefsumme = hash;
// Die Prüfsumme im Dateinamen: ein neues „latest“-Image wird neu geladen, ein gleiches nicht.
stand.imageDatei = `proxdeploy-${d.id}-${hash.slice(0, 12)}.${vm.format}`;
await k.log(`${vm.checksum_type} ${hash} für ${quelle}`);
if (stand.weg === "api") {
const volid = `${stand.importStorage}:import/${stand.imageDatei}`;
stand.volid = volid;
const vorhanden = await client.get<Array<{ volid: string }>>(
pfad`/nodes/${knoten}/storage/${stand.importStorage!}/content`,
{ content: "import" },
);
if (vorhanden.some((v) => v.volid === volid))
return { detail: `${stand.imageDatei} schon vorhanden` };
const upid = await client.post<string>(
pfad`/nodes/${knoten}/storage/${stand.importStorage!}/download-url`,
{
content: "import",
filename: stand.imageDatei,
url: vm.image_url,
checksum: hash,
"checksum-algorithm": vm.checksum_type,
"verify-certificates": true,
},
);
await aufgabeAbwarten(client, upid, {
zeitlimitMs: 60 * 60 * 1000,
intervallMs: 2000,
beiLog: (zeilen) => {
const prozent = zeilen
.map((z) => /(\d+(?:\.\d+)?)%/.exec(z)?.[1])
.filter(Boolean)
.at(-1);
if (prozent) void k.detail(`${quelle} · ${Math.round(Number(prozent))} %`);
for (const z of zeilen) void k.log(z, "debug");
},
});
return { detail: `${quelle} geladen und geprüft (${vm.checksum_type})` };
}
// SSH: auf den Node laden und dort prüfen; vorhandene gültige Datei wird wiederverwendet.
const ordner = "/var/tmp/proxdeploy";
stand.nodePfad = `${ordner}/${stand.imageDatei}`;
const pruefen = `echo ${q(`${hash} ${stand.nodePfad}`)} | ${vm.checksum_type}sum -c --status`;
await k.detail(`${quelle} wird auf ${knoten} geladen …`);
await ssh(
`set -e; mkdir -p ${ordner}; if ! ${pruefen} 2>/dev/null; then curl -fsSL --retry 3 -o ${q(stand.nodePfad + ".teil")} ${q(vm.image_url)} && mv ${q(stand.nodePfad + ".teil")} ${q(stand.nodePfad)}; fi; ${pruefen}`,
k,
{ zeitlimitMs: 60 * 60 * 1000 },
);
return { detail: `${quelle} auf ${knoten} geladen und geprüft (${vm.checksum_type})` };
},
},
{
schluessel: "vm",
titel: "VM anlegen",
ausfuehren: async (k) => {
const vmid = stand.vmid!;
const jetzt = (e.jetzt?.() ?? new Date()).toISOString();
const importVon = stand.weg === "api" ? stand.volid! : stand.nodePfad!;
const disk = `${e.storage}:0,import-from=${importVon},discard=on,ssd=1,iothread=1`;
const parameter = {
vmid,
name: templateName(d),
ostype: "l26",
machine: "q35",
bios: vm.bios,
...(vm.bios === "ovmf" ? { efidisk0: `${e.storage}:1,efitype=4m,pre-enrolled-keys=1` } : {}),
cpu: "host",
cores: 2,
memory: 2048,
scsihw: "virtio-scsi-single",
net0: `virtio,bridge=${e.bridge}${e.vlan ? `,tag=${e.vlan}` : ""}`,
serial0: "socket",
vga: "serial0",
agent: "enabled=1,fstrim_cloned_disks=1",
ide2: `${e.storage}:cloudinit`,
ipconfig0: "ip=dhcp",
ciupgrade: false,
tags: `proxdeploy-template;${d.id}`,
description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}\nGebaut: ${jetzt} (Auftrag ${e.auftragKurzId})`,
};
if (stand.weg === "api") {
await aufgabeAbwarten(
client,
await client.post<string>(pfad`/nodes/${knoten}/qemu`, {
...parameter,
scsi0: disk,
boot: "order=scsi0",
}),
{
zeitlimitMs: 30 * 60 * 1000,
},
);
} else {
// Ein absoluter Pfad in import-from ist per API nur root@pam erlaubt – daher qm über SSH.
await aufgabeAbwarten(client, await client.post<string>(pfad`/nodes/${knoten}/qemu`, parameter), {
zeitlimitMs: 10 * 60 * 1000,
});
await k.detail("Disk wird importiert …");
await ssh(`qm set ${vmid} --scsi0 ${q(disk)} --boot order=scsi0`, k, {
zeitlimitMs: 30 * 60 * 1000,
});
}
return {
detail: `VM ${vmid} (${templateName(d)}) · q35 · ${vm.bios === "ovmf" ? "UEFI" : "SeaBIOS"} · Disk importiert`,
};
},
},
{
schluessel: "agent",
titel: "Guest-Agent",
ausfuehren: async (k) => {
if (vm.guest_agent_preinstalled) return { uebersprungen: true, detail: "im Image enthalten" };
const vmid = stand.vmid!;
const vmPfad = pfad`/nodes/${knoten}/qemu/${vmid}`;
const snippet = `${stand.snippetStorage}:snippets/${snippetDatei}`;
await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, {
eingabe: vorbereitungsSnippet(),
});
try {
await client.put(`${vmPfad}/config`, { cicustom: `vendor=${snippet}` });
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/start`), {
zeitlimitMs: 5 * 60 * 1000,
});
if (!client.trocken) {
const beginn = Date.now();
const grenze = e.vorbereitungMs ?? 20 * 60 * 1000;
for (;;) {
await k.pruefeAbbruch();
const status = await client.get<{ status: string }>(`${vmPfad}/status/current`);
if (status.status === "stopped") break;
if (Date.now() - beginn > grenze) {
throw new AblaufFehler(
`Die Vorbereitung von VM ${vmid} ist nach ${Math.round(grenze / 60000)} min nicht fertig.`,
"Hat die VM per DHCP eine Adresse und Internetzugang (für qemu-guest-agent)? Konsole der VM ansehen.",
);
}
await k.detail(
`Vorbereitungsstart: qemu-guest-agent wird installiert … ${Math.round((Date.now() - beginn) / 1000)}s`,
);
await warten(5000);
}
}
} finally {
// Auch nach einem Fehler: das Snippet soll nicht im Template hängen bleiben.
await client.put(`${vmPfad}/config`, { delete: "cicustom" }).catch(() => undefined);
await ssh(`rm -f "$(pvesm path ${q(snippet)})"`, k).catch(() => undefined);
}
return { detail: "qemu-guest-agent installiert · Maschinen-ID geleert" };
},
},
{
schluessel: "template",
titel: "Template",
ausfuehren: async () => {
await aufgabeAbwarten(
client,
await client.post<string>(pfad`/nodes/${knoten}/qemu/${stand.vmid!}/template`),
{ zeitlimitMs: 10 * 60 * 1000 },
);
return { detail: `VM ${stand.vmid} ist jetzt Template ${templateName(d)}` };
},
},
];
}
+473
View File
@@ -0,0 +1,473 @@
import { AblaufFehler } from "@/engine/ablauf/fehler";
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
import { adapterFuer } from "@/engine/adapter";
import type { Distribution } from "@/engine/katalog/schema";
import { profileRendern, MARKE } from "@/engine/profile/rendern";
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
import type { AusrollAnfrage } from "@/lib/schemas/ausrollen";
import { templateBauSchritte, type TemplateBauEingabe, type TemplateBauStand } from "./template-bau";
/**
* Eine VM aus einem ProxDeploy-Template ausrollen – vollständig per API, ohne SSH:
* Klonen, Hardware, cloud-init (Benutzer, Schlüssel, Netz), Start, Guest-Agent, Provisionierung
* der Profile über den Guest-Agent (Skript per file-write, Ausführung per exec, Fortschritt aus der
* Logdatei im Gast).
*/
export type VmAusrollEingabe = {
client: ProxmoxClient;
anfrage: AusrollAnfrage;
distribution: Distribution;
profile: AufgeloestesProfil[];
/** Gast-VMID reservieren (nach Prüfung, was belegt ist); im Trockenlauf nur ermitteln. */
vmidReservieren: (belegt: Set<number>, wunsch?: number) => Promise<number>;
/** Für ein fehlendes Template (nur mit anfrage.templateBauen). */
templateBau?: Omit<
TemplateBauEingabe,
"client" | "distribution" | "knoten" | "storage" | "bridge" | "vlan" | "ssh"
> & {
/** SSH zum Node – erst bekannt, wenn die Prüfung den Node gewählt hat. */
sshFuer: (knoten: string) => TemplateBauEingabe["ssh"];
};
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
auftragKurzId: string;
warten?: (ms: number) => Promise<void>;
/** Zeitlimits – im Test kürzer. */
agentMs?: number;
provisionierungMs?: number;
};
export type VmAusrollStand = {
knoten?: string;
template?: { vmid: number; knoten: string };
vmid?: number;
ip?: string;
agentNachMs?: number;
};
type Ressource = {
vmid?: number;
node?: string;
template?: number;
tags?: string;
type?: string;
status?: string;
maxmem?: number;
mem?: number;
name?: string;
};
type StorageEintrag = {
storage: string;
content: string;
active?: number;
enabled?: number;
shared?: number;
};
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
/** Proxmox erwartet die Schlüssel URI-kodiert – strenger als encodeURIComponent (auch ! ' ( ) *). */
export function sshkeysKodieren(schluessel: string[]): string {
return encodeURIComponent(schluessel.join("\n")).replace(
/[!'()*]/g,
(z) => `%${z.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
export function ipconfig(netz: AusrollAnfrage["netz"]): string {
if (netz.modus === "dhcp") return "ip=dhcp";
return `ip=${netz.ip}${netz.gateway ? `,gw=${netz.gateway}` : ""}`;
}
const SKRIPT = "/root/proxdeploy-provisionierung.sh";
const LOG = "/var/log/proxdeploy-provisionierung.log";
/**
* file-write nimmt höchstens 60 KiB je Aufruf (API-Schema, Feld content). ProxDeploy kodiert selbst
* als base64 (encode=0): Proxmox' eigene Kodierung scheitert an Nicht-ASCII („Wide character in
* subroutine entry“, gefunden beim ersten echten Deploy). 40 KiB Rohdaten werden ~54 KiB base64.
*/
const STUECK = 40 * 1024;
type AgentAntwort<T> = { result: T };
type ExecStatus = { exited: number | boolean; exitcode?: number; "out-data"?: string; "err-data"?: string };
type Netzschnittstelle = {
name: string;
"ip-addresses"?: Array<{ "ip-address-type": string; "ip-address": string }>;
};
export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {}): PhasenSchritt[] {
const { client, anfrage: a, distribution: d } = e;
const warten = e.warten ?? schlafen;
const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`;
// Die Agent-Endpunkte antworten unterschiedlich: exec → {pid}, exec-status und file-read direkt,
// ping und network-get-interfaces → {result}. Deshalb liefert der Helfer die Rohdaten.
const agent = async <T>(
befehl: string,
methode: "GET" | "POST" = "POST",
parameter?: Record<string, string | readonly string[]>,
) => await client.aufrufen<T>({ methode, pfad: `${vmPfad()}/agent/${befehl}`, parameter });
const templateStand: TemplateBauStand = {};
const schritte: PhasenSchritt[] = [
{
schluessel: "pruefungen",
titel: "Prüfungen",
ausfuehren: async () => {
if (!d.vm)
throw new AblaufFehler(`${d.name} bietet kein VM-Image.`, "Eine andere Distribution wählen.");
const ressourcen = await client.get<Ressource[]>("/cluster/resources");
const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online");
const templates = ressourcen.filter(
(r) =>
r.type === "qemu" &&
r.template === 1 &&
(r.tags ?? "").split(/[;, ]/).includes("proxdeploy-template") &&
(r.tags ?? "").split(/[;, ]/).includes(d.id),
);
// Node: gewünscht, sonst der mit dem Template, sonst der mit dem meisten freien RAM.
stand.knoten =
a.knoten ??
templates.find((t) => nodes.some((n) => n.node === t.node))?.node ??
[...nodes].sort((x, y) => (y.maxmem ?? 0) - (y.mem ?? 0) - ((x.maxmem ?? 0) - (x.mem ?? 0)))[0]
?.node;
if (!stand.knoten || !nodes.some((n) => n.node === stand.knoten)) {
throw new AblaufFehler(
`Node ${stand.knoten ?? "?"} ist nicht online.`,
"Anderen Node wählen oder Cluster-Status prüfen.",
);
}
const tpl = templates.find((t) => t.node === stand.knoten) ?? templates[0];
if (tpl) stand.template = { vmid: tpl.vmid!, knoten: tpl.node! };
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${stand.knoten}/storage`);
const ziel = storages.find((s) => s.storage === a.storage);
if (!ziel || ziel.active !== 1 || !ziel.content.split(",").includes("images")) {
throw new AblaufFehler(
`Storage ${a.storage} nimmt auf ${stand.knoten} keine VM-Disks auf.`,
"Anderen Storage wählen.",
);
}
if (tpl && tpl.node !== stand.knoten && ziel.shared !== 1) {
// Ein Voll-Klon auf einen anderen Node geht nur, wenn das Template auf geteiltem Storage liegt.
throw new AblaufFehler(
`Das Template ${tpl.vmid} liegt auf ${tpl.node}, die VM soll auf ${stand.knoten}.`,
`Node ${tpl.node} wählen oder auf ${stand.knoten} ein eigenes Template bauen.`,
);
}
const netze = await client.get<Array<{ iface: string; type: string; bridge_vlan_aware?: number }>>(
pfad`/nodes/${stand.knoten}/network`,
);
const bridge = netze.find(
(n) => n.iface === a.netz.bridge && (n.type === "bridge" || n.type === "OVSBridge"),
);
if (!bridge)
throw new AblaufFehler(
`Bridge ${a.netz.bridge} gibt es auf ${stand.knoten} nicht.`,
"Andere Bridge wählen.",
);
if (a.netz.vlan && bridge.type === "bridge" && bridge.bridge_vlan_aware !== 1) {
throw new AblaufFehler(
`Bridge ${a.netz.bridge} ist nicht VLAN-fähig – VLAN ${a.netz.vlan} würde nicht ankommen.`,
"In Proxmox „VLAN aware“ an der Bridge einschalten oder ohne VLAN ausrollen.",
);
}
if (!tpl && !a.templateBauen) {
throw new AblaufFehler(
`Auf diesem Server gibt es kein ProxDeploy-Template für ${d.name}.`,
"Unter „Templates“ eins bauen oder beim Ausrollen „Template vorher bauen“ wählen.",
);
}
const belegt = new Set(
ressourcen.map((r) => r.vmid).filter((v): v is number => typeof v === "number"),
);
stand.vmid = await e.vmidReservieren(belegt, a.vmid);
await e.angelegt({ knoten: stand.knoten, typ: "VM" });
return {
detail: `VMID ${stand.vmid} frei · ${a.storage} · ${a.netz.bridge}${a.netz.vlan ? ` VLAN ${a.netz.vlan}` : ""}`,
};
},
},
{
schluessel: "template",
titel: "Template",
ausfuehren: async (k) => {
if (stand.template)
return { uebersprungen: true, detail: `${stand.template.vmid} auf ${stand.template.knoten}` };
if (!e.templateBau) throw new AblaufFehler("Template fehlt.", "Template bauen.");
// Die Phasen des Template-Baus laufen hier als Unterschritte; ihr Fortschritt erscheint als Detail.
const unter = templateBauSchritte(
{
...e.templateBau,
ssh: e.templateBau.sshFuer(stand.knoten!),
client,
distribution: d,
knoten: stand.knoten!,
storage: a.storage,
bridge: a.netz.bridge,
vlan: a.netz.vlan,
},
templateStand,
);
for (const schritt of unter) {
await k.detail(`${schritt.titel} …`);
const ergebnis = await schritt.ausfuehren({
...k,
detail: (text) => k.detail(`${schritt.titel}: ${text}`),
});
await k.log(`Template · ${schritt.titel}: ${ergebnis?.detail ?? "erledigt"}`);
}
stand.template = { vmid: templateStand.vmid!, knoten: stand.knoten! };
return { detail: `${templateStand.vmid} gebaut` };
},
},
{
schluessel: "klonen",
titel: "Klonen",
ausfuehren: async (k) => {
const tpl = stand.template!;
const klonen = async (vmid: number) =>
await aufgabeAbwarten(
client,
await client.post<string>(pfad`/nodes/${tpl.knoten}/qemu/${tpl.vmid}/clone`, {
newid: vmid,
name: a.name,
full: true,
target: stand.knoten!,
storage: a.storage,
}),
{ zeitlimitMs: 30 * 60 * 1000 },
);
try {
await klonen(stand.vmid!);
} catch (fehler) {
// Gleichzeitig außerhalb von ProxDeploy vergeben? Einmal mit neuer ID versuchen.
if (!(fehler instanceof ProxmoxFehler) || !/already exists/i.test(fehler.ursache) || a.vmid)
throw fehler;
const belegt = new Set(
(await client.get<Ressource[]>("/cluster/resources", { type: "vm" })).map((r) => r.vmid!),
);
stand.vmid = await e.vmidReservieren(belegt);
await k.log(`VMID war inzwischen belegt – neuer Versuch mit ${stand.vmid}`, "warn");
await klonen(stand.vmid);
}
await e.angelegt({ vmid: stand.vmid });
return { detail: `full clone ${tpl.vmid} → ${stand.vmid}` };
},
},
{
schluessel: "hardware",
titel: "Hardware",
ausfuehren: async () => {
await client.put(`${vmPfad()}/config`, {
cores: a.cores,
memory: a.speicherMb,
cpu: "host",
net0: `virtio,bridge=${a.netz.bridge}${a.netz.vlan ? `,tag=${a.netz.vlan}` : ""}`,
onboot: a.startBeiBoot,
tags: ["proxdeploy", d.id, ...a.tags].join(";"),
description: `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`,
});
// Vergrößern nur, wenn größer: Proxmox kann Disks nicht verkleinern.
const konfig = client.trocken ? {} : await client.get<Record<string, string>>(`${vmPfad()}/config`);
const groesse = /size=(\d+)G/.exec(konfig["scsi0"] ?? "")?.[1];
if (client.trocken || !groesse || Number(groesse) < a.diskGb) {
await client.put(`${vmPfad()}/resize`, { disk: "scsi0", size: `${a.diskGb}G` });
}
return { detail: `${a.cores} CPU · ${a.speicherMb} MB · Disk ${a.diskGb}G` };
},
},
{
schluessel: "cloudinit",
titel: "cloud-init",
ausfuehren: async () => {
await client.put(`${vmPfad()}/config`, {
ciuser: a.zugang.benutzer,
sshkeys: sshkeysKodieren(a.zugang.sshSchluessel),
ipconfig0: ipconfig(a.netz),
...(a.netz.dns.length > 0 ? { nameserver: a.netz.dns.join(" ") } : {}),
...(a.netz.suchdomain ? { searchdomain: a.netz.suchdomain } : {}),
// Updates macht das Profil base als sichtbare Phase, nicht stumm beim ersten Boot.
ciupgrade: false,
});
const schluessel = a.zugang.sshSchluessel.length;
return {
detail: `user ${a.zugang.benutzer} · ${schluessel} SSH-Key${schluessel === 1 ? "" : "s"} · ${a.netz.modus === "dhcp" ? "DHCP" : a.netz.ip}${
e.profile.length > 0 ? ` · Profile ${e.profile.map((p) => p.profil.id).join(", ")}` : ""
}`,
};
},
},
{
schluessel: "start",
titel: "Start",
ausfuehren: async () => {
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad()}/status/start`), {
zeitlimitMs: 5 * 60 * 1000,
});
return { detail: `VM ${stand.vmid} läuft` };
},
},
{
schluessel: "agent",
titel: "Guest-Agent",
ausfuehren: async (k) => {
if (client.trocken) return { detail: "wartet auf Antwort (ping)" };
const beginn = Date.now();
const grenze = e.agentMs ?? 5 * 60 * 1000;
for (;;) {
await k.pruefeAbbruch();
try {
await agent("ping");
break;
} catch (fehler) {
if (Date.now() - beginn > grenze) {
throw new AblaufFehler(
`Der Guest-Agent antwortet nach ${Math.round(grenze / 1000)} s nicht.`,
"Bootet die VM? Konsole ansehen. Wurde das Template von ProxDeploy gebaut (Agent eingebaut)?",
);
}
if (fehler instanceof ProxmoxFehler && fehler.art === "recht") throw fehler;
}
await k.detail(`wartet … ${Math.round((Date.now() - beginn) / 1000)}s`);
await warten(3000);
}
stand.agentNachMs = Date.now() - beginn;
// IP: bei statischer Adresse bekannt, sonst aus dem Agent (erste IPv4 außer Loopback).
if (a.netz.modus === "statisch") {
stand.ip = a.netz.ip!.split("/")[0];
} else {
for (let versuch = 0; versuch < 20 && !stand.ip; versuch += 1) {
const schnittstellen = (
await agent<AgentAntwort<Netzschnittstelle[]>>("network-get-interfaces", "GET")
).result;
stand.ip = schnittstellen
.filter((s) => s.name !== "lo")
.flatMap((s) => s["ip-addresses"] ?? [])
.find((ip) => ip["ip-address-type"] === "ipv4" && !ip["ip-address"].startsWith("169.254."))?.[
"ip-address"
];
if (!stand.ip) await warten(3000);
}
}
return {
detail: `antwortet nach ${Math.round(stand.agentNachMs / 1000)}s${stand.ip ? ` · IP ${stand.ip}` : ""}`,
};
},
},
{
schluessel: "provisionierung",
titel: "Provisionierung",
ausfuehren: async (k) => {
const adapter = adapterFuer(d.family);
if (!adapter)
throw new AblaufFehler(
`Für die Familie „${d.family}“ gibt es keinen Adapter.`,
"Adapter ergänzen (docs/katalog.md).",
);
const { skript } = profileRendern(
e.profile,
adapter,
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer } },
[
adapter.benutzerAnlegen({
name: a.zugang.benutzer,
sshSchluessel: a.zugang.sshSchluessel,
sudo: true,
}),
],
);
await k.log(
`Skript: ${skript.split("\n").length} Zeilen, ${Math.round(skript.length / 1024)} KiB`,
"debug",
);
if (client.trocken) {
await k.log(skript, "info");
return {
detail:
e.profile.length > 0
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")}`
: "nur Benutzer",
};
}
// Hochladen in Stücken (file-write ersetzt die Datei, hängt nicht an) und zusammensetzen.
const bytes = Buffer.from(skript, "utf8");
const stuecke: Buffer[] = [];
for (let i = 0; i < bytes.length; i += STUECK) stuecke.push(bytes.subarray(i, i + STUECK));
for (const [nr, inhalt] of stuecke.entries()) {
await agent("file-write", "POST", {
file: `${SKRIPT}.${nr}`,
content: inhalt.toString("base64"),
encode: "0",
});
}
const zusammensetzen = stuecke.map((_, nr) => `${SKRIPT}.${nr}`).join(" ");
const { pid } = await agent<{ pid: number }>("exec", "POST", {
command: [
"/bin/bash",
"-c",
`cat ${zusammensetzen} > ${SKRIPT} && rm -f ${zusammensetzen} && chmod 700 ${SKRIPT} && ${SKRIPT} > ${LOG} 2>&1`,
],
});
const beginn = Date.now();
const grenze = e.provisionierungMs ?? 60 * 60 * 1000;
let gelesen = 0;
let profil = "";
for (;;) {
await warten(3000);
const status = await agent<ExecStatus>("exec-status", "GET", { pid: String(pid) });
// Fortschritt: neue Zeilen der Logdatei im Gast.
try {
const datei = await agent<{ content: string; truncated?: boolean }>("file-read", "GET", {
file: LOG,
});
const zeilen = datei.content.split("\n");
for (const zeile of zeilen.slice(gelesen, zeilen.length - 1)) {
const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile);
if (marke) profil = marke[1]!;
await k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug");
}
gelesen = Math.max(gelesen, zeilen.length - 1);
} catch {
// Logdatei noch nicht da – beim nächsten Mal.
}
const sekunden = Math.round((Date.now() - beginn) / 1000);
if (status.exited) {
if (status.exitcode !== 0) {
throw new AblaufFehler(
`Provisionierung ${profil ? `im Profil ${profil} ` : ""}mit Code ${status.exitcode} abgebrochen.`,
`Detail-Log oder ${LOG} in der VM ansehen.`,
);
}
break;
}
if (Date.now() - beginn > grenze) {
throw new AblaufFehler(
`Provisionierung läuft länger als ${Math.round(grenze / 60000)} min.`,
`${LOG} in der VM ansehen.`,
);
}
await k.detail(
`${profil ? `Profil ${profil}` : "Vorbereitung"} läuft … ${Math.floor(sekunden / 60)}m ${String(sekunden % 60).padStart(2, "0")}s`,
);
}
await agent("exec", "POST", { command: ["rm", "-f", SKRIPT] }).catch(() => undefined);
return {
detail:
e.profile.length > 0
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet`
: "Benutzer eingerichtet",
};
},
},
];
return schritte;
}
/** SSH-Befehl für die Ergebnis-Karte. */
export function sshBefehl(benutzer: string, ip: string | undefined): string | undefined {
return ip ? `ssh ${benutzer}@${ip}` : undefined;
}
+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.",
};
}
+172
View File
@@ -0,0 +1,172 @@
import { base64, q } from "@/engine/shell";
import type { Benutzerangaben, FamilienAdapter, PaketQuelle, SshHaertung } from "./schnittstelle";
/**
* Debian und Ubuntu: apt, deb822-Quellen, systemd. Geprüft gegen Debian 12/13 und Ubuntu
* 22.04/24.04/26.04 (Cloud-Images und Proxmox-LXC-Templates).
*/
const APT = "apt_get";
/** Ungültige Eingaben fallen beim Rendern auf, nicht erst mitten im Gast. */
export class AdapterFehler extends Error {
constructor(meldung: string) {
super(meldung);
this.name = "AdapterFehler";
}
}
function pruefen(wert: string, muster: RegExp, was: string): string {
if (!muster.test(wert)) throw new AdapterFehler(`${was} „${wert}“ ist ungültig.`);
return wert;
}
const ZEITZONE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+){0,2}$/;
const LOCALE = /^[a-z]{2,3}_[A-Z]{2}(\.[A-Za-z0-9-]+)?(@[a-z]+)?$/;
const LAYOUT = /^[a-z]{2,8}(,[a-z]{2,8})*$/;
export const debianAdapter: FamilienAdapter = {
familie: "debian",
kopf: () => `set -euo pipefail
export DEBIAN_FRONTEND=noninteractive LC_ALL=C.UTF-8
# apt wartet selbst bis zu 10 min auf die Paketsperre – cloud-init und unattended-upgrades laufen
# beim ersten Start oft noch.
${APT}() { apt-get -y -q -o DPkg::Lock::Timeout=600 -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "$@"; }
if command -v cloud-init >/dev/null 2>&1; then cloud-init status --wait >/dev/null 2>&1 || true; fi
# Frische Images und Container haben oft keine Paketlisten – ohne dieses update findet apt nichts.
${APT} update
`,
aktualisieren: () => `${APT} update
${APT} dist-upgrade
`,
paketeInstallieren: (pakete) => `${APT} install ${pakete.map(q).join(" ")}
`,
dienstAktivieren: (name, { starten, neuStarten }) => {
// In Containern ohne systemd (selten, aber möglich) nicht scheitern.
const zeilen = [`systemctl enable ${q(name)}`];
if (neuStarten) zeilen.push(`systemctl restart ${q(name)}`);
else if (starten) zeilen.push(`systemctl start ${q(name)}`);
return zeilen.join("\n") + "\n";
},
repoHinzufuegen: (quelle: PaketQuelle) => {
const schluessel = `/etc/apt/keyrings/${quelle.name}.asc`;
const quellen = `/etc/apt/sources.list.d/${quelle.name}.sources`;
return `${APT} install ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL ${q(quelle.schluesselUrl)} -o ${q(schluessel)}
chmod a+r ${q(schluessel)}
cat > ${q(quellen)} <<PROXDEPLOY_QUELLE
Types: deb
URIs: ${quelle.uris}
Suites: ${quelle.suites}
Components: ${quelle.komponenten}
Architectures: $(dpkg --print-architecture)
Signed-By: ${schluessel}
PROXDEPLOY_QUELLE
${APT} update
`;
},
repoPaketInstallieren: (url) => `${APT} install ca-certificates curl
pd_deb="$(mktemp --suffix=.deb)"
curl -fsSL ${q(url)} -o "$pd_deb"
${APT} install "$pd_deb"
rm -f "$pd_deb"
${APT} update
`,
dateiSchreiben: (pfad, inhalt, modus) => `install -d -m 0755 "$(dirname ${q(pfad)})"
printf '%s' ${q(base64(inhalt))} | base64 -d > ${q(pfad)}
chmod ${q(modus)} ${q(pfad)}
`,
zeitzoneSetzen: (eingabe) => {
const zeitzone = pruefen(eingabe, ZEITZONE, "Zeitzone");
return `${APT} install tzdata
test -f /usr/share/zoneinfo/${q(zeitzone)} || { echo "Unbekannte Zeitzone: "${q(zeitzone)} >&2; exit 1; }
ln -sf /usr/share/zoneinfo/${q(zeitzone)} /etc/localtime
echo ${q(zeitzone)} > /etc/timezone
`;
},
localeSetzen: (eingabe) => {
const locale = pruefen(eingabe, LOCALE, "Locale");
const zeichensatz = locale.includes(".") ? locale.split(".")[1]! : "UTF-8";
const zeile = `${locale} ${zeichensatz}`;
// Debian erzeugt aus /etc/locale.gen; Ubuntus locale-gen nimmt die Locale zusätzlich als Argument.
return `${APT} install locales
touch /etc/locale.gen
grep -qxF ${q(zeile)} /etc/locale.gen || echo ${q(zeile)} >> /etc/locale.gen
if grep -qi ubuntu /etc/os-release; then locale-gen ${q(locale)}; else locale-gen; fi
update-locale LANG=${q(locale)}
`;
},
tastaturSetzen: (eingabe) => {
const layout = pruefen(eingabe, LAYOUT, "Tastaturbelegung");
return `echo "keyboard-configuration keyboard-configuration/layoutcode string ${layout}" | debconf-set-selections
${APT} install keyboard-configuration console-setup
sed -i 's/^XKBLAYOUT=.*/XKBLAYOUT="${layout}"/' /etc/default/keyboard
setupcon --save-only >/dev/null 2>&1 || true
`;
},
benutzerAnlegen: (b: Benutzerangaben) => {
const name = q(b.name);
const schluessel = b.sshSchluessel.join("\n") + "\n";
const zeilen = [
`id -u ${name} >/dev/null 2>&1 || useradd -m -s /bin/bash ${name}`,
`pd_home="$(getent passwd ${name} | cut -d: -f6)"`,
`install -d -m 0700 -o ${name} -g ${name} "$pd_home/.ssh"`,
// Schlüssel ergänzen statt ersetzen: cloud-init hat sie bei VMs vielleicht schon eingetragen.
`printf '%s' ${q(base64(schluessel))} | base64 -d | while IFS= read -r pd_key; do [ -n "$pd_key" ] && { grep -qxF "$pd_key" "$pd_home/.ssh/authorized_keys" 2>/dev/null || echo "$pd_key" >> "$pd_home/.ssh/authorized_keys"; }; done`,
`touch "$pd_home/.ssh/authorized_keys" && chown ${name}:${name} "$pd_home/.ssh/authorized_keys" && chmod 0600 "$pd_home/.ssh/authorized_keys"`,
];
if (b.sudo) {
const datei = `/etc/sudoers.d/90-proxdeploy-${b.name}`;
zeilen.push(
`${APT} install sudo`,
`usermod -aG sudo ${name}`,
// Ohne Passwort, weil der Benutzer keins hat (nur SSH-Schlüssel). visudo prüft vor dem Aktivieren.
`echo ${q(`${b.name} ALL=(ALL) NOPASSWD:ALL`)} > ${q(datei)}.neu`,
`visudo -cf ${q(datei)}.neu >/dev/null && mv ${q(datei)}.neu ${q(datei)} && chmod 0440 ${q(datei)}`,
);
}
return zeilen.join("\n") + "\n";
},
sshHaerten: (h: SshHaertung) => `install -d /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/10-proxdeploy.conf <<'PROXDEPLOY_SSH'
# Von ProxDeploy angelegt
PasswordAuthentication ${h.passwortAnmeldung ? "yes" : "no"}
KbdInteractiveAuthentication ${h.passwortAnmeldung ? "yes" : "no"}
PermitRootLogin ${h.rootAnmeldung ? "prohibit-password" : "no"}
PROXDEPLOY_SSH
if command -v sshd >/dev/null 2>&1; then
sshd -t
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
fi
`,
gastAgentInstallieren: () => `# Im Container gibt es keinen Guest-Agent.
if ! systemd-detect-virt --container --quiet; then
${APT} install qemu-guest-agent
systemctl enable --now qemu-guest-agent 2>/dev/null || systemctl start qemu-guest-agent || true
fi
`,
automatischeUpdates: (an) => `${APT} install unattended-upgrades
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'PROXDEPLOY_UPGRADES'
APT::Periodic::Update-Package-Lists "${an ? 1 : 0}";
APT::Periodic::Unattended-Upgrade "${an ? 1 : 0}";
PROXDEPLOY_UPGRADES
`,
fakten: () => `. /etc/os-release
printf '{"id":"%s","version_id":"%s","codename":"%s"}\\n' "$ID" "$VERSION_ID" "\${VERSION_CODENAME:-}"
`,
};
+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() ?? "");
}
+174
View File
@@ -0,0 +1,174 @@
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."),
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>;
+101
View File
@@ -0,0 +1,101 @@
import type { Profil, ProfilVariable } from "@/engine/katalog/schema";
/**
* Aus der Auswahl im Assistenten wird eine geordnete Liste: Voraussetzungen zuerst (`requires`,
* transitiv), sonst die Reihenfolge der Auswahl. Dazu die Variablen mit Standardwerten und
* Typprüfung. Fehler werden gesammelt, damit das Formular alle auf einmal zeigen kann.
*/
export type AufgeloestesProfil = { profil: Profil; werte: Record<string, string | number | boolean> };
export type ProfilFehler = { profil: string; variable?: string; meldung: string };
export type Aufloesung =
{ ok: true; profile: AufgeloestesProfil[]; automatisch: string[] } | { ok: false; fehler: ProfilFehler[] };
export function profileAufloesen(
auswahl: string[],
verfuegbar: ReadonlyMap<string, Profil>,
familie: string,
eingaben: Record<string, Record<string, unknown>> = {},
): Aufloesung {
const fehler: ProfilFehler[] = [];
const reihenfolge: string[] = [];
const besucht = new Set<string>();
const inArbeit = new Set<string>();
const besuchen = (id: string, von: string | null) => {
if (besucht.has(id)) return;
if (inArbeit.has(id)) {
fehler.push({ profil: id, meldung: `Zyklische Voraussetzung über „${von}“.` });
return;
}
const profil = verfuegbar.get(id);
if (!profil) {
fehler.push({
profil: id,
meldung: von
? `Wird von „${von}“ vorausgesetzt, ist aber nicht verfügbar.`
: "Unbekanntes oder deaktiviertes Profil.",
});
return;
}
inArbeit.add(id);
for (const voraussetzung of profil.requires) besuchen(voraussetzung, id);
inArbeit.delete(id);
besucht.add(id);
reihenfolge.push(id);
};
for (const id of auswahl) besuchen(id, null);
const profile: AufgeloestesProfil[] = [];
for (const id of reihenfolge) {
const profil = verfuegbar.get(id)!;
if (!profil.families[familie]) {
fehler.push({ profil: id, meldung: `Keine Umsetzung für die Familie „${familie}“.` });
continue;
}
const werte: Record<string, string | number | boolean> = {};
for (const variable of profil.variables) {
const ergebnis = wertPruefen(variable, eingaben[id]?.[variable.name]);
if ("fehler" in ergebnis)
fehler.push({ profil: id, variable: variable.name, meldung: ergebnis.fehler });
else werte[variable.name] = ergebnis.wert;
}
profile.push({ profil, werte });
}
if (fehler.length > 0) return { ok: false, fehler };
return { ok: true, profile, automatisch: reihenfolge.filter((id) => !auswahl.includes(id)) };
}
function wertPruefen(
variable: ProfilVariable,
eingabe: unknown,
): { wert: string | number | boolean } | { fehler: string } {
const roh = eingabe === undefined || eingabe === null || eingabe === "" ? variable.default : eingabe;
if (roh === undefined || roh === "") {
if (variable.required) return { fehler: `${variable.label} fehlt.` };
return { wert: variable.type === "boolean" ? false : "" };
}
switch (variable.type) {
case "boolean":
if (typeof roh === "boolean") return { wert: roh };
if (roh === "true" || roh === "1") return { wert: true };
if (roh === "false" || roh === "0") return { wert: false };
return { fehler: `${variable.label}: ja oder nein erwartet.` };
case "number": {
const zahl = typeof roh === "number" ? roh : Number(roh);
return Number.isFinite(zahl) ? { wert: zahl } : { fehler: `${variable.label}: Zahl erwartet.` };
}
case "select":
return variable.options!.includes(String(roh))
? { wert: String(roh) }
: { fehler: `${variable.label}: erlaubt sind ${variable.options!.join(", ")}.` };
default: {
const text = String(roh);
// Ein Zeilenumbruch in einer Variable ist fast immer ein Kopierfehler – und in Konfigurationsdateien gefährlich.
if (/[\r\n\0]/.test(text)) return { fehler: `${variable.label}: keine Zeilenumbrüche.` };
return { wert: text };
}
}
}
+109
View File
@@ -0,0 +1,109 @@
import type { FamilienAdapter } from "@/engine/adapter/schnittstelle";
import type { ProfilSchritt } from "@/engine/katalog/schema";
import type { AufgeloestesProfil } from "./aufloesen";
import { einsetzen, tiefEinsetzen, type VorlagenKontext } from "./vorlage";
/**
* Baut aus aufgelösten Profilen ein Provisionierungsskript.
*
* Zwischen den Profilen steht eine Markenzeile (`::proxdeploy:: profil <id>`), an der die Engine
* im Live-Log erkennt, welches Profil gerade läuft. SSH-Härtung wird ans Ende verschoben: Läuft die
* Provisionierung selbst über SSH als root (LXC ohne Node-SSH), schlösse sie sich sonst aus.
*/
export const MARKE = "::proxdeploy::";
export type RenderKontext = {
os: { id: string; version_id: string; codename?: string };
gast: { name: string; benutzer: string };
};
export type Abschnitt = { profil: string; skript: string };
export type Gerendert = { skript: string; abschnitte: Abschnitt[] };
function bedingungErfuellt(schritt: ProfilSchritt, werte: Record<string, unknown>): boolean {
if (!schritt.if) return true;
const verneint = schritt.if.startsWith("!");
const wert = Boolean(werte[schritt.if.replace(/^!/, "")]);
return verneint ? !wert : wert;
}
function schrittRendern(schritt: ProfilSchritt, adapter: FamilienAdapter, kontext: VorlagenKontext): string {
if ("update" in schritt) return adapter.aktualisieren();
if ("packages" in schritt)
return adapter.paketeInstallieren(schritt.packages.map((p) => einsetzen(p, kontext)));
if ("service" in schritt) {
return adapter.dienstAktivieren(einsetzen(schritt.service.name, kontext), {
starten: schritt.service.start,
neuStarten: schritt.service.restart,
});
}
if ("repo" in schritt) {
const r = tiefEinsetzen(schritt.repo, kontext);
return adapter.repoHinzufuegen({
name: r.name,
schluesselUrl: r.key_url,
uris: r.uris,
suites: r.suites,
komponenten: r.components,
});
}
if ("repo_package" in schritt)
return adapter.repoPaketInstallieren(einsetzen(schritt.repo_package, kontext));
if ("file" in schritt) {
const f = tiefEinsetzen(schritt.file, kontext);
return adapter.dateiSchreiben(f.path, f.content, f.mode);
}
if ("timezone" in schritt) return adapter.zeitzoneSetzen(einsetzen(schritt.timezone, kontext));
if ("locale" in schritt) return adapter.localeSetzen(einsetzen(schritt.locale, kontext));
if ("keyboard" in schritt) return adapter.tastaturSetzen(einsetzen(schritt.keyboard, kontext));
if ("guest_agent" in schritt) return adapter.gastAgentInstallieren();
if ("unattended_upgrades" in schritt) return adapter.automatischeUpdates(schritt.unattended_upgrades);
if ("run" in schritt) return einsetzen(schritt.run, kontext, "shell") + "\n";
if ("ssh_harden" in schritt) {
return adapter.sshHaerten({
passwortAnmeldung: schritt.ssh_harden.password_auth,
rootAnmeldung: schritt.ssh_harden.root_login,
});
}
const unbekannt: never = schritt;
throw new Error(`Unbekannter Schritt: ${JSON.stringify(unbekannt)}`);
}
export function profileRendern(
profile: AufgeloestesProfil[],
adapter: FamilienAdapter,
kontext: RenderKontext,
vorspann: string[] = [],
): Gerendert {
const abschnitte: Abschnitt[] = [];
const zumSchluss: string[] = [];
for (const { profil, werte } of profile) {
const vorlagenKontext: VorlagenKontext = {
var: werte,
os: { id: kontext.os.id, version_id: kontext.os.version_id, codename: kontext.os.codename ?? "" },
guest: { name: kontext.gast.name, user: kontext.gast.benutzer },
};
const teile: string[] = [];
for (const schritt of profil.families[adapter.familie] ?? []) {
if (!bedingungErfuellt(schritt, werte)) continue;
const text = schrittRendern(schritt, adapter, vorlagenKontext);
if ("ssh_harden" in schritt) zumSchluss.push(text);
else teile.push(text);
}
abschnitte.push({ profil: profil.id, skript: teile.join("") });
}
const skript = [
"#!/bin/bash",
"# Von ProxDeploy erzeugt",
adapter.kopf(),
...vorspann,
...abschnitte.map((a) => `echo '${MARKE} profil ${a.profil}'\n${a.skript}`),
...(zumSchluss.length > 0 ? [`echo '${MARKE} abschluss'\n${zumSchluss.join("")}`] : []),
`echo '${MARKE} fertig'\n`,
].join("\n");
return { skript, abschnitte };
}
+53
View File
@@ -0,0 +1,53 @@
import { q } from "@/engine/shell";
/**
* Platzhalter in Profilen: `{{ var.name }}`, `{{ os.id }}`, `{{ guest.user }}` und die Ausweichform
* `{{ var.a ?? var.b }}` (b, wenn a leer ist). Bewusst keine Logik, keine Filter, keine Schleifen –
* ein Profil soll lesbar bleiben.
*
* Im Modus `shell` (für `run`-Schritte) wird jeder eingesetzte Wert gequotet: eine Variable aus dem
* Formular kann so nie Shell-Code werden. Alle anderen Felder gehen an den Adapter, der selbst quotet.
*/
export type VorlagenKontext = Record<
"var" | "os" | "guest",
Record<string, string | number | boolean | undefined>
>;
export class VorlagenFehler extends Error {
constructor(meldung: string) {
super(meldung);
this.name = "VorlagenFehler";
}
}
const PLATZHALTER = /\{\{\s*([^}]+?)\s*\}\}/g;
const VERWEIS = /^(var|os|guest)\.([a-z][a-z0-9_]*)$/;
function aufloesen(ausdruck: string, kontext: VorlagenKontext): string {
for (const teil of ausdruck.split("??").map((t) => t.trim())) {
const treffer = VERWEIS.exec(teil);
if (!treffer) throw new VorlagenFehler(`Unbekannter Platzhalter „{{ ${ausdruck} }}“.`);
const bereich = kontext[treffer[1] as keyof VorlagenKontext];
if (!(treffer[2]! in bereich)) throw new VorlagenFehler(`„${teil}“ ist nicht definiert.`);
const wert = bereich[treffer[2]!];
if (wert !== undefined && wert !== "") return String(wert);
}
return "";
}
export function einsetzen(text: string, kontext: VorlagenKontext, modus: "roh" | "shell" = "roh"): string {
return text.replace(PLATZHALTER, (_, ausdruck: string) => {
const wert = aufloesen(ausdruck, kontext);
return modus === "shell" ? q(wert) : wert;
});
}
/** Ersetzt in allen Strings eines Objekts (rekursiv) – für Schritte wie `repo` oder `file`. */
export function tiefEinsetzen<T>(wert: T, kontext: VorlagenKontext): T {
if (typeof wert === "string") return einsetzen(wert, kontext) as T;
if (Array.isArray(wert)) return wert.map((eintrag) => tiefEinsetzen(eintrag, kontext)) as T;
if (wert && typeof wert === "object") {
return Object.fromEntries(Object.entries(wert).map(([k, v]) => [k, tiefEinsetzen(v, kontext)])) as T;
}
return wert;
}
+3 -1
View File
@@ -1,4 +1,4 @@
import { pfad, type ProxmoxClient } from "./client";
import { istTrockenUpid, pfad, type ProxmoxClient } from "./client";
import { ProxmoxFehler } from "./fehler";
/**
@@ -10,6 +10,7 @@ type LogZeile = { n: number; t: string };
/** UPID:node:pid:pstart:starttime:type:id:user: – der Node steht an zweiter Stelle. */
export function nodeAusUpid(upid: string): string {
if (istTrockenUpid(upid)) return upid.split(":")[2] ?? "node";
const teile = upid.split(":");
if (teile[0] !== "UPID" || !teile[1]) {
throw new ProxmoxFehler("api", `„${upid}“ ist keine UPID.`, "Antwort von Proxmox prüfen.");
@@ -32,6 +33,7 @@ export async function aufgabeAbwarten(
upid: string,
optionen: AbwartenOptionen = {},
): Promise<void> {
if (istTrockenUpid(upid)) return;
const node = nodeAusUpid(upid);
const intervall = optionen.intervallMs ?? 1000;
const zeitlimit = optionen.zeitlimitMs ?? 30 * 60 * 1000;
+29 -1
View File
@@ -26,6 +26,17 @@ export type AufrufBeobachter = (
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler },
) => void;
/**
* Trockenlauf (Dry-Run): lesende Aufrufe gehen wirklich an Proxmox, schreibende werden nur
* aufgezeichnet und mit einer Platzhalter-UPID beantwortet. Dieselben Phasen laufen so im
* Trockenlauf und im Ernstfall – der angezeigte Plan kann nicht vom tatsächlichen Ablauf abweichen.
*/
export const TROCKEN_UPID_PRAEFIX = "UPID:trocken:";
export function istTrockenUpid(upid: unknown): boolean {
return typeof upid === "string" && upid.startsWith(TROCKEN_UPID_PRAEFIX);
}
const STANDARD_ZEITLIMIT_MS = 30_000;
/** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */
@@ -60,7 +71,11 @@ export class ProxmoxClient {
constructor(
private readonly verbindung: ProxmoxVerbindung,
private readonly optionen: { zeitlimitMs?: number; beobachter?: AufrufBeobachter } = {},
private readonly optionen: {
zeitlimitMs?: number;
beobachter?: AufrufBeobachter;
trocken?: boolean;
} = {},
) {
if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) {
throw new ProxmoxFehler(
@@ -90,7 +105,20 @@ export class ProxmoxClient {
return await this.aufrufen<T>({ methode: "DELETE", pfad, parameter });
}
/** Im Trockenlauf aufgezeichnete schreibende Aufrufe, in Reihenfolge. */
readonly geplant: Aufruf[] = [];
get trocken(): boolean {
return Boolean(this.optionen.trocken);
}
async aufrufen<T>(aufruf: Aufruf): Promise<T> {
if (this.optionen.trocken && aufruf.methode !== "GET") {
this.geplant.push(aufruf);
this.optionen.beobachter?.(aufruf, { dauerMs: 0 });
const knoten = /^\/nodes\/([^/]+)/.exec(aufruf.pfad)?.[1] ?? "node";
return `${TROCKEN_UPID_PRAEFIX}${knoten}:${this.geplant.length}` as T;
}
const beginn = Date.now();
try {
const ergebnis = await this.senden<T>(aufruf);
+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");
}
+4 -1
View File
@@ -1,5 +1,8 @@
import { createHash } from "node:crypto";
import { Client } from "ssh2";
// ssh2 ist CommonJS: unter reinem Node-ESM (worker) gibt es keine benannten Exporte.
import ssh2 from "ssh2";
const { Client } = ssh2;
/**
* SSH zu Proxmox-Nodes oder Gästen. Der Host-Key wird wie das TLS-Zertifikat gepinnt: Ist kein
+7 -1
View File
@@ -1,15 +1,21 @@
/**
* 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> {
if (process.env["NEXT_RUNTIME"] !== "nodejs") return;
const { schluesselPruefen } = await import("@/lib/geheimnisse");
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
const { logger } = await import("@/lib/logger");
const { katalogSynchronisieren } = await import("@/server/services/katalog");
try {
schluesselPruefen();
await ersterAdminFallsLeer();
const sync = await katalogSynchronisieren();
logger.info(
{ distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length },
"Katalog abgeglichen",
);
} catch (fehler) {
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",
};
+4 -4
View File
@@ -22,11 +22,11 @@ export type NavigationsEintrag = {
export const NAVIGATION: readonly NavigationsEintrag[] = [
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen", ab: "#006" },
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" },
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
{ 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: "/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 };
+156
View File
@@ -0,0 +1,156 @@
import { z } from "@/lib/validierung";
/**
* Die Anfrage „Ausrollen“ – eine Form für Web-Assistent, CLI (`proxdeploy vm …`), Batch und
* Vorlagen. Sie liegt in `src/lib`, weil Formular (Client) und API (Server) dieselben Regeln
* brauchen. Felder sind deutsch; die CLI übersetzt ihre Flags darauf (`--memory` → `speicherMb`).
*/
/** Hostname nach RFC 1123, ein Label: so heißt der Gast in Proxmox und im Netz. */
export const GAST_NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
const Ipv4Cidr = z
.string()
.trim()
.regex(/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, "Format: 10.0.20.15/24")
.refine((wert) => {
const [ip, praefix] = wert.split("/");
return (
ip!.split(".").every((teil) => Number(teil) <= 255) && Number(praefix) >= 1 && Number(praefix) <= 32
);
}, "Ungültige Adresse oder Präfixlänge.");
const Ipv4 = z
.string()
.trim()
.regex(/^(\d{1,3}\.){3}\d{1,3}$/, "Format: 10.0.20.1")
.refine((ip) => ip.split(".").every((teil) => Number(teil) <= 255), "Ungültige Adresse.");
export const SSH_SCHLUESSEL =
/^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com) [A-Za-z0-9+/=]+( [^\r\n]*)?$/;
export const Netz = z
.object({
bridge: z
.string()
.trim()
.min(1, "Bridge fehlt.")
.regex(/^[A-Za-z0-9_.-]+$/, "Ungültiger Bridge-Name."),
vlan: z.coerce.number().int().min(1).max(4094).optional(),
modus: z.enum(["dhcp", "statisch"]).default("dhcp"),
ip: Ipv4Cidr.optional(),
gateway: Ipv4.optional(),
dns: z.array(Ipv4).max(3).default([]),
suchdomain: z
.string()
.trim()
.regex(/^[a-z0-9.-]*$/i, "Ungültige Suchdomain.")
.optional(),
})
.refine((n) => n.modus === "dhcp" || Boolean(n.ip), {
message: "Bei statischer Adresse fehlt die IP.",
path: ["ip"],
});
export const Zugang = z.object({
benutzer: z
.string()
.trim()
.regex(/^[a-z_][a-z0-9_-]{0,31}$/, "Benutzername: Kleinbuchstaben, Ziffern, _ und -")
.refine((name) => name !== "root", "root ist nicht erlaubt – ProxDeploy legt einen sudo-Benutzer an."),
sshSchluessel: z
.array(z.string().trim().regex(SSH_SCHLUESSEL, "Kein gültiger öffentlicher SSH-Schlüssel."))
.min(1, "Mindestens ein SSH-Schlüssel – Passwörter vergibt ProxDeploy nicht."),
});
export const AusrollAnfrage = z
.object({
typ: z.enum(["vm", "lxc"]),
server: z.string().trim().min(1, "Server fehlt."),
/** Leer = ProxDeploy wählt: Node mit dem Template, sonst der mit dem meisten freien RAM. */
knoten: z.string().trim().optional(),
distribution: z.string().trim().min(1, "Distribution fehlt."),
name: z
.string()
.trim()
.toLowerCase()
.regex(GAST_NAME, "Name: Kleinbuchstaben, Ziffern und Bindestrich (Hostname)."),
/** Leer = nächste freie ID. */
vmid: z.coerce.number().int().min(100).max(999_999_999).optional(),
tags: z
.array(
z
.string()
.trim()
.regex(/^[a-z0-9_+.-]+$/i, "Tags: Buchstaben, Ziffern, _ + . -"),
)
.max(10)
.default([]),
cores: z.coerce.number().int().min(1).max(128).default(2),
speicherMb: z.coerce.number().int().min(256).max(1_048_576).default(2048),
diskGb: z.coerce.number().int().min(1).max(65_536).default(20),
storage: z.string().trim().min(1, "Storage fehlt."),
/** LXC */
swapMb: z.coerce.number().int().min(0).max(1_048_576).default(512),
unprivilegiert: z.boolean().default(true),
features: z
.object({ nesting: z.boolean().default(false), keyctl: z.boolean().default(false) })
.default({ nesting: false, keyctl: false }),
netz: Netz,
zugang: Zugang,
profile: z.array(z.string()).default(["base"]),
variablen: z
.record(z.string(), z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])))
.default({}),
/** Fehlt das Template, vorher bauen (sonst bricht die Prüfung mit Hinweis ab). */
templateBauen: z.boolean().default(false),
startBeiBoot: z.boolean().default(true),
})
.refine((a) => a.typ === "lxc" || a.diskGb >= 4, {
message: "Mindestens 4 GB für eine VM.",
path: ["diskGb"],
});
export type AusrollAnfrage = z.infer<typeof AusrollAnfrage>;
export type AusrollEingabe = z.input<typeof AusrollAnfrage>;
/**
* Der passende CLI-Befehl zu einer Anfrage – im Assistenten zum Kopieren angezeigt. Nur Flags, die
* vom Standard abweichen, damit der Befehl lesbar bleibt.
*/
export function cliBefehl(a: AusrollAnfrage): string {
const teile = [`proxdeploy ${a.typ} ${a.name}`, `--server ${a.server}`];
if (a.knoten) teile.push(`--node ${a.knoten}`);
teile.push(`--distro ${a.distribution}`);
if (a.profile.length > 0 && !(a.profile.length === 1 && a.profile[0] === "base"))
teile.push(`--profile ${a.profile.join(",")}`);
if (a.netz.modus === "statisch" && a.netz.ip) {
teile.push(`--ip ${a.netz.ip}`);
if (a.netz.gateway) teile.push(`--gw ${a.netz.gateway}`);
}
if (a.netz.dns.length > 0) teile.push(`--dns ${a.netz.dns.join(",")}`);
if (a.netz.suchdomain) teile.push(`--search-domain ${a.netz.suchdomain}`);
if (a.netz.bridge !== "vmbr0") teile.push(`--bridge ${a.netz.bridge}`);
if (a.netz.vlan) teile.push(`--vlan ${a.netz.vlan}`);
if (a.vmid) teile.push(`--vmid ${a.vmid}`);
if (a.cores !== 2) teile.push(`--cores ${a.cores}`);
if (a.speicherMb !== 2048) teile.push(`--memory ${a.speicherMb}`);
if (a.diskGb !== 20) teile.push(`--disk ${a.diskGb}G`);
teile.push(`--storage ${a.storage}`);
teile.push(`--user ${a.zugang.benutzer}`);
if (a.tags.length > 0) teile.push(`--tags ${a.tags.join(",")}`);
for (const [profil, werte] of Object.entries(a.variablen)) {
for (const [name, wert] of Object.entries(werte)) {
const text = String(wert);
teile.push(
`--var ${profil}.${name}=${/^[\w.,:/@-]+$/.test(text) ? text : `'${text.replace(/'/g, `'"'"'`)}'`}`,
);
}
}
if (a.typ === "lxc") {
if (a.swapMb !== 512) teile.push(`--swap ${a.swapMb}`);
if (!a.unprivilegiert) teile.push("--privileged");
}
if (a.templateBauen) teile.push("--build-template");
return teile.join(" ");
}
+12
View File
@@ -0,0 +1,12 @@
import { z } from "@/lib/validierung";
/** Anfrage „Template bauen“ – aus Web-GUI, CLI (`proxdeploy template build`) und dem Assistenten. */
export const TemplateBauAnfrage = z.object({
distribution: z.string().min(1, "Distribution fehlt."),
knoten: z.string().min(1, "Node fehlt."),
storage: z.string().min(1, "Storage fehlt."),
bridge: z.string().min(1).default("vmbr0"),
vlan: z.coerce.number().int().min(1).max(4094).optional(),
});
export type TemplateBauAnfrage = z.infer<typeof TemplateBauAnfrage>;
+1 -1
View File
@@ -2,7 +2,7 @@
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
*/
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.1.0";
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.2.0";
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
export function versionText(): string {
+1
View File
@@ -19,6 +19,7 @@ export function fehlerAntwort(fehler: unknown): NextResponse<FehlerAntwort> {
code: fehler.code,
nachricht: fehler.message,
...(fehler.hinweis ? { hinweis: fehler.hinweis } : {}),
...(fehler.felder ? { felder: fehler.felder } : {}),
},
},
{ status: fehler.status },
+49
View File
@@ -0,0 +1,49 @@
// pg ist CommonJS – Default-Import, damit es auch im worker (reines Node-ESM) lädt.
import pg from "pg";
import { logger } from "@/lib/logger";
import { KANAL } from "./senke";
/**
* Eine LISTEN-Verbindung je Prozess, an der beliebig viele SSE-Ströme hängen. Bricht sie ab, wird
* sie neu aufgebaut; die Ströme holen sich beim nächsten Signal ohnehin den vollen Stand.
*/
type Hoerer = (auftragId: string) => void;
const hoerer = new Set<Hoerer>();
let verbindung: Promise<pg.Client> | undefined;
async function verbinden(): Promise<pg.Client> {
const client = new pg.Client({
connectionString: process.env["DATABASE_URL"],
application_name: "proxdeploy-listen",
});
client.on("notification", (meldung) => {
if (meldung.channel !== KANAL || !meldung.payload) return;
for (const h of hoerer) h(meldung.payload);
});
client.on("error", (fehler) => {
logger.warn({ err: fehler }, "LISTEN-Verbindung verloren, baue neu auf");
verbindung = undefined;
setTimeout(() => void sicherstellen(), 1000);
});
await client.connect();
await client.query(`LISTEN ${KANAL}`);
return client;
}
function sicherstellen(): Promise<pg.Client> {
verbindung ??= verbinden().catch((fehler) => {
verbindung = undefined;
throw fehler;
});
return verbindung;
}
export async function abonnieren(auftragId: string, rueckruf: () => void): Promise<() => void> {
await sicherstellen();
const h: Hoerer = (id) => {
if (id === auftragId) rueckruf();
};
hoerer.add(h);
return () => hoerer.delete(h);
}
+83
View File
@@ -0,0 +1,83 @@
import type { EreignisSenke, LogStufe } from "@/engine/ablauf/ablauf";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
/**
* Schreibt die Ereignisse eines Auftrags in die Datenbank und meldet sie per NOTIFY. Die app hört
* mit (LISTEN) und schiebt sie an alle offenen SSE-Verbindungen – Web und CLI sehen dasselbe.
*/
export const KANAL = "proxdeploy_auftrag";
export async function melden(auftragId: string): Promise<void> {
await prisma.$executeRaw`SELECT pg_notify(${KANAL}, ${auftragId})`;
}
export class DatenbankSenke implements EreignisSenke {
constructor(private readonly auftragId: string) {}
async phasenAnlegen(phasen: Array<{ schluessel: string; titel: string }>): Promise<void> {
await prisma.$transaction([
prisma.phase.deleteMany({ where: { auftragId: this.auftragId } }),
prisma.phase.createMany({
data: phasen.map((p, reihenfolge) => ({ auftragId: this.auftragId, reihenfolge, ...p })),
}),
]);
await melden(this.auftragId);
}
async phaseGestartet(schluessel: string, detail?: string): Promise<void> {
await prisma.phase.update({
where: { auftragId_schluessel: { auftragId: this.auftragId, schluessel } },
data: { status: "LAEUFT", gestartetAm: new Date(), ...(detail ? { detail } : {}) },
});
await melden(this.auftragId);
}
async phaseDetail(schluessel: string, detail: string): Promise<void> {
await prisma.phase.update({
where: { auftragId_schluessel: { auftragId: this.auftragId, schluessel } },
data: { detail },
});
await melden(this.auftragId);
}
async phaseBeendet(
schluessel: string,
status: "ERLEDIGT" | "FEHLER" | "UEBERSPRUNGEN",
angaben: { detail?: string; fehler?: { ursache: string; hinweis: string } },
): Promise<void> {
await prisma.phase.update({
where: { auftragId_schluessel: { auftragId: this.auftragId, schluessel } },
data: {
status,
beendetAm: new Date(),
...(angaben.detail !== undefined ? { detail: angaben.detail } : {}),
...(angaben.fehler ? { ursache: angaben.fehler.ursache, hinweis: angaben.fehler.hinweis } : {}),
},
});
await melden(this.auftragId);
}
async log(
stufe: LogStufe,
nachricht: string,
angaben: { phase?: string; daten?: unknown } = {},
): Promise<void> {
await prisma.logeintrag.create({
data: {
auftragId: this.auftragId,
stufe,
nachricht,
phase: angaben.phase ?? null,
daten: (angaben.daten ?? undefined) as Prisma.InputJsonValue | undefined,
},
});
// Ins Betriebslog (JSON) geht jede Zeile mit Auftrags-ID – auch ohne GUI nachvollziehbar.
logger[stufe === "debug" ? "debug" : stufe](
{ auftragId: this.auftragId, phase: angaben.phase },
nachricht,
);
await melden(this.auftragId);
}
}
+2
View File
@@ -10,6 +10,8 @@ export class AnwendungsFehler extends Error {
readonly code: string,
nachricht: string,
readonly hinweis?: string,
/** Feldfehler für Formulare, Schlüssel wie der Feldpfad (z. B. „variablen.zabbix-agent.server“). */
readonly felder?: Record<string, string>,
) {
super(nachricht);
this.name = "AnwendungsFehler";
+191
View File
@@ -0,0 +1,191 @@
import { randomInt } from "node:crypto";
import type { Auftrag, Prisma } from "@/generated/prisma/client";
import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import { melden } from "@/server/ereignisse/senke";
import { protokollieren } from "@/server/services/protokoll";
import { auftragEinstellen } from "@/server/warteschlange";
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
export type { AuftragSicht, LogZeile };
/** Kurz-IDs ohne Verwechsler (kein 0/o, 1/l/i): „d-7f3k2“ für Ausrollen, „t-…“ für Templates. */
const ZEICHEN = "23456789abcdefghjkmnpqrstuvwxyz";
const PRAEFIX: Record<AuftragsArt, string> = { AUSROLLEN: "d", TEMPLATE_BAUEN: "t", AUFRAEUMEN: "c" };
export function kurzIdErzeugen(art: AuftragsArt): string {
let id = "";
for (let i = 0; i < 5; i += 1) id += ZEICHEN[randomInt(ZEICHEN.length)];
return `${PRAEFIX[art]}-${id}`;
}
export type NeuerAuftrag = {
art: AuftragsArt;
serverId: string;
knoten?: string;
gastTyp?: GastTyp;
name?: string;
distributionId?: string;
profile?: string[];
anfrage: Prisma.InputJsonValue;
plan?: Prisma.InputJsonValue;
bezugId?: string;
wiederholtVonId?: string;
};
export async function auftragAnlegen(
neu: NeuerAuftrag,
aufrufer: Aufrufer,
ip: string | null,
einstellen: (id: string) => Promise<void> = auftragEinstellen,
): Promise<Auftrag> {
let auftrag: Auftrag | undefined;
for (let versuch = 0; versuch < 5 && !auftrag; versuch += 1) {
try {
auftrag = await prisma.$transaction(async (tx) => {
const angelegt = await tx.auftrag.create({
data: {
...neu,
profile: neu.profile ?? [],
kurzId: kurzIdErzeugen(neu.art),
quelle: aufrufer.quelle as Quelle,
erstelltVonId: aufrufer.benutzer.id,
},
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion:
neu.art === "AUSROLLEN"
? "Ausrollen gestartet"
: neu.art === "TEMPLATE_BAUEN"
? "Template-Bau gestartet"
: "Aufräumen gestartet",
ziel: `auftrag:${angelegt.kurzId}`,
details: { name: neu.name, distribution: neu.distributionId },
ip,
},
tx,
);
return angelegt;
});
} catch (fehler) {
// Kurz-ID zufällig doppelt – selten, dann einfach neu würfeln.
if ((fehler as { code?: string }).code !== "P2002") throw fehler;
}
}
if (!auftrag) throw new AnwendungsFehler(500, "intern", "Konnte keine freie Auftrags-ID erzeugen.");
await einstellen(auftrag.id);
return auftrag;
}
const MIT = {
server: { select: { name: true, titel: true } },
erstelltVon: { select: { benutzername: true } },
phasen: { orderBy: { reihenfolge: "asc" as const } },
};
type MitBeziehungen = Prisma.AuftragGetPayload<{ include: typeof MIT }>;
export function auftragSicht(a: MitBeziehungen): AuftragSicht {
const iso = (d: Date | null) => d?.toISOString() ?? null;
return {
id: a.id,
kurzId: a.kurzId,
art: a.art,
status: a.status,
kopf:
a.kopf ?? [`Server ${a.server.name}`, a.knoten ? `Node ${a.knoten}` : null].filter(Boolean).join(" · "),
server: a.server,
knoten: a.knoten,
gastTyp: a.gastTyp,
vmid: a.vmid,
name: a.name,
distributionId: a.distributionId,
profile: a.profile,
ergebnis: a.ergebnis,
fehler: a.fehler,
erstelltVon: a.erstelltVon.benutzername,
erstelltAm: a.erstelltAm.toISOString(),
gestartetAm: iso(a.gestartetAm),
beendetAm: iso(a.beendetAm),
phasen: a.phasen.map((p) => ({
schluessel: p.schluessel,
titel: p.titel,
status: p.status,
detail: p.detail,
ursache: p.ursache,
hinweis: p.hinweis,
gestartetAm: iso(p.gestartetAm),
beendetAm: iso(p.beendetAm),
})),
};
}
/** Sucht per Kurz-ID oder interner ID – die CLI nimmt, was der Benutzer tippt. */
export async function auftragHolen(kennung: string): Promise<AuftragSicht> {
const auftrag = await prisma.auftrag.findFirst({
where: { OR: [{ kurzId: kennung }, { id: kennung }] },
include: MIT,
});
if (!auftrag) throw nichtGefunden(`Den Auftrag „${kennung}“`);
return auftragSicht(auftrag);
}
export async function auftraegeListe(
filter: { serverId?: string; grenze?: number } = {},
): Promise<AuftragSicht[]> {
const zeilen = await prisma.auftrag.findMany({
where: filter.serverId ? { serverId: filter.serverId } : {},
include: MIT,
orderBy: { erstelltAm: "desc" },
take: filter.grenze ?? 50,
});
return zeilen.map(auftragSicht);
}
export async function logAb(auftragId: string, abId: bigint, grenze = 500): Promise<LogZeile[]> {
const zeilen = await prisma.logeintrag.findMany({
where: { auftragId, id: { gt: abId } },
orderBy: { id: "asc" },
take: grenze,
});
return zeilen.map((z) => ({
id: z.id.toString(),
zeit: z.zeit.toISOString(),
stufe: z.stufe,
phase: z.phase,
nachricht: z.nachricht,
}));
}
export const ENDZUSTAENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
/** Bittet den worker um Abbruch; er prüft das zwischen den Phasen. */
export async function auftragAbbrechen(
kennung: string,
aufrufer: Aufrufer,
ip: string | null,
): Promise<void> {
const auftrag = await auftragHolen(kennung);
if (ENDZUSTAENDE.has(auftrag.status)) {
throw new AnwendungsFehler(409, "beendet", "Der Auftrag ist bereits beendet.");
}
await prisma.$transaction(async (tx) => {
await tx.auftrag.update({ where: { id: auftrag.id }, data: { abbrechen: true } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Abbruch angefordert",
ziel: `auftrag:${auftrag.kurzId}`,
ip,
},
tx,
);
});
await melden(auftrag.id);
}
+189
View File
@@ -0,0 +1,189 @@
import type { Server } from "@/generated/prisma/client";
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
import { vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen";
import type { Distribution, Profil } from "@/engine/katalog/schema";
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
import { prisma } from "@/lib/db";
import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen";
import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler } from "@/server/fehler";
import { auftragAnlegen } from "@/server/services/auftraege";
import { serverHolen, verbindungAus } from "@/server/services/server";
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
import { vmidVorschlagen } from "@/server/services/vmid";
/**
* Ausrollen vorbereiten: Anfrage prüfen, Distribution und Profile auflösen. Fehler kommen als
* Feldfehler zurück, damit der Assistent sie am richtigen Feld zeigt.
*/
export type Vorbereitet = {
anfrage: AusrollAnfrage;
server: Server;
distribution: Distribution;
profile: AufgeloestesProfil[];
automatisch: string[];
};
export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> {
const anfrage = AusrollAnfrage.parse(roh);
const server = await serverHolen(anfrage.server);
const zeile = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } });
if (!zeile || zeile.entferntAm || !zeile.aktiv) {
throw new AnwendungsFehler(
400,
"ungueltig",
`Die Distribution „${anfrage.distribution}“ ist nicht verfügbar.`,
);
}
const distribution = zeile.definition as Distribution;
if (!distribution[anfrage.typ]) {
throw new AnwendungsFehler(
400,
"ungueltig",
`${distribution.name} gibt es nicht als ${anfrage.typ === "vm" ? "VM" : "LXC-Container"}.`,
);
}
if (anfrage.typ === "lxc") {
throw new AnwendungsFehler(
400,
"nicht_umgesetzt",
"LXC-Container folgen mit #009.",
"Vorerst als VM ausrollen.",
);
}
const verfuegbar = new Map(
(await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [
p.id,
p.definition as Profil,
]),
);
const aufloesung = profileAufloesen(anfrage.profile, verfuegbar, distribution.family, anfrage.variablen);
if (!aufloesung.ok) {
const felder = Object.fromEntries(
aufloesung.fehler.map((f) => [`variablen.${f.profil}${f.variable ? `.${f.variable}` : ""}`, f.meldung]),
);
throw new AnwendungsFehler(
400,
"ungueltig",
aufloesung.fehler.map((f) => f.meldung).join(" "),
undefined,
felder,
);
}
return { anfrage, server, distribution, profile: aufloesung.profile, automatisch: aufloesung.automatisch };
}
export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: string | null) {
const v = await ausrollenVorbereiten(roh);
return await auftragAnlegen(
{
art: "AUSROLLEN",
serverId: v.server.id,
knoten: v.anfrage.knoten,
gastTyp: v.anfrage.typ === "vm" ? "VM" : "LXC",
name: v.anfrage.name,
distributionId: v.distribution.id,
profile: v.profile.map((p) => p.profil.id),
anfrage: v.anfrage,
},
aufrufer,
ip,
);
}
export type Plan = {
kopf: string;
phasen: Array<{
schluessel: string;
titel: string;
status: string;
detail: string | null;
ursache: string | null;
hinweis: string | null;
}>;
aufrufe: Aufruf[];
skript: string | null;
cliBefehl: string;
automatisch: string[];
ok: boolean;
};
/**
* Dry-Run: dieselben Phasen wie im Ernstfall, mit einem Proxmox-Client im Trockenmodus. Lesendes
* geht wirklich an Proxmox (VMID frei? Template da? Bridge VLAN-fähig?), Schreibendes wird nur
* aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde.
*/
export async function ausrollenPlanen(roh: unknown): Promise<Plan> {
const v = await ausrollenVorbereiten(roh);
const client = new ProxmoxClient(verbindungAus(v.server), { trocken: true, zeitlimitMs: 15_000 });
const phasen: Plan["phasen"] = [];
let skript: string | null = null;
const finde = (s: string) => phasen.find((p) => p.schluessel === s)!;
const senke: EreignisSenke = {
phasenAnlegen: async (liste) => {
for (const p of liste)
phasen.push({ ...p, status: "OFFEN", detail: null, ursache: null, hinweis: null });
},
phaseGestartet: async (s) => void (finde(s).status = "LAEUFT"),
phaseDetail: async (s, d) => void (finde(s).detail = d),
phaseBeendet: async (s, status, a) => {
Object.assign(finde(s), {
status,
...(a.detail !== undefined ? { detail: a.detail } : {}),
...(a.fehler ?? {}),
});
},
log: async (_stufe, nachricht, angaben) => {
if (angaben?.phase === "provisionierung" && nachricht.startsWith("#!/bin/bash")) skript = nachricht;
},
};
const stand: VmAusrollStand = {};
try {
const ergebnis = await phasenAusfuehren(
vmAusrollSchritte(
{
client,
anfrage: v.anfrage,
distribution: v.distribution,
profile: v.profile,
vmidReservieren: (belegt, wunsch) =>
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.gastVmidVon, wunsch }),
templateBau: v.anfrage.templateBauen
? {
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten),
auftragKurzId: "dry-run",
vmidReservieren: (belegt) =>
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }),
}
: undefined,
angelegt: async () => {},
auftragKurzId: "dry-run",
},
stand,
),
senke,
);
return {
kopf: kopfzeile(v, stand),
phasen,
aufrufe: client.geplant,
skript,
cliBefehl: cliBefehl(v.anfrage),
automatisch: v.automatisch,
ok: ergebnis.ok,
};
} finally {
await client.schliessen();
}
}
export function kopfzeile(v: Pick<Vorbereitet, "server" | "distribution">, stand: VmAusrollStand): string {
return [
`Server ${v.server.name}`,
stand.knoten ? `Node ${stand.knoten}` : null,
`${v.distribution.name}${stand.template ? ` (Template ${stand.template.vmid})` : ""}`,
]
.filter(Boolean)
.join(" · ");
}
+211
View File
@@ -0,0 +1,211 @@
import { join } from "node:path";
import type { Prisma } from "@/generated/prisma/client";
import { katalogLaden, type KatalogFehler } from "@/engine/katalog/laden";
import type { Distribution, Profil } from "@/engine/katalog/schema";
import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
import type { Aufrufer } from "@/server/auth/zugang";
import { nichtGefunden } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll";
/** Wo der Katalog liegt: im Repo und im Image unter ./catalog, überschreibbar für Tests. */
export function katalogWurzel(): string {
return process.env["PROXDEPLOY_KATALOG"] ?? join(process.cwd(), "catalog");
}
export type SyncErgebnis = {
distributionen: { neu: number; geaendert: number; entfernt: number };
profile: { neu: number; geaendert: number; entfernt: number };
fehler: KatalogFehler[];
};
const json = (wert: unknown) => wert as Prisma.InputJsonValue;
/**
* Gleicht catalog/ mit der Datenbank ab. Die Datei ist maßgeblich für den Inhalt, die Datenbank
* für `aktiv`. Verschwundene Einträge werden markiert statt gelöscht – Aufträge verweisen darauf.
* Idempotent: app und worker dürfen es beide beim Start aufrufen.
*/
export async function katalogSynchronisieren(wurzel = katalogWurzel()): Promise<SyncErgebnis> {
const katalog = await katalogLaden(wurzel);
const jetzt = new Date();
const ergebnis: SyncErgebnis = {
distributionen: { neu: 0, geaendert: 0, entfernt: 0 },
profile: { neu: 0, geaendert: 0, entfernt: 0 },
fehler: [...katalog.fehler],
};
await prisma.$transaction(async (tx) => {
// Gleichzeitiger Start von app und worker: nur einer gleicht ab, der andere wartet.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('proxdeploy:katalog'))`;
const bisherD = new Map((await tx.distribution.findMany()).map((d) => [d.id, d]));
for (const { eintrag, quelltext, hash } of katalog.distributionen) {
const alt = bisherD.get(eintrag.id);
const daten = {
name: eintrag.name,
familie: eintrag.family,
definition: json(eintrag),
quelltext,
dateiHash: hash,
entferntAm: null,
synchronisiertAm: jetzt,
};
if (!alt) {
await tx.distribution.create({ data: { id: eintrag.id, ...daten } });
ergebnis.distributionen.neu += 1;
} else if (alt.dateiHash !== hash || alt.entferntAm) {
await tx.distribution.update({ where: { id: eintrag.id }, data: daten });
ergebnis.distributionen.geaendert += 1;
}
bisherD.delete(eintrag.id);
}
for (const verschwunden of bisherD.values()) {
if (verschwunden.entferntAm) continue;
await tx.distribution.update({ where: { id: verschwunden.id }, data: { entferntAm: jetzt } });
ergebnis.distributionen.entfernt += 1;
}
const bisherP = new Map((await tx.profil.findMany()).map((p) => [p.id, p]));
for (const { eintrag, quelltext, hash, datei } of katalog.profile) {
const alt = bisherP.get(eintrag.id);
if (alt?.herkunft === "EIGEN") {
ergebnis.fehler.push({
datei,
meldung: `Ein eigenes Profil „${eintrag.id}“ gibt es schon – das Katalogprofil wird nicht übernommen.`,
});
bisherP.delete(eintrag.id);
continue;
}
const daten = {
name: eintrag.name,
beschreibung: eintrag.description ?? null,
familien: Object.keys(eintrag.families),
herkunft: "KATALOG" as const,
definition: json(eintrag),
quelltext,
dateiHash: hash,
entferntAm: null,
synchronisiertAm: jetzt,
};
if (!alt) {
await tx.profil.create({ data: { id: eintrag.id, ...daten } });
ergebnis.profile.neu += 1;
} else if (alt.dateiHash !== hash || alt.entferntAm) {
await tx.profil.update({ where: { id: eintrag.id }, data: daten });
ergebnis.profile.geaendert += 1;
}
bisherP.delete(eintrag.id);
}
for (const verschwunden of bisherP.values()) {
if (verschwunden.herkunft !== "KATALOG" || verschwunden.entferntAm) continue;
await tx.profil.update({ where: { id: verschwunden.id }, data: { entferntAm: jetzt } });
ergebnis.profile.entfernt += 1;
}
});
for (const f of ergebnis.fehler) logger.warn({ datei: f.datei }, `Katalog: ${f.meldung}`);
return ergebnis;
}
// ── Lesen ───────────────────────────────────────────────────────────────────────────────────────
export type DistributionSicht = {
id: string;
name: string;
familie: string;
aktiv: boolean;
vm: boolean;
lxc: boolean;
definition: Distribution;
};
export async function distributionenListe(filter: { typ?: "vm" | "lxc"; nurAktive?: boolean } = {}) {
const zeilen = await prisma.distribution.findMany({
where: { entferntAm: null, ...(filter.nurAktive ? { aktiv: true } : {}) },
orderBy: { id: "asc" },
});
return zeilen
.map((d): DistributionSicht => {
const definition = d.definition as Distribution;
return {
id: d.id,
name: d.name,
familie: d.familie,
aktiv: d.aktiv,
vm: Boolean(definition.vm),
lxc: Boolean(definition.lxc),
definition,
};
})
.filter((d) => !filter.typ || d[filter.typ]);
}
export type ProfilSicht = {
id: string;
name: string;
beschreibung: string | null;
familien: string[];
herkunft: "KATALOG" | "EIGEN";
aktiv: boolean;
definition: Profil;
quelltext: string;
};
export async function profileListe(filter: { nurAktive?: boolean } = {}): Promise<ProfilSicht[]> {
const zeilen = await prisma.profil.findMany({
where: { entferntAm: null, ...(filter.nurAktive ? { aktiv: true } : {}) },
orderBy: { id: "asc" },
});
return zeilen.map((p) => ({
id: p.id,
name: p.name,
beschreibung: p.beschreibung,
familien: p.familien,
herkunft: p.herkunft,
aktiv: p.aktiv,
definition: p.definition as Profil,
quelltext: p.quelltext,
}));
}
export async function distributionAktivSetzen(
id: string,
aktiv: boolean,
aufrufer: Aufrufer,
ip: string | null,
): Promise<void> {
const vorhanden = await prisma.distribution.findUnique({ where: { id } });
if (!vorhanden || vorhanden.entferntAm) throw nichtGefunden(`Die Distribution „${id}“`);
await prisma.$transaction(async (tx) => {
await tx.distribution.update({ where: { id }, data: { aktiv, geaendertVonId: aufrufer.benutzer.id } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: aktiv ? "Distribution aktiviert" : "Distribution deaktiviert",
ziel: `distribution:${id}`,
ip,
},
tx,
);
});
}
export async function profilAktivSetzen(id: string, aktiv: boolean, aufrufer: Aufrufer, ip: string | null) {
const vorhanden = await prisma.profil.findUnique({ where: { id } });
if (!vorhanden || vorhanden.entferntAm) throw nichtGefunden(`Das Profil „${id}“`);
await prisma.$transaction(async (tx) => {
await tx.profil.update({ where: { id }, data: { aktiv, geaendertVonId: aufrufer.benutzer.id } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: aktiv ? "Profil aktiviert" : "Profil deaktiviert",
ziel: `profil:${id}`,
ip,
},
tx,
);
});
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { utils as sshUtils } from "ssh2";
import ssh2 from "ssh2";
import type { Server } from "@/generated/prisma/client";
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
@@ -170,7 +170,7 @@ export async function sshSchluesselErzeugen(
ip: string | null,
): Promise<ServerSicht> {
const server = await serverHolen(name);
const paar = sshUtils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
const neu = await prisma.$transaction(async (tx) => {
const aktualisiert = await tx.server.update({
where: { id: server.id },
+34
View File
@@ -0,0 +1,34 @@
import type { Server } from "@/generated/prisma/client";
import type { NodeBefehl } from "@/engine/ablaeufe/template-bau";
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { sshAusfuehren } from "@/engine/ssh/verbindung";
import { entschluesseln } from "@/lib/geheimnisse";
/**
* SSH zu einem Node – nur, wenn alles bestätigt ist: SSH eingeschaltet, Schlüssel vorhanden,
* Host-Key des Nodes gepinnt. Sonst null, und die Engine weicht aus oder sagt, was fehlt.
*/
export function nodeBefehlFuer(server: Server, knoten: string): NodeBefehl | null {
if (!server.sshAktiv || !server.sshPrivat) return null;
const pin = (server.sshHostSchluessel as Record<string, string> | null)?.[knoten];
if (!pin) return null;
const bericht = server.letzterTest as VerbindungsBericht | null;
const eintrag = bericht?.knoten.find((k) => k.name === knoten);
const host =
eintrag?.ssh?.host ??
eintrag?.ip ??
(bericht?.knoten.length === 1 ? new URL(server.url).hostname : knoten);
const privat = entschluesseln(server.sshPrivat);
return async (befehl, optionen = {}) =>
await sshAusfuehren(
{
host,
port: server.sshPort,
benutzer: server.sshBenutzer,
privatSchluessel: privat,
hostSchluessel: pin,
},
befehl,
{ eingabe: optionen.eingabe, zeitlimitMs: optionen.zeitlimitMs },
);
}
+90
View File
@@ -0,0 +1,90 @@
import type { Distribution } from "@/engine/katalog/schema";
import { ProxmoxClient } from "@/engine/proxmox/client";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
import { prisma } from "@/lib/db";
import { TemplateBauAnfrage } from "@/lib/schemas/template";
import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler } from "@/server/fehler";
import { auftragAnlegen } from "@/server/services/auftraege";
import { serverHolen, verbindungAus } from "@/server/services/server";
export async function templateBauStarten(
serverName: string,
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
) {
const server = await serverHolen(serverName);
const anfrage = TemplateBauAnfrage.parse(roh);
const distribution = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } });
if (!distribution || distribution.entferntAm || !distribution.aktiv) {
throw new AnwendungsFehler(
400,
"ungueltig",
`Die Distribution „${anfrage.distribution}“ ist nicht verfügbar.`,
);
}
if (!(distribution.definition as Distribution).vm) {
throw new AnwendungsFehler(400, "ungueltig", `${distribution.name} bietet kein VM-Image.`);
}
return await auftragAnlegen(
{
art: "TEMPLATE_BAUEN",
serverId: server.id,
knoten: anfrage.knoten,
gastTyp: "VM",
distributionId: distribution.id,
name: `Template ${distribution.name}`,
anfrage,
},
aufrufer,
ip,
);
}
export type TemplateSicht = {
vmid: number;
name: string;
knoten: string;
distributionId: string | null;
gebautAm: string | null;
imageDatei: string | null;
};
/**
* Templates eines Servers – Proxmox ist maßgeblich (Tag `proxdeploy-template`), die Datenbank
* ergänzt, woraus gebaut wurde. Ein in Proxmox gelöschtes Template verschwindet so auch hier.
*/
export async function templatesListe(serverName: string): Promise<TemplateSicht[]> {
const server = await serverHolen(serverName);
const client = new ProxmoxClient(verbindungAus(server), { zeitlimitMs: 10_000 });
try {
const ressourcen = await client.get<
Array<{ vmid: number; name?: string; node: string; template?: number; tags?: string }>
>("/cluster/resources", { type: "vm" });
const bekannt = new Map(
(await prisma.template.findMany({ where: { serverId: server.id } })).map((t) => [t.vmid, t]),
);
return ressourcen
.filter((r) => r.template === 1 && (r.tags ?? "").split(/[;, ]/).includes("proxdeploy-template"))
.map((r) => {
const db = bekannt.get(r.vmid);
const tags = (r.tags ?? "").split(/[;, ]/);
return {
vmid: r.vmid,
name: r.name ?? `VM ${r.vmid}`,
knoten: r.node,
distributionId: db?.distributionId ?? tags.find((t) => t !== "proxdeploy-template") ?? null,
gebautAm: db?.gebautAm.toISOString() ?? null,
imageDatei: db?.imageDatei ?? null,
};
})
.sort((a, b) => a.vmid - b.vmid);
} catch (fehler) {
if (fehler instanceof ProxmoxFehler)
throw new AnwendungsFehler(502, "proxmox", fehler.ursache, fehler.hinweis);
throw fehler;
} finally {
await client.schliessen();
}
}
+101
View File
@@ -0,0 +1,101 @@
import { prisma } from "@/lib/db";
import { AblaufFehler } from "@/engine/ablauf/fehler";
/**
* Vergibt VMIDs ohne Doppelungen zwischen parallelen Aufträgen: unter einer Advisory-Sperre je
* Server wird die kleinste freie ID ≥ `von` gesucht, die weder in Proxmox belegt noch von einem
* anderen laufenden Auftrag reserviert ist, und sofort reserviert. Die Sperre fällt mit der
* Transaktion; die Reservierung bleibt bis zum Auftragsende (Sicherheitsnetz: Ablauf nach 4 h).
*
* Gegen Gäste, die gleichzeitig außerhalb von ProxDeploy entstehen, hilft das nicht – dort
* scheitert `clone`/`create` mit „already exists“, und die Phase wiederholt einmal mit neuer ID.
*/
const RESERVIERUNG_STUNDEN = 4;
export async function vmidReservieren(eingabe: {
serverId: string;
auftragId: string;
belegt: ReadonlySet<number>;
von: number;
bis?: number;
/** Eine bestimmte ID statt der nächsten freien (vom Benutzer gewählt). */
wunsch?: number;
}): Promise<number> {
const bis = eingabe.bis ?? 999_999_999;
return await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('proxdeploy:vmid'), hashtext(${eingabe.serverId}))`;
await tx.vmidReservierung.deleteMany({ where: { ablaufAm: { lt: new Date() } } });
const reserviert = new Set(
(
await tx.vmidReservierung.findMany({ where: { serverId: eingabe.serverId }, select: { vmid: true } })
).map((r) => r.vmid),
);
const frei = (id: number) => !eingabe.belegt.has(id) && !reserviert.has(id);
let vmid: number | null = null;
if (eingabe.wunsch !== undefined) {
if (!frei(eingabe.wunsch)) {
throw new AblaufFehler(
`VMID ${eingabe.wunsch} ist bereits belegt oder reserviert.`,
"Eine andere ID wählen oder das Feld leer lassen – dann nimmt ProxDeploy die nächste freie.",
);
}
vmid = eingabe.wunsch;
} else {
for (let id = eingabe.von; id <= bis; id += 1) {
if (frei(id)) {
vmid = id;
break;
}
}
}
if (vmid === null) {
throw new AblaufFehler(
`Im Bereich ${eingabe.von}–${bis} ist keine VMID frei.`,
"Bereich in den Server-Einstellungen erweitern.",
);
}
await tx.vmidReservierung.create({
data: {
serverId: eingabe.serverId,
vmid,
auftragId: eingabe.auftragId,
ablaufAm: new Date(Date.now() + RESERVIERUNG_STUNDEN * 3600 * 1000),
},
});
return vmid;
});
}
export async function reservierungenFreigeben(auftragId: string): Promise<void> {
await prisma.vmidReservierung.deleteMany({ where: { auftragId } });
}
/** Für den Trockenlauf: die nächste freie ID nennen, ohne sie zu reservieren. */
export async function vmidVorschlagen(eingabe: {
serverId: string;
belegt: ReadonlySet<number>;
von: number;
wunsch?: number;
}): Promise<number> {
const reserviert = new Set(
(
await prisma.vmidReservierung.findMany({
where: { serverId: eingabe.serverId, ablaufAm: { gt: new Date() } },
select: { vmid: true },
})
).map((r) => r.vmid),
);
if (eingabe.wunsch !== undefined) {
if (eingabe.belegt.has(eingabe.wunsch) || reserviert.has(eingabe.wunsch)) {
throw new AblaufFehler(
`VMID ${eingabe.wunsch} ist bereits belegt oder reserviert.`,
"Eine andere ID wählen oder das Feld leer lassen.",
);
}
return eingabe.wunsch;
}
let id = eingabe.von;
while (eingabe.belegt.has(id) || reserviert.has(id)) id += 1;
return id;
}
+43
View File
@@ -0,0 +1,43 @@
import { PgBoss } from "pg-boss";
import { logger } from "@/lib/logger";
/**
* Die Auftragswarteschlange (pg-boss auf derselben PostgreSQL, Schema `pgboss`). Die app stellt
* nur ein, der worker arbeitet ab. Ein Auftrag wird nie automatisch wiederholt: halb angelegte
* Gäste soll ein Mensch ansehen, nicht eine Schleife.
*/
export const WARTESCHLANGE = "auftrag";
/** Template-Bau kann bei langsamem Download dauern; danach gilt ein Job als verwaist. */
const LAUFZEIT_SEKUNDEN = 4 * 3600;
export type AuftragsJob = { auftragId: string };
let instanz: Promise<PgBoss> | undefined;
export function boss(rolle: "app" | "worker"): Promise<PgBoss> {
instanz ??= (async () => {
const url = process.env["DATABASE_URL"];
if (!url) throw new Error("DATABASE_URL ist nicht gesetzt.");
const b = new PgBoss({
connectionString: url,
application_name: `proxdeploy-${rolle}`,
// Wartung und Zeitpläne macht nur der worker; die app stellt nur ein.
supervise: rolle === "worker",
schedule: false,
});
b.on("error", (fehler) => logger.error({ err: fehler }, "pg-boss"));
await b.start();
await b.createQueue(WARTESCHLANGE, { retryLimit: 0, expireInSeconds: LAUFZEIT_SEKUNDEN });
return b;
})();
return instanz;
}
export async function auftragEinstellen(auftragId: string): Promise<void> {
const b = await boss("app");
await b.send(WARTESCHLANGE, { auftragId } satisfies AuftragsJob, {
retryLimit: 0,
expireInSeconds: LAUFZEIT_SEKUNDEN,
});
}
+3
View File
@@ -0,0 +1,3 @@
/** Meldet alle Abläufe beim worker an. Aufräumen folgt. */
import "./ablaeufe/ausrollen";
import "./ablaeufe/template";
+83
View File
@@ -0,0 +1,83 @@
import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
import { sshBefehl, vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen";
import { ProxmoxClient } from "@/engine/proxmox/client";
import { prisma } from "@/lib/db";
import { melden } from "@/server/ereignisse/senke";
import { ausrollenVorbereiten, kopfzeile } from "@/server/services/ausrollen";
import { verbindungAus } from "@/server/services/server";
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
import { vmidReservieren } from "@/server/services/vmid";
import { ablaufRegistrieren } from "../ausfuehren";
import { aufrufLogger } from "../aufruf-log";
ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, angelegt }) => {
// Dieselbe Vorbereitung wie beim Anlegen – der Katalog könnte sich seitdem geändert haben.
const v = await ausrollenVorbereiten(auftrag.anfrage);
const stand: VmAusrollStand = {};
const kopfSetzen = async () => {
await prisma.auftrag.update({
where: { id: auftrag.id },
data: { kopf: kopfzeile(v, stand), knoten: stand.knoten ?? null },
});
await melden(auftrag.id);
};
await kopfSetzen();
const client = new ProxmoxClient(verbindungAus(v.server), { beobachter: aufrufLogger(senke) });
try {
const ergebnis = await phasenAusfuehren(
vmAusrollSchritte(
{
client,
anfrage: v.anfrage,
distribution: v.distribution,
profile: v.profile,
vmidReservieren: async (belegt, wunsch) => {
const vmid = await vmidReservieren({
serverId: v.server.id,
auftragId: auftrag.id,
belegt,
von: v.server.gastVmidVon,
wunsch,
});
await prisma.auftrag.update({ where: { id: auftrag.id }, data: { vmid } });
await kopfSetzen();
return vmid;
},
templateBau: v.anfrage.templateBauen
? {
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten),
auftragKurzId: auftrag.kurzId,
vmidReservieren: (belegt) =>
vmidReservieren({
serverId: v.server.id,
auftragId: auftrag.id,
belegt,
von: v.server.templateVmidVon,
bis: v.server.templateVmidBis,
}),
}
: undefined,
angelegt,
auftragKurzId: auftrag.kurzId,
},
stand,
),
senke,
{ abbruchGewuenscht },
);
await kopfSetzen();
if (!ergebnis.ok) return ergebnis;
return {
ok: true,
ergebnis: {
vmid: stand.vmid,
knoten: stand.knoten,
ip: stand.ip,
ssh: sshBefehl(v.anfrage.zugang.benutzer, stand.ip),
},
};
} finally {
await client.schliessen();
}
});
+78
View File
@@ -0,0 +1,78 @@
import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
import { templateBauSchritte, templateName, type TemplateBauStand } from "@/engine/ablaeufe/template-bau";
import type { Distribution } from "@/engine/katalog/schema";
import { ProxmoxClient } from "@/engine/proxmox/client";
import { prisma } from "@/lib/db";
import { TemplateBauAnfrage } from "@/lib/schemas/template";
import { melden } from "@/server/ereignisse/senke";
import { verbindungAus } from "@/server/services/server";
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
import { vmidReservieren } from "@/server/services/vmid";
import { ablaufRegistrieren } from "../ausfuehren";
import { aufrufLogger } from "../aufruf-log";
ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht, angelegt }) => {
const anfrage = TemplateBauAnfrage.parse(auftrag.anfrage);
const server = await prisma.server.findUniqueOrThrow({ where: { id: auftrag.serverId } });
const zeile = await prisma.distribution.findUniqueOrThrow({ where: { id: anfrage.distribution } });
const distribution = zeile.definition as Distribution;
await prisma.auftrag.update({
where: { id: auftrag.id },
data: { kopf: `Server ${server.name} · Node ${anfrage.knoten} · Template ${distribution.name}` },
});
await melden(auftrag.id);
const client = new ProxmoxClient(verbindungAus(server), { beobachter: aufrufLogger(senke) });
const stand: TemplateBauStand = {};
try {
const ergebnis = await phasenAusfuehren(
templateBauSchritte(
{
client,
distribution,
knoten: anfrage.knoten,
storage: anfrage.storage,
bridge: anfrage.bridge,
vlan: anfrage.vlan,
ssh: nodeBefehlFuer(server, anfrage.knoten),
auftragKurzId: auftrag.kurzId,
vmidReservieren: async (belegt) => {
const vmid = await vmidReservieren({
serverId: server.id,
auftragId: auftrag.id,
belegt,
von: server.templateVmidVon,
bis: server.templateVmidBis,
});
await prisma.auftrag.update({ where: { id: auftrag.id }, data: { vmid } });
await angelegt({ vmid, knoten: anfrage.knoten, typ: "VM" });
return vmid;
},
},
stand,
),
senke,
{ abbruchGewuenscht },
);
if (!ergebnis.ok) return ergebnis;
await prisma.template.create({
data: {
serverId: server.id,
knoten: anfrage.knoten,
vmid: stand.vmid!,
distributionId: distribution.id,
storage: anfrage.storage,
imageDatei: stand.imageDatei!,
pruefsumme: stand.pruefsumme!,
gebautVonAuftragId: auftrag.id,
},
});
return {
ok: true,
ergebnis: { vmid: stand.vmid, template: templateName(distribution), knoten: anfrage.knoten },
};
} finally {
await client.schliessen();
}
});
+20
View File
@@ -0,0 +1,20 @@
import type { EreignisSenke } from "@/engine/ablauf/ablauf";
import type { AufrufBeobachter } from "@/engine/proxmox/client";
/**
* Schreibt Proxmox-Aufrufe ins Detail-Log. Die Abfrageschleifen (Task-Status, Task-Log,
* VM-Status) erscheinen nur bei Fehlern – sonst stünden bei jedem Download Hunderte gleicher
* Zeilen da, und das Wichtige ginge unter.
*/
const SCHLEIFE = /\/tasks\/[^/]+\/(status|log)$|\/status\/current$/;
export function aufrufLogger(senke: EreignisSenke): AufrufBeobachter {
return (aufruf, ergebnis) => {
const pfad = decodeURIComponent(aufruf.pfad);
if (!ergebnis.fehler && aufruf.methode === "GET" && SCHLEIFE.test(pfad)) return;
void senke.log(
ergebnis.fehler ? "warn" : "debug",
`${aufruf.methode} ${pfad} · ${ergebnis.dauerMs} ms${ergebnis.fehler ? ` · ${ergebnis.fehler.ursache}` : ""}`,
);
};
}
+127
View File
@@ -0,0 +1,127 @@
import type { Auftrag, Prisma } from "@/generated/prisma/client";
import type { AuftragsArt } from "@/generated/prisma/enums";
import type { AblaufErgebnis, EreignisSenke } from "@/engine/ablauf/ablauf";
import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
import { DatenbankSenke, melden } from "@/server/ereignisse/senke";
import { reservierungenFreigeben } from "@/server/services/vmid";
/**
* Führt einen Auftrag aus: Status setzen, den passenden Ablauf starten, Ergebnis festhalten.
* Die Abläufe selbst (Template bauen, VM/LXC ausrollen, aufräumen) melden sich hier an.
*/
export type AblaufKontext = {
auftrag: Auftrag;
senke: EreignisSenke;
abbruchGewuenscht: () => Promise<boolean>;
/** Merkt sich, was angelegt wurde – fürs Aufräumen, auch wenn später etwas scheitert. */
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
};
export type AblaufAusgang = AblaufErgebnis & { ergebnis?: Record<string, unknown> };
export type AblaufFunktion = (kontext: AblaufKontext) => Promise<AblaufAusgang>;
const ABLAEUFE = new Map<AuftragsArt, AblaufFunktion>();
export function ablaufRegistrieren(art: AuftragsArt, funktion: AblaufFunktion): void {
ABLAEUFE.set(art, funktion);
}
export async function auftragAusfuehren(
auftragId: string,
senke: EreignisSenke = new DatenbankSenke(auftragId),
) {
// Nur einmal starten, auch wenn pg-boss einen Job doppelt liefern sollte.
const gestartet = await prisma.auftrag.updateMany({
where: { id: auftragId, status: "WARTEND" },
data: { status: "LAEUFT", gestartetAm: new Date() },
});
if (gestartet.count === 0) {
logger.warn({ auftragId }, "Auftrag ist nicht (mehr) wartend – übersprungen");
return;
}
const auftrag = await prisma.auftrag.findUniqueOrThrow({ where: { id: auftragId } });
await melden(auftragId);
let ausgang: AblaufAusgang;
const ablauf = ABLAEUFE.get(auftrag.art);
try {
if (!ablauf) throw new Error(`Für ${auftrag.art} ist kein Ablauf registriert.`);
ausgang = await ablauf({
auftrag,
senke,
abbruchGewuenscht: async () =>
(await prisma.auftrag.findUnique({ where: { id: auftragId }, select: { abbrechen: true } }))
?.abbrechen ?? false,
angelegt: async (eintrag) => {
const aktuell = (
await prisma.auftrag.findUnique({ where: { id: auftragId }, select: { angelegt: true } })
)?.angelegt as Record<string, unknown> | null;
await prisma.auftrag.update({
where: { id: auftragId },
data: { angelegt: { ...(aktuell ?? {}), ...eintrag } as Prisma.InputJsonValue },
});
},
});
} catch (fehler) {
// Fehler außerhalb einer Phase (Programmfehler) – trotzdem sauber beenden.
logger.error({ err: fehler, auftragId }, "Ablauf ist abgestürzt");
const text = fehler instanceof Error ? fehler.message : String(fehler);
ausgang = {
ok: false,
abgebrochen: false,
phase: "",
fehler: { ursache: `Unerwarteter Fehler: ${text}`, hinweis: "Detail-Log prüfen." },
};
await senke.log("error", `Unerwarteter Fehler: ${text}`);
}
await prisma.auftrag.update({
where: { id: auftragId },
data: {
status: ausgang.ok ? "ERFOLGREICH" : ausgang.abgebrochen ? "ABGEBROCHEN" : "FEHLGESCHLAGEN",
beendetAm: new Date(),
...(ausgang.ok
? { ergebnis: (ausgang.ergebnis ?? {}) as Prisma.InputJsonValue }
: { fehler: { phase: ausgang.phase, ...ausgang.fehler } }),
},
});
await reservierungenFreigeben(auftragId);
await melden(auftragId);
}
/**
* Nach einem Absturz oder Neustart des workers hängen Aufträge auf LAEUFT. Sie werden ehrlich als
* fehlgeschlagen markiert – fortsetzen wäre riskanter als neu starten.
*/
export async function verwaisteAuftraegeBeenden(): Promise<number> {
const verwaist = await prisma.auftrag.findMany({ where: { status: "LAEUFT" }, select: { id: true } });
for (const { id } of verwaist) {
await prisma.$transaction([
prisma.phase.updateMany({
where: { auftragId: id, status: "LAEUFT" },
data: {
status: "FEHLER",
beendetAm: new Date(),
ursache: "Der worker wurde neu gestartet.",
hinweis: "Auftrag wiederholen; Reste ggf. aufräumen.",
},
}),
prisma.auftrag.update({
where: { id },
data: {
status: "FEHLGESCHLAGEN",
beendetAm: new Date(),
fehler: {
ursache: "Der worker wurde während des Auftrags neu gestartet.",
hinweis: "Auftrag wiederholen; Reste ggf. aufräumen.",
},
},
}),
]);
await reservierungenFreigeben(id);
await melden(id);
}
return verwaist.length;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Der worker: arbeitet die Auftragswarteschlange ab. Derselbe Codestand wie die app, eigener
* Prozess – ein langer Template-Bau blockiert so nie eine Web-Anfrage.
*/
import "./umgebung";
import { schluesselPruefen } from "@/lib/geheimnisse";
import { logger } from "@/lib/logger";
import { katalogSynchronisieren } from "@/server/services/katalog";
import { boss, WARTESCHLANGE, type AuftragsJob } from "@/server/warteschlange";
import { auftragAusfuehren, verwaisteAuftraegeBeenden } from "./ausfuehren";
import "./ablaeufe";
async function starten(): Promise<void> {
schluesselPruefen();
const sync = await katalogSynchronisieren();
logger.info({ distributionen: sync.distributionen, profile: sync.profile }, "Katalog abgeglichen");
const verwaist = await verwaisteAuftraegeBeenden();
if (verwaist > 0) logger.warn({ anzahl: verwaist }, "Verwaiste Aufträge als fehlgeschlagen markiert");
const parallel = Math.max(1, Number(process.env["WORKER_PARALLEL"] ?? 4));
const b = await boss("worker");
await b.work<AuftragsJob>(WARTESCHLANGE, { batchSize: 1, localConcurrency: parallel }, async ([job]) => {
if (job) await auftragAusfuehren(job.data.auftragId);
});
logger.info({ parallel }, "worker bereit");
const beenden = async (signal: string) => {
logger.info({ signal }, "worker fährt herunter");
await b.stop({ graceful: true, timeout: 30_000 });
process.exit(0);
};
process.on("SIGTERM", () => void beenden("SIGTERM"));
process.on("SIGINT", () => void beenden("SIGINT"));
}
starten().catch((fehler) => {
logger.fatal({ err: fehler }, "worker konnte nicht starten");
process.exit(1);
});
+11
View File
@@ -0,0 +1,11 @@
/**
* Muss das erste Modul des workers sein: der Logger liest LOG_LEVEL und den Dienstnamen beim
* Import. Next lädt `.env` von selbst, der worker nicht – im Container kommt die Umgebung von
* außen, dann gibt es keine Datei und nichts passiert.
*/
try {
process.loadEnvFile();
} catch {
// keine .env – Umgebung kommt von außen
}
process.env["PROXDEPLOY_DIENST"] = "worker";
+270
View File
@@ -0,0 +1,270 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { GET as ereignisseGet } from "@/app/api/v1/auftraege/[id]/ereignisse/route";
import { GET as auftragGet } from "@/app/api/v1/auftraege/[id]/route";
import { POST as abbrechenPost } from "@/app/api/v1/auftraege/[id]/abbrechen/route";
import { verschluesseln } from "@/lib/geheimnisse";
import { prisma } from "@/lib/db";
import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
import { auftragAnlegen } from "@/server/services/auftraege";
import { vmidReservieren } from "@/server/services/vmid";
import { ablaufRegistrieren, auftragAusfuehren, verwaisteAuftraegeBeenden } from "@/worker/ausfuehren";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
async function serverAnlegen() {
return await testPrisma.server.create({
data: {
name: "rz1",
titel: "RZ 1",
url: "https://127.0.0.1:8006",
tokenId: "a@pve!b",
tokenGeheimnis: verschluesseln("x"),
fingerabdruck: "AA",
},
});
}
/** Liest einen SSE-Strom, bis `ende` kommt, und gibt die Ereignisse zurück. */
async function stromLesen(
antwort: Response,
nebenbei?: () => Promise<void>,
): Promise<Array<{ art: string; daten: unknown; id?: string }>> {
const leser = antwort.body!.getReader();
const dekodierer = new TextDecoder();
let puffer = "";
const ereignisse: Array<{ art: string; daten: unknown; id?: string }> = [];
const parallel = nebenbei?.();
for (;;) {
const { value, done } = await leser.read();
if (done) break;
puffer += dekodierer.decode(value, { stream: true });
let grenze: number;
while ((grenze = puffer.indexOf("\n\n")) >= 0) {
const block = puffer.slice(0, grenze);
puffer = puffer.slice(grenze + 2);
const art = /^event: (.+)$/m.exec(block)?.[1];
const daten = /^data: (.+)$/m.exec(block)?.[1];
const id = /^id: (.+)$/m.exec(block)?.[1];
if (art && daten) ereignisse.push({ art, daten: JSON.parse(daten), id });
}
}
await parallel;
return ereignisse;
}
describe.runIf(datenbankVerfuegbar)("Aufträge, worker und Live-Strom", () => {
afterAll(async () => {
await prisma.$disconnect();
});
beforeEach(async () => {
await datenbankLeeren();
});
it("VMID-Vergabe: zehn parallele Reservierungen bekommen zehn verschiedene IDs, Belegtes wird übersprungen", async () => {
const server = await serverAnlegen();
const ids = await Promise.all(
Array.from({ length: 10 }, (_, i) =>
vmidReservieren({
serverId: server.id,
auftragId: `a${i}`,
belegt: new Set([100, 101, 103]),
von: 100,
}),
),
);
expect(new Set(ids).size).toBe(10);
expect(ids).not.toContain(100);
expect(ids).not.toContain(103);
expect(Math.min(...ids)).toBe(102);
await expect(
vmidReservieren({ serverId: server.id, auftragId: "x", belegt: new Set(), von: 100, wunsch: 102 }),
).rejects.toThrow(/bereits belegt/);
});
it("worker führt einen Auftrag aus; SSE liefert Stand, Log und Ende; Reservierungen werden frei", async () => {
const server = await serverAnlegen();
const { benutzer, cookie } = await angemeldet("OPERATOR");
ablaufRegistrieren("AUFRAEUMEN", async ({ auftrag, senke }) => {
await vmidReservieren({
serverId: auftrag.serverId,
auftragId: auftrag.id,
belegt: new Set(),
von: 100,
});
const ergebnis = await phasenAusfuehren(
[
{
schluessel: "eins",
titel: "Eins",
ausfuehren: async (k) => {
await k.log("Zeile 1");
return { detail: "fertig" };
},
},
{
schluessel: "zwei",
titel: "Zwei",
ausfuehren: async (k) => {
await k.log("Zeile 2");
return {};
},
},
],
senke,
);
return { ...ergebnis, ergebnis: { ip: "10.0.0.5" } };
});
const auftrag = await auftragAnlegen(
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
{ benutzer, quelle: "WEB" },
null,
async () => {},
);
expect(auftrag.kurzId).toMatch(/^c-[2-9a-z]{5}$/);
const strom = await ereignisseGet(
anfrage("GET", `/api/v1/auftraege/${auftrag.kurzId}/ereignisse`, cookie),
kontext({ id: auftrag.kurzId }),
);
expect(strom.headers.get("content-type")).toContain("text/event-stream");
const ereignisse = await stromLesen(strom, () => auftragAusfuehren(auftrag.id));
const staende = ereignisse
.filter((e) => e.art === "stand")
.map((e) => (e.daten as { status: string }).status);
// Je nach Takt sieht der erste Stand den Auftrag wartend oder schon laufend – nie fertig.
expect(["WARTEND", "LAEUFT"]).toContain(staende[0]);
expect(staende.at(-1)).toBe("ERFOLGREICH");
const logs = ereignisse
.filter((e) => e.art === "log")
.flatMap((e) => (e.daten as Array<{ nachricht: string }>).map((z) => z.nachricht));
expect(logs).toEqual(["Zeile 1", "Zeile 2"]);
expect(ereignisse.at(-1)?.art).toBe("ende");
const fertig = await (
await auftragGet(
anfrage("GET", `/api/v1/auftraege/${auftrag.kurzId}`, cookie),
kontext({ id: auftrag.kurzId }),
)
).json();
expect(
fertig.phasen.map((p: { schluessel: string; status: string }) => `${p.schluessel}:${p.status}`),
).toEqual(["eins:ERLEDIGT", "zwei:ERLEDIGT"]);
expect(fertig.ergebnis).toEqual({ ip: "10.0.0.5" });
expect(await testPrisma.vmidReservierung.count()).toBe(0);
});
it("Wiederverbinden mit Last-Event-ID liefert nur neue Log-Zeilen", async () => {
const server = await serverAnlegen();
const { benutzer, cookie } = await angemeldet("OPERATOR");
ablaufRegistrieren("AUFRAEUMEN", async ({ senke }) =>
phasenAusfuehren(
[
{
schluessel: "a",
titel: "A",
ausfuehren: async (k) => {
await k.log("alt");
await k.log("neu");
return {};
},
},
],
senke,
),
);
const auftrag = await auftragAnlegen(
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
{ benutzer, quelle: "WEB" },
null,
async () => {},
);
await auftragAusfuehren(auftrag.id);
const erste = await testPrisma.logeintrag.findFirstOrThrow({
where: { auftragId: auftrag.id, nachricht: "alt" },
});
const strom = await ereignisseGet(
new Request(`http://localhost/api/v1/auftraege/${auftrag.kurzId}/ereignisse`, {
headers: { cookie, "last-event-id": erste.id.toString() },
}),
kontext({ id: auftrag.kurzId }),
);
const ereignisse = await stromLesen(strom);
const logs = ereignisse
.filter((e) => e.art === "log")
.flatMap((e) => (e.daten as Array<{ nachricht: string }>).map((z) => z.nachricht));
expect(logs).toEqual(["neu"]);
});
it("Abbruch: Operator fordert an, der Ablauf endet vor der nächsten Phase als ABGEBROCHEN", async () => {
const server = await serverAnlegen();
const { benutzer, cookie } = await angemeldet("OPERATOR");
let auftragKurz = "";
ablaufRegistrieren("AUFRAEUMEN", async ({ senke, abbruchGewuenscht }) =>
phasenAusfuehren(
[
{
schluessel: "a",
titel: "A",
ausfuehren: async () => {
await abbrechenPost(
anfrage("POST", `/api/v1/auftraege/${auftragKurz}/abbrechen`, cookie),
kontext({ id: auftragKurz }),
);
return {};
},
},
{ schluessel: "b", titel: "B", ausfuehren: async () => ({}) },
],
senke,
{ abbruchGewuenscht },
),
);
const auftrag = await auftragAnlegen(
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
{ benutzer, quelle: "WEB" },
null,
async () => {},
);
auftragKurz = auftrag.kurzId;
await auftragAusfuehren(auftrag.id);
const fertig = await testPrisma.auftrag.findUniqueOrThrow({
where: { id: auftrag.id },
include: { phasen: { orderBy: { reihenfolge: "asc" } } },
});
expect(fertig.status).toBe("ABGEBROCHEN");
expect(fertig.phasen.map((p) => p.status)).toEqual(["ERLEDIGT", "FEHLER"]);
expect(await testPrisma.protokoll.count({ where: { aktion: "Abbruch angefordert" } })).toBe(1);
});
it("ein doppelt gelieferter Job läuft nur einmal; verwaiste Aufträge werden beim Start beendet", async () => {
const server = await serverAnlegen();
const { benutzer } = await angemeldet("OPERATOR");
let laeufe = 0;
ablaufRegistrieren("AUFRAEUMEN", async () => {
laeufe += 1;
return { ok: true };
});
const auftrag = await auftragAnlegen(
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
{ benutzer, quelle: "WEB" },
null,
async () => {},
);
await Promise.all([auftragAusfuehren(auftrag.id), auftragAusfuehren(auftrag.id)]);
expect(laeufe).toBe(1);
const haengt = await auftragAnlegen(
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
{ benutzer, quelle: "WEB" },
null,
async () => {},
);
await testPrisma.auftrag.update({ where: { id: haengt.id }, data: { status: "LAEUFT" } });
expect(await verwaisteAuftraegeBeenden()).toBe(1);
expect((await testPrisma.auftrag.findUniqueOrThrow({ where: { id: haengt.id } })).status).toBe(
"FEHLGESCHLAGEN",
);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it } from "vitest";
import { POST as ausrollenPost } from "@/app/api/v1/auftraege/route";
import { verschluesseln } from "@/lib/geheimnisse";
import { cliBefehl, AusrollAnfrage } from "@/lib/schemas/ausrollen";
import { katalogSynchronisieren } from "@/server/services/katalog";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet, anfrage } from "./helpers/anfragen";
const SCHLUESSEL =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@test";
const gueltig = {
typ: "vm",
server: "rz1",
distribution: "debian-13",
name: "web01",
storage: "local-zfs",
netz: { bridge: "vmbr0" },
zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] },
profile: ["docker"],
};
describe("CLI-Befehl aus der Anfrage", () => {
it("nennt nur Abweichungen vom Standard und entspricht der Vorgabe", () => {
const a = AusrollAnfrage.parse({
...gueltig,
profile: ["base", "docker"],
netz: { bridge: "vmbr0", vlan: 20, modus: "statisch", ip: "10.0.20.15/24", gateway: "10.0.20.1" },
cores: 2,
speicherMb: 4096,
diskGb: 40,
});
expect(cliBefehl(a)).toBe(
"proxdeploy vm web01 --server rz1 --distro debian-13 --profile base,docker --ip 10.0.20.15/24 --gw 10.0.20.1 --vlan 20 --memory 4096 --disk 40G --storage local-zfs --user florian",
);
});
it("quotet Variablenwerte mit Leerzeichen", () => {
const a = AusrollAnfrage.parse({ ...gueltig, variablen: { "zabbix-agent": { server: "a b" } } });
expect(cliBefehl(a)).toContain("--var zabbix-agent.server='a b'");
});
});
describe.runIf(datenbankVerfuegbar)("POST /api/v1/auftraege", () => {
let cookie: string;
beforeEach(async () => {
await datenbankLeeren();
await katalogSynchronisieren();
cookie = (await angemeldet("OPERATOR")).cookie;
await testPrisma.server.create({
data: {
name: "rz1",
titel: "RZ 1",
url: "https://127.0.0.1:8006",
tokenId: "a@pve!b",
tokenGeheimnis: verschluesseln("x"),
fingerabdruck: "AA",
},
});
});
const senden = async (rumpf: unknown) => {
const antwort = await ausrollenPost(anfrage("POST", "/api/v1/auftraege", cookie, rumpf));
return { status: antwort.status, inhalt: await antwort.json() };
};
it("meldet Feldfehler einzeln: Name, Schlüssel, root", async () => {
const { status, inhalt } = await senden({
...gueltig,
name: "Web_01",
zugang: { benutzer: "root", sshSchluessel: ["kein schlüssel"] },
});
expect(status).toBe(400);
expect(Object.keys(inhalt.fehler.felder)).toEqual(
expect.arrayContaining(["name", "zugang.benutzer", "zugang.sshSchluessel.0"]),
);
});
it("fehlende Profil-Variable landet am richtigen Feld", async () => {
const { status, inhalt } = await senden({ ...gueltig, profile: ["zabbix-agent"] });
expect(status).toBe(400);
expect(inhalt.fehler.felder).toEqual({ "variablen.zabbix-agent.server": "Zabbix-Server fehlt." });
});
it("deaktivierte Distribution und unbekannter Server werden abgewiesen", async () => {
await testPrisma.distribution.update({ where: { id: "debian-13" }, data: { aktiv: false } });
expect((await senden(gueltig)).status).toBe(400);
expect((await senden({ ...gueltig, server: "gibtsnicht" })).status).toBe(404);
});
});
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Läuft im Container: systemctl-Stub, Skript ausführen, Ergebnis prüfen.
printf '#!/bin/sh\necho "[systemctl] $*" >> /tmp/systemctl.log\n' > /usr/local/bin/systemctl && chmod +x /usr/local/bin/systemctl
bash "$1" > /tmp/lauf.log 2>&1; code=$?
grep '::proxdeploy::' /tmp/lauf.log
echo "exit=$code"
[ $code -ne 0 ] && tail -25 /tmp/lauf.log
echo "--- Prüfung"
docker --version; docker compose version
zabbix_agent2 -V | head -1; cat /etc/zabbix/zabbix_agent2.d/proxdeploy.conf
cat /etc/timezone; readlink /etc/localtime; grep LANG /etc/default/locale; locale -a | grep -i de_de
grep XKBLAYOUT /etc/default/keyboard
id florian; cat /etc/sudoers.d/90-proxdeploy-florian; cat ~florian/.ssh/authorized_keys
cat /etc/apt/apt.conf.d/20auto-upgrades; dpkg -l fail2ban chrony qemu-guest-agent | grep ^ii | awk '{print $2}'
cat /tmp/systemctl.log | sort -u | tr '\n' ' '; echo
echo "--- Zweiter Lauf (Idempotenz)"; bash "$1" > /tmp/lauf2.log 2>&1; echo "exit2=$?"; grep -c florian ~florian/.ssh/authorized_keys
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# Führt die Profile base, docker und zabbix-agent wirklich aus: je ein frischer Container pro
# Distribution, danach Prüfung des Ergebnisses und ein zweiter Lauf (Idempotenz).
# Dauert einige Minuten und braucht Docker und Netz – deshalb nicht im Commit-Tor.
# pnpm test:container # alle
# pnpm test:container debian-13 # eine
set -euo pipefail
cd "$(dirname "$0")/../.."
export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
declare -A IMAGES=(
[debian-12]=debian:bookworm-slim [debian-13]=debian:trixie-slim
[ubuntu-22.04]=ubuntu:22.04 [ubuntu-24.04]=ubuntu:24.04 [ubuntu-26.04]=ubuntu:26.04
)
ziele=("${@:-debian-12 debian-13 ubuntu-22.04 ubuntu-24.04 ubuntu-26.04}")
arbeit="$(mktemp -d)"; trap 'rm -rf "$arbeit"' EXIT
cp tests/container/im-gast-pruefen.sh "$arbeit/"
fehler=0
for distro in ${ziele[*]}; do
pnpm -s exec tsx scripts/profil-rendern.ts "$distro" docker,zabbix-agent server=zbx.example fail2ban=true > "$arbeit/$distro.sh"
echo "=== $distro (${IMAGES[$distro]})"
if docker run --rm -v "$arbeit:/s:ro" "${IMAGES[$distro]}" bash /s/im-gast-pruefen.sh "/s/$distro.sh" > "$arbeit/$distro.log" 2>&1 \
&& grep -q '^exit=0$' "$arbeit/$distro.log" && grep -q '^exit2=0$' "$arbeit/$distro.log"; then
echo "✓ $distro"
else
echo "✗ $distro"; tail -30 "$arbeit/$distro.log"; fehler=1
fi
done
exit $fehler
+28
View File
@@ -0,0 +1,28 @@
import { expect, test } from "@playwright/test";
import { anmelden, keinQuerscrollen } from "./helfer";
test("Katalog: Distributionen und Profile sehen, Distribution ab- und wieder einschalten", async ({
page,
}) => {
await anmelden(page, "/katalog");
await expect(page.getByRole("heading", { name: "Katalog", level: 1 })).toBeVisible();
await expect(page.getByText("Debian 13 „Trixie“")).toBeVisible();
await expect(page.getByText("Docker CE")).toBeVisible();
await keinQuerscrollen(page);
const schalter = page.getByRole("switch", { name: /Ubuntu 22\.04 LTS/ });
await expect(schalter).toHaveAttribute("aria-checked", "true");
await schalter.click();
await expect(schalter).toHaveAttribute("aria-checked", "false");
await page.reload();
await expect(page.getByRole("switch", { name: /Ubuntu 22\.04 LTS/ })).toHaveAttribute(
"aria-checked",
"false",
);
await page.screenshot({ path: `test-results/katalog-${test.info().project.name}.png`, fullPage: true });
await page.getByRole("switch", { name: /Ubuntu 22\.04 LTS/ }).click();
await expect(page.getByRole("switch", { name: /Ubuntu 22\.04 LTS/ })).toHaveAttribute(
"aria-checked",
"true",
);
});
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
import { AblaufFehler } from "@/engine/ablauf/fehler";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
/** Eine Senke, die mitschreibt – so sieht ein Test genau, was Web und CLI zu sehen bekämen. */
function mitschreiber() {
const ereignisse: string[] = [];
const senke: EreignisSenke = {
phasenAnlegen: async (p) => void ereignisse.push(`anlegen ${p.map((x) => x.schluessel).join(",")}`),
phaseGestartet: async (s) => void ereignisse.push(`start ${s}`),
phaseDetail: async (s, d) => void ereignisse.push(`detail ${s}: ${d}`),
phaseBeendet: async (s, status, a) =>
void ereignisse.push(
`ende ${s} ${status}${a.detail ? `: ${a.detail}` : ""}${a.fehler ? ` – ${a.fehler.ursache} → ${a.fehler.hinweis}` : ""}`,
),
log: async (stufe, n) => void ereignisse.push(`log ${stufe} ${n}`),
};
return { senke, ereignisse };
}
describe("Phasen-Runner", () => {
it("führt Phasen der Reihe nach aus und meldet Detail, Überspringen und Erfolg", async () => {
const { senke, ereignisse } = mitschreiber();
const ergebnis = await phasenAusfuehren(
[
{ schluessel: "pruefen", titel: "Prüfungen", ausfuehren: async () => ({ detail: "VMID 120 frei" }) },
{
schluessel: "template",
titel: "Template",
ausfuehren: async () => ({ uebersprungen: true, detail: "9013 vorhanden" }),
},
{
schluessel: "klonen",
titel: "Klonen",
ausfuehren: async (k) => {
await k.detail("läuft …");
await k.log("clone gestartet");
return { detail: "full clone 9013 → 120" };
},
},
],
senke,
);
expect(ergebnis).toEqual({ ok: true });
expect(ereignisse).toEqual([
"anlegen pruefen,template,klonen",
"start pruefen",
"ende pruefen ERLEDIGT: VMID 120 frei",
"start template",
"ende template UEBERSPRUNGEN: 9013 vorhanden",
"start klonen",
"detail klonen: läuft …",
"log info clone gestartet",
"ende klonen ERLEDIGT: full clone 9013 → 120",
]);
});
it("bricht bei einem Fehler ab: Ursache und Hinweis, folgende Phasen bleiben offen", async () => {
const { senke, ereignisse } = mitschreiber();
let danach = false;
const ergebnis = await phasenAusfuehren(
[
{
schluessel: "klonen",
titel: "Klonen",
ausfuehren: async () => {
throw new ProxmoxFehler(
"recht",
"Permission check failed (/vms/120, VM.Clone)",
"Der Rolle fehlt VM.Clone auf /vms/120.",
);
},
},
{ schluessel: "start", titel: "Start", ausfuehren: async () => void (danach = true) },
],
senke,
);
expect(ergebnis).toMatchObject({ ok: false, abgebrochen: false, phase: "klonen" });
expect(danach).toBe(false);
expect(ereignisse).toContain(
"ende klonen FEHLER – Permission check failed (/vms/120, VM.Clone) → Der Rolle fehlt VM.Clone auf /vms/120.",
);
expect(ereignisse.some((e) => e.startsWith("start start"))).toBe(false);
});
it("prüft vor jeder Phase auf Abbruch", async () => {
const { senke } = mitschreiber();
let abbrechen = false;
const ergebnis = await phasenAusfuehren(
[
{ schluessel: "a", titel: "A", ausfuehren: async () => void (abbrechen = true) },
{ schluessel: "b", titel: "B", ausfuehren: async () => ({}) },
],
senke,
{ abbruchGewuenscht: async () => abbrechen },
);
expect(ergebnis).toMatchObject({ ok: false, abgebrochen: true, phase: "b" });
});
it("benennt unerwartete Fehler ehrlich", async () => {
const { senke } = mitschreiber();
const ergebnis = await phasenAusfuehren(
[
{
schluessel: "x",
titel: "X",
ausfuehren: async () => {
throw new TypeError("kaputt");
},
},
],
senke,
);
expect(ergebnis.ok || ergebnis.fehler.ursache).toBe("Unerwarteter Fehler: kaputt");
const bewusst = await phasenAusfuehren(
[
{
schluessel: "y",
titel: "Y",
ausfuehren: async () => {
throw new AblaufFehler("U", "H");
},
},
],
senke,
);
expect(bewusst.ok || bewusst.fehler).toEqual({ ursache: "U", hinweis: "H" });
});
});
+177
View File
@@ -0,0 +1,177 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { katalogLaden, yamlPruefen } from "@/engine/katalog/laden";
import { templateWaehlen, versionVergleichen } from "@/engine/katalog/lxc-template";
import { dateiAusUrl, pruefsummeFinden } from "@/engine/katalog/pruefsumme";
import { DistributionSchema, ProfilSchema } from "@/engine/katalog/schema";
describe("Mitgelieferter Katalog", () => {
it("lädt ohne Fehler: fünf Distributionen, drei Profile", async () => {
const katalog = await katalogLaden("catalog");
expect(katalog.fehler).toEqual([]);
expect(katalog.distributionen.map((d) => d.eintrag.id)).toEqual([
"debian-12",
"debian-13",
"ubuntu-22.04",
"ubuntu-24.04",
"ubuntu-26.04",
]);
expect(katalog.profile.map((p) => p.eintrag.id)).toEqual(["base", "docker", "zabbix-agent"]);
});
it("jede Distribution kann VM und LXC und gehört zur Familie debian", async () => {
const katalog = await katalogLaden("catalog");
for (const { eintrag } of katalog.distributionen) {
expect(eintrag.family).toBe("debian");
expect(eintrag.vm?.guest_agent_preinstalled).toBe(false);
expect(eintrag.vm?.bios).toBe("ovmf");
expect(dateiAusUrl(eintrag.vm!.image_url)).toMatch(/\.(qcow2|img)$/);
expect(eintrag.lxc?.template_pattern).toContain(eintrag.os.version_id);
}
});
it("jedes Profil hat eine Umsetzung für debian", async () => {
const katalog = await katalogLaden("catalog");
for (const { eintrag } of katalog.profile) expect(Object.keys(eintrag.families)).toContain("debian");
});
});
describe("Schema", () => {
const debian = {
id: "debian-13",
name: "Debian 13",
family: "debian",
os: { id: "debian", version_id: "13" },
lxc: { template_pattern: "debian-13-standard_*_amd64.tar.zst", ostype: "debian" },
};
it("verlangt vm oder lxc", () => {
expect(DistributionSchema.safeParse({ ...debian, lxc: undefined }).success).toBe(false);
});
it("lehnt unbekannte Schlüssel und HTTP-Quellen ab", () => {
expect(DistributionSchema.safeParse({ ...debian, imgae_url: "x" }).success).toBe(false);
const http = yamlPruefen(
`id: x\nname: X\nfamily: debian\nos: { id: debian, version_id: "1" }\nvm: { image_url: "http://a/b.qcow2", checksum_url: "https://a/S", checksum_type: sha256, format: qcow2, default_user: u }`,
DistributionSchema,
);
expect(http.ok).toBe(false);
if (!http.ok) expect(http.fehler.join()).toContain("vm.image_url");
});
it("meldet kaputtes YAML mit Zeile", () => {
const ergebnis = yamlPruefen("id: [kaputt", DistributionSchema);
expect(ergebnis.ok).toBe(false);
if (!ergebnis.ok) expect(ergebnis.fehler[0]).toMatch(/^YAML:/);
});
it("Profil: genau eine Aktion je Schritt, Bedingungen, eindeutige Variablen", () => {
const basis = { id: "p", name: "P", families: { debian: [{ packages: ["htop"] }] } };
expect(ProfilSchema.safeParse(basis).success).toBe(true);
expect(
ProfilSchema.safeParse({ ...basis, families: { debian: [{ packages: ["a"], update: true }] } }).success,
).toBe(false);
expect(
ProfilSchema.safeParse({ ...basis, families: { debian: [{ packages: ["a"], if: "x" }] } }).success,
).toBe(true);
expect(
ProfilSchema.safeParse({
...basis,
variables: [
{ name: "a", label: "A" },
{ name: "a", label: "B" },
],
}).success,
).toBe(false);
expect(
ProfilSchema.safeParse({ ...basis, variables: [{ name: "a", label: "A", type: "select" }] }).success,
).toBe(false);
});
});
describe("Katalog laden: Fehler einzeln", () => {
function katalog(dateien: Record<string, string>): string {
const wurzel = mkdtempSync(join(tmpdir(), "pd-katalog-"));
mkdirSync(join(wurzel, "distros"));
mkdirSync(join(wurzel, "profiles"));
for (const [pfad, inhalt] of Object.entries(dateien)) writeFileSync(join(wurzel, pfad), inhalt);
return wurzel;
}
it("überspringt fehlerhafte Dateien, lädt den Rest und nennt Dateiname und Pfad", async () => {
const wurzel = katalog({
"profiles/gut.yaml": "id: gut\nname: Gut\nfamilies:\n debian:\n - update: true\n",
"profiles/kaputt.yaml": "id: kaputt\nname: K\nfamilies:\n debian:\n - pakete: [a]\n",
"profiles/anders.yaml": "id: falsch\nname: F\nfamilies:\n debian:\n - update: true\n",
"profiles/haengt.yaml":
"id: haengt\nname: H\nrequires: [gibtsnicht]\nfamilies:\n debian:\n - update: true\n",
});
const ergebnis = await katalogLaden(wurzel);
expect(ergebnis.profile.map((p) => p.eintrag.id)).toEqual(["gut"]);
expect(ergebnis.fehler.map((f) => f.datei).sort()).toEqual(["anders.yaml", "haengt.yaml", "kaputt.yaml"]);
expect(ergebnis.fehler.find((f) => f.datei === "anders.yaml")!.meldung).toMatch(/Dateinamen/);
});
});
describe("Prüfsummen", () => {
const sha512 = "a".repeat(128);
const sha256 = "b".repeat(64);
it("liest das Debian-Format (zwei Leerzeichen)", () => {
expect(
pruefsummeFinden(
`${sha512} debian-13-genericcloud-amd64.qcow2\n${"c".repeat(128)} andere.raw`,
"debian-13-genericcloud-amd64.qcow2",
"sha512",
),
).toBe(sha512);
});
it("liest das Ubuntu-Format (Binärmodus mit *)", () => {
expect(
pruefsummeFinden(
`${sha256} *noble-server-cloudimg-amd64.img`,
"noble-server-cloudimg-amd64.img",
"sha256",
),
).toBe(sha256);
});
it("liest das BSD-Format und ignoriert falsche Längen", () => {
expect(pruefsummeFinden(`SHA256 (x.img) = ${sha256.toUpperCase()}`, "x.img", "sha256")).toBe(sha256);
expect(pruefsummeFinden(`${sha256} x.img`, "x.img", "sha512")).toBeNull();
expect(pruefsummeFinden(`${sha256} y.img`, "x.img", "sha256")).toBeNull();
});
it("nimmt den Dateinamen aus der URL", () => {
expect(
dateiAusUrl("https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2"),
).toBe("debian-13-genericcloud-amd64.qcow2");
});
});
describe("LXC-Template wählen", () => {
const verfuegbar = [
"debian-13-standard_13.1-2_amd64.tar.zst",
"debian-13-standard_13.6-1_amd64.tar.zst",
"debian-13-standard_13.6-1_arm64.tar.zst",
"debian-12-standard_12.12-1_amd64.tar.zst",
"debian-12-standard_12.2-1_amd64.tar.zst",
];
it("nimmt bei mehreren Treffern die höchste Version (wie sort -V)", () => {
expect(templateWaehlen("debian-13-standard_*_amd64.tar.zst", verfuegbar)).toBe(
"debian-13-standard_13.6-1_amd64.tar.zst",
);
expect(templateWaehlen("debian-12-standard_*_amd64.tar.zst", verfuegbar)).toBe(
"debian-12-standard_12.12-1_amd64.tar.zst",
);
});
it("findet nichts Falsches", () => {
expect(templateWaehlen("ubuntu-24.04-standard_*_amd64.tar.zst", verfuegbar)).toBeNull();
expect(versionVergleichen("12.2-1", "12.12-1")).toBeLessThan(0);
});
});
+206
View File
@@ -0,0 +1,206 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { adapterFuer, bekannteFamilien } from "@/engine/adapter";
import { AdapterFehler, debianAdapter } from "@/engine/adapter/debian";
import { katalogLaden } from "@/engine/katalog/laden";
import { ProfilSchema, type Profil } from "@/engine/katalog/schema";
import { profileAufloesen } from "@/engine/profile/aufloesen";
import { MARKE, profileRendern } from "@/engine/profile/rendern";
import { einsetzen, VorlagenFehler } from "@/engine/profile/vorlage";
import { q } from "@/engine/shell";
const kontext = {
var: { a: "eins", leer: "" },
os: { id: "debian", version_id: "13", codename: "trixie" },
guest: { name: "web01", user: "florian" },
};
async function katalogProfile(): Promise<Map<string, Profil>> {
const k = await katalogLaden("catalog");
return new Map(k.profile.map((p) => [p.eintrag.id, p.eintrag]));
}
const profil = (roh: unknown) => ProfilSchema.parse(roh);
describe("Shell-Quoting", () => {
it("lässt Harmloses stehen und packt alles andere in einfache Anführungszeichen", () => {
expect(q("docker-ce")).toBe("docker-ce");
expect(q("a b")).toBe("'a b'");
expect(q("$(rm -rf /)")).toBe("'$(rm -rf /)'");
expect(q("it's")).toBe(`'it'"'"'s'`);
expect(q("")).toBe("''");
});
it("ein gequoteter Wert kommt in bash unverändert an", () => {
for (const wert of ["$(id)", "`id`", "a'b\"c", "x; rm -rf /", "ä ö ü", "\\n"]) {
expect(execFileSync("bash", ["-c", `printf '%s' ${q(wert)}`]).toString()).toBe(wert);
}
});
});
describe("Platzhalter", () => {
it("setzt var, os und guest ein; ?? nimmt den ersten nicht leeren Wert", () => {
expect(einsetzen("{{ os.id }}{{ os.version_id }}-{{ guest.name }}", kontext)).toBe("debian13-web01");
expect(einsetzen("{{ var.leer ?? var.a }}", kontext)).toBe("eins");
});
it("wirft bei unbekannten Platzhaltern statt sie leer zu lassen", () => {
expect(() => einsetzen("{{ var.gibtsnicht }}", kontext)).toThrow(VorlagenFehler);
expect(() => einsetzen("{{ env.HOME }}", kontext)).toThrow(/Unbekannter Platzhalter/);
});
it("im Shell-Modus kann eine Variable nie Code werden", () => {
const boese = { ...kontext, var: { name: "x; touch /tmp/gehackt" } };
const befehl = einsetzen("echo {{ var.name }}", boese, "shell");
expect(befehl).toBe("echo 'x; touch /tmp/gehackt'");
expect(execFileSync("bash", ["-c", befehl]).toString().trim()).toBe("x; touch /tmp/gehackt");
});
});
describe("Profile auflösen", () => {
it("zieht Voraussetzungen nach vorne und meldet sie als automatisch", async () => {
const ergebnis = profileAufloesen(["docker", "zabbix-agent"], await katalogProfile(), "debian", {
"zabbix-agent": { server: "zbx" },
});
expect(ergebnis.ok).toBe(true);
if (!ergebnis.ok) return;
expect(ergebnis.profile.map((p) => p.profil.id)).toEqual(["base", "docker", "zabbix-agent"]);
expect(ergebnis.automatisch).toEqual(["base"]);
});
it("setzt Standardwerte und prüft Pflichtvariablen", async () => {
const fehlt = profileAufloesen(["zabbix-agent"], await katalogProfile(), "debian");
expect(fehlt).toEqual({
ok: false,
fehler: [{ profil: "zabbix-agent", variable: "server", meldung: "Zabbix-Server fehlt." }],
});
const ok = profileAufloesen(["base"], await katalogProfile(), "debian");
expect(ok.ok && ok.profile[0]!.werte).toEqual({
zeitzone: "Europe/Berlin",
locale: "de_DE.UTF-8",
tastatur: "de",
fail2ban: false,
});
});
it("lehnt Zeilenumbrüche, falsche Typen, Zyklen und fehlende Familien ab", () => {
const verfuegbar = new Map<string, Profil>([
["a", profil({ id: "a", name: "A", requires: ["b"], families: { debian: [{ update: true }] } })],
["b", profil({ id: "b", name: "B", requires: ["a"], families: { debian: [{ update: true }] } })],
["nur-rhel", profil({ id: "nur-rhel", name: "R", families: { rhel: [{ update: true }] } })],
[
"v",
profil({
id: "v",
name: "V",
variables: [
{ name: "text", label: "Text" },
{ name: "zahl", label: "Zahl", type: "number" },
{ name: "wahl", label: "Wahl", type: "select", options: ["x", "y"] },
],
families: { debian: [{ update: true }] },
}),
],
]);
const ergebnis = profileAufloesen(["a", "nur-rhel", "v"], verfuegbar, "debian", {
v: { text: "a\nb", zahl: "abc", wahl: "z" },
});
expect(ergebnis.ok).toBe(false);
if (ergebnis.ok) return;
const meldungen = ergebnis.fehler.map(
(f) => `${f.profil}${f.variable ? "." + f.variable : ""}: ${f.meldung}`,
);
expect(meldungen).toEqual(
expect.arrayContaining([
expect.stringMatching(/Zyklische Voraussetzung/),
"nur-rhel: Keine Umsetzung für die Familie „debian“.",
"v.text: Text: keine Zeilenumbrüche.",
"v.zahl: Zahl: Zahl erwartet.",
"v.wahl: Wahl: erlaubt sind x, y.",
]),
);
});
});
describe("Rendern", () => {
const renderKontext = {
os: { id: "debian", version_id: "13", codename: "trixie" },
gast: { name: "web01", benutzer: "florian" },
};
async function rendern(auswahl: string[], eingaben = {}) {
const ergebnis = profileAufloesen(auswahl, await katalogProfile(), "debian", eingaben);
if (!ergebnis.ok) throw new Error(JSON.stringify(ergebnis.fehler));
return profileRendern(ergebnis.profile, debianAdapter, renderKontext);
}
it("setzt Marken je Profil und die SSH-Härtung ganz ans Ende", async () => {
const { skript } = await rendern(["docker"]);
const marken = skript.split("\n").filter((z) => z.includes(MARKE));
expect(marken).toEqual([
`echo '${MARKE} profil base'`,
`echo '${MARKE} profil docker'`,
`echo '${MARKE} abschluss'`,
`echo '${MARKE} fertig'`,
]);
expect(skript.indexOf("10-proxdeploy.conf")).toBeGreaterThan(skript.indexOf("docker-compose-plugin"));
});
it("setzt Distribution und Gast ein (Docker-Repo, Zabbix-Paket, Hostname)", async () => {
const { skript } = await rendern(["docker", "zabbix-agent"], {
"zabbix-agent": { server: "zbx.example" },
});
expect(skript).toContain("https://download.docker.com/linux/debian/gpg");
expect(skript).toContain("Suites: trixie");
expect(skript).toContain("zabbix-release_latest_7.0+debian13_all.deb");
expect(skript).toContain("usermod -aG docker florian");
// Der Inhalt der Konfigurationsdatei steht base64-kodiert im Skript.
const b64 = /printf '%s' (\S+) \| base64 -d > \/etc\/zabbix/.exec(skript)![1]!;
expect(Buffer.from(b64, "base64").toString()).toContain("Hostname=web01\n");
});
it("überspringt Schritte, deren Bedingung nicht erfüllt ist", async () => {
expect((await rendern(["base"])).skript).not.toContain("fail2ban");
expect((await rendern(["base"], { base: { fail2ban: true } })).skript).toContain("fail2ban");
});
it("weist ungültige Zeitzone, Locale und Tastatur schon beim Rendern ab", async () => {
await expect(rendern(["base"], { base: { zeitzone: "Europe/Berlin; rm" } })).rejects.toThrow(
AdapterFehler,
);
await expect(rendern(["base"], { base: { locale: "de" } })).rejects.toThrow(/Locale/);
await expect(rendern(["base"], { base: { tastatur: "de'" } })).rejects.toThrow(/Tastatur/);
});
it("ist syntaktisch gültiges bash", async () => {
const { skript } = await rendern(["docker", "zabbix-agent"], {
"zabbix-agent": { server: "zbx" },
base: { fail2ban: true },
});
const datei = join(mkdtempSync(join(tmpdir(), "pd-skript-")), "s.sh");
writeFileSync(datei, skript);
expect(() => execFileSync("bash", ["-n", datei])).not.toThrow();
});
});
describe("Adapter-Registry", () => {
it("kennt debian; eine unbekannte Familie ergibt null statt Absturz", () => {
expect(bekannteFamilien()).toEqual(["debian"]);
expect(adapterFuer("debian")).toBe(debianAdapter);
expect(adapterFuer("rhel")).toBeNull();
});
it("Benutzer: sudo-Datei wird vor dem Aktivieren mit visudo geprüft, Schlüssel ergänzt statt ersetzt", () => {
const skript = debianAdapter.benutzerAnlegen({
name: "florian",
sshSchluessel: ["ssh-ed25519 AAAA x"],
sudo: true,
});
expect(skript).toMatch(/visudo -cf .*\.neu >\/dev\/null && mv/);
expect(skript).toContain('>> "$pd_home/.ssh/authorized_keys"');
});
});

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