9 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
florian.wildeandClaude Opus 5.5 a9a48dccfa chore(release): 0.1.0 – Server-Verwaltung und Verbindungstest
Etappe 1 abgeschlossen und auf dem Testhost pve-demo (PVE 9.2.2)
abgenommen: Rolle und Token nach docs/proxmox-rechte.md angelegt,
Fingerprint und SSH-Host-Key gegen den Node verglichen, Verbindungstest
mit und ohne SSH.

Der Fingerprint-Hinweis nennt jetzt ausdrücklich pve-ssl.pem bzw.
pveproxy-ssl.pem – pvenode cert info listet zuerst die Root-CA.

Closes #003

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:01:54 +02:00
florian.wildeandClaude Opus 5.5 d64b8fb24d test: Hinweis auf die Rechte-Doku statt README erwarten
Der Hinweis zeigt seit dem vorigen Commit auf docs/proxmox-rechte.md;
der Test erwartete noch das README.

Refs #003

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:45:14 +02:00
florian.wildeandClaude Opus 5.5 86b650e20a feat(server): Verbindungstest mit Fähigkeitserkennung
Der Test liest Version, Cluster, Nodes, Storages, Bridges, VNets und die
effektiven Rechte des Tokens und leitet daraus ab, welcher Vorgang per
API, per SSH oder gar nicht geht – je PVE-Version: Template-Bau per API
erst ab 8.4 mit import-Storage, Snippets und LXC-Exec nur per SSH.

Fehlende Rechte erscheinen einzeln mit Pfad und Zweck; die Rollenliste
in docs/proxmox-rechte.md prüft ein Test gegen den Code. SSH-Host-Keys
werden wie Zertifikate erst nach Bestätigung genutzt, und bestätigt
werden kann nur ein Schlüssel, den der letzte Test gesehen hat.

Statussymbole in der GUI sind jetzt gezeichnet: ◐ und ○ fehlen im
Web-Subset von JetBrains Mono.

Refs #003

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:44:27 +02:00
130 changed files with 10539 additions and 114 deletions
+35 -6
View File
@@ -7,6 +7,38 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
### Hinzugefügt
### Geändert
### Behoben
### Entfernt
### 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 ### Hinzugefügt
- Architektur- und Umsetzungsplan (`docs/architektur.md`) mit Prüfung der Proxmox-API 8.x/9.x und der Image-Quellen (#001) - Architektur- und Umsetzungsplan (`docs/architektur.md`) mit Prüfung der Proxmox-API 8.x/9.x und der Image-Quellen (#001)
- Issues #001–#020 für die sechs Etappen und den Feldtest (#001) - Issues #001–#020 für die sechs Etappen und den Feldtest (#001)
@@ -17,14 +49,11 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Server-Verwaltung: Proxmox-Server anlegen, bearbeiten, löschen; Zertifikat ansehen und Fingerprint pinnen; Verbindung vor dem Speichern prüfen (#002) - Server-Verwaltung: Proxmox-Server anlegen, bearbeiten, löschen; Zertifikat ansehen und Fingerprint pinnen; Verbindung vor dem Speichern prüfen (#002)
- Optionales SSH-Schlüsselpaar je Server mit Einzeiler zum Hinterlegen auf den Nodes (#002) - Optionales SSH-Schlüsselpaar je Server mit Einzeiler zum Hinterlegen auf den Nodes (#002)
- Server-Umschalter in der Seitenleiste, Serverliste auf der Übersicht (#002) - Server-Umschalter in der Seitenleiste, Serverliste auf der Übersicht (#002)
- Verbindungstest: Version, Cluster, Nodes, Storages, Bridges, VNets und die Rechte des Tokens; je Vorgang, ob er per API, per SSH oder gar nicht geht (#003)
- SSH zu den Nodes mit Host-Key-Pinning nach Bestätigung (#003)
- `docs/proxmox-rechte.md`: Rolle `ProxDeploy` für PVE 8 und 9 mit fertigen `pveum`-Befehlen (#003)
- Proxmox-Client mit verständlichen Fehlern (Ursache + Hinweis, fehlendes Recht samt Pfad) und Warten auf Proxmox-Tasks (#002) - Proxmox-Client mit verständlichen Fehlern (Ursache + Hinweis, fehlendes Recht samt Pfad) und Warten auf Proxmox-Tasks (#002)
### Geändert
### Behoben
### Entfernt
### Sicherheit ### Sicherheit
- Geheimnisse werden mit AES-256-GCM verschlüsselt, Schlüssel aus `PROXDEPLOY_SCHLUESSEL` (#001) - Geheimnisse werden mit AES-256-GCM verschlüsselt, Schlüssel aus `PROXDEPLOY_SCHLUESSEL` (#001)
- Passwörter mit argon2id; Sperre nach zehn Fehlversuchen in 15 Minuten; Content-Security-Policy mit Nonce (#001) - Passwörter mit argon2id; Sperre nach zehn Fehlversuchen in 15 Minuten; Content-Security-Policy mit Nonce (#001)
+26 -4
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 1 h 01 min (laufende Sitzung) **Gesamt:** 2 h 50 min (Sitzung 1)
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten. Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
@@ -10,16 +10,23 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|---|---| |---|---|
| #001 Projekt-Setup | 0 h 50 min | | #001 Projekt-Setup | 0 h 50 min |
| #002 Server-Verwaltung | 0 h 11 min | | #002 Server-Verwaltung | 0 h 11 min |
| #003 Verbindungstest | 0 h 25 min |
| #004 Katalog | 0 h 09 min |
| #005 Adapter und Profile | 0 h 20 min |
| #006 Aufträge und Live-Fortschritt | 0 h 15 min |
| #007 Template-Bau | 0 h 12 min |
| #008 VM ausrollen und Assistent | 0 h 36 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
| v0.1.0 | – | | v0.1.0 | 1 h 15 min | **abgeschlossen**
| v0.2.0 | 1 h 32 min | **abgeschlossen**
--- ---
## 2026-10-12 · Sitzung 1 · 08:45–laufend ## 2026-10-12 · Sitzung 1 · 08:45–11:35
**Issue:** #001 **Issue:** #001
**Version:** 0.1.0-dev **Version:** 0.1.0, 0.2.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt: Reverse-Proxy (ja), OIDC (implementieren, vorerst nur lokal testen), Testumgebung (folgt), CLI (Go) - Auftrag gelesen, Rückfragen gestellt: Reverse-Proxy (ja), OIDC (implementieren, vorerst nur lokal testen), Testumgebung (folgt), CLI (Go)
@@ -28,6 +35,16 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
- Image-URLs, Prüfsummen-Dateien, LXC-Template-Namen, Docker- und Zabbix-Repos per Abruf geprüft - Image-URLs, Prüfsummen-Dateien, LXC-Template-Namen, Docker- und Zabbix-Repos per Abruf geprüft
- Git-Repository (`main`), CHANGELOG, DEVLOG, Architekturplan und 20 Issues angelegt - Git-Repository (`main`), CHANGELOG, DEVLOG, Architekturplan und 20 Issues angelegt
- Plan freigegeben: Ubuntu 26.04 dazu, Templates mit UEFI, Template-VMID = nächste freie im Template-Bereich, Namensabbildung bestätigt - Plan freigegeben: Ubuntu 26.04 dazu, Templates mit UEFI, Template-VMID = nächste freie im Template-Bereich, Namensabbildung bestätigt
- #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 - #002 abgeschlossen: Server-Verwaltung mit Fingerprint-Pinning, Proxmox-Client, Proxmox-Mock (HTTPS, selbst signiert); 99 Tests, 10 Playwright-Abläufe
- Testhost 10.0.10.228 von Florian bereitgestellt (Installation läuft) - Testhost 10.0.10.228 von Florian bereitgestellt (Installation läuft)
- #001 abgeschlossen: Grundgerüst mit Anmeldung, Seitenleiste, beiden Themes; 60 Tests, 8 Playwright-Abläufe (Desktop + 390 px) - #001 abgeschlossen: Grundgerüst mit Anmeldung, Seitenleiste, beiden Themes; 60 Tests, 8 Playwright-Abläufe (Desktop + 390 px)
@@ -43,4 +60,9 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
- **Sitzungsgeheimnis und Datenschlüssel getrennt** (`SITZUNG_GEHEIMNIS`, `PROXDEPLOY_SCHLUESSEL`): ein Tausch des Sitzungsgeheimnisses meldet nur alle ab, der Datenschlüssel darf sich nie ändern. - **Sitzungsgeheimnis und Datenschlüssel getrennt** (`SITZUNG_GEHEIMNIS`, `PROXDEPLOY_SCHLUESSEL`): ein Tausch des Sitzungsgeheimnisses meldet nur alle ab, der Datenschlüssel darf sich nie ändern.
- **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1. - **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1.
- **TLS-Pinning über einen eigenen undici-Connector** statt `rejectUnauthorized: false` + Nachprüfen: die Verbindung wird getrennt, bevor das Token gesendet wird. Sitzungs-Cache aus, sonst fehlt bei Wiederaufnahme das Zertifikat. - **TLS-Pinning über einen eigenen undici-Connector** statt `rejectUnauthorized: false` + Nachprüfen: die Verbindung wird getrennt, bevor das Token gesendet wird. Sitzungs-Cache aus, sonst fehlt bei Wiederaufnahme das Zertifikat.
- **Zugang zum Testhost über einen eigenen SSH-Schlüssel** (`~/.ssh/proxdeploy-testhost`), nicht über das root-Passwort: Claude Code verweigert das Anlegen von Zugängen mit Passwort, und ein Schlüssel nur für diesen Host ist ohnehin der sauberere Weg.
- **Trockenmodus im Proxmox-Client statt eigenem Planer:** Dry-Run und Ernstfall laufen durch dieselben Phasen; Lesendes geht echt an Proxmox, Schreibendes wird aufgezeichnet. So können Plan und Wirklichkeit nicht auseinanderlaufen.
- **Guest-Agent wird beim Template-Bau per Vorbereitungsstart eingebaut** (vendor-data-Snippet), nicht bei jedem Deployment – Deployments laufen danach ohne SSH.
- **Skripte base64 über file-write:** Proxmox' eigene Kodierung scheitert an Umlauten (Fund beim ersten echten Deploy).
- **Template-Bau ohne SSH (Bau-Adresse) ausgegliedert** als #021, damit Etappe 2 nicht wartet.
- **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe). - **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe).
+69
View File
@@ -0,0 +1,69 @@
# ProxDeploy
**VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.**
ProxDeploy ist eine selbst gehostete Webanwendung mit einer CLI (`proxdeploy`). Damit wählt man einen von
mehreren Proxmox-VE-Servern (8.x/9.x, Einzelhost oder Cluster) und rollt darauf vollständig unattended
eine VM oder einen LXC-Container mit Debian oder Ubuntu aus, inklusive Profilen wie `base`, `docker`
oder `zabbix-agent`. Weitere Distributionen und Profile sind YAML-Dateien im Katalog, kein Code.
> ProxDeploy ist ein unabhängiges Werkzeug und stammt nicht von Proxmox Server Solutions GmbH.
> „Proxmox“ ist eine Marke der Proxmox Server Solutions GmbH.
## Stand
In Entwicklung. Version und Fortschritt stehen im [CHANGELOG](CHANGELOG.md), der Plan in
[docs/architektur.md](docs/architektur.md), die Aufgaben in [docs/issues/](docs/issues/README.md).
| Etappe | Inhalt | Stand |
|---|---|---|
| 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft |
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt, Assistent | ✓ fertig, auf PVE 9.2 geprüft |
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen | ◐ als Nächstes |
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ○ geplant |
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ geplant |
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
## Was ProxDeploy auf einem Server kann
Der Verbindungstest prüft jeden Server und zeigt, welcher Vorgang per API, per SSH oder gar nicht geht.
Maßgeblich ist die Proxmox-API (geprüft gegen PVE 8.x und 9.2):
- **Template bauen:** per API ab PVE 8.4 (`download-url` + `import-from`), davor per SSH
- **VM provisionieren:** über den Guest-Agent, ohne SSH
- **LXC provisionieren:** braucht SSH (Proxmox hat keine Exec-API für Container)
- **cloud-init-Snippets:** nur per SSH (keine API)
Das API-Token braucht eine eigene Rolle; die Befehle stehen in
[docs/proxmox-rechte.md](docs/proxmox-rechte.md).
## Entwicklung
Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL.
```bash
pnpm install
cp .env.example .env # Schlüssel erzeugen: openssl rand -base64 32 / 48, ADMIN_PASSWORT setzen
pnpm db:up # PostgreSQL 17 auf 127.0.0.1:5437 (inkl. Testdatenbank)
pnpm db:migrate
pnpm build && PORT=3310 APP_URL=http://127.0.0.1:3310 pnpm start
```
Prüfen vor jedem Commit:
```bash
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test
pnpm test:e2e # Browser-Abläufe gegen den gebauten Stand (Desktop und 390 px)
```
Konventionen (deutsche Bezeichner, Schichtung, Versionen) stehen in [CLAUDE.md](CLAUDE.md).
## Betrieb
Docker Compose (app, worker, postgres) und ein Installer für einen Debian-LXC auf Proxmox folgen mit
Etappe 6. ProxDeploy ist für den Betrieb hinter einem Reverse-Proxy mit TLS gedacht
(`TRUST_PROXY=true`, `APP_URL` auf die öffentliche Adresse).
## Lizenz
Noch nicht festgelegt.
+22
View File
@@ -0,0 +1,22 @@
# Geprüft am 2026-10-12: Image, SHA512SUMS (Zeilenformat "<hash> <datei>") und LXC-Template
# existieren. Der Guest-Agent ist im Image nicht enthalten – er wird beim Template-Bau eingebaut.
id: debian-12
name: Debian 12 „Bookworm“
family: debian
os:
id: debian
version_id: "12"
codename: bookworm
arch: amd64
vm:
image_url: https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
checksum_url: https://cloud.debian.org/images/cloud/bookworm/latest/SHA512SUMS
checksum_type: sha512
format: qcow2
default_user: debian
guest_agent_preinstalled: false
bios: ovmf
min_disk_gb: 4
lxc:
template_pattern: "debian-12-standard_*_amd64.tar.zst" # aus pveam available
ostype: debian
+22
View File
@@ -0,0 +1,22 @@
# Geprüft am 2026-10-12: Image, SHA512SUMS (Zeilenformat "<hash> <datei>") und LXC-Template
# existieren. Der Guest-Agent ist im Image nicht enthalten – er wird beim Template-Bau eingebaut.
id: debian-13
name: Debian 13 „Trixie“
family: debian
os:
id: debian
version_id: "13"
codename: trixie
arch: amd64
vm:
image_url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
checksum_url: https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS
checksum_type: sha512
format: qcow2
default_user: debian
guest_agent_preinstalled: false
bios: ovmf
min_disk_gb: 4
lxc:
template_pattern: "debian-13-standard_*_amd64.tar.zst" # aus pveam available
ostype: debian
+22
View File
@@ -0,0 +1,22 @@
# Geprüft am 2026-10-12: Image (.img ist qcow2), SHA256SUMS (Zeilenformat "<hash> *<datei>")
# und LXC-Template existieren. Guest-Agent nicht im Image – wird beim Template-Bau eingebaut.
id: ubuntu-22.04
name: Ubuntu 22.04 LTS „Jammy Jellyfish“
family: debian
os:
id: ubuntu
version_id: "22.04"
codename: jammy
arch: amd64
vm:
image_url: https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img
checksum_url: https://cloud-images.ubuntu.com/jammy/current/SHA256SUMS
checksum_type: sha256
format: qcow2
default_user: ubuntu
guest_agent_preinstalled: false
bios: ovmf
min_disk_gb: 4
lxc:
template_pattern: "ubuntu-22.04-standard_*_amd64.tar.zst"
ostype: ubuntu
+22
View File
@@ -0,0 +1,22 @@
# Geprüft am 2026-10-12: Image (.img ist qcow2), SHA256SUMS (Zeilenformat "<hash> *<datei>")
# und LXC-Template existieren. Guest-Agent nicht im Image – wird beim Template-Bau eingebaut.
id: ubuntu-24.04
name: Ubuntu 24.04 LTS „Noble Numbat“
family: debian
os:
id: ubuntu
version_id: "24.04"
codename: noble
arch: amd64
vm:
image_url: https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
checksum_url: https://cloud-images.ubuntu.com/noble/current/SHA256SUMS
checksum_type: sha256
format: qcow2
default_user: ubuntu
guest_agent_preinstalled: false
bios: ovmf
min_disk_gb: 4
lxc:
template_pattern: "ubuntu-24.04-standard_*_amd64.tar.zst"
ostype: ubuntu
+22
View File
@@ -0,0 +1,22 @@
# Geprüft am 2026-10-12: Image (.img ist qcow2), SHA256SUMS (Zeilenformat "<hash> *<datei>")
# und LXC-Template existieren. Guest-Agent nicht im Image – wird beim Template-Bau eingebaut.
id: ubuntu-26.04
name: Ubuntu 26.04 LTS „Resolute Raccoon“
family: debian
os:
id: ubuntu
version_id: "26.04"
codename: resolute
arch: amd64
vm:
image_url: https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img
checksum_url: https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS
checksum_type: sha256
format: qcow2
default_user: ubuntu
guest_agent_preinstalled: false
bios: ovmf
min_disk_gb: 4
lxc:
template_pattern: "ubuntu-26.04-standard_*_amd64.tar.zst"
ostype: ubuntu
+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 }
+17 -10
View File
@@ -1,25 +1,32 @@
# #003 Verbindungstest und Fähigkeitserkennung # #003 Verbindungstest und Fähigkeitserkennung
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.1.0 **Meilenstein:** v0.1.0
**Labels:** feature, proxmox **Labels:** feature, proxmox
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 0 h 25 min
## Ziel ## Ziel
Button „Verbindung testen“ zeigt Version, Cluster/Nodes, Storages, Bridges/VLANs und was per API bzw. nur per SSH geht. Button „Verbindung testen“ zeigt Version, Cluster/Nodes, Storages, Bridges/VLANs und was per API bzw. nur per SSH geht.
## Aufgaben ## Aufgaben
- [ ] `/version`, `/cluster/status`, `/cluster/resources`, `/nodes/{n}/storage`, `/nodes/{n}/network`, SDN-VNets - [x] `/version`, `/cluster/status`, `/nodes`, `/nodes/{n}/version|storage|network`, SDN-VNets
- [ ] Rechte über `/access/permissions` gegen Rolle (8.x/9.x-Variante) abgleichen, fehlende einzeln nennen - [x] Rechte über `/access/permissions?path=…` gegen Rolle (8.x/9.x-Variante) abgleichen, fehlende einzeln nennen
- [ ] Fähigkeiten: download-url import (≥ 8.4), import-from per Volume, Snippets-Storage, SSH erreichbar - [x] Fähigkeiten: download-url import (≥ 8.4), import-from per Volume, Snippets-Storage, SSH erreichbar
- [ ] Übersichtsseite mit Serverstatus und freien Ressourcen; Serverdetail mit Nodes/Storages/Bridges - [x] SSH je Node mit Host-Key-Pinning (erst erkunden, dann bestätigen)
- [ ] Proxmox-Mock für Tests (8.3, 8.4, 9.2 als Varianten) - [x] Übersichtsseite mit Serverstatus; Serverdetail mit Nodes/Storages/Bridges/Rechten/Fähigkeiten
- [x] Proxmox-Mock für Tests (8.3, 8.4, 9.2, Cluster) und SSH-Mock
- [x] `docs/proxmox-rechte.md` mit `pveum`-Befehlen; Test hält die Liste mit dem Code gleich
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Gegen echten Proxmox geprüft (sobald Testumgebung da ist) - [x] Gegen echten Proxmox geprüft: Testhost `pve-demo` (10.0.10.228), PVE 9.2.2, am 2026-10-12
- [ ] Tests decken 8.3 (SSH nötig), 8.4 und 9.2 ab - [x] Tests decken 8.3 (SSH nötig), 8.4 und 9.2 ab
## Abhängigkeiten ## Abhängigkeiten
#002 #002
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **Feldtest am 2026-10-12 auf PVE 9.2.2:** Rolle, Benutzer und Token genau nach `docs/proxmox-rechte.md` angelegt; alle Pflichtrechte erkannt, nur die vier optionalen fehlen. Fingerprint aus ProxDeploy = `pve-ssl.pem` laut `pvenode cert info`; SSH-Host-Key = `ssh-keygen -lf` auf dem Node. Mit SSH: Snippets, `pct exec` und erweiterte LXC-Features per SSH, Template-Bau per API.
- **`pvenode cert info` listet zuerst die Root-CA** – der Hinweis im Formular nennt deshalb ausdrücklich `pveproxy-ssl.pem` bzw. `pve-ssl.pem`.
- **Ein Host-Key lässt sich nur bestätigen, wenn der letzte Test ihn gesehen hat** – über die API kann niemand einen beliebigen Schlüssel unterschieben.
- **Fehlende Leserechte sind Warnungen, kein Abbruch:** der Bericht soll gerade bei unvollständiger Rolle helfen.
- **Statussymbole gezeichnet:** ◐ und ○ fehlen im Web-Subset von JetBrains Mono.
- Feld `Server.faehigkeiten` heißt jetzt `letzterTest` (Migration per RENAME, keine Daten verloren).
+14 -9
View File
@@ -1,24 +1,29 @@
# #004 Katalog: Distributionen und Profile als Daten # #004 Katalog: Distributionen und Profile als Daten
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature, katalog **Labels:** feature, katalog
**Geschätzt:** 5 h **Tatsächlich:** – **Geschätzt:** 5 h **Tatsächlich:** 0 h 09 min
## Ziel ## Ziel
YAML-Katalog laden, validieren, in die DB synchronisieren, in der GUI ansehen und deaktivieren. YAML-Katalog laden, validieren, in die DB synchronisieren, in der GUI ansehen und deaktivieren.
## Aufgaben ## Aufgaben
- [ ] zod-Schemas für `catalog/distros` und `catalog/profiles` - [x] zod-Schemas für `catalog/distros` und `catalog/profiles`
- [ ] Debian 12/13, Ubuntu 22.04/24.04 (URLs/Prüfsummen geprüft am 2026-10-12) - [x] Debian 12/13, Ubuntu 22.04/24.04/26.04 (URLs/Prüfsummen geprüft am 2026-10-12)
- [ ] Prüfsummen-Parser für beide Zeilenformate; LXC-Musterauflösung mit Versionssortierung - [x] Prüfsummen-Parser für beide Zeilenformate; LXC-Musterauflösung mit Versionssortierung
- [ ] Sync beim worker-Start (Upsert, Aktiv-Schalter bleibt), fehlerhafte Datei überspringen + melden - [x] Sync beim worker-Start (Upsert, Aktiv-Schalter bleibt), fehlerhafte Datei überspringen + melden
- [ ] Katalog-Seite (Distributionen, Profile, aktiv/inaktiv) - [x] Katalog-Seite (Distributionen, Profile, aktiv/inaktiv)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Unit-Tests für Schema, Sync und Muster - [x] Unit-Tests für Schema, Sync und Muster
## Abhängigkeiten ## Abhängigkeiten
#001 #001
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - Profile `base`, `docker`, `zabbix-agent` liegen als Daten schon im Katalog; ihre Umsetzung in Shell folgt mit dem Adapter (#005).
- Zusätzliche Felder gegenüber der Vorgabe: `os` (id, version_id, codename – für Repo-URLs in Profilen), `arch`, `vm.bios`, `vm.min_disk_gb`.
- Unbekannte YAML-Schlüssel sind ein Fehler; Dateiname und `id` müssen übereinstimmen.
- Verschwundene Einträge werden markiert (`entferntAm`), nicht gelöscht – Aufträge verweisen später darauf.
- Ein eigenes Profil mit gleicher id wie ein Katalogprofil gewinnt; der Abgleich meldet den Konflikt.
- Abgleich unter Advisory-Lock, weil app und worker beim Start gleichzeitig abgleichen.
+17 -9
View File
@@ -1,23 +1,31 @@
# #005 Adapter debian und Profil-Engine # #005 Adapter debian und Profil-Engine
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature, engine **Labels:** feature, engine
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min
## Ziel ## Ziel
Interface `FamilienAdapter`, Umsetzung `debian`, Profil `base` wird zu einem idempotenten Skript. Interface `FamilienAdapter`, Umsetzung `debian`, Profile werden zu einem idempotenten Skript.
## Aufgaben ## Aufgaben
- [ ] Interface + Registry; `debian`-Adapter (apt, systemd, deb822, locale, keyboard, sshd_config.d) - [x] Interface + Registry; `debian`-Adapter (apt mit Lock-Timeout, deb822, systemd, locale, keyboard, sshd_config.d, sudoers mit visudo-Prüfung)
- [ ] Profilauflösung (requires, Reihenfolge, Variablen, Shell-Quoting) - [x] Profilauflösung (requires transitiv, Zyklen, Variablen mit Typen und Standardwerten, Familien-Prüfung)
- [ ] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, sudo-User, SSH-Härtung, chrony, fail2ban optional) - [x] Platzhalter `{{ var.x }}`, `{{ os.* }}`, `{{ guest.* }}`, `a ?? b`; in `run` jeder Wert shell-gequotet
- [ ] Snapshot-Tests der erzeugten Skripte; shellcheck auf die Ausgabe - [x] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, chrony, SSH-Härtung, fail2ban optional)
- [x] Tests der erzeugten Skripte; shellcheck auf die Ausgabe aller Distributionen
- [x] `pnpm test:container`: Skripte wirklich ausführen (Debian 12/13, Ubuntu 22.04/24.04/26.04), Ergebnis prüfen, zweiter Lauf auf Idempotenz
- [x] `docs/katalog.md`: neue Distribution, neuer Adapter, eigenes Profil
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code - [x] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code
- [x] base + docker + zabbix-agent laufen auf allen fünf Distributionen durch und sind idempotent (Container, 2026-10-12)
## Abhängigkeiten ## Abhängigkeiten
#004 #004
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **Fund im Container:** frische Images haben keine Paketlisten – der Vorspann macht jetzt immer `apt-get update`, sonst scheiterte schon die Benutzeranlage an „Unable to locate package sudo“.
- **Zeitzone, Locale und Tastatur werden gegen strenge Muster geprüft**, bevor sie ins Skript kommen (shellcheck fand verschachteltes Quoting im sed-Ausdruck).
- **SSH-Härtung läuft immer zuletzt:** läuft die Provisionierung per SSH als root (LXC ohne Node-SSH), schlösse sie sich sonst selbst aus.
- **Marken `::proxdeploy:: profil <id>`** im Skript zeigen im Live-Log, welches Profil gerade läuft.
- Dateiinhalte gehen base64-kodiert ins Skript – beliebige Zeichen kommen unverändert an.
+17 -10
View File
@@ -1,25 +1,32 @@
# #006 Aufträge, Phasen, worker und Live-Fortschritt # #006 Aufträge, Phasen, worker und Live-Fortschritt
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature, engine **Labels:** feature, engine
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 15 min
## Ziel ## Ziel
Hintergrund-Jobs mit Phasen in der DB, live per SSE in der Web-GUI. Hintergrund-Jobs mit Phasen in der DB, live per SSE in der Web-GUI.
## Aufgaben ## Aufgaben
- [ ] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung` - [x] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung`
- [ ] Phasen-Runner mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis - [x] Phasen-Runner (frameworkfrei) mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis, Abbruch zwischen Phasen
- [ ] SSE-Endpunkt mit Last-Event-ID, Polling-Fallback - [x] SSE-Endpunkt mit Last-Event-ID, Herzschlag, `X-Accel-Buffering: no`; Polling-Fallback im Client
- [ ] VMID-Vergabe mit Advisory-Lock, Test mit parallelen Aufträgen - [x] VMID-Vergabe mit Advisory-Lock, Test mit zehn parallelen Reservierungen
- [ ] Live-Ansicht als Terminal-Karte, Detail-Log, Ergebnis-Karte - [x] Live-Ansicht als Terminal-Karte (Aufbau der CLI-Ausgabe), Detail-Log, Ergebnis-Box; Verlaufsseite
- [x] Verwaiste Aufträge nach worker-Neustart als fehlgeschlagen markieren; doppelt gelieferte Jobs laufen nur einmal
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test) - [x] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test)
- [ ] Neuladen der Seite mitten im Auftrag zeigt den vollständigen Stand - [x] Neuladen der Seite mitten im Auftrag zeigt den vollständigen Stand (Stand kommt immer ganz, Log ab Last-Event-ID)
- [x] Live geprüft mit einem echten Template-Bau auf pve-demo (#007)
## Abhängigkeiten ## Abhängigkeiten
#003 #003
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **ssh2 und pg sind CommonJS:** benannte Importe gehen im Next-Bundle, aber nicht im worker unter reinem Node-ESM – jetzt Default-Importe.
- **Der worker lädt `.env` selbst** (`process.loadEnvFile`, falls vorhanden); im Container kommt die Umgebung von außen.
- **Kein automatisches Wiederholen** (`retryLimit: 0`): halb angelegte Gäste soll ein Mensch ansehen.
- **Abfrageschleifen** (Task-Status, VM-Status) erscheinen im Detail-Log nur bei Fehlern.
- **Kopfzeile** (`Auftrag.kopf`) setzt die Engine – Web und CLI zeigen denselben Text.
- Client-Komponenten importieren nichts aus `src/server` (struktureller Test in `tests/zugang.test.ts`).
+15 -11
View File
@@ -1,26 +1,30 @@
# #007 Template-Bau für VMs # #007 Template-Bau für VMs
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature, proxmox **Labels:** feature, proxmox
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 12 min
## Ziel ## Ziel
Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren. Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren.
## Aufgaben ## Aufgaben
- [ ] Weg A (≥ 8.4): `download-url` (content=import, checksum) + `import-from` per Volume-ID - [x] Weg A (≥ 8.4): `download-url` (content=import, checksum) + `import-from` per Volume-ID
- [ ] Weg B (SSH): Download + Prüfung auf dem Node, Import per `qm importdisk`/`import-from` - [x] Weg B (SSH): Download + Prüfung auf dem Node, Import per `qm importdisk`/`import-from`
- [ ] VMID: nächste freie im Template-Bereich (Entscheidung 2026-10-12) - [x] VMID: nächste freie im Template-Bereich (Entscheidung 2026-10-12)
- [ ] Hardware: q35, OVMF+EFI-Disk (Standard) oder SeaBIOS, virtio-scsi-single, discard/ssd/iothread, CPU host, serielle Konsole, agent=1, cloud-init-Laufwerk - [x] Hardware: q35, OVMF+EFI-Disk (Standard) oder SeaBIOS, virtio-scsi-single, discard/ssd/iothread, CPU host, serielle Konsole, agent=1, cloud-init-Laufwerk
- [ ] Guest-Agent einbauen: Snippet (SSH) oder Bau-Adresse + SSH in die VM - [x] Guest-Agent einbauen per Snippet (SSH); Weg über Bau-Adresse ohne SSH → #021
- [ ] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar - [x] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar
- [ ] Singleton je Server+Distribution - [x] Singleton je Server+Distribution
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Debian-13-Template auf echtem Proxmox gebaut (8.x und 9.x, sobald verfügbar) - [x] Debian-13-Template auf pve-demo (PVE 9.2.2) gebaut, Auftrag t-8zca3, 5 min 26 s; 8.x folgt im Feldtest (#020)
## Abhängigkeiten ## Abhängigkeiten
#005, #006 #005, #006
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **Image-Dateiname mit Prüfsummen-Präfix** (`proxdeploy-debian-13-f46f0671a6e5.qcow2`): ein neues „latest“-Image wird neu geladen, ein gleiches wiederverwendet.
- **Proxmox prüft die Prüfsumme selbst** (`download-url` mit `checksum`); ProxDeploy holt sie vorher aus SHA512SUMS/SHA256SUMS.
- **Vorbereitungsstart mit vendor-data:** qemu-guest-agent, Maschinen-ID leeren (sonst teilen sich Klone DHCP-Client-ID), `cloud-init clean`, poweroff. Snippet und `cicustom` werden auch nach einem Fehler entfernt.
- **UEFI mit `pre-enrolled-keys=1`** (Secure Boot an) – Debian 13 bootet damit; Proxmox setzt selbst `ms-cert=2023k`.
- Auf dem Testhost dauerte der Vorbereitungsstart ~70 s.
+16 -9
View File
@@ -1,24 +1,31 @@
# #008 Deploy-Assistent und VM ausrollen # #008 Deploy-Assistent und VM ausrollen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature **Labels:** feature
**Geschätzt:** 10 h **Tatsächlich:** – **Geschätzt:** 10 h **Tatsächlich:** 0 h 36 min
## Ziel ## Ziel
Debian-13-VM aus Template vollständig unattended, Assistent mit Dry-Run und CLI-Befehl. Debian-13-VM aus Template vollständig unattended, Assistent mit Dry-Run und CLI-Befehl.
## Aufgaben ## Aufgaben
- [ ] Planer VM (Phasen + geplante API-Aufrufe), `POST /auftraege/plan` - [x] Anfrageschema für Web, CLI, Batch und Vorlagen (`src/lib/schemas/ausrollen.ts`), CLI-Befehl daraus
- [ ] Ausführung: Klonen, Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Agent, Provisionierung per Agent - [x] Ablauf VM: Prüfungen (Node, Template, Storage, Bridge/VLAN, VMID), Klonen (mit einem Wiederholversuch bei belegter VMID), Hardware + Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Guest-Agent (ping, IP), Provisionierung per Agent mit Live-Fortschritt
- [ ] Fehlendes Template → automatisch bauen anbieten - [x] Dry-Run über denselben Ablauf mit Trocken-Client: `POST /api/v1/auftraege/plan`
- [ ] Assistent mit 9 Schritten, URL-Zustand, Handy-tauglich - [x] Fehlendes Template → „Template vorher bauen“ im Assistenten, läuft als Phase im selben Auftrag
- [ ] Aufräumen nach Bestätigung - [x] Assistent mit 9 Schritten, Zustand in der URL, jederzeit zurückspringbar, Handy-tauglich
- [ ] Aufräumen nach Bestätigung → folgt mit #014 (Verlauf); bis dahin werden angelegte VMIDs je Auftrag festgehalten
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] VM mit base-Profil per SSH erreichbar; Fehlerfall zeigt Ursache + Hinweis - [x] VM mit base-Profil per SSH erreichbar: pd-test01 (base, docker) per API, pd-test02 (base, zabbix-agent) über den Assistenten auf pve-demo, 2026-10-12
- [x] Fehlerfall zeigt Ursache + Hinweis (Tests: VLAN auf nicht VLAN-fähiger Bridge, Agent antwortet nicht, Profil scheitert mit Code)
## Abhängigkeiten ## Abhängigkeiten
#007 #007
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **Fund beim ersten echten Deploy:** `agent/file-write` scheitert an Nicht-ASCII („Wide character in subroutine entry“). ProxDeploy kodiert die Skriptstücke jetzt selbst als base64 (`encode=0`) und teilt nach Bytes.
- **Die Agent-Endpunkte antworten unterschiedlich** (exec → `{pid}`, exec-status/file-read direkt, ping/network-get-interfaces → `{result}`).
- **Fortschritt der Provisionierung** aus der Logdatei im Gast (`/var/log/proxdeploy-provisionierung.log`, per file-read), Marken je Profil.
- **Klon auf anderen Node** nur bei geteiltem Storage; sonst sagt die Prüfung, welchen Node man wählen soll.
- Zwei Felder hießen „Server“ (Umschalter und Assistent) – der Umschalter heißt jetzt „Aktiver Server“.
- Testgäste auf pve-demo: VM 100 `pd-test01`, VM 101 `pd-test02` (Tag `proxdeploy-test`).
+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.
+7 -6
View File
@@ -6,12 +6,12 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|---|---|---|---|---|---| |---|---|---|---|---|---|
| [#001](001-projekt-setup.md) | Projekt-Setup | erledigt | v0.1.0 | 4 h | 0 h 50 min | | [#001](001-projekt-setup.md) | Projekt-Setup | erledigt | v0.1.0 | 4 h | 0 h 50 min |
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min | | [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min |
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | offen | v0.1.0 | 6 h | – | | [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min |
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | offen | v0.2.0 | 5 h | – | | [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | erledigt | v0.2.0 | 5 h | 0 h 09 min |
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | offen | v0.2.0 | 6 h | – | | [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | erledigt | v0.2.0 | 6 h | 0 h 20 min |
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | offen | v0.2.0 | 8 h | – | | [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min |
| [#007](007-template-bau.md) | Template-Bau für VMs | offen | v0.2.0 | 8 h | – | | [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min |
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | offen | v0.2.0 | 10 h | – | | [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min |
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – | | [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – |
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – | | [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – |
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – | | [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – |
@@ -24,3 +24,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – | | [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – | | [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – | | [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | offen | v0.3.0 | 3 h | – |
+123
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
```
+94
View File
@@ -0,0 +1,94 @@
# Proxmox-Rechte für das API-Token
ProxDeploy braucht auf jedem Proxmox-Server ein API-Token. Es sollte **nicht** `root@pam` gehören,
sondern einem eigenen Benutzer mit einer eigenen Rolle `ProxDeploy`, die nur das Nötige erlaubt.
Der Verbindungstest in ProxDeploy vergleicht die tatsächlichen Rechte mit dieser Liste und nennt
fehlende Rechte einzeln, mit Pfad und Zweck.
## Rolle anlegen (PVE 9.x)
Auf einem Node des Clusters als root:
```bash
pveum role add ProxDeploy --privs "VM.Allocate VM.Clone VM.Audit VM.Config.Disk VM.Config.CDROM VM.Config.CPU VM.Config.Memory VM.Config.Network VM.Config.HWType VM.Config.Options VM.Config.Cloudinit VM.PowerMgmt VM.GuestAgent.Audit VM.GuestAgent.Unrestricted Datastore.AllocateSpace Datastore.AllocateTemplate Datastore.Audit SDN.Use Sys.Audit Sys.AccessNetwork"
```
## Rolle anlegen (PVE 8.x)
```bash
pveum role add ProxDeploy --privs "VM.Allocate VM.Clone VM.Audit VM.Config.Disk VM.Config.CDROM VM.Config.CPU VM.Config.Memory VM.Config.Network VM.Config.HWType VM.Config.Options VM.Config.Cloudinit VM.PowerMgmt VM.Monitor Datastore.AllocateSpace Datastore.AllocateTemplate Datastore.Audit SDN.Use Sys.Audit Sys.AccessNetwork"
```
Vor PVE 8.2 gibt es `Sys.AccessNetwork` noch nicht; dort statt dessen `Sys.Modify` eintragen.
Nach einem Upgrade von 8 auf 9 die Rolle anpassen: `VM.Monitor` gibt es in PVE 9 nicht mehr, der
Guest-Agent hat eigene Rechte bekommen.
```bash
pveum role modify ProxDeploy --privs "…Liste für PVE 9.x…"
```
## Benutzer, Rechte und Token
```bash
pveum user add proxdeploy@pve --comment "ProxDeploy"
pveum acl modify / --users proxdeploy@pve --roles ProxDeploy
pveum user token add proxdeploy@pve deploy --privsep 0
```
Die letzte Zeile gibt das Secret **einmal** aus – zusammen mit der Token-ID
`proxdeploy@pve!deploy` in ProxDeploy unter *Server → Server anlegen* eintragen.
`--privsep 0` lässt das Token die Rechte seines Benutzers erben. Mit Privilege Separation
(`--privsep 1`, Standard in der Oberfläche) braucht das Token die Rolle selbst:
`pveum acl modify / --tokens 'proxdeploy@pve!deploy' --roles ProxDeploy`.
## Enger gefasst
Statt der Rolle auf `/` genügen diese Pfade:
| Pfad | Wofür |
|---|---|
| `/vms` (oder ein Pool `/pool/<name>`) | VMs und Container anlegen, konfigurieren, starten, Guest-Agent |
| `/storage/<id>` je Storage für Gäste und Images | Disks anlegen, Images und LXC-Templates laden |
| `/sdn/zones/localnetwork` (bzw. die genutzten SDN-Zonen) | Bridges und VNets an Gäste hängen |
| `/nodes/<node>` | `Sys.AccessNetwork` für `download-url` |
| `/` | `Sys.Audit` – Cluster-Status und Rechte lesen |
## Wozu welches Recht
| Recht | Wofür |
|---|---|
| `VM.Allocate` | VMs und Container anlegen und (beim Aufräumen) löschen |
| `VM.Clone` | Templates klonen |
| `VM.Audit` | Konfiguration lesen |
| `VM.Config.Disk` | Disks anlegen, importieren, vergrößern |
| `VM.Config.CDROM` | cloud-init-Laufwerk |
| `VM.Config.CPU`, `VM.Config.Memory` | Ressourcen |
| `VM.Config.Network` | Netzwerkkarten |
| `VM.Config.HWType` | Maschinentyp q35, UEFI, SCSI-Controller |
| `VM.Config.Options` | Name, Tags, Guest-Agent, Start bei Boot |
| `VM.Config.Cloudinit` | Benutzer, SSH-Keys, IP-Konfiguration |
| `VM.PowerMgmt` | Starten und Stoppen |
| `VM.GuestAgent.Audit` (9.x) | IP-Adressen über den Guest-Agent |
| `VM.GuestAgent.Unrestricted` (9.x) | Provisionierung über den Guest-Agent (`exec`, `file-write`) |
| `VM.Monitor` (8.x) | Guest-Agent in PVE 8 |
| `Datastore.AllocateSpace` | Disks auf dem Storage |
| `Datastore.AllocateTemplate` | Cloud-Images und LXC-Templates herunterladen |
| `Datastore.Audit` | Storage-Inhalt lesen |
| `SDN.Use` | Bridges und VNets nutzen |
| `Sys.Audit` | Cluster-Status, Rechte |
| `Sys.AccessNetwork` (ab 8.2) | `download-url` |
**Optional:** `Datastore.Allocate` (heruntergeladene Images beim Aufräumen löschen), `SDN.Audit`
(VNets anzeigen), `VM.Console` (Konsole), `Sys.Modify` (privilegierte Container ab PVE 9).
## Was ein Token grundsätzlich nicht kann
Diese Dinge erlaubt Proxmox nur `root@pam` mit Passwort-Anmeldung, nie einem Token. ProxDeploy
erledigt sie deshalb per SSH, wenn SSH für den Server eingerichtet ist:
- Container-Features außer `nesting` (`keyctl`, `fuse`, `mknod`)
- cloud-init-Snippets schreiben (es gibt dafür keine API)
- Befehle in Containern ausführen (`pct exec`; es gibt keine Exec-API für Container)
- Disk-Images von einem absoluten Pfad importieren (vor PVE 8.4 nötig)
+6 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxdeploy", "name": "proxdeploy",
"version": "0.1.0-dev", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI", "description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
@@ -24,7 +24,9 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"typecheck": "tsc --noEmit" "test:container": "tests/container/profile-pruefen.sh",
"typecheck": "tsc --noEmit",
"worker": "tsx src/worker/main.ts"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0",
@@ -37,11 +39,13 @@
"lucide-react": "1.47.0", "lucide-react": "1.47.0",
"next": "16.4.0", "next": "16.4.0",
"pg": "^8.23.0", "pg": "^8.23.0",
"pg-boss": "12.37.1",
"pino": "10.4.0", "pino": "10.4.0",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
"ssh2": "1.17.0", "ssh2": "1.17.0",
"undici": "8.11.2", "undici": "8.11.2",
"yaml": "2.9.1",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
+91 -6
View File
@@ -192,10 +192,13 @@ importers:
version: 1.47.0(react@19.3.0) version: 1.47.0(react@19.3.0)
next: next:
specifier: 16.4.0 specifier: 16.4.0
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
pg: pg:
specifier: ^8.23.0 specifier: ^8.23.0
version: 8.23.1 version: 8.23.1
pg-boss:
specifier: 12.37.1
version: 12.37.1(@opentelemetry/api@1.9.1)
pino: pino:
specifier: 10.4.0 specifier: 10.4.0
version: 10.4.0 version: 10.4.0
@@ -211,6 +214,9 @@ importers:
undici: undici:
specifier: 8.11.2 specifier: 8.11.2
version: 8.11.2 version: 8.11.2
yaml:
specifier: 2.9.1
version: 2.9.1
zod: zod:
specifier: ^4.6.5 specifier: ^4.6.5
version: 4.6.5 version: 4.6.5
@@ -283,7 +289,7 @@ importers:
version: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) version: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
vitest: vitest:
specifier: 5.0.1 specifier: 5.0.1
version: 5.0.1(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
packages: packages:
@@ -1196,6 +1202,10 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} engines: {node: '>=12.4.0'}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@oxc-project/types@0.153.0': '@oxc-project/types@0.153.0':
resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
@@ -2106,6 +2116,10 @@ packages:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
cron-parser@5.10.2:
resolution: {integrity: sha512-JfAYMY0LZaWQTuict8PhtF/ksSnq8xefSDmQUnnoqvkPYkU/y3UP0DTWzJKFBz0P1IXsDG8q0vY3zHkoFZOhvw==}
engines: {node: '>=18'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3077,6 +3091,10 @@ packages:
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -3161,6 +3179,10 @@ packages:
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
engines: {node: '>=18'} engines: {node: '>=18'}
non-error@0.1.0:
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
engines: {node: '>=20'}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3245,6 +3267,13 @@ packages:
perfect-debounce@2.1.0: perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
pg-boss@12.37.1:
resolution: {integrity: sha512-LghWeOIbMCiLcTY3+8MInXaJ0wk0AOkbeUZ8oE+cGO0uoheGSW3GK57eDqEbrDGpwTIpX9BVphvdvWEDYTcJ9A==}
engines: {node: '>=22.12.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.9.0
pg-cloudflare@1.4.1: pg-cloudflare@1.4.1:
resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==}
@@ -3466,6 +3495,9 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
rrule-temporal@2.2.8:
resolution: {integrity: sha512-+wWUe0J02oMh1UWKIt1RTu2H0Cz+gP11O/iweK0MN2UP6JLGeepTUkbSBa90Hxq0W+ywSxM9IlJNouIUdMOwhg==}
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -3507,6 +3539,10 @@ packages:
seq-queue@0.0.5: seq-queue@0.0.5:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
serialize-error@13.0.2:
resolution: {integrity: sha512-G+YKwp9I6IvWMe5RlwqxdmTPIcq/G8HK/sU51gdV1XAJznYkRSTB0ph+zB9AfSxG0fx6xrqYA6i9HeIcHFTXow==}
engines: {node: '>=20'}
set-function-length@1.2.2: set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3665,6 +3701,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwindcss@4.3.3: tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
@@ -3672,6 +3712,9 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'} engines: {node: '>=6'}
temporal-spec@1.0.1:
resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==}
thread-stream@4.2.0: thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -3720,6 +3763,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
type-fest@5.10.0:
resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==}
engines: {node: '>=20'}
typed-array-buffer@1.0.3: typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4660,6 +4707,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {} '@nolyfill/is-core-module@1.0.39': {}
'@opentelemetry/api@1.9.1': {}
'@oxc-project/types@0.153.0': {} '@oxc-project/types@0.153.0': {}
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
@@ -5554,6 +5603,10 @@ snapshots:
nan: 2.29.0 nan: 2.29.0
optional: true optional: true
cron-parser@5.10.2:
dependencies:
luxon: 3.7.2
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -6637,6 +6690,8 @@ snapshots:
dependencies: dependencies:
react: 19.3.0 react: 19.3.0
luxon@3.7.2: {}
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/sourcemap-codec': 1.6.0
@@ -6691,7 +6746,7 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies: dependencies:
'@next/env': 16.4.0 '@next/env': 16.4.0
'@swc/helpers': 0.5.23 '@swc/helpers': 0.5.23
@@ -6710,6 +6765,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.4.0 '@next/swc-linux-x64-musl': 16.4.0
'@next/swc-win32-arm64-msvc': 16.4.0 '@next/swc-win32-arm64-msvc': 16.4.0
'@next/swc-win32-x64-msvc': 16.4.0 '@next/swc-win32-x64-msvc': 16.4.0
'@opentelemetry/api': 1.9.1
'@playwright/test': 1.63.0 '@playwright/test': 1.63.0
sharp: 0.35.5(@types/node@24.19.2) sharp: 0.35.5(@types/node@24.19.2)
transitivePeerDependencies: transitivePeerDependencies:
@@ -6726,6 +6782,8 @@ snapshots:
node-releases@2.0.58: {} node-releases@2.0.58: {}
non-error@0.1.0: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
@@ -6819,6 +6877,16 @@ snapshots:
perfect-debounce@2.1.0: {} perfect-debounce@2.1.0: {}
pg-boss@12.37.1(@opentelemetry/api@1.9.1):
dependencies:
'@opentelemetry/api': 1.9.1
cron-parser: 5.10.2
pg: 8.23.1
rrule-temporal: 2.2.8
serialize-error: 13.0.2
transitivePeerDependencies:
- pg-native
pg-cloudflare@1.4.1: pg-cloudflare@1.4.1:
optional: true optional: true
@@ -7049,6 +7117,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.13 '@rolldown/binding-win32-arm64-msvc': 1.2.13
'@rolldown/binding-win32-x64-msvc': 1.2.13 '@rolldown/binding-win32-x64-msvc': 1.2.13
rrule-temporal@2.2.8:
dependencies:
temporal-spec: 1.0.1
run-parallel@1.2.0: run-parallel@1.2.0:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
@@ -7088,6 +7160,11 @@ snapshots:
seq-queue@0.0.5: {} seq-queue@0.0.5: {}
serialize-error@13.0.2:
dependencies:
non-error: 0.1.0
type-fest: 5.10.0
set-function-length@1.2.2: set-function-length@1.2.2:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@@ -7300,10 +7377,14 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
tagged-tag@1.0.0: {}
tailwindcss@4.3.3: {} tailwindcss@4.3.3: {}
tapable@2.3.3: {} tapable@2.3.3: {}
temporal-spec@1.0.1: {}
thread-stream@4.2.0: thread-stream@4.2.0:
dependencies: dependencies:
real-require: 1.0.0 real-require: 1.0.0
@@ -7348,6 +7429,10 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-fest@5.10.0:
dependencies:
tagged-tag: 1.0.0
typed-array-buffer@1.0.3: typed-array-buffer@1.0.3:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -7464,7 +7549,7 @@ snapshots:
tsx: 4.23.15 tsx: 4.23.15
yaml: 2.9.1 yaml: 2.9.1
vitest@5.0.1(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): vitest@5.0.1(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
dependencies: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
@@ -7481,6 +7566,7 @@ snapshots:
vite: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) vite: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.19.2 '@types/node': 24.19.2
transitivePeerDependencies: transitivePeerDependencies:
- msw - msw
@@ -7549,8 +7635,7 @@ snapshots:
yallist@3.1.1: {} yallist@3.1.1: {}
yaml@2.9.1: yaml@2.9.1: {}
optional: true
yargs-parser@22.0.0: {} yargs-parser@22.0.0: {}
@@ -0,0 +1,2 @@
-- Umbenennen statt neu anlegen: der Inhalt bleibt erhalten.
ALTER TABLE "Server" RENAME COLUMN "faehigkeiten" TO "letzterTest";
@@ -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;
+179 -2
View File
@@ -38,6 +38,7 @@ model Benutzer {
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
protokoll Protokoll[] protokoll Protokoll[]
auftraege Auftrag[]
} }
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE. /// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
@@ -92,11 +93,187 @@ model Server {
bauNetz Json? bauNetz Json?
/// Standard-Node, -Storage, -Bridge für den Assistenten. /// Standard-Node, -Storage, -Bridge für den Assistenten.
standards Json? standards Json?
/// Ergebnis der letzten Fähigkeitserkennung (#003). /// Bericht des letzten Verbindungstests inkl. Fähigkeiten (VerbindungsBericht, #003).
faehigkeiten Json? letzterTest Json?
erreichbar Boolean? erreichbar Boolean?
pveVersion String? pveVersion String?
geprueftAm DateTime? geprueftAm DateTime?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
auftraege Auftrag[]
templates Template[]
}
enum Herkunft {
KATALOG
EIGEN
}
/// Spiegel von catalog/distros/*.yaml. Die Datei ist maßgeblich; nur `aktiv` gehört der Datenbank.
model Distribution {
id String @id
name String
familie String
/// Validierter Inhalt der YAML-Datei.
definition Json
quelltext String
dateiHash String
aktiv Boolean @default(true)
/// Gesetzt, wenn die Datei aus dem Katalog verschwunden ist – die Zeile bleibt für den Verlauf.
entferntAm DateTime?
synchronisiertAm DateTime
geaendertVonId String?
}
/// Profile aus catalog/profiles/*.yaml (KATALOG, schreibgeschützt) und eigene aus der GUI (EIGEN).
model Profil {
id String @id
name String
beschreibung String?
familien String[]
herkunft Herkunft
definition Json
/// YAML wie eingegeben – Kommentare bleiben erhalten.
quelltext String
dateiHash String?
aktiv Boolean @default(true)
entferntAm DateTime?
synchronisiertAm DateTime?
geaendertVonId String?
geaendertAm DateTime @updatedAt
}
enum GastTyp {
VM
LXC
}
enum AuftragsArt {
AUSROLLEN
TEMPLATE_BAUEN
AUFRAEUMEN
}
enum AuftragsStatus {
WARTEND
LAEUFT
ERFOLGREICH
FEHLGESCHLAGEN
ABGEBROCHEN
AUFGERAEUMT
}
enum PhasenStatus {
OFFEN
LAEUFT
ERLEDIGT
FEHLER
UEBERSPRUNGEN
}
/// Ein Hintergrundjob: Ausrollen, Template bauen oder Aufräumen. Läuft im worker.
model Auftrag {
id String @id @default(cuid())
/// Kurz und tippbar für CLI und URLs: „d-7f3k2“.
kurzId String @unique
art AuftragsArt
status AuftragsStatus @default(WARTEND)
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Restrict)
knoten String?
gastTyp GastTyp?
vmid Int?
name String?
distributionId String?
profile String[]
/// Validierte Anfrage – Grundlage für „Wiederholen“.
anfrage Json
/// Plan zum Zeitpunkt des Starts (Phasen und geplante API-Aufrufe).
plan Json?
/// Kopfzeile wie in der CLI: „Server rz1 · Node pve01 · Debian 13 (Template 9013)“ – von der Engine gesetzt.
kopf String?
/// IP, SSH-Befehl, Dauer …
ergebnis Json?
/// Was ProxDeploy angelegt hat – Grundlage fürs Aufräumen.
angelegt Json?
/// { phase, ursache, hinweis }
fehler Json?
quelle Quelle
erstelltVonId String
erstelltVon Benutzer @relation(fields: [erstelltVonId], references: [id], onDelete: Restrict)
wiederholtVonId String?
/// Bei AUFRAEUMEN: der Auftrag, dessen Reste entfernt werden.
bezugId String?
abbrechen Boolean @default(false)
erstelltAm DateTime @default(now())
gestartetAm DateTime?
beendetAm DateTime?
phasen Phase[]
log Logeintrag[]
@@index([serverId, erstelltAm])
@@index([status])
@@index([erstelltAm])
}
model Phase {
id String @id @default(cuid())
auftragId String
auftrag Auftrag @relation(fields: [auftragId], references: [id], onDelete: Cascade)
reihenfolge Int
schluessel String
titel String
status PhasenStatus @default(OFFEN)
/// Der graue Detailtext hinter dem Phasennamen – in Web und CLI derselbe.
detail String?
ursache String?
hinweis String?
gestartetAm DateTime?
beendetAm DateTime?
@@unique([auftragId, schluessel])
@@index([auftragId, reihenfolge])
}
model Logeintrag {
/// Zugleich die Event-ID im SSE-Strom (Last-Event-ID).
id BigInt @id @default(autoincrement())
auftragId String
auftrag Auftrag @relation(fields: [auftragId], references: [id], onDelete: Cascade)
zeit DateTime @default(now())
stufe String
phase String?
nachricht String
daten Json?
@@index([auftragId, id])
}
/// Vergebene VMIDs, solange ein Auftrag läuft – verhindert Doppelvergabe zwischen parallelen Aufträgen.
model VmidReservierung {
serverId String
vmid Int
auftragId String
ablaufAm DateTime
@@id([serverId, vmid])
@@index([auftragId])
}
/// Von ProxDeploy gebaute Templates. Maßgeblich ist Proxmox (Tag proxdeploy-template); hier steht, woraus gebaut wurde.
model Template {
id String @id @default(cuid())
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
knoten String
vmid Int
distributionId String
storage String
imageDatei String
pruefsumme String
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>
);
}
+10 -1
View File
@@ -11,6 +11,7 @@ import { seiteSchuetzen } from "@/server/auth/zugang";
import { serverSicht } from "@/server/services/server"; import { serverSicht } from "@/server/services/server";
import { ServerStatus } from "../server-status"; import { ServerStatus } from "../server-status";
import { ServerAktionen } from "./server-aktionen"; import { ServerAktionen } from "./server-aktionen";
import { Verbindungsbericht } from "./verbindungsbericht";
export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise<Metadata> {
return { title: (await params).name }; return { title: (await params).name };
@@ -67,6 +68,13 @@ export default async function ServerDetail({ params }: { params: Promise<{ name:
</dl> </dl>
</Karte> </Karte>
<Verbindungsbericht
name={server.name}
bericht={server.letzterTest}
geprueftAm={server.geprueftAm}
verwalten={verwalten}
/>
<Karte titel="SSH zu den Nodes (optional)"> <Karte titel="SSH zu den Nodes (optional)">
{!server.sshAktiv ? ( {!server.sshAktiv ? (
<Status art="offen">Nicht genutzt. Deployments laufen dann nur über die API.</Status> <Status art="offen">Nicht genutzt. Deployments laufen dann nur über die API.</Status>
@@ -80,7 +88,8 @@ export default async function ServerDetail({ params }: { params: Promise<{ name:
</p> </p>
<Code>{`echo '${server.sshOeffentlich}' >> ~/.ssh/authorized_keys`}</Code> <Code>{`echo '${server.sshOeffentlich}' >> ~/.ssh/authorized_keys`}</Code>
<p className="text-muted"> <p className="text-muted">
Host-Keys werden beim ersten Verbindungstest angezeigt und nach Bestätigung gepinnt (#003). Host-Keys erscheinen nach dem Verbindungstest beim jeweiligen Node und werden erst nach
Bestätigung genutzt.
</p> </p>
</div> </div>
) : ( ) : (
@@ -0,0 +1,320 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status, type StatusArt } from "@/components/status";
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
import type { Strategie } from "@/engine/proxmox/faehigkeiten";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
import { bytes, prozent, zeitpunkt } from "@/lib/formate";
type TestErgebnis =
{ ok: true; bericht: VerbindungsBericht } | { ok: false; art: string; ursache: string; hinweis: string };
const WEG_STATUS: Record<Strategie["weg"], { art: StatusArt; text: string }> = {
api: { art: "erledigt", text: "per API" },
ssh: { art: "laeuft", text: "per SSH" },
nicht_moeglich: { art: "fehler", text: "nicht möglich" },
};
const VORGAENGE: Array<[keyof VerbindungsBericht["faehigkeiten"], string]> = [
["templateBau", "Template bauen"],
["vmProvisionierung", "VM provisionieren"],
["lxcProvisionierung", "LXC provisionieren"],
["snippets", "cloud-init-Snippets"],
["lxcFeaturesErweitert", "LXC-Features außer nesting"],
];
/**
* Ergebnis des Verbindungstests: Version, Nodes, Storages, Bridges, fehlende Rechte und – am
* wichtigsten – welcher Vorgang auf welchem Weg geht. Host-Keys lassen sich hier bestätigen.
*/
export function Verbindungsbericht({
name,
bericht: anfangsBericht,
geprueftAm,
verwalten,
}: {
name: string;
bericht: VerbindungsBericht | null;
geprueftAm: string | null;
verwalten: boolean;
}) {
const router = useRouter();
const [bericht, setBericht] = useState(anfangsBericht);
const [fehler, setFehler] = useState<{ ursache: string; hinweis?: string } | null>(null);
const [laeuft, setLaeuft] = useState(false);
const [zeit, setZeit] = useState(geprueftAm);
async function testen() {
setLaeuft(true);
setFehler(null);
try {
const ergebnis = await apiAufruf<TestErgebnis>("POST", `/api/v1/server/${name}/test`);
setZeit(new Date().toISOString());
if (ergebnis.ok) setBericht(ergebnis.bericht);
else setFehler({ ursache: ergebnis.ursache, hinweis: ergebnis.hinweis });
router.refresh();
} catch (ursache) {
setFehler({ ursache: ursache instanceof ApiFehler ? ursache.message : "Unerwarteter Fehler." });
} finally {
setLaeuft(false);
}
}
async function bestaetigen(knoten: string, hostSchluessel: string) {
try {
await apiAufruf("POST", `/api/v1/server/${name}/ssh/hostschluessel`, { knoten, hostSchluessel });
await testen();
} catch (ursache) {
setFehler({
ursache: ursache instanceof ApiFehler ? ursache.message : "Unerwarteter Fehler.",
hinweis: ursache instanceof ApiFehler ? ursache.hinweis : undefined,
});
}
}
const pflichtFehlend = bericht?.fehlendeRechte.filter((r) => r.pflicht) ?? [];
const optionalFehlend = bericht?.fehlendeRechte.filter((r) => !r.pflicht) ?? [];
return (
<div className="flex flex-col gap-6">
<Karte
titel="Verbindungstest"
aktion={
<Knopf variante="sekundaer" onClick={testen} disabled={laeuft}>
{laeuft ? "Testet …" : "Verbindung testen"}
</Knopf>
}
>
<div aria-live="polite" className="flex flex-col gap-3 text-sm">
{laeuft ? (
<Status art="laeuft">Liest Version, Nodes, Storages, Netzwerk und Rechte …</Status>
) : null}
{fehler ? (
<div>
<Status art="fehler">{fehler.ursache}</Status>
{fehler.hinweis ? <p className="text-muted mt-1">{fehler.hinweis}</p> : null}
</div>
) : null}
{!bericht && !fehler && !laeuft ? <Status art="offen">Noch nicht getestet.</Status> : null}
{bericht ? (
<dl className="grid gap-x-6 gap-y-2 sm:grid-cols-[max-content_1fr]">
<dt className="text-muted">Proxmox VE</dt>
<dd className="font-mono">{bericht.version}</dd>
<dt className="text-muted">Cluster</dt>
<dd>
{bericht.cluster ? (
<>
<span className="font-mono">{bericht.cluster.name}</span> ·{" "}
{bericht.cluster.quorate ? "Quorum vorhanden" : "kein Quorum"}
</>
) : (
"Einzelhost"
)}
</dd>
<dt className="text-muted">Geprüft</dt>
<dd>{zeitpunkt(zeit)}</dd>
</dl>
) : null}
{bericht?.warnungen.map((warnung) => (
<Status key={warnung} art="laeuft">
{warnung}
</Status>
))}
</div>
</Karte>
{bericht ? (
<>
<Karte titel="Was geht auf diesem Server">
<ul className="flex flex-col gap-3 text-sm">
{VORGAENGE.map(([schluessel, titel]) => {
const strategie = bericht.faehigkeiten[schluessel];
const anzeige = WEG_STATUS[strategie.weg];
return (
<li key={schluessel} className="grid gap-1 sm:grid-cols-[14rem_8rem_1fr] sm:gap-3">
<span className="font-medium">{titel}</span>
<Status art={anzeige.art}>{anzeige.text}</Status>
<span className="text-muted">{strategie.grund}</span>
</li>
);
})}
</ul>
</Karte>
<Karte titel="Rechte des API-Tokens">
{pflichtFehlend.length === 0 ? (
<Status art="erledigt">Alle nötigen Rechte vorhanden.</Status>
) : (
<div className="flex flex-col gap-3 text-sm">
<Status art="fehler">
{pflichtFehlend.length === 1 ? "Ein Recht fehlt" : `${pflichtFehlend.length} Rechte fehlen`}{" "}
– die Rolle „ProxDeploy“ steht vollständig in docs/proxmox-rechte.md.
</Status>
<RechteListe rechte={pflichtFehlend} />
</div>
)}
{optionalFehlend.length > 0 ? (
<details className="mt-4 text-sm">
<summary className="text-muted cursor-pointer">
{optionalFehlend.length} optionale Rechte fehlen
</summary>
<div className="mt-2">
<RechteListe rechte={optionalFehlend} />
</div>
</details>
) : null}
</Karte>
{bericht.knoten.map((knoten) => (
<Karte
key={knoten.name}
titel={
<span className="font-mono">
{knoten.name}
{knoten.ip ? <span className="text-muted font-normal"> · {knoten.ip}</span> : null}
</span>
}
aktion={
knoten.online ? <Status art="erledigt">online</Status> : <Status art="fehler">offline</Status>
}
>
{knoten.online ? (
<div className="flex flex-col gap-5 text-sm">
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-4">
<dt className="text-muted">Version</dt>
<dd className="font-mono">{knoten.version ?? "–"}</dd>
<dt className="text-muted">CPU</dt>
<dd>
{knoten.cpus ?? "–"} Kerne · {prozent(knoten.cpuAuslastung)}
</dd>
<dt className="text-muted">RAM</dt>
<dd>
{bytes(knoten.speicherBelegt)} / {bytes(knoten.speicherGesamt)}
</dd>
</dl>
<div className="overflow-x-auto">
<table className="w-full min-w-[32rem] text-left">
<caption className="text-muted mb-2 text-left text-xs font-medium tracking-wide uppercase">
Storages
</caption>
<thead className="text-muted">
<tr>
<th className="py-1 pr-4 font-medium">Name</th>
<th className="py-1 pr-4 font-medium">Typ</th>
<th className="py-1 pr-4 font-medium">Inhalte</th>
<th className="py-1 font-medium">Frei</th>
</tr>
</thead>
<tbody>
{knoten.storages.map((s) => (
<tr key={s.storage} className="border-linie border-t">
<td className="py-1.5 pr-4 font-mono">{s.storage}</td>
<td className="py-1.5 pr-4">{s.typ}</td>
<td className="text-muted py-1.5 pr-4">{s.inhalte.join(", ")}</td>
<td className="py-1.5">
{bytes(s.frei)} / {bytes(s.gesamt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<p className="text-muted mb-2 text-xs font-medium tracking-wide uppercase">Bridges</p>
<ul className="flex flex-wrap gap-2">
{knoten.bridges.map((b) => (
<li key={b.name} className="border-linie rounded-lg border px-3 py-1.5">
<span className="font-mono">{b.name}</span>
<span className="text-muted">
{b.vlanBewusst ? " · VLAN-fähig" : ""}
{b.cidr ? ` · ${b.cidr}` : ""}
{b.kommentar ? ` · ${b.kommentar}` : ""}
</span>
</li>
))}
</ul>
</div>
{knoten.ssh ? (
<div className="flex flex-col gap-2">
<p className="text-muted text-xs font-medium tracking-wide uppercase">SSH</p>
{knoten.ssh.zustand === "ok" ? (
<Status art="erledigt">Verbunden mit {knoten.ssh.host} · Host-Key bestätigt</Status>
) : knoten.ssh.zustand === "nicht_bestaetigt" ? (
<div className="flex flex-col gap-2">
<Status art="laeuft">
Host-Key noch nicht bestätigt – bitte mit dem Node vergleichen:
</Status>
<code className="bg-grund border-linie rounded-lg border px-3 py-2 font-mono text-xs break-all">
{knoten.ssh.hostSchluessel}
</code>
<p className="text-muted">
Auf dem Node:{" "}
<span className="font-mono">
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
</span>
</p>
{verwalten && knoten.ssh.hostSchluessel ? (
<Knopf
variante="sekundaer"
className="self-start"
onClick={() => bestaetigen(knoten.name, knoten.ssh!.hostSchluessel!)}
>
Host-Key von {knoten.name} bestätigen
</Knopf>
) : null}
</div>
) : (
<Status art="fehler">{knoten.ssh.meldung ?? "SSH nicht möglich."}</Status>
)}
</div>
) : null}
</div>
) : (
<p className="text-muted text-sm">Node ist offline – keine Details.</p>
)}
</Karte>
))}
{bericht.vnets && bericht.vnets.length > 0 ? (
<Karte titel="SDN-VNets">
<ul className="flex flex-wrap gap-2 text-sm">
{bericht.vnets.map((v) => (
<li key={v.name} className="border-linie rounded-lg border px-3 py-1.5">
<span className="font-mono">{v.name}</span>
<span className="text-muted">
{" "}
· Zone {v.zone}
{v.tag ? ` · VLAN ${v.tag}` : ""}
{v.alias ? ` · ${v.alias}` : ""}
</span>
</li>
))}
</ul>
</Karte>
) : null}
</>
) : null}
</div>
);
}
function RechteListe({ rechte }: { rechte: VerbindungsBericht["fehlendeRechte"] }) {
return (
<ul className="flex flex-col gap-1.5">
{rechte.map((r) => (
<li key={`${r.recht}${r.pfad}`} className="grid gap-x-3 sm:grid-cols-[16rem_10rem_1fr]">
<span className="font-mono">{r.recht}</span>
<span className="text-muted font-mono">{r.pfad}</span>
<span className="text-muted">{r.wofuer}</span>
</li>
))}
</ul>
);
}
+6 -5
View File
@@ -6,7 +6,7 @@ import { useState } from "react";
import { EINGABE_KLASSE, Feld, Schalter, beschreibung, Code } from "@/components/formular"; import { EINGABE_KLASSE, Feld, Schalter, beschreibung, Code } from "@/components/formular";
import { Karte } from "@/components/karte"; import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf"; import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status"; import { Status, StatusSymbol } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client"; import { apiAufruf, ApiFehler } from "@/lib/api-client";
import type { ServerSicht } from "@/lib/schemas/server"; import type { ServerSicht } from "@/lib/schemas/server";
@@ -219,8 +219,9 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
<span className="text-muted text-sm">SHA-256-Fingerprint</span> <span className="text-muted text-sm">SHA-256-Fingerprint</span>
<Code>{zertifikat.fingerabdruck}</Code> <Code>{zertifikat.fingerabdruck}</Code>
<p className="text-muted text-sm"> <p className="text-muted text-sm">
Zum Vergleichen auf dem Node: <span className="font-mono">pvenode cert info</span> oder in Zum Vergleichen auf dem Node: <span className="font-mono">pvenode cert info</span> –
der Proxmox-Oberfläche unter Node → System → Zertifikate. maßgeblich ist <span className="font-mono">pveproxy-ssl.pem</span>, falls vorhanden, sonst{" "}
<span className="font-mono">pve-ssl.pem</span> (nicht die Root-CA).
</p> </p>
</div> </div>
<Schalter <Schalter
@@ -246,7 +247,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
{felder["fingerabdruck"] ? ( {felder["fingerabdruck"] ? (
<p className="text-fehler text-sm"> <p className="text-fehler text-sm">
<span aria-hidden="true">✗ </span> <StatusSymbol art="fehler" className="mr-1 inline translate-y-[0.125em]" />
{felder["fingerabdruck"]} {felder["fingerabdruck"]}
</p> </p>
) : null} ) : null}
@@ -295,7 +296,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
</Feld> </Feld>
</div> </div>
<p className="text-muted text-sm"> <p className="text-muted text-sm">
Welche Rechte das Token braucht, steht im README unter „Proxmox-Rechte“. Welche Rechte das Token braucht, steht in der Doku „Proxmox-Rechte“ (docs/proxmox-rechte.md).
</p> </p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
+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,19 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { hostSchluesselBestaetigen } from "@/server/services/server";
type Kontext = { params: Promise<{ name: string }> };
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "serverVerwalten");
return NextResponse.json(
await hostSchluesselBestaetigen(
(await params).name,
await jsonLesen(anfrage),
aufrufer,
aufruferAdresse(anfrage.headers),
),
);
});
@@ -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 });
});
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { serverTesten } from "@/server/services/server";
type Kontext = { params: Promise<{ name: string }> };
/** Verbindungstest: liest nur. Auch Operatoren dürfen ihn auslösen, um vor dem Ausrollen zu prüfen. */
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(
await serverTesten((await params).name, aufrufer, aufruferAdresse(anfrage.headers)),
);
});
+2 -1
View File
@@ -1,4 +1,5 @@
import { clsx } from "clsx"; import { clsx } from "clsx";
import { StatusSymbol } from "@/components/status";
/** /**
* Formular-Bausteine: echtes `<label>`, Fehlermeldung per `aria-describedby` verbunden, * Formular-Bausteine: echtes `<label>`, Fehlermeldung per `aria-describedby` verbunden,
@@ -30,7 +31,7 @@ export function Feld({
{children} {children}
{fehler ? ( {fehler ? (
<p id={`${id}-fehler`} className="text-fehler text-sm"> <p id={`${id}-fehler`} className="text-fehler text-sm">
<span aria-hidden="true">✗ </span> <StatusSymbol art="fehler" className="mr-1 inline translate-y-[0.125em]" />
{fehler} {fehler}
</p> </p>
) : hilfe ? ( ) : hilfe ? (
+1 -1
View File
@@ -20,7 +20,7 @@ export function ServerUmschalter({
return ( return (
<div className="flex flex-col gap-1.5 px-2"> <div className="flex flex-col gap-1.5 px-2">
<label htmlFor="server-umschalter" className="text-muted text-xs font-medium tracking-wide uppercase"> <label htmlFor="server-umschalter" className="text-muted text-xs font-medium tracking-wide uppercase">
Server Aktiver Server
</label> </label>
<select <select
id="server-umschalter" id="server-umschalter"
+31 -6
View File
@@ -1,11 +1,13 @@
import { clsx } from "clsx"; import { clsx } from "clsx";
/** /**
* Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut). * Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut). Die Symbole
* Dieselben Symbole wie in der CLI-Ausgabe. * entsprechen ✓ ◐ ✗ ○ der CLI, sind hier aber gezeichnet: ◐ und ○ fehlen im Web-Subset von
* JetBrains Mono, der Browser fiele auf eine beliebige Schrift mit winzigem Glyph zurück.
*/ */
export type StatusArt = "erledigt" | "laeuft" | "fehler" | "offen"; export type StatusArt = "erledigt" | "laeuft" | "fehler" | "offen";
/** Die Zeichen der CLI – für Textausgaben (Terminal-Karte, Kopiertext). */
export const STATUS_SYMBOL: Record<StatusArt, string> = { export const STATUS_SYMBOL: Record<StatusArt, string> = {
erledigt: "✓", erledigt: "✓",
laeuft: "◐", laeuft: "◐",
@@ -20,6 +22,31 @@ const STATUS_FARBE: Record<StatusArt, string> = {
offen: "text-muted", offen: "text-muted",
}; };
export function StatusSymbol({ art, className }: { art: StatusArt; className?: string }) {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={clsx("inline-block h-[1em] w-[1em] shrink-0", className)}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{art === "erledigt" ? <path d="M3 8.5 6.5 12 13 4.5" /> : null}
{art === "fehler" ? <path d="M4 4 12 12M12 4 4 12" /> : null}
{art === "offen" ? <circle cx="8" cy="8" r="5.5" strokeWidth="1.6" /> : null}
{art === "laeuft" ? (
<>
<circle cx="8" cy="8" r="5.5" strokeWidth="1.6" />
<path d="M8 2.5a5.5 5.5 0 0 1 0 11Z" fill="currentColor" stroke="none" />
</>
) : null}
</svg>
);
}
export function Status({ export function Status({
art, art,
children, children,
@@ -30,10 +57,8 @@ export function Status({
className?: string; className?: string;
}) { }) {
return ( return (
<span className={clsx("inline-flex items-center gap-1.5", STATUS_FARBE[art], className)}> <span className={clsx("inline-flex items-baseline gap-1.5", STATUS_FARBE[art], className)}>
<span aria-hidden="true" className="font-mono"> <StatusSymbol art={art} className="translate-y-[0.125em]" />
{STATUS_SYMBOL[art]}
</span>
<span>{children}</span> <span>{children}</span>
</span> </span>
); );
+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"; import { ProxmoxFehler } from "./fehler";
/** /**
@@ -10,6 +10,7 @@ type LogZeile = { n: number; t: string };
/** UPID:node:pid:pstart:starttime:type:id:user: – der Node steht an zweiter Stelle. */ /** UPID:node:pid:pstart:starttime:type:id:user: – der Node steht an zweiter Stelle. */
export function nodeAusUpid(upid: string): string { export function nodeAusUpid(upid: string): string {
if (istTrockenUpid(upid)) return upid.split(":")[2] ?? "node";
const teile = upid.split(":"); const teile = upid.split(":");
if (teile[0] !== "UPID" || !teile[1]) { if (teile[0] !== "UPID" || !teile[1]) {
throw new ProxmoxFehler("api", `„${upid}“ ist keine UPID.`, "Antwort von Proxmox prüfen."); throw new ProxmoxFehler("api", `„${upid}“ ist keine UPID.`, "Antwort von Proxmox prüfen.");
@@ -32,6 +33,7 @@ export async function aufgabeAbwarten(
upid: string, upid: string,
optionen: AbwartenOptionen = {}, optionen: AbwartenOptionen = {},
): Promise<void> { ): Promise<void> {
if (istTrockenUpid(upid)) return;
const node = nodeAusUpid(upid); const node = nodeAusUpid(upid);
const intervall = optionen.intervallMs ?? 1000; const intervall = optionen.intervallMs ?? 1000;
const zeitlimit = optionen.zeitlimitMs ?? 30 * 60 * 1000; const zeitlimit = optionen.zeitlimitMs ?? 30 * 60 * 1000;
+29 -1
View File
@@ -26,6 +26,17 @@ export type AufrufBeobachter = (
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler }, ergebnis: { dauerMs: number; fehler?: ProxmoxFehler },
) => void; ) => void;
/**
* Trockenlauf (Dry-Run): lesende Aufrufe gehen wirklich an Proxmox, schreibende werden nur
* aufgezeichnet und mit einer Platzhalter-UPID beantwortet. Dieselben Phasen laufen so im
* Trockenlauf und im Ernstfall – der angezeigte Plan kann nicht vom tatsächlichen Ablauf abweichen.
*/
export const TROCKEN_UPID_PRAEFIX = "UPID:trocken:";
export function istTrockenUpid(upid: unknown): boolean {
return typeof upid === "string" && upid.startsWith(TROCKEN_UPID_PRAEFIX);
}
const STANDARD_ZEITLIMIT_MS = 30_000; const STANDARD_ZEITLIMIT_MS = 30_000;
/** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */ /** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */
@@ -60,7 +71,11 @@ export class ProxmoxClient {
constructor( constructor(
private readonly verbindung: ProxmoxVerbindung, private readonly verbindung: ProxmoxVerbindung,
private readonly optionen: { zeitlimitMs?: number; beobachter?: AufrufBeobachter } = {}, private readonly optionen: {
zeitlimitMs?: number;
beobachter?: AufrufBeobachter;
trocken?: boolean;
} = {},
) { ) {
if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) { if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) {
throw new ProxmoxFehler( throw new ProxmoxFehler(
@@ -90,7 +105,20 @@ export class ProxmoxClient {
return await this.aufrufen<T>({ methode: "DELETE", pfad, parameter }); return await this.aufrufen<T>({ methode: "DELETE", pfad, parameter });
} }
/** Im Trockenlauf aufgezeichnete schreibende Aufrufe, in Reihenfolge. */
readonly geplant: Aufruf[] = [];
get trocken(): boolean {
return Boolean(this.optionen.trocken);
}
async aufrufen<T>(aufruf: Aufruf): Promise<T> { async aufrufen<T>(aufruf: Aufruf): Promise<T> {
if (this.optionen.trocken && aufruf.methode !== "GET") {
this.geplant.push(aufruf);
this.optionen.beobachter?.(aufruf, { dauerMs: 0 });
const knoten = /^\/nodes\/([^/]+)/.exec(aufruf.pfad)?.[1] ?? "node";
return `${TROCKEN_UPID_PRAEFIX}${knoten}:${this.geplant.length}` as T;
}
const beginn = Date.now(); const beginn = Date.now();
try { try {
const ergebnis = await this.senden<T>(aufruf); const ergebnis = await this.senden<T>(aufruf);
+319
View File
@@ -0,0 +1,319 @@
import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
import { pfad, type ProxmoxClient } from "./client";
import {
faehigkeitenAbleiten,
pfadFuer,
rechtePruefen,
versionLesen,
type Faehigkeiten,
type FehlendesRecht,
type PveVersion,
type RechteJePfad,
type SshZustand,
type StorageInfo,
} from "./faehigkeiten";
import { ProxmoxFehler } from "./fehler";
/**
* Der Verbindungstest: liest alles, was ProxDeploy über einen Server wissen muss, und leitet die
* Fähigkeiten ab. Nur lesende Aufrufe. Fehlt für einen Teil ein Recht, wird das als Warnung
* vermerkt statt den ganzen Test abzubrechen – der Bericht soll gerade dann helfen.
*/
export type BridgeInfo = {
name: string;
typ: string;
vlanBewusst: boolean;
aktiv: boolean;
cidr: string | null;
kommentar: string | null;
};
export type SshKnoten = {
zustand: SshZustand;
host: string;
hostSchluessel: string | null;
bestaetigt: boolean;
meldung: string | null;
};
export type KnotenInfo = {
name: string;
online: boolean;
ip: string | null;
version: string | null;
cpuAuslastung: number | null;
cpus: number | null;
speicherBelegt: number | null;
speicherGesamt: number | null;
storages: StorageInfo[];
bridges: BridgeInfo[];
ssh: SshKnoten | null;
};
export type VnetInfo = { name: string; zone: string; tag: number | null; alias: string | null };
export type VerbindungsBericht = {
geprueftAm: string;
version: string;
release: string;
cluster: { name: string; quorate: boolean } | null;
knoten: KnotenInfo[];
vnets: VnetInfo[] | null;
fehlendeRechte: FehlendesRecht[];
ssh: SshZustand;
faehigkeiten: Faehigkeiten;
warnungen: string[];
};
export type SshZugang = {
benutzer: string;
port: number;
privatSchluessel: string | null;
hostSchluessel: Record<string, string>;
};
type ClusterStatusEintrag =
| { type: "cluster"; name: string; quorate: number; nodes: number }
| { type: "node"; name: string; online: number; ip?: string; local?: number };
type NodeEintrag = {
node: string;
status: string;
cpu?: number;
maxcpu?: number;
mem?: number;
maxmem?: number;
};
type StorageEintrag = {
storage: string;
type: string;
content: string;
active?: number;
enabled?: number;
shared?: number;
avail?: number;
total?: number;
};
type NetzEintrag = {
iface: string;
type: string;
active?: number;
bridge_vlan_aware?: number;
cidr?: string;
comments?: string;
};
async function versuchen<T>(warnungen: string[], text: string, aufruf: () => Promise<T>): Promise<T | null> {
try {
return await aufruf();
} catch (fehler) {
if (fehler instanceof ProxmoxFehler && (fehler.art === "recht" || fehler.art === "nicht_gefunden")) {
warnungen.push(`${text}: ${fehler.ursache}`);
return null;
}
throw fehler;
}
}
export async function erkunden(
client: ProxmoxClient,
optionen: { serverHost: string; ssh: SshZugang | null; jetzt?: () => Date },
): Promise<VerbindungsBericht> {
const warnungen: string[] = [];
const version = await client.get<{ version: string; release: string }>("/version");
const pveVersion: PveVersion = versionLesen(version.version) ?? {
major: 0,
minor: 0,
patch: 0,
text: version.version,
};
if (pveVersion.major < 8) {
warnungen.push(`PVE ${version.version} wird nicht unterstützt – ProxDeploy braucht 8.0 oder neuer.`);
}
// Cluster und Nodes. /cluster/status braucht Sys.Audit; ohne bleibt die Node-Liste.
const clusterStatus = await versuchen(warnungen, "Cluster-Status", () =>
client.get<ClusterStatusEintrag[]>("/cluster/status"),
);
const clusterEintrag = clusterStatus?.find((e) => e.type === "cluster");
const nodeIps = new Map(
(clusterStatus ?? [])
.filter((e): e is Extract<ClusterStatusEintrag, { type: "node" }> => e.type === "node")
.map((e) => [e.name, e.ip ?? null]),
);
const nodes = await client.get<NodeEintrag[]>("/nodes");
const knoten: KnotenInfo[] = [];
for (const node of [...nodes].sort((a, b) => a.node.localeCompare(b.node))) {
const online = node.status === "online";
let storages: StorageInfo[] = [];
let bridges: BridgeInfo[] = [];
let nodeVersion: string | null = null;
if (online) {
const v = await versuchen(warnungen, `Version von ${node.node}`, () =>
client.get<{ version: string }>(pfad`/nodes/${node.node}/version`),
);
nodeVersion = v?.version ?? null;
storages = (
(await versuchen(warnungen, `Storages von ${node.node}`, () =>
client.get<StorageEintrag[]>(pfad`/nodes/${node.node}/storage`),
)) ?? []
)
.filter((s) => s.enabled !== 0)
.map((s) => ({
storage: s.storage,
typ: s.type,
inhalte: s.content.split(",").filter(Boolean),
aktiv: s.active === 1,
geteilt: s.shared === 1,
frei: s.avail ?? null,
gesamt: s.total ?? null,
}))
.sort((a, b) => a.storage.localeCompare(b.storage));
bridges = (
(await versuchen(warnungen, `Netzwerk von ${node.node}`, () =>
client.get<NetzEintrag[]>(pfad`/nodes/${node.node}/network`),
)) ?? []
)
.filter((n) => n.type === "bridge" || n.type === "OVSBridge")
.map((n) => ({
name: n.iface,
typ: n.type,
vlanBewusst: n.bridge_vlan_aware === 1 || n.type === "OVSBridge",
aktiv: n.active === 1,
cidr: n.cidr ?? null,
kommentar: n.comments?.trim() || null,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
knoten.push({
name: node.node,
online,
ip: nodeIps.get(node.node) ?? null,
version: nodeVersion,
cpuAuslastung: node.cpu ?? null,
cpus: node.maxcpu ?? null,
speicherBelegt: node.mem ?? null,
speicherGesamt: node.maxmem ?? null,
storages,
bridges,
ssh: null,
});
}
const vnetsRoh = await versuchen(warnungen, "SDN-VNets", () =>
client.get<Array<{ vnet: string; zone: string; tag?: number; alias?: string }>>("/cluster/sdn/vnets"),
);
const vnets =
vnetsRoh?.map((v) => ({ name: v.vnet, zone: v.zone, tag: v.tag ?? null, alias: v.alias ?? null })) ??
null;
// Rechte: effektiv je Pfad. Storage-Rechte nur für Storages, die Gäste aufnehmen können.
const gastStorages = [
...new Set(
knoten.flatMap((k) =>
k.storages
.filter((s) => s.inhalte.includes("images") || s.inhalte.includes("rootdir"))
.map((s) => s.storage),
),
),
];
const onlineNodes = knoten.filter((k) => k.online).map((k) => k.name);
const pfade = [
"/",
"/vms",
pfadFuer("sdn", {}),
...onlineNodes.map((node) => pfadFuer("node", { node })),
...gastStorages.map((storage) => pfadFuer("storage", { storage })),
];
const rechte: RechteJePfad = {};
for (const p of pfade) {
const antwort = await versuchen(warnungen, `Rechte auf ${p}`, () =>
client.get<RechteJePfad>("/access/permissions", { path: p }),
);
rechte[p] = antwort?.[p] ?? {};
}
const fehlendeRechte = rechtePruefen(pveVersion, rechte, { storages: gastStorages, nodes: onlineNodes });
// SSH je Node: nur wenn eingerichtet. Ohne gepinnten Host-Key wird erkundet, nicht genutzt.
let sshGesamt: SshZustand = "aus";
if (optionen.ssh) {
if (!optionen.ssh.privatSchluessel) {
sshGesamt = "kein_schluessel";
} else {
const zustaende: SshZustand[] = [];
for (const k of knoten.filter((k) => k.online)) {
// Einzelhost ohne Cluster-Status: die Adresse aus der Server-URL.
const host = k.ip ?? (knoten.length === 1 ? optionen.serverHost : k.name);
const pin = optionen.ssh.hostSchluessel[k.name] ?? null;
try {
const ergebnis = await sshAusfuehren(
{
host,
port: optionen.ssh.port,
benutzer: optionen.ssh.benutzer,
privatSchluessel: optionen.ssh.privatSchluessel,
hostSchluessel: pin,
},
"pveversion",
{ erkunden: !pin, zeitlimitMs: 15_000 },
);
const zustand: SshZustand = pin ? "ok" : "nicht_bestaetigt";
k.ssh = {
zustand,
host,
hostSchluessel: ergebnis.hostSchluessel,
bestaetigt: Boolean(pin),
meldung: ergebnis.stdout.trim() || null,
};
zustaende.push(zustand);
} catch (fehler) {
if (!(fehler instanceof SshFehler)) throw fehler;
k.ssh = {
zustand: "fehler",
host,
hostSchluessel: fehler.hostSchluessel ?? null,
bestaetigt: Boolean(pin),
meldung: `${fehler.ursache} ${fehler.hinweis}`,
};
zustaende.push("fehler");
}
}
sshGesamt = zustaende.includes("fehler")
? "fehler"
: zustaende.includes("nicht_bestaetigt")
? "nicht_bestaetigt"
: zustaende.length > 0
? "ok"
: "fehler";
}
}
// Im gemischten Cluster zählt die älteste Version.
const versionen = knoten.map((k) => (k.version ? versionLesen(k.version) : null)).filter((v) => v !== null);
const aelteste = versionen.reduce<PveVersion>(
(alt, v) => (v.major < alt.major || (v.major === alt.major && v.minor < alt.minor) ? v : alt),
pveVersion,
);
const alleStorages = knoten.flatMap((k) => k.storages);
return {
geprueftAm: (optionen.jetzt?.() ?? new Date()).toISOString(),
version: version.version,
release: version.release,
cluster: clusterEintrag ? { name: clusterEintrag.name, quorate: clusterEintrag.quorate === 1 } : null,
knoten,
vnets,
fehlendeRechte,
ssh: sshGesamt,
faehigkeiten: faehigkeitenAbleiten({
version: aelteste,
storages: alleStorages,
ssh: sshGesamt,
rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
}),
warnungen,
};
}
+287
View File
@@ -0,0 +1,287 @@
/**
* Was geht auf diesem Server per API, was nur per SSH, was gar nicht – als reine Funktion aus
* den erkundeten Daten. Grundlage ist die Prüfung der Proxmox-API vom 2026-10-12
* (docs/architektur.md, Abschnitt 3).
*/
export type PveVersion = { major: number; minor: number; patch: number; text: string };
export function versionLesen(text: string): PveVersion | null {
const treffer = /^(\d+)\.(\d+)(?:[.-](\d+))?/.exec(text.trim());
if (!treffer) return null;
return {
major: Number(treffer[1]),
minor: Number(treffer[2]),
patch: Number(treffer[3] ?? 0),
text: text.trim(),
};
}
export function mindestens(version: PveVersion, major: number, minor: number): boolean {
return version.major > major || (version.major === major && version.minor >= minor);
}
// ── Rechte ──────────────────────────────────────────────────────────────────────────────────────
export type RechtBereich = "vms" | "storage" | "sdn" | "node" | "wurzel";
export type RechtAnforderung = {
recht: string;
bereich: RechtBereich;
/** Wofür – erscheint in der Oberfläche neben einem fehlenden Recht. */
wofuer: string;
pflicht: boolean;
};
const VM_RECHTE: Array<[string, string]> = [
["VM.Allocate", "VMs und Container anlegen und löschen"],
["VM.Clone", "Templates klonen"],
["VM.Audit", "Konfiguration lesen"],
["VM.Config.Disk", "Disks anlegen, importieren, vergrößern"],
["VM.Config.CDROM", "cloud-init-Laufwerk"],
["VM.Config.CPU", "CPU einstellen"],
["VM.Config.Memory", "RAM einstellen"],
["VM.Config.Network", "Netzwerk einstellen"],
["VM.Config.HWType", "Maschinentyp, BIOS, SCSI-Controller"],
["VM.Config.Options", "Name, Tags, Agent, Start bei Boot"],
["VM.Config.Cloudinit", "cloud-init (Benutzer, Keys, IP)"],
["VM.PowerMgmt", "Starten und Stoppen"],
];
/**
* Die Rolle `ProxDeploy` je Hauptversion. PVE 9 hat `VM.Monitor` gestrichen und den Guest-Agent
* in eigene Rechte aufgeteilt; `Sys.AccessNetwork` (für download-url) gibt es seit 8.2.
*/
export function benoetigteRechte(version: PveVersion): RechtAnforderung[] {
const liste: RechtAnforderung[] = VM_RECHTE.map(([recht, wofuer]) => ({
recht,
bereich: "vms",
wofuer,
pflicht: true,
}));
if (version.major >= 9) {
liste.push(
{
recht: "VM.GuestAgent.Audit",
bereich: "vms",
wofuer: "IP-Adressen über den Guest-Agent",
pflicht: true,
},
{
recht: "VM.GuestAgent.Unrestricted",
bereich: "vms",
wofuer: "Provisionierung über den Guest-Agent (exec, file-write)",
pflicht: true,
},
);
} else {
liste.push({ recht: "VM.Monitor", bereich: "vms", wofuer: "Guest-Agent (in PVE 8)", pflicht: true });
}
liste.push(
{
recht: "Datastore.AllocateSpace",
bereich: "storage",
wofuer: "Disks auf dem Storage anlegen",
pflicht: true,
},
{
recht: "Datastore.AllocateTemplate",
bereich: "storage",
wofuer: "Images und LXC-Templates herunterladen",
pflicht: true,
},
{ recht: "Datastore.Audit", bereich: "storage", wofuer: "Storage-Inhalt lesen", pflicht: true },
{ recht: "SDN.Use", bereich: "sdn", wofuer: "Bridges und VNets an Gäste hängen", pflicht: true },
{ recht: "Sys.Audit", bereich: "wurzel", wofuer: "Cluster-Status und Rechte lesen", pflicht: true },
);
if (mindestens(version, 8, 2)) {
liste.push({
recht: "Sys.AccessNetwork",
bereich: "node",
wofuer: "Images per download-url laden",
pflicht: true,
});
} else {
liste.push({
recht: "Sys.Modify",
bereich: "wurzel",
wofuer: "Images per download-url laden (vor PVE 8.2)",
pflicht: true,
});
}
liste.push(
{
recht: "Datastore.Allocate",
bereich: "storage",
wofuer: "Heruntergeladene Images beim Aufräumen löschen",
pflicht: false,
},
{ recht: "SDN.Audit", bereich: "sdn", wofuer: "VNets anzeigen", pflicht: false },
{ recht: "VM.Console", bereich: "vms", wofuer: "Konsole öffnen (Link aus ProxDeploy)", pflicht: false },
);
if (version.major >= 9) {
liste.push({
recht: "Sys.Modify",
bereich: "wurzel",
wofuer: "Privilegierte Container anlegen (seit PVE 9)",
pflicht: false,
});
}
return liste;
}
/** Effektive Rechte je ACL-Pfad, wie `GET /access/permissions?path=…` sie liefert. */
export type RechteJePfad = Record<string, Record<string, number | boolean>>;
export type FehlendesRecht = { recht: string; pfad: string; wofuer: string; pflicht: boolean };
export function pfadFuer(bereich: RechtBereich, kontext: { storage?: string; node?: string }): string {
switch (bereich) {
case "vms":
return "/vms";
case "storage":
return kontext.storage ? `/storage/${kontext.storage}` : "/storage";
case "sdn":
return "/sdn/zones/localnetwork";
case "node":
return kontext.node ? `/nodes/${kontext.node}` : "/nodes";
case "wurzel":
return "/";
}
}
/**
* Vergleicht die effektiven Rechte mit der Anforderung. Storage-Rechte werden je Storage geprüft,
* der Gäste aufnehmen kann – gilt ein Recht auf keinem davon, fehlt es.
*/
export function rechtePruefen(
version: PveVersion,
rechte: RechteJePfad,
kontext: { storages: string[]; nodes: string[] },
): FehlendesRecht[] {
const hat = (pfad: string, recht: string) => Boolean(rechte[pfad]?.[recht]);
const fehlend: FehlendesRecht[] = [];
for (const anforderung of benoetigteRechte(version)) {
let pfade: string[];
if (anforderung.bereich === "storage") {
pfade = kontext.storages.map((storage) => pfadFuer("storage", { storage }));
} else if (anforderung.bereich === "node") {
pfade = kontext.nodes.map((node) => pfadFuer("node", { node }));
} else {
pfade = [pfadFuer(anforderung.bereich, {})];
}
if (pfade.length === 0) pfade = [pfadFuer(anforderung.bereich, {})];
// Storage: es genügt, wenn ein Storage passt. Nodes: jeder Node braucht es.
const erfuellt =
anforderung.bereich === "storage"
? pfade.some((p) => hat(p, anforderung.recht))
: pfade.every((p) => hat(p, anforderung.recht));
if (!erfuellt) {
const ohne =
anforderung.bereich === "storage" ? pfade : pfade.filter((p) => !hat(p, anforderung.recht));
fehlend.push({
recht: anforderung.recht,
pfad: ohne.join(", "),
wofuer: anforderung.wofuer,
pflicht: anforderung.pflicht,
});
}
}
return fehlend;
}
// ── Strategien ──────────────────────────────────────────────────────────────────────────────────
export type StorageInfo = {
storage: string;
typ: string;
inhalte: string[];
aktiv: boolean;
geteilt: boolean;
frei: number | null;
gesamt: number | null;
};
export type SshZustand = "aus" | "kein_schluessel" | "nicht_bestaetigt" | "fehler" | "ok";
export type Weg = "api" | "ssh" | "nicht_moeglich";
export type Strategie = { weg: Weg; grund: string };
export type Faehigkeiten = {
templateBau: Strategie;
vmProvisionierung: Strategie;
lxcProvisionierung: Strategie & { ueber?: "node" | "gast" };
snippets: Strategie;
lxcFeaturesErweitert: Strategie;
};
/**
* Leitet je Vorgang den Weg ab. Grundsatz: API, wo es geht; SSH nur, wo Proxmox keine API hat
* oder die Version zu alt ist.
*/
export function faehigkeitenAbleiten(eingabe: {
version: PveVersion;
storages: StorageInfo[];
ssh: SshZustand;
rechteFehlen: boolean;
}): Faehigkeiten {
const { version, storages, ssh } = eingabe;
const sshOk = ssh === "ok";
const importSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("import"));
const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets"));
let templateBau: Strategie;
if (mindestens(version, 8, 4) && importSpeicher.length > 0) {
templateBau = {
weg: "api",
grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}`,
};
} else if (sshOk) {
templateBau = {
weg: "ssh",
grund: mindestens(version, 8, 4)
? "Kein Storage mit Inhaltstyp „import“ – Download und Import laufen per SSH."
: `PVE ${version.text} kann Disk-Images erst ab 8.4 per API laden – Download und Import laufen per SSH.`,
};
} else {
templateBau = {
weg: "nicht_moeglich",
grund: mindestens(version, 8, 4)
? "Einem Storage den Inhaltstyp „Import“ geben (Datacenter → Storage) oder SSH einrichten."
: `PVE ${version.text} lädt Disk-Images erst ab 8.4 per API – SSH einrichten oder auf 8.4+ aktualisieren.`,
};
}
const snippets: Strategie =
sshOk && snippetSpeicher.length > 0
? { weg: "ssh", grund: `Snippets per SSH auf ${snippetSpeicher[0]!.storage}` }
: sshOk
? { weg: "nicht_moeglich", grund: "Kein Storage mit Inhaltstyp „Snippets“." }
: { weg: "nicht_moeglich", grund: "Proxmox hat keine API für Snippets – nur per SSH." };
return {
templateBau,
vmProvisionierung: { weg: "api", grund: "Über den Guest-Agent (im Template eingebaut)." },
lxcProvisionierung: sshOk
? { weg: "ssh", ueber: "node", grund: "pct exec über SSH zum Node." }
: {
weg: "ssh",
ueber: "gast",
grund:
"Proxmox hat keine Exec-API für Container – Profile laufen per SSH direkt in den Container (Netzweg nötig).",
},
snippets,
lxcFeaturesErweitert: sshOk
? { weg: "ssh", grund: "keyctl, fuse usw. per pct set über SSH." }
: {
weg: "nicht_moeglich",
grund: "Außer „nesting“ darf nur root@pam Container-Features setzen – per Token nicht.",
},
};
}
+2 -2
View File
@@ -117,10 +117,10 @@ export function rechtHinweis(meldung: string): string {
if (treffer) { if (treffer) {
return ( return (
`Der Rolle des Tokens fehlt ${treffer[2]} auf ${treffer[1]}. ` + `Der Rolle des Tokens fehlt ${treffer[2]} auf ${treffer[1]}. ` +
"Bei aktiver Privilege Separation braucht das Token die Rolle selbst (siehe README, Proxmox-Rechte)." "Bei aktiver Privilege Separation braucht das Token die Rolle selbst (siehe docs/proxmox-rechte.md)."
); );
} }
return "Rechte des API-Tokens prüfen (siehe README, Proxmox-Rechte)."; return "Rechte des API-Tokens prüfen (siehe docs/proxmox-rechte.md).";
} }
/** Übersetzt eine HTTP-Fehlerantwort der Proxmox-API. */ /** Übersetzt eine HTTP-Fehlerantwort der Proxmox-API. */
+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");
}
+148
View File
@@ -0,0 +1,148 @@
import { createHash } from "node:crypto";
// 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
* Pin bekannt, darf nur „erkundet“ werden (der Schlüssel wird gemeldet und muss bestätigt
* werden); für alles Schreibende ist ein Pin Pflicht.
*/
export type SshZiel = {
host: string;
port: number;
benutzer: string;
privatSchluessel: string;
/** „SHA256:…“ wie ssh-keygen -lf; null nur beim Erkunden. */
hostSchluessel: string | null;
};
export type SshErgebnis = { code: number | null; stdout: string; stderr: string; hostSchluessel: string };
export class SshFehler extends Error {
constructor(
readonly art: "verbindung" | "anmeldung" | "host_schluessel" | "zeitueberschreitung" | "befehl",
readonly ursache: string,
readonly hinweis: string,
readonly hostSchluessel?: string,
) {
super(ursache);
this.name = "SshFehler";
}
}
/** Fingerprint im Format von ssh-keygen: SHA256:<base64 ohne Padding>. */
export function hostSchluesselFingerabdruck(schluessel: Buffer): string {
return `SHA256:${createHash("sha256").update(schluessel).digest("base64").replace(/=+$/, "")}`;
}
export function sshAusfuehren(
ziel: SshZiel,
befehl: string,
optionen: { zeitlimitMs?: number; eingabe?: string; erkunden?: boolean } = {},
): Promise<SshErgebnis> {
if (!ziel.hostSchluessel && !optionen.erkunden) {
return Promise.reject(
new SshFehler(
"host_schluessel",
`Für ${ziel.host} ist noch kein Host-Key bestätigt.`,
"Verbindungstest ausführen und den angezeigten Host-Key bestätigen.",
),
);
}
const zeitlimit = optionen.zeitlimitMs ?? 60_000;
return new Promise((erfuellen, ablehnen) => {
const client = new Client();
let gesehen = "";
let erledigt = false;
const ende = (fehler: SshFehler | null, ergebnis?: SshErgebnis) => {
if (erledigt) return;
erledigt = true;
clearTimeout(uhr);
client.end();
if (fehler) ablehnen(fehler);
else erfuellen(ergebnis!);
};
const uhr = setTimeout(
() =>
ende(
new SshFehler(
"zeitueberschreitung",
`SSH zu ${ziel.host} hat nach ${Math.round(zeitlimit / 1000)} s nicht geantwortet.`,
"Erreichbarkeit von Port " + ziel.port + " prüfen.",
gesehen || undefined,
),
),
zeitlimit,
);
client
.on("ready", () => {
client.exec(befehl, (fehler, kanal) => {
if (fehler) {
ende(
new SshFehler(
"befehl",
`Befehl ließ sich nicht starten: ${fehler.message}`,
"SSH-Benutzer prüfen.",
gesehen,
),
);
return;
}
let stdout = "";
let stderr = "";
let code: number | null = null;
kanal.on("data", (teil: Buffer) => (stdout += teil.toString("utf8")));
kanal.stderr.on("data", (teil: Buffer) => (stderr += teil.toString("utf8")));
kanal.on("exit", (exitCode: number | null) => (code = exitCode));
kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen }));
if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe);
else kanal.end();
});
})
.on("error", (fehler: Error & { level?: string; code?: string }) => {
if (fehler.level === "client-authentication") {
ende(
new SshFehler(
"anmeldung",
`${ziel.benutzer}@${ziel.host} nimmt den Schlüssel von ProxDeploy nicht an.`,
"Öffentlichen Schlüssel aus den Server-Einstellungen in ~/.ssh/authorized_keys des Nodes eintragen.",
gesehen || undefined,
),
);
} else if (fehler.message.includes("Host denied")) {
ende(
new SshFehler(
"host_schluessel",
`Der Host-Key von ${ziel.host} hat sich geändert (gesehen ${gesehen}).`,
"Wurde der Node neu installiert? Dann den Host-Key neu bestätigen. Sonst nicht verbinden.",
gesehen,
),
);
} else {
ende(
new SshFehler(
"verbindung",
`SSH zu ${ziel.host}:${ziel.port} fehlgeschlagen: ${fehler.code ?? fehler.message}`,
"Läuft sshd? Ist der Port aus dem Netz von ProxDeploy erreichbar?",
gesehen || undefined,
),
);
}
})
.connect({
host: ziel.host,
port: ziel.port,
username: ziel.benutzer,
privateKey: ziel.privatSchluessel,
readyTimeout: Math.min(zeitlimit, 20_000),
hostVerifier: (schluessel: Buffer) => {
gesehen = hostSchluesselFingerabdruck(schluessel);
return ziel.hostSchluessel ? gesehen === ziel.hostSchluessel : Boolean(optionen.erkunden);
},
});
});
}
+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 * Läuft einmal beim Start des Next-Servers. Prüft die Konfiguration früh und legt den ersten
* Administrator an – statt beim ersten Seitenaufruf mit einer unklaren Meldung zu scheitern. * Administrator an, gleicht den Katalog ab – statt beim ersten Seitenaufruf mit einer unklaren Meldung zu scheitern.
*/ */
export async function register(): Promise<void> { export async function register(): Promise<void> {
if (process.env["NEXT_RUNTIME"] !== "nodejs") return; if (process.env["NEXT_RUNTIME"] !== "nodejs") return;
const { schluesselPruefen } = await import("@/lib/geheimnisse"); const { schluesselPruefen } = await import("@/lib/geheimnisse");
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung"); const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
const { logger } = await import("@/lib/logger"); const { logger } = await import("@/lib/logger");
const { katalogSynchronisieren } = await import("@/server/services/katalog");
try { try {
schluesselPruefen(); schluesselPruefen();
await ersterAdminFallsLeer(); await ersterAdminFallsLeer();
const sync = await katalogSynchronisieren();
logger.info(
{ distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length },
"Katalog abgeglichen",
);
} catch (fehler) { } catch (fehler) {
logger.error({ err: fehler }, "Start-Prüfung fehlgeschlagen"); logger.error({ err: fehler }, "Start-Prüfung fehlgeschlagen");
} }
+18
View File
@@ -0,0 +1,18 @@
import type { StatusArt } from "@/components/status";
import type { AuftragSicht } from "@/lib/schemas/auftrag";
/** Beschriftungen für Aufträge – geteilt von Verlauf, Übersicht und Live-Ansicht. */
export const STATUS_ANZEIGE: Record<AuftragSicht["status"], [StatusArt, string]> = {
WARTEND: ["offen", "wartet"],
LAEUFT: ["laeuft", "läuft"],
ERFOLGREICH: ["erledigt", "erfolgreich"],
FEHLGESCHLAGEN: ["fehler", "fehlgeschlagen"],
ABGEBROCHEN: ["fehler", "abgebrochen"],
AUFGERAEUMT: ["offen", "aufgeräumt"],
};
export const ART_TEXT: Record<AuftragSicht["art"], string> = {
AUSROLLEN: "Ausrollen",
TEMPLATE_BAUEN: "Template",
AUFRAEUMEN: "Aufräumen",
};
+37
View File
@@ -0,0 +1,37 @@
/** Formate für die Anzeige – deutsch, gleich in Web und (sinngemäß) CLI. */
export function bytes(wert: number | null | undefined): string {
if (wert === null || wert === undefined) return "–";
const einheiten = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
let zahl = wert;
let stufe = 0;
while (zahl >= 1024 && stufe < einheiten.length - 1) {
zahl /= 1024;
stufe += 1;
}
return `${zahl.toLocaleString("de-DE", { maximumFractionDigits: zahl < 10 && stufe > 0 ? 1 : 0 })} ${einheiten[stufe]}`;
}
export function prozent(anteil: number | null | undefined): string {
if (anteil === null || anteil === undefined) return "–";
return `${Math.round(anteil * 100)} %`;
}
/** „vor 3 min“, „vor 2 h“, sonst Datum mit Uhrzeit. */
export function zeitpunkt(iso: string | null | undefined, jetzt = new Date()): string {
if (!iso) return "nie";
const datum = new Date(iso);
const sekunden = Math.round((jetzt.getTime() - datum.getTime()) / 1000);
if (sekunden < 60) return "gerade eben";
if (sekunden < 3600) return `vor ${Math.round(sekunden / 60)} min`;
if (sekunden < 86_400) return `vor ${Math.round(sekunden / 3600)} h`;
return datum.toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" });
}
/** Dauer wie in der CLI: „18s“, „1m 12s“, „1h 03m“. */
export function dauer(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
}
+4 -4
View File
@@ -22,11 +22,11 @@ export type NavigationsEintrag = {
export const NAVIGATION: readonly NavigationsEintrag[] = [ export const NAVIGATION: readonly NavigationsEintrag[] = [
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" }, { pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" }, { pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen", ab: "#006" }, { pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" }, { pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" },
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" }, { pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", 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: "/server", titel: "Server", symbol: "server", recht: "lesen" },
{ {
pfad: "/einstellungen", pfad: "/einstellungen",
+35
View File
@@ -0,0 +1,35 @@
import type { AuftragsArt, AuftragsStatus, GastTyp, PhasenStatus } from "@/generated/prisma/enums";
/** Was die API über einen Auftrag herausgibt – geteilt von Server, Web-GUI und (als JSON) CLI. */
export type AuftragSicht = {
id: string;
kurzId: string;
art: AuftragsArt;
status: AuftragsStatus;
kopf: string;
server: { name: string; titel: string };
knoten: string | null;
gastTyp: GastTyp | null;
vmid: number | null;
name: string | null;
distributionId: string | null;
profile: string[];
ergebnis: unknown;
fehler: unknown;
erstelltVon: string;
erstelltAm: string;
gestartetAm: string | null;
beendetAm: string | null;
phasen: Array<{
schluessel: string;
titel: string;
status: PhasenStatus;
detail: string | null;
ursache: string | null;
hinweis: string | null;
gestartetAm: string | null;
beendetAm: string | null;
}>;
};
export type LogZeile = { id: string; zeit: string; stufe: string; phase: string | null; nachricht: string };
+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
@@ -1,3 +1,4 @@
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { z } from "@/lib/validierung"; import { z } from "@/lib/validierung";
/** /**
@@ -114,4 +115,15 @@ export type ServerSicht = {
erreichbar: boolean | null; erreichbar: boolean | null;
pveVersion: string | null; pveVersion: string | null;
geprueftAm: string | null; geprueftAm: string | null;
letzterTest: VerbindungsBericht | null;
/** Gepinnte SSH-Host-Keys je Node – öffentlich, kein Geheimnis. */
sshHostSchluessel: Record<string, string>;
}; };
export const HostSchluesselBestaetigen = z.object({
knoten: z.string().trim().min(1),
hostSchluessel: z
.string()
.trim()
.regex(/^SHA256:[A-Za-z0-9+/]{43}$/, "Kein SHA256-Fingerprint eines Host-Keys."),
});
+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. * Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer). * Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
*/ */
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.1.0-dev"; export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.2.0";
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7); export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
export function versionText(): string { export function versionText(): string {
+1
View File
@@ -19,6 +19,7 @@ export function fehlerAntwort(fehler: unknown): NextResponse<FehlerAntwort> {
code: fehler.code, code: fehler.code,
nachricht: fehler.message, nachricht: fehler.message,
...(fehler.hinweis ? { hinweis: fehler.hinweis } : {}), ...(fehler.hinweis ? { hinweis: fehler.hinweis } : {}),
...(fehler.felder ? { felder: fehler.felder } : {}),
}, },
}, },
{ status: fehler.status }, { status: fehler.status },
+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, readonly code: string,
nachricht: string, nachricht: string,
readonly hinweis?: string, readonly hinweis?: string,
/** Feldfehler für Formulare, Schlüssel wie der Feldpfad (z. B. „variablen.zabbix-agent.server“). */
readonly felder?: Record<string, string>,
) { ) {
super(nachricht); super(nachricht);
this.name = "AnwendungsFehler"; this.name = "AnwendungsFehler";
+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,
);
});
}
+121 -3
View File
@@ -1,11 +1,18 @@
import { utils as sshUtils } from "ssh2"; import ssh2 from "ssh2";
import type { Server } from "@/generated/prisma/client"; import type { Server } from "@/generated/prisma/client";
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client"; import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { ProxmoxFehler } from "@/engine/proxmox/fehler"; import { ProxmoxFehler } from "@/engine/proxmox/fehler";
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls"; import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse"; import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
import { ServerAendern, ServerAnlegen, ServerPruefen, type ServerSicht } from "@/lib/schemas/server"; import {
HostSchluesselBestaetigen,
ServerAendern,
ServerAnlegen,
ServerPruefen,
type ServerSicht,
} from "@/lib/schemas/server";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler"; import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import type { Aufrufer } from "@/server/auth/zugang"; import type { Aufrufer } from "@/server/auth/zugang";
import { protokollieren } from "@/server/services/protokoll"; import { protokollieren } from "@/server/services/protokoll";
@@ -28,6 +35,8 @@ export function serverSicht(server: Server): ServerSicht {
erreichbar: server.erreichbar, erreichbar: server.erreichbar,
pveVersion: server.pveVersion, pveVersion: server.pveVersion,
geprueftAm: server.geprueftAm?.toISOString() ?? null, geprueftAm: server.geprueftAm?.toISOString() ?? null,
letzterTest: (server.letzterTest as VerbindungsBericht | null) ?? null,
sshHostSchluessel: (server.sshHostSchluessel as Record<string, string> | null) ?? {},
}; };
} }
@@ -161,7 +170,7 @@ export async function sshSchluesselErzeugen(
ip: string | null, ip: string | null,
): Promise<ServerSicht> { ): Promise<ServerSicht> {
const server = await serverHolen(name); const server = await serverHolen(name);
const paar = sshUtils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` }); const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
const neu = await prisma.$transaction(async (tx) => { const neu = await prisma.$transaction(async (tx) => {
const aktualisiert = await tx.server.update({ const aktualisiert = await tx.server.update({
where: { id: server.id }, where: { id: server.id },
@@ -259,3 +268,112 @@ export async function serverPruefen(roh: unknown): Promise<PruefErgebnis> {
await client.schliessen(); await client.schliessen();
} }
} }
export type TestErgebnis =
{ ok: true; bericht: VerbindungsBericht } | { ok: false; art: string; ursache: string; hinweis: string };
/**
* Der Verbindungstest (#003): erkundet den Server, speichert den Bericht und die Erreichbarkeit.
* Ein nicht erreichbarer Server ist kein Fehler der Anfrage, sondern ein Ergebnis.
*/
export async function serverTesten(
name: string,
aufrufer: Aufrufer,
ip: string | null,
): Promise<TestErgebnis> {
const server = await serverHolen(name);
const client = new ProxmoxClient(verbindungAus(server), { zeitlimitMs: 15_000 });
let ergebnis: TestErgebnis;
try {
const bericht = await erkunden(client, {
serverHost: new URL(server.url).hostname,
ssh: server.sshAktiv
? {
benutzer: server.sshBenutzer,
port: server.sshPort,
privatSchluessel: server.sshPrivat ? entschluesseln(server.sshPrivat) : null,
hostSchluessel: (server.sshHostSchluessel as Record<string, string> | null) ?? {},
}
: null,
});
ergebnis = { ok: true, bericht };
} catch (fehler) {
if (!(fehler instanceof ProxmoxFehler)) throw fehler;
ergebnis = { ok: false, art: fehler.art, ursache: fehler.ursache, hinweis: fehler.hinweis };
} finally {
await client.schliessen();
}
await prisma.$transaction(async (tx) => {
await tx.server.update({
where: { id: server.id },
data: {
erreichbar: ergebnis.ok,
geprueftAm: new Date(),
...(ergebnis.ok ? { pveVersion: ergebnis.bericht.version, letzterTest: ergebnis.bericht } : {}),
},
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Verbindung getestet",
ziel: `server:${server.name}`,
details: ergebnis.ok
? { version: ergebnis.bericht.version, fehlendeRechte: ergebnis.bericht.fehlendeRechte.length }
: { fehler: ergebnis.ursache },
ip,
},
tx,
);
});
return ergebnis;
}
/**
* Pinnt den SSH-Host-Key eines Nodes – nur einen, den der letzte Test tatsächlich gesehen hat.
* So kann niemand über die API einen beliebigen Schlüssel unterschieben.
*/
export async function hostSchluesselBestaetigen(
name: string,
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<ServerSicht> {
const server = await serverHolen(name);
const eingabe = HostSchluesselBestaetigen.parse(roh);
const bericht = server.letzterTest as VerbindungsBericht | null;
const gesehen = bericht?.knoten.find((k) => k.name === eingabe.knoten)?.ssh?.hostSchluessel;
if (!gesehen || gesehen !== eingabe.hostSchluessel) {
throw new AnwendungsFehler(
409,
"nicht_gesehen",
"Dieser Host-Key wurde beim letzten Verbindungstest nicht gesehen.",
"Verbindung erneut testen und den dann angezeigten Host-Key bestätigen.",
);
}
const neu = await prisma.$transaction(async (tx) => {
const aktualisiert = await tx.server.update({
where: { id: server.id },
data: {
sshHostSchluessel: {
...((server.sshHostSchluessel as Record<string, string> | null) ?? {}),
[eingabe.knoten]: eingabe.hostSchluessel,
},
},
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "SSH-Host-Key bestätigt",
ziel: `server:${server.name}`,
details: { knoten: eingabe.knoten, hostSchluessel: eingabe.hostSchluessel },
ip,
},
tx,
);
return aktualisiert;
});
return serverSicht(neu);
}
+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,
});
}

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