7 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5.5 21edff2141 feat(templates): Template-Bau ohne SSH über eine Bau-Adresse
Ohne SSH zum Node kommt der Guest-Agent jetzt über eine feste
Bau-Adresse ins Template: cloud-init setzt Adresse und den
Bereitstellungsschlüssel für den Standardbenutzer, ProxDeploy meldet
sich per SSH in der VM an, installiert den Agent und entfernt danach
Schlüssel, Adresse und cloud-init-Angaben.

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

Refs #021

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

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

Refs #015

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

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

Closes #022

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

Closes #010

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

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

Closes #012

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

Closes #011

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

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

Closes #009

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:19:13 +02:00
75 changed files with 3771 additions and 308 deletions
+11
View File
@@ -8,10 +8,21 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
### Hinzugefügt ### Hinzugefügt
- Template-Bau ohne SSH zum Node: über eine Bau-Adresse installiert ProxDeploy den Guest-Agent per SSH in der Vorbereitungs-VM (#021)
- SSH-Schlüssel in den Einstellungen hinterlegen (persönlich und, für Admins, Team-Schlüssel); sie kommen automatisch in jeden Gast – auch per API/CLI ohne Angabe – und sind im Assistenten abwählbar (#015)
- Templates mit eingebackenen Profilen: beim Template-Bau Profile wählen; Ausrollen nimmt das passende Template und überspringt, was schon drin ist (#022)
- Profil-Editor: eigene Profile als YAML mit Live-Prüfung und Skriptvorschau, Katalogprofile als Kopie bearbeiten (#011)
- Vorlagen (Presets): im Assistenten wählen und speichern, Vorlagenseite, `vorlage` in der API; Beispiel „Docker-Host klein“ (#012)
- Ubuntu 22.04/24.04/26.04 als VM und LXC; Fortschritt beim Klonen aus dem Task-Log (#010)
- LXC-Container ausrollen: Container-Template prüfen und laden, anlegen (unprivilegiert, nesting für docker), Netz, Provisionierung per `pct exec` über Node-SSH oder per SSH in den Container mit einem Bereitstellungsschlüssel (#009)
- Profile kennen die festen Bedingungen `if: vm` und `if: lxc` (#009)
### Geändert ### Geändert
### Behoben ### Behoben
- Verbindungstest meldete „Template-Bau per API“ auch dann, wenn der Guest-Agent mangels SSH gar nicht ins Template käme (#021)
- chrony wird in Containern nicht mehr installiert – ohne CAP_SYS_TIME scheiterte dort das Profil base (#009)
- SSH-Härtung auf Ubuntu 24.04 scheiterte an fehlendem /run/sshd (#009)
### Entfernt ### Entfernt
+6 -1
View File
@@ -1,6 +1,7 @@
# Grundausstattung für jeden Gast. Platzhalter: {{ var.<name> }} aus den Variablen unten, # Grundausstattung für jeden Gast. Platzhalter: {{ var.<name> }} aus den Variablen unten,
# {{ os.id }}, {{ os.version_id }}, {{ os.codename }} aus der Distribution, # {{ 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. # {{ guest.name }}, {{ guest.user }} aus dem Deployment. `a ?? b` nimmt b, wenn a leer ist.
# Bedingungen: `if: <variable>`, dazu fest `if: vm` / `if: lxc`.
id: base id: base
name: Basis name: Basis
description: Updates, Guest-Agent, Zeitzone, Locale, Tastatur, automatische Sicherheitsupdates, Zeitsync, SSH-Härtung description: Updates, Guest-Agent, Zeitzone, Locale, Tastatur, automatische Sicherheitsupdates, Zeitsync, SSH-Härtung
@@ -16,8 +17,12 @@ families:
- timezone: "{{ var.zeitzone }}" - timezone: "{{ var.zeitzone }}"
- locale: "{{ var.locale }}" - locale: "{{ var.locale }}"
- keyboard: "{{ var.tastatur }}" - keyboard: "{{ var.tastatur }}"
- packages: [chrony, sudo, curl, ca-certificates] - packages: [sudo, curl, ca-certificates]
# Zeitsync nur in VMs: ein unprivilegierter Container darf die Uhr nicht stellen, er nimmt die des Hosts.
- packages: [chrony]
if: vm
- service: { name: chrony, enable: true, start: true } - service: { name: chrony, enable: true, start: true }
if: vm
- unattended_upgrades: true - unattended_upgrades: true
- ssh_harden: { password_auth: false, root_login: false } - ssh_harden: { password_auth: false, root_login: false }
- packages: [fail2ban] - packages: [fail2ban]
+11 -6
View File
@@ -1,21 +1,26 @@
# #009 LXC-Container ausrollen # #009 LXC-Container ausrollen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature, proxmox **Labels:** feature, proxmox
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 30 min
## Ziel ## Ziel
LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container. LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container.
## Aufgaben ## Aufgaben
- [ ] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH) - [x] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH)
- [ ] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen) - [x] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] LXC mit base auf echtem Proxmox, einmal mit und einmal ohne Node-SSH - [x] Auf pve-demo: pd-ct01 (Debian 13, base+docker, pct exec, docker hello-world im unprivilegierten CT mit nesting) und pd-ct02 (Ubuntu 24.04, base, SSH in den Container, Bereitstellungsschlüssel danach entfernt)
## Abhängigkeiten ## Abhängigkeiten
#008 #008
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **Fund:** chrony läuft in unprivilegierten Containern nicht (keine CAP_SYS_TIME). Profile kennen jetzt die festen Bedingungen `if: vm` / `if: lxc`; base installiert chrony nur in VMs.
- **Fund:** Ubuntu 24.04 startet sshd per Socket, `/run/sshd` fehlt bis zur ersten Verbindung – `sshd -t` scheiterte. Der Adapter legt das Verzeichnis vorher an.
- **Docker im unprivilegierten Container braucht nur nesting**, kein keyctl (geprüft auf PVE 9.2.2 mit Debian 13).
- **keyctl** darf per Token nicht gesetzt werden; mit Node-SSH setzt ProxDeploy es per `pct set`, ohne lehnt die Prüfung ab.
- **Bereitstellungsschlüssel** (eigener ed25519, verschlüsselt in den Einstellungen) nur für den Weg ohne Node-SSH; die Schlüssel des Benutzers gehen nie an root.
- Bei mehreren passenden Container-Templates wird das neueste aus `aplinfo` geladen, wenn lokal nur ein älteres liegt.
+8 -6
View File
@@ -1,21 +1,23 @@
# #010 Ubuntu 22.04, 24.04 und 26.04 # #010 Ubuntu 22.04, 24.04 und 26.04
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature, katalog **Labels:** feature, katalog
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 05 min
## Ziel ## Ziel
Ubuntu als VM und LXC über den debian-Adapter. Ubuntu als VM und LXC über den debian-Adapter.
## Aufgaben ## Aufgaben
- [ ] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades) - [x] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades)
- [ ] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12) - [x] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Je eine Ubuntu-VM und ein Ubuntu-LXC ausgerollt - [x] Auf pve-demo: pd-ubu01 (Ubuntu 24.04 VM, Template 9001 im selben Auftrag gebaut) und pd-ct02 (Ubuntu 24.04 LXC)
## Abhängigkeiten ## Abhängigkeiten
#009 #009
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - Der Adapter brauchte für Ubuntu nur eine Änderung: `/run/sshd` anlegen (sshd per Socket, #009). Locale-Erzeugung unterscheidet Debian/Ubuntu.
- Ubuntu 22.04 und 26.04 laufen im Containertest (`pnpm test:container`); auf dem Testhost geprüft ist 24.04.
- Der Voll-Klon dauerte auf pve-demo ~9 min (3,5 GB, Storage ~3 MB/s) – die Phase „Klonen“ zeigt jetzt den Fortschritt aus dem Task-Log.
+10 -7
View File
@@ -1,22 +1,25 @@
# #011 Profile docker und zabbix-agent, Variablen, Editor # #011 Profile docker und zabbix-agent, Variablen, Editor
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature **Labels:** feature
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 0 h 10 min
## Ziel ## Ziel
Kombinierbare Profile mit Variablen; eigene Profile im YAML-Editor. Kombinierbare Profile mit Variablen; eigene Profile im YAML-Editor.
## Aufgaben ## Aufgaben
- [ ] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive) - [x] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive)
- [ ] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie - [x] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie
- [ ] YAML-Editor mit Validierung und Fehlerstelle - [x] YAML-Editor mit Validierung und Fehlerstelle
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] docker run hello-world im ausgerollten Gast erfolgreich - [x] docker hello-world in pd-test01 (VM) und pd-ct01 (LXC); zabbix-agent2 aktiv in pd-test02
## Abhängigkeiten ## Abhängigkeiten
#005 #005
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - Editor prüft beim Tippen (400 ms Pause) gegen dasselbe Schema wie der Katalog und zeigt das erzeugte Skript.
- Katalogprofile sind schreibgeschützt; „Als Kopie bearbeiten“ legt `<id>-eigen` an. Eigene Profile mit Katalog-id werden abgewiesen.
- Profile anlegen dürfen nur Admins – sie führen Befehle als root aus.
- Löschen verweigert, solange ein anderes Profil es per `requires` braucht.
+8 -5
View File
@@ -1,20 +1,23 @@
# #012 Vorlagen (Presets) # #012 Vorlagen (Presets)
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature **Labels:** feature
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 11 min
## Ziel ## Ziel
Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar. Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar.
## Aufgaben ## Aufgaben
- [ ] CRUD, „aus diesem Auftrag als Vorlage speichern“, Beispiel „Docker-Host klein“ - [x] CRUD, „Als Vorlage speichern“ im Assistenten, Beispiel „Docker-Host klein“ (einmalig angelegt)
- [x] Vorlagen-Auswahl im Assistenten, `?vorlage=` von der Vorlagenseite, `vorlage` in der API (für `--preset` der CLI)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] `--preset` in API und Assistent identisch aufgelöst - [x] `--preset` in API und Assistent identisch aufgelöst
## Abhängigkeiten ## Abhängigkeiten
#008 #008
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - Eine Vorlage liegt **unter** der Anfrage: was die Anfrage sagt, gewinnt; netz/features/variablen feldweise, Listen ersetzt.
- Name, VMID, IP und SSH-Schlüssel gehören nicht in eine Vorlage (Schema `strict`).
- Operatoren dürfen Vorlagen pflegen (Recht `vorlagenPflegen`).
+3 -2
View File
@@ -9,7 +9,8 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
## Aufgaben ## Aufgaben
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test) - [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test)
- [ ] Benutzerverwaltung, CLI-Tokens, SSH-Keys über `SchluesselQuelle` - [ ] Benutzerverwaltung, CLI-Tokens
- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar
- [ ] Audit-Log-Ansicht mit Filter - [ ] Audit-Log-Ansicht mit Filter
## Akzeptanzkriterien ## Akzeptanzkriterien
@@ -19,4 +20,4 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
#001 #001
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - SSH-Schlüssel auf Florians Frage nach Schlüsseln im Template vorgezogen (2026-10-12): Schlüssel gehören in die Verwaltung, nicht ins Template. `SchluesselQuelle` ist die Andockstelle für eine externe Verwaltung (z. B. sshkeyvault).
+9 -6
View File
@@ -1,18 +1,18 @@
# #021 Template-Bau ohne SSH (Bau-Adresse) # #021 Template-Bau ohne SSH (Bau-Adresse)
**Status:** offen **Status:** umgesetzt, Abnahme auf pve-demo ausstehend
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature, proxmox **Labels:** feature, proxmox
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min
## Ziel ## Ziel
Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet, 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. sondern über SSH **in die Vorbereitungs-VM** eingebaut.
## Aufgaben ## Aufgaben
- [ ] Server-Einstellung `bauNetz`: Bridge, VLAN, statische IP/CIDR, Gateway, DNS - [x] Server-Einstellung `bauNetz`: statische IP/CIDR, Gateway, DNS (Bridge/VLAN wie beim Template-Bau gewählt)
- [ ] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys` - [x] 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 - [x] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten
- [ ] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“ - [x] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH - [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH
@@ -21,4 +21,7 @@ sondern über SSH **in die Vorbereitungs-VM** eingebaut.
#007 #007
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Fähigkeitserkennung war zu optimistisch:** „Template-Bau per API“ stand auch ohne Node-SSH da, obwohl der Guest-Agent dann nicht hineinkam. Jetzt: per API nur mit Snippet-Weg oder Bau-Adresse.
- Der Bereitstellungsschlüssel kommt per cloud-init an den Standardbenutzer des Images; danach werden alle authorized_keys, ciuser, sshkeys und die feste Adresse entfernt.
- Host-Key der frischen VM ist vorher unbekannt (TOFU, nur für diese eine Verbindung).
- Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH. - Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH.
+30
View File
@@ -0,0 +1,30 @@
# #022 Templates mit eingebackenen Profilen
**Status:** erledigt
**Meilenstein:** v0.3.0
**Labels:** feature, proxmox
**Geschätzt:** 4 h **Tatsächlich:** 0 h 35 min
## Ziel
Beim Template-Bau Profile (z. B. base, docker) gleich mit installieren, damit Deployments schneller
fertig sind. Wunsch von Florian am 2026-10-12.
## Aufgaben
- [x] Template-Bau-Anfrage um `profile` erweitern (nur Profile ohne Pflichtvariablen bzw. mit Standardwerten)
- [x] Nach dem Vorbereitungsstart: VM erneut starten, Profile über den Guest-Agent ausführen (gleicher Code wie beim Deployment)
- [x] Aufräumen vor dem Template: Maschinen-ID leeren, `cloud-init clean`, SSH-Host-Keys löschen, `apt-get clean`, Logdatei entfernen
- [x] Name/Tags: `tpl-debian-13-docker`, Tags `proxdeploy-template;debian-13;profil-docker`
- [x] Ausrollen: passendes Template bevorzugen (eingebackene Profile ⊆ gewählte); eingebackene Profile beim Deploy überspringen, wenn ihre Variablen den Standardwerten entsprechen
- [ ] Optional: zusätzlicher Admin-/Notfall-Schlüssel im Template – zurückgestellt; Standard-Schlüssel je Benutzer/Team kommen stattdessen (#015)
## Akzeptanzkriterien
- [x] Template 9002 `tpl-debian-13-docker` auf pve-demo (8 min). Provisionierung beim Deploy: 141 s ohne, 29–32 s mit eingebackenem base+docker
## Abhängigkeiten
#007, #008
## Notizen / Entscheidungen
- SSH-Schlüssel gehören grundsätzlich nicht ins Template: beim Wechsel müsste jedes Template und jeder Klon angefasst werden. Dafür kommen Standard-Schlüssel je Benutzer/Team (aus #015 vorgezogen).
- **Fund:** Profile mit `{{ guest.* }}` hängen vom einzelnen Gast ab (docker nimmt den Benutzer in die Gruppe auf). Sie laufen beim Deploy trotzdem noch einmal – idempotent und schnell. Übersprungen wird nur, was gastunabhängig ist und Standardwerte hat.
- Vor dem Template: Maschinen-ID leeren, SSH-Host-Keys und Docker-engine-id löschen, apt-Cache leeren, `cloud-init clean`; geprüft: Klone haben eigene Maschinen-ID und eigene Host-Keys.
- Ausrollen wählt das Template mit den meisten eingebackenen Profilen, deren Profile alle gewählt sind – nie eins mit ungewählter Software.
- Gemeinsames Modul `gastagent.ts` für Provisionierung über den Guest-Agent (Deploy und Template).
+6 -5
View File
@@ -12,10 +12,10 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min | | [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min |
| [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min | | [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min |
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min | | [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min |
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – | | [#009](009-lxc.md) | LXC-Container ausrollen | erledigt | v0.3.0 | 8 h | 0 h 30 min |
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – | | [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | erledigt | v0.3.0 | 3 h | 0 h 05 min |
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – | | [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | erledigt | v0.3.0 | 6 h | 0 h 10 min |
| [#012](012-vorlagen.md) | Vorlagen (Presets) | offen | v0.3.0 | 3 h | – | | [#012](012-vorlagen.md) | Vorlagen (Presets) | erledigt | v0.3.0 | 3 h | 0 h 11 min |
| [#013](013-cli.md) | CLI proxdeploy (Go) | offen | v0.4.0 | 12 h | – | | [#013](013-cli.md) | CLI proxdeploy (Go) | offen | v0.4.0 | 12 h | – |
| [#014](014-verlauf.md) | Verlauf und Wiederholen | offen | v0.5.0 | 4 h | – | | [#014](014-verlauf.md) | Verlauf und Wiederholen | offen | v0.5.0 | 4 h | – |
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | offen | v0.5.0 | 6 h | – | | [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | offen | v0.5.0 | 6 h | – |
@@ -24,4 +24,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – | | [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – | | [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – | | [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | offen | v0.3.0 | 3 h | – | | [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | umgesetzt, Abnahme offen | v0.3.0 | 3 h | 0 h 25 min |
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 35 min |
+2 -1
View File
@@ -99,7 +99,8 @@ families:
| `run: <befehl>` | freier Shell-Befehl – nicht portabel, nur wenn nichts anderes passt | | `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 Jeder Schritt kann `if: <variable>` (oder `if: "!<variable>"`) tragen und läuft dann nur, wenn die
Variable wahr bzw. falsch ist. Variable wahr bzw. falsch ist. Fest eingebaut sind `if: vm` und `if: lxc` – etwa für Zeitsync, die
in einem unprivilegierten Container nicht laufen kann.
### Platzhalter ### Platzhalter
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "Vorlage" (
"name" TEXT NOT NULL,
"titel" TEXT NOT NULL,
"beschreibung" TEXT,
"parameter" JSONB NOT NULL,
"erstelltVonId" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Vorlage_pkey" PRIMARY KEY ("name")
);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "profile" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "SshSchluessel" (
"id" TEXT NOT NULL,
"benutzerId" TEXT,
"name" TEXT NOT NULL,
"oeffentlich" TEXT NOT NULL,
"fingerabdruck" TEXT NOT NULL,
"quelle" TEXT NOT NULL DEFAULT 'lokal',
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SshSchluessel_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SshSchluessel_benutzerId_fingerabdruck_key" ON "SshSchluessel"("benutzerId", "fingerabdruck");
-- AddForeignKey
ALTER TABLE "SshSchluessel" ADD CONSTRAINT "SshSchluessel_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+32
View File
@@ -39,6 +39,7 @@ model Benutzer {
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
protokoll Protokoll[] protokoll Protokoll[]
auftraege Auftrag[] auftraege Auftrag[]
schluessel SshSchluessel[]
} }
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE. /// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
@@ -271,9 +272,40 @@ model Template {
storage String storage String
imageDatei String imageDatei String
pruefsumme String pruefsumme String
/// Eingebackene Profile (#022), aufgelöst inkl. Voraussetzungen.
profile String[] @default([])
gebautVonAuftragId String? gebautVonAuftragId String?
gebautAm DateTime @default(now()) gebautAm DateTime @default(now())
@@unique([serverId, vmid]) @@unique([serverId, vmid])
@@index([serverId, distributionId]) @@index([serverId, distributionId])
} }
/// Vorlage (Preset): eine gespeicherte Kombination, die Assistent, CLI (--preset) und API vorbelegen.
model Vorlage {
/// Kurzname für die CLI: „docker-host-klein“
name String @id
titel String
beschreibung String?
/// Teilmenge der Ausroll-Anfrage (typ, distribution, cores, speicherMb, diskGb, profile, variablen, tags …).
parameter Json
erstelltVonId String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
}
/// Öffentliche SSH-Schlüssel, die in Gäste kommen. benutzerId leer = Team-Schlüssel (in jeden Gast).
model SshSchluessel {
id String @id @default(cuid())
benutzerId String?
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
name String
oeffentlich String
/// SHA256:… wie ssh-keygen -lf
fingerabdruck String
/// Kennung der SchluesselQuelle („lokal“; später z. B. eine externe Schlüsselverwaltung).
quelle String @default("lokal")
erstelltAm DateTime @default(now())
@@unique([benutzerId, fingerabdruck])
}
+253 -33
View File
@@ -11,6 +11,7 @@ import { Status } from "@/components/status";
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte"; import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
import { apiAufruf, ApiFehler } from "@/lib/api-client"; import { apiAufruf, ApiFehler } from "@/lib/api-client";
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen"; import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen";
import type { VorlagenParameter } from "@/lib/schemas/vorlage";
// ── Daten vom Server ──────────────────────────────────────────────────────────────────────────── // ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
@@ -47,6 +48,13 @@ export type AssistentDaten = {
}>; }>;
benutzer: string; benutzer: string;
version: string; version: string;
vorlagen: Array<{ name: string; titel: string; beschreibung: string | null; parameter: VorlagenParameter }>;
schluessel: Array<{
name: string;
oeffentlich: string;
fingerabdruck: string;
bereich: "persoenlich" | "team";
}>;
}; };
type Plan = { type Plan = {
@@ -92,9 +100,12 @@ type Werte = {
suchdomain: string; suchdomain: string;
benutzer: string; benutzer: string;
schluessel: string; schluessel: string;
/** Fingerprints hinterlegter Schlüssel, die für diesen Gast abgewählt sind. */
ohneSchluessel: string[];
profile: string[]; profile: string[];
variablen: Record<string, Record<string, string | boolean>>; variablen: Record<string, Record<string, string | boolean>>;
templateBauen: boolean; templateBauen: boolean;
vorlage: string;
}; };
const SCHRITTE = [ const SCHRITTE = [
@@ -125,9 +136,7 @@ function dekodieren(text: string | null): Partial<Werte> {
} }
} }
const SCHLUESSEL_SPEICHER = "proxdeploy.ssh-schluessel"; function anfrageAus(w: Werte, hinterlegt: AssistentDaten["schluessel"] = []): AusrollEingabe {
function anfrageAus(w: Werte): AusrollEingabe {
const zahl = (text: string) => (text.trim() === "" ? undefined : Number(text)); const zahl = (text: string) => (text.trim() === "" ? undefined : Number(text));
return { return {
typ: w.typ, typ: w.typ,
@@ -155,14 +164,63 @@ function anfrageAus(w: Werte): AusrollEingabe {
}, },
zugang: { zugang: {
benutzer: w.benutzer, benutzer: w.benutzer,
sshSchluessel: w.schluessel sshSchluessel: [
.split("\n") ...hinterlegt.filter((s) => !w.ohneSchluessel.includes(s.fingerabdruck)).map((s) => s.oeffentlich),
.map((z) => z.trim()) ...w.schluessel
.filter(Boolean), .split("\n")
.map((z) => z.trim())
.filter(Boolean),
],
}, },
profile: w.profile, profile: w.profile,
variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))), variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))),
templateBauen: w.templateBauen, templateBauen: w.templateBauen,
vorlage: w.vorlage || undefined,
};
}
/** Eine Vorlage in die Formularwerte übernehmen – nur, was sie festlegt. */
function vorlageUebernehmen(w: Werte, name: string, p: VorlagenParameter, daten: AssistentDaten): Werte {
const s = (zahl: number | undefined, alt: string) => (zahl === undefined ? alt : String(zahl));
const server = p.server && daten.server.some((x) => x.name === p.server) ? p.server : w.server;
return {
...w,
vorlage: name,
server,
typ: p.typ ?? w.typ,
distribution: p.distribution ?? w.distribution,
cores: s(p.cores, w.cores),
speicherMb: s(p.speicherMb, w.speicherMb),
diskGb: s(p.diskGb, w.diskGb),
storage: p.storage ?? w.storage,
swapMb: s(p.swapMb, w.swapMb),
unprivilegiert: p.unprivilegiert ?? w.unprivilegiert,
nesting: p.features?.nesting ?? w.nesting,
bridge: p.netz?.bridge ?? w.bridge,
vlan: s(p.netz?.vlan, w.vlan),
profile: p.profile ?? w.profile,
variablen: (p.variablen as Werte["variablen"] | undefined) ?? w.variablen,
tags: p.tags ? p.tags.join(",") : w.tags,
};
}
function werteZuVorlage(w: Werte): VorlagenParameter {
const a = anfrageAus(w, []);
return {
typ: a.typ,
server: a.server,
distribution: a.distribution,
cores: Number(a.cores),
speicherMb: Number(a.speicherMb),
diskGb: Number(a.diskGb),
storage: a.storage,
...(a.typ === "lxc"
? { swapMb: Number(a.swapMb), unprivilegiert: a.unprivilegiert, features: a.features }
: {}),
netz: { bridge: a.netz.bridge, ...(a.netz.vlan ? { vlan: Number(a.netz.vlan) } : {}) },
profile: a.profile,
variablen: a.variablen as VorlagenParameter["variablen"],
tags: a.tags,
}; };
} }
@@ -207,13 +265,33 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
suchdomain: "", suchdomain: "",
benutzer: daten.benutzer.replace(/[^a-z0-9_-]/g, "") || "admin", benutzer: daten.benutzer.replace(/[^a-z0-9_-]/g, "") || "admin",
schluessel: "", schluessel: "",
ohneSchluessel: [],
profile: ["base"], profile: ["base"],
variablen: {}, variablen: {},
templateBauen: false, templateBauen: false,
vorlage: "",
}; };
}, [daten, vorauswahl]); }, [daten, vorauswahl]);
const [werte, setWerte] = useState<Werte>(() => ({ ...anfang, ...dekodieren(parameter.get("d")) })); const [werte, setWerte] = useState<Werte>(() => {
const ausUrl = { ...anfang, ...dekodieren(parameter.get("d")) };
// ?vorlage=<name> von der Vorlagenseite: einmal anwenden, danach steht alles in d.
const vorlage = !parameter.get("d")
? daten.vorlagen.find((v) => v.name === parameter.get("vorlage"))
: undefined;
return vorlage ? vorlageUebernehmen(ausUrl, vorlage.name, vorlage.parameter, daten) : ausUrl;
});
const [vorlageSpeichern, setVorlageSpeichern] = useState<{
name: string;
titel: string;
offen: boolean;
meldung: string | null;
}>({
name: "",
titel: "",
offen: false,
meldung: null,
});
const [plan, setPlan] = useState<Plan | null>(null); const [plan, setPlan] = useState<Plan | null>(null);
const [fehler, setFehler] = useState<{ const [fehler, setFehler] = useState<{
text: string; text: string;
@@ -237,19 +315,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
}; };
const gehe = (ziel: number) => { const gehe = (ziel: number) => {
let naechste = werte; const 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); if (SCHRITTE[ziel] === "Zusammenfassung") void planen(naechste);
const neu = new URLSearchParams({ s: String(ziel), d: kodieren(naechste) }); const neu = new URLSearchParams({ s: String(ziel), d: kodieren(naechste) });
router.replace(`${pfad}?${neu.toString()}`, { scroll: false }); router.replace(`${pfad}?${neu.toString()}`, { scroll: false });
@@ -289,7 +355,9 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
.filter(Boolean); .filter(Boolean);
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(werte.benutzer) || werte.benutzer === "root") if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(werte.benutzer) || werte.benutzer === "root")
return "Gültigen Benutzernamen angeben (nicht root)."; return "Gültigen Benutzernamen angeben (nicht root).";
if (schluessel.length === 0) return "Mindestens einen öffentlichen SSH-Schlüssel angeben."; const hinterlegt = daten.schluessel.filter((s) => !werte.ohneSchluessel.includes(s.fingerabdruck));
if (schluessel.length + hinterlegt.length === 0)
return "Mindestens einen öffentlichen SSH-Schlüssel wählen oder angeben.";
if (schluessel.some((z) => !SSH_SCHLUESSEL.test(z))) if (schluessel.some((z) => !SSH_SCHLUESSEL.test(z)))
return "Eine Zeile ist kein öffentlicher SSH-Schlüssel."; return "Eine Zeile ist kein öffentlicher SSH-Schlüssel.";
return null; return null;
@@ -304,7 +372,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
setLaeuft(true); setLaeuft(true);
setFehler(null); setFehler(null);
try { try {
setPlan(await apiAufruf<Plan>("POST", "/api/v1/auftraege/plan", anfrageAus(stand))); setPlan(await apiAufruf<Plan>("POST", "/api/v1/auftraege/plan", anfrageAus(stand, daten.schluessel)));
} catch (ursache) { } catch (ursache) {
setFehler( setFehler(
ursache instanceof ApiFehler ursache instanceof ApiFehler
@@ -320,12 +388,11 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
setLaeuft(true); setLaeuft(true);
setFehler(null); setFehler(null);
try { try {
try { const { kurzId } = await apiAufruf<{ kurzId: string }>(
localStorage.setItem(SCHLUESSEL_SPEICHER, werte.schluessel); "POST",
} catch { "/api/v1/auftraege",
// egal anfrageAus(werte, daten.schluessel),
} );
const { kurzId } = await apiAufruf<{ kurzId: string }>("POST", "/api/v1/auftraege", anfrageAus(werte));
router.push(`/auftraege/${kurzId}`); router.push(`/auftraege/${kurzId}`);
} catch (ursache) { } catch (ursache) {
setFehler( setFehler(
@@ -388,6 +455,34 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
<div className="flex min-w-0 flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
{SCHRITTE[schritt] === "Server" ? ( {SCHRITTE[schritt] === "Server" ? (
<Karte titel="Server und Node"> <Karte titel="Server und Node">
{daten.vorlagen.length > 0 ? (
<div className="mb-5">
<Feld
id="vorlage"
label="Vorlage"
hilfe="Belegt Distribution, Ressourcen und Profile vor – alles bleibt änderbar."
>
<select
id="vorlage"
className={EINGABE_KLASSE}
value={werte.vorlage}
onChange={(e) => {
const v = daten.vorlagen.find((x) => x.name === e.target.value);
if (v) setWerte((alt) => vorlageUebernehmen(alt, v.name, v.parameter, daten));
else setze({ vorlage: "" });
setPlan(null);
}}
>
<option value="">keine</option>
{daten.vorlagen.map((v) => (
<option key={v.name} value={v.name}>
{v.titel}
</option>
))}
</select>
</Feld>
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Feld id="server" label="Server"> <Feld id="server" label="Server">
<select <select
@@ -465,7 +560,6 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
className={clsx( className={clsx(
"border-linie flex cursor-pointer flex-col gap-1 rounded-lg border p-4", "border-linie flex cursor-pointer flex-col gap-1 rounded-lg border p-4",
werte.typ === wert && "border-akzent", werte.typ === wert && "border-akzent",
wert === "lxc" && "cursor-not-allowed opacity-50",
)} )}
> >
<span className="flex items-center gap-2 font-semibold"> <span className="flex items-center gap-2 font-semibold">
@@ -555,7 +649,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
autoFocus autoFocus
/> />
</Feld> </Feld>
<Feld id="vmid" label="VMID" hilfe="Leer = nächste freie"> <Feld id="vmid" label={werte.typ === "lxc" ? "CTID" : "VMID"} hilfe="Leer = nächste freie">
<input <input
id="vmid" id="vmid"
inputMode="numeric" inputMode="numeric"
@@ -631,6 +725,40 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
</select> </select>
</Feld> </Feld>
</div> </div>
{werte.typ === "lxc" ? (
<div className="mt-5 grid gap-4 sm:grid-cols-3">
<Feld id="swap" label="Swap (MB)">
<input
id="swap"
type="number"
min={0}
step={256}
className={EINGABE_KLASSE}
value={werte.swapMb}
onChange={(e) => setze({ swapMb: e.target.value })}
/>
</Feld>
<Schalter
id="unprivilegiert"
label="Unprivilegiert"
hilfe="Standard und empfohlen. Privilegierte Container braucht es selten."
checked={werte.unprivilegiert}
onChange={(an) => setze({ unprivilegiert: an })}
/>
<Schalter
id="nesting"
label="nesting"
hilfe={
werte.profile.includes("docker")
? "Das Profil docker schaltet es ohnehin ein."
: "Für Docker oder systemd-Dienste mit eigenem Namensraum."
}
checked={werte.nesting || werte.profile.includes("docker")}
disabled={werte.profile.includes("docker")}
onChange={(an) => setze({ nesting: an })}
/>
</div>
) : null}
</Karte> </Karte>
) : null} ) : null}
@@ -742,10 +870,35 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
autoComplete="off" autoComplete="off"
/> />
</Feld> </Feld>
{daten.schluessel.length > 0 ? (
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Hinterlegte Schlüssel</legend>
{daten.schluessel.map((s) => (
<Schalter
key={s.fingerabdruck}
id={`schluessel-${s.fingerabdruck}`}
label={`${s.name}${s.bereich === "team" ? " (Team)" : ""}`}
hilfe={s.fingerabdruck}
checked={!werte.ohneSchluessel.includes(s.fingerabdruck)}
onChange={(an) =>
setze({
ohneSchluessel: an
? werte.ohneSchluessel.filter((f) => f !== s.fingerabdruck)
: [...werte.ohneSchluessel, s.fingerabdruck],
})
}
/>
))}
</fieldset>
) : null}
<Feld <Feld
id="schluessel" id="schluessel"
label="Öffentliche SSH-Schlüssel" label={
hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Gespeicherte Schlüssel folgen mit #015." daten.schluessel.length > 0
? "Weitere öffentliche SSH-Schlüssel"
: "Öffentliche SSH-Schlüssel"
}
hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Dauerhaft hinterlegen: Einstellungen."
> >
<textarea <textarea
id="schluessel" id="schluessel"
@@ -944,6 +1097,73 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
</Knopf> </Knopf>
</div> </div>
</div> </div>
<div className="flex flex-col gap-3">
{!vorlageSpeichern.offen ? (
<Knopf
variante="sekundaer"
className="self-start"
onClick={() => setVorlageSpeichern((v) => ({ ...v, offen: true, meldung: null }))}
>
Als Vorlage speichern
</Knopf>
) : (
<Karte titel="Als Vorlage speichern">
<div className="grid gap-4 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
<Feld id="vorlage-name" label="Kurzname (für --preset)">
<input
id="vorlage-name"
className={`${EINGABE_KLASSE} font-mono`}
value={vorlageSpeichern.name}
onChange={(e) => setVorlageSpeichern((v) => ({ ...v, name: e.target.value }))}
placeholder="docker-host-klein"
/>
</Feld>
<Feld id="vorlage-titel" label="Titel">
<input
id="vorlage-titel"
className={EINGABE_KLASSE}
value={vorlageSpeichern.titel}
onChange={(e) => setVorlageSpeichern((v) => ({ ...v, titel: e.target.value }))}
placeholder="Docker-Host klein"
/>
</Feld>
<Knopf
onClick={async () => {
try {
await apiAufruf("POST", "/api/v1/vorlagen", {
name: vorlageSpeichern.name,
titel: vorlageSpeichern.titel,
parameter: werteZuVorlage(werte),
});
setVorlageSpeichern({
name: "",
titel: "",
offen: false,
meldung: `Vorlage „${vorlageSpeichern.titel}“ gespeichert.`,
});
} catch (ursache) {
setVorlageSpeichern((v) => ({
...v,
meldung:
ursache instanceof ApiFehler ? ursache.message : "Fehler beim Speichern.",
}));
}
}}
>
Speichern
</Knopf>
</div>
<p className="text-muted mt-3 text-sm">
Name, VMID, IP und SSH-Schlüssel gehören nicht in eine Vorlage.
</p>
</Karte>
)}
{vorlageSpeichern.meldung ? (
<Status art={vorlageSpeichern.meldung.startsWith("Vorlage") ? "erledigt" : "fehler"}>
{vorlageSpeichern.meldung}
</Status>
) : null}
</div>
</> </>
) : null} ) : null}
</div> </div>
+9
View File
@@ -8,6 +8,8 @@ import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
import { versionText } from "@/lib/version"; import { versionText } from "@/lib/version";
import { seiteSchuetzen } from "@/server/auth/zugang"; import { seiteSchuetzen } from "@/server/auth/zugang";
import { distributionenListe, profileListe } from "@/server/services/katalog"; import { distributionenListe, profileListe } from "@/server/services/katalog";
import { vorlagenListe } from "@/server/services/vorlagen";
import { schluesselFuer } from "@/server/services/schluessel";
import { Assistent, type AssistentDaten } from "./assistent"; import { Assistent, type AssistentDaten } from "./assistent";
export const metadata: Metadata = { title: "Ausrollen" }; export const metadata: Metadata = { title: "Ausrollen" };
@@ -73,6 +75,13 @@ export default async function Ausrollen() {
})), })),
benutzer: benutzer.benutzername, benutzer: benutzer.benutzername,
version: versionText(), version: versionText(),
vorlagen: await vorlagenListe(),
schluessel: (await schluesselFuer(benutzer)).map((x) => ({
name: x.name,
oeffentlich: x.oeffentlich,
fingerabdruck: x.fingerabdruck,
bereich: x.bereich,
})),
}; };
const vorauswahl = aktiverServer( const vorauswahl = aktiverServer(
daten.server.map((s) => s.name), daten.server.map((s) => s.name),
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { schluesselFuer } from "@/server/services/schluessel";
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
export const metadata: Metadata = { title: "Einstellungen" };
/** Einstellungen. Heute: SSH-Schlüssel. Benutzer, CLI-Tokens und Standardwerte folgen mit #015. */
export default async function Einstellungen() {
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
<p className="text-muted text-sm">Angemeldet als {benutzer.benutzername}</p>
</header>
<SchluesselVerwaltung
anfang={await schluesselFuer(benutzer)}
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
/>
</div>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { EINGABE_KLASSE, Feld, Schalter } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
type Schluessel = {
id: string;
name: string;
fingerabdruck: string;
bereich: "persoenlich" | "team";
quelle: string;
oeffentlich: string;
};
/**
* Öffentliche SSH-Schlüssel, die automatisch in jeden Gast kommen – eigene und (von Admins
* gepflegte) Team-Schlüssel. Im Assistenten lassen sie sich je Gast abwählen.
*/
export function SchluesselVerwaltung({
anfang,
teamErlaubt,
}: {
anfang: Schluessel[];
teamErlaubt: boolean;
}) {
const router = useRouter();
const [neu, setNeu] = useState({ name: "", oeffentlich: "", team: false });
const [meldung, setMeldung] = useState<{
art: "erledigt" | "fehler";
text: string;
felder?: Record<string, string>;
} | null>(null);
async function hinzufuegen(ereignis: React.FormEvent) {
ereignis.preventDefault();
try {
await apiAufruf("POST", "/api/v1/schluessel", neu);
setNeu({ name: "", oeffentlich: "", team: false });
setMeldung({ art: "erledigt", text: "Schlüssel hinzugefügt." });
router.refresh();
} catch (fehler) {
setMeldung({
art: "fehler",
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
});
}
}
async function entfernen(s: Schluessel) {
if (!window.confirm(`Schlüssel „${s.name}“ entfernen? Bereits ausgerollte Gäste behalten ihn.`)) return;
try {
await apiAufruf("DELETE", `/api/v1/schluessel/${s.id}`);
router.refresh();
} catch (fehler) {
setMeldung({ art: "fehler", text: fehler instanceof ApiFehler ? fehler.message : "Fehler." });
}
}
return (
<Karte titel="SSH-Schlüssel">
<div className="flex flex-col gap-5">
<p className="text-muted text-sm">
Diese öffentlichen Schlüssel kommen in jeden Gast, den du ausrollst – auch per CLI, wenn dort keine
angegeben sind.
</p>
{anfang.length === 0 ? (
<Status art="offen">Noch keine Schlüssel hinterlegt.</Status>
) : (
<ul className="divide-linie flex flex-col divide-y">
{anfang.map((s) => (
<li key={s.id} className="flex flex-col gap-2 py-3 first:pt-0 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<p className="font-medium">
{s.name}{" "}
<span className="text-muted text-sm">
· {s.bereich === "team" ? "Team" : "persönlich"}
</span>
</p>
<p className="text-muted font-mono text-xs break-all">{s.fingerabdruck}</p>
</div>
{s.bereich === "persoenlich" || teamErlaubt ? (
<Knopf
variante="gefahr"
onClick={() => void entfernen(s)}
aria-label={`Schlüssel ${s.name} entfernen`}
>
Entfernen
</Knopf>
) : null}
</li>
))}
</ul>
)}
<form onSubmit={hinzufuegen} className="border-linie flex flex-col gap-4 border-t pt-5">
<Feld id="schluessel-name" label="Name" fehler={meldung?.felder?.["name"]}>
<input
id="schluessel-name"
className={`${EINGABE_KLASSE} sm:max-w-sm`}
value={neu.name}
onChange={(e) => setNeu({ ...neu, name: e.target.value })}
placeholder="Laptop"
/>
</Feld>
<Feld
id="schluessel-text"
label="Öffentlicher Schlüssel"
hilfe="Inhalt von ~/.ssh/id_ed25519.pub"
fehler={meldung?.felder?.["oeffentlich"]}
>
<textarea
id="schluessel-text"
rows={3}
className={`${EINGABE_KLASSE} h-auto py-2 font-mono text-xs`}
value={neu.oeffentlich}
onChange={(e) => setNeu({ ...neu, oeffentlich: e.target.value })}
placeholder="ssh-ed25519 AAAA… name@rechner"
spellCheck={false}
/>
</Feld>
{teamErlaubt ? (
<Schalter
id="schluessel-team"
label="Team-Schlüssel"
hilfe="Kommt in jeden Gast, egal wer ausrollt."
checked={neu.team}
onChange={(an) => setNeu({ ...neu, team: an })}
/>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Knopf type="submit" disabled={!neu.name || !neu.oeffentlich}>
Hinzufügen
</Knopf>
<div aria-live="polite" className="text-sm">
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
</div>
</div>
</form>
</div>
</Karte>
);
}
+14 -2
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { Karte } from "@/components/karte"; import { Karte } from "@/components/karte";
import { Status } from "@/components/status"; import { Status } from "@/components/status";
import { darf } from "@/server/auth/rechte"; import { darf } from "@/server/auth/rechte";
@@ -55,7 +56,16 @@ export default async function KatalogSeite() {
</ul> </ul>
</Karte> </Karte>
<Karte titel="Profile"> <Karte
titel="Profile"
aktion={
verwalten ? (
<Link href="/katalog/profile/neu" className="text-akzent text-sm font-semibold">
Profil anlegen
</Link>
) : null
}
>
<ul className="divide-linie flex flex-col divide-y"> <ul className="divide-linie flex flex-col divide-y">
{profile.map((p) => ( {profile.map((p) => (
<li <li
@@ -64,7 +74,9 @@ export default async function KatalogSeite() {
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium"> <p className="font-medium">
{p.name}{" "} <Link href={`/katalog/profile/${p.id}`} className="hover:underline">
{p.name}
</Link>{" "}
<span className="text-muted font-mono text-xs"> <span className="text-muted font-mono text-xs">
{p.id} · {p.herkunft === "KATALOG" ? "Katalog" : "eigenes"} {p.id} · {p.herkunft === "KATALOG" ? "Katalog" : "eigenes"}
</span> </span>
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { ProfilEditor } from "../profil-editor";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
return { title: `Profil ${(await params).id}` };
}
/** Katalogprofile nur ansehen (bearbeiten als Kopie), eigene bearbeiten. */
export default async function ProfilSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const benutzer = await seiteSchuetzen("lesen", `/katalog/profile/${id}`);
const profil = await prisma.profil.findUnique({ where: { id } });
if (!profil || profil.entferntAm) notFound();
const bearbeitbar = profil.herkunft === "EIGEN" && darf(benutzer.rolle, "katalogVerwalten");
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-muted font-mono text-sm">
{profil.id} · {profil.herkunft === "KATALOG" ? "Katalog (schreibgeschützt)" : "eigenes Profil"}
</p>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{profil.name}</h1>
</div>
{profil.herkunft === "KATALOG" && darf(benutzer.rolle, "katalogVerwalten") ? (
<Link
href={`/katalog/profile/neu?von=${profil.id}`}
className="border-linie bg-panel-hoch hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
>
Als Kopie bearbeiten
</Link>
) : null}
</header>
<ProfilEditor
id={bearbeitbar ? profil.id : undefined}
anfang={profil.quelltext}
schreibgeschuetzt={!bearbeitbar}
/>
</div>
);
}
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { ProfilEditor } from "../profil-editor";
export const metadata: Metadata = { title: "Profil anlegen" };
const VORLAGE = `# Eigenes Profil – Format und Schritte: docs/katalog.md
id: mein-profil
name: Mein Profil
description: Was das Profil tut
requires: [base]
variables:
- { name: beispiel, label: Beispielwert, default: hallo }
families:
debian:
- packages: [htop]
- file:
path: /etc/motd
content: |
{{ guest.name }} – {{ var.beispiel }}
`;
/** Neues Profil, leer oder als Kopie (`?von=<id>`). */
export default async function ProfilNeu({ searchParams }: { searchParams: Promise<{ von?: string }> }) {
await seiteSchuetzen("katalogVerwalten", "/katalog/profile/neu");
const von = (await searchParams).von;
const quelle = von ? await prisma.profil.findUnique({ where: { id: von } }) : null;
const anfang = quelle
? quelle.quelltext
.replace(/^id:\s*.+$/m, `id: ${quelle.id}-eigen`)
.replace(/^name:\s*(.+)$/m, "name: $1 (eigen)")
: VORLAGE;
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
{quelle ? `${quelle.name} als Kopie` : "Profil anlegen"}
</h1>
<ProfilEditor anfang={anfang} schreibgeschuetzt={false} />
</div>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { Knopf } from "@/components/knopf";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
type Pruefung = { ok: true; vorschau: string | null } | { ok: false; fehler: string[] };
/**
* YAML-Editor für Profile mit Prüfung beim Tippen (nach kurzer Pause) und Vorschau des Skripts.
* Gespeichert wird nur, was der Server für gültig hält.
*/
export function ProfilEditor({
id,
anfang,
schreibgeschuetzt,
}: {
id?: string;
anfang: string;
schreibgeschuetzt: boolean;
}) {
const router = useRouter();
const [text, setText] = useState(anfang);
const [pruefung, setPruefung] = useState<Pruefung | null>(null);
const [meldung, setMeldung] = useState<{ text: string; hinweis?: string } | null>(null);
const [speichert, setSpeichert] = useState(false);
const zaehler = useRef(0);
useEffect(() => {
const nummer = ++zaehler.current;
const uhr = setTimeout(async () => {
try {
const ergebnis = await apiAufruf<Pruefung>("POST", "/api/v1/profile/pruefen", { quelltext: text });
// Eine ältere Antwort darf eine neuere nicht überschreiben.
if (nummer === zaehler.current) setPruefung(ergebnis);
} catch {
// Prüfung ist Komfort – beim Speichern prüft der Server ohnehin.
}
}, 400);
return () => clearTimeout(uhr);
}, [text]);
async function speichern() {
setSpeichert(true);
setMeldung(null);
try {
const gespeichert = id
? await apiAufruf<{ id: string }>("PUT", `/api/v1/profile/${id}`, { quelltext: text })
: await apiAufruf<{ id: string }>("POST", "/api/v1/profile", { quelltext: text });
router.push(`/katalog/profile/${gespeichert.id}`);
router.refresh();
} catch (fehler) {
setMeldung(
fehler instanceof ApiFehler
? { text: fehler.message, hinweis: fehler.hinweis }
: { text: "Unerwarteter Fehler." },
);
} finally {
setSpeichert(false);
}
}
async function loeschen() {
if (!id || !window.confirm(`Profil „${id}“ wirklich löschen?`)) return;
try {
await apiAufruf("DELETE", `/api/v1/profile/${id}`);
router.push("/katalog");
router.refresh();
} catch (fehler) {
setMeldung(
fehler instanceof ApiFehler
? { text: fehler.message, hinweis: fehler.hinweis }
: { text: "Unerwarteter Fehler." },
);
}
}
return (
<div className="flex flex-col gap-6">
<Karte titel="YAML">
<label htmlFor="quelltext" className="sr-only">
Profil als YAML
</label>
<textarea
id="quelltext"
value={text}
onChange={(e) => setText(e.target.value)}
readOnly={schreibgeschuetzt}
spellCheck={false}
rows={Math.min(40, Math.max(14, text.split("\n").length + 2))}
className="bg-grund border-linie text-text w-full rounded-lg border p-3 font-mono text-xs leading-5 sm:text-sm"
aria-describedby="pruefung"
/>
<div id="pruefung" aria-live="polite" className="mt-3 text-sm">
{pruefung === null ? (
<Status art="laeuft">Prüft …</Status>
) : pruefung.ok ? (
<Status art="erledigt">Gültig</Status>
) : (
<ul className="flex flex-col gap-1">
{pruefung.fehler.map((f) => (
<li key={f}>
<Status art="fehler">{f}</Status>
</li>
))}
</ul>
)}
</div>
</Karte>
{pruefung?.ok && pruefung.vorschau ? (
<details className="bg-panel border-linie rounded-karte border">
<summary className="cursor-pointer px-5 py-3.5 font-semibold">Vorschau: erzeugtes Skript</summary>
<pre className="border-linie max-h-[50vh] overflow-auto border-t p-5 font-mono text-xs">
{pruefung.vorschau}
</pre>
</details>
) : null}
{!schreibgeschuetzt ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<div aria-live="polite" className="text-sm">
{meldung ? (
<div>
<Status art="fehler">{meldung.text}</Status>
{meldung.hinweis ? <p className="text-muted">{meldung.hinweis}</p> : null}
</div>
) : null}
</div>
<div className="flex gap-3">
{id ? (
<Knopf variante="gefahr" onClick={loeschen}>
Löschen
</Knopf>
) : null}
<Knopf onClick={speichern} disabled={speichert || !pruefung?.ok}>
{speichert ? "Speichert …" : "Speichern"}
</Knopf>
</div>
</div>
) : null}
</div>
);
}
+49 -1
View File
@@ -43,6 +43,9 @@ type Werte = {
templateVmidVon: string; templateVmidVon: string;
templateVmidBis: string; templateVmidBis: string;
gastVmidVon: string; gastVmidVon: string;
bauIp: string;
bauGateway: string;
bauDns: string;
}; };
function anfangswerte(server?: ServerSicht): Werte { function anfangswerte(server?: ServerSicht): Werte {
@@ -60,6 +63,9 @@ function anfangswerte(server?: ServerSicht): Werte {
templateVmidVon: String(server?.templateVmidVon ?? 9000), templateVmidVon: String(server?.templateVmidVon ?? 9000),
templateVmidBis: String(server?.templateVmidBis ?? 9999), templateVmidBis: String(server?.templateVmidBis ?? 9999),
gastVmidVon: String(server?.gastVmidVon ?? 100), gastVmidVon: String(server?.gastVmidVon ?? 100),
bauIp: server?.bauNetz?.ip ?? "",
bauGateway: server?.bauNetz?.gateway ?? "",
bauDns: server?.bauNetz?.dns.join(", ") ?? "",
}; };
} }
@@ -117,12 +123,16 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
setSpeichert(true); setSpeichert(true);
setFelder({}); setFelder({});
setMeldung(null); setMeldung(null);
const { bauIp, bauGateway, bauDns, ...ohneBau } = werte;
const rumpf = { const rumpf = {
...werte, ...ohneBau,
sshPort: Number(werte.sshPort), sshPort: Number(werte.sshPort),
templateVmidVon: Number(werte.templateVmidVon), templateVmidVon: Number(werte.templateVmidVon),
templateVmidBis: Number(werte.templateVmidBis), templateVmidBis: Number(werte.templateVmidBis),
gastVmidVon: Number(werte.gastVmidVon), gastVmidVon: Number(werte.gastVmidVon),
bauNetz: bauIp.trim()
? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) }
: null,
}; };
try { try {
const gespeichert = bearbeiten const gespeichert = bearbeiten
@@ -361,6 +371,44 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
</Feld> </Feld>
</div> </div>
<fieldset className="flex flex-col gap-3">
<legend className="text-sm font-medium">Bau-Adresse (optional)</legend>
<p className="text-muted text-sm">
Feste Adresse für den Vorbereitungsstart beim Template-Bau. Nur nötig ohne SSH zum Node:
ProxDeploy meldet sich darüber kurz in der VM an und installiert den Guest-Agent. Braucht
Internetzugang.
</p>
<div className="grid gap-4 sm:grid-cols-3">
<Feld id="bauIp" label="IP/Präfix" fehler={felder["bauNetz.ip"]}>
<input
id="bauIp"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.bauIp}
onChange={(e) => setze("bauIp", e.target.value)}
placeholder="10.0.10.250/24"
/>
</Feld>
<Feld id="bauGateway" label="Gateway" fehler={felder["bauNetz.gateway"]}>
<input
id="bauGateway"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.bauGateway}
onChange={(e) => setze("bauGateway", e.target.value)}
placeholder="10.0.10.1"
/>
</Feld>
<Feld id="bauDns" label="DNS" fehler={felder["bauNetz.dns"]}>
<input
id="bauDns"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.bauDns}
onChange={(e) => setze("bauDns", e.target.value)}
placeholder="10.0.10.1"
/>
</Feld>
</div>
</fieldset>
<Schalter <Schalter
id="sshAktiv" id="sshAktiv"
label="SSH zu den Nodes nutzen" label="SSH zu den Nodes nutzen"
+11 -1
View File
@@ -10,7 +10,7 @@ import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
import { darf } from "@/server/auth/rechte"; import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang"; import { seiteSchuetzen } from "@/server/auth/zugang";
import { AnwendungsFehler } from "@/server/fehler"; import { AnwendungsFehler } from "@/server/fehler";
import { distributionenListe } from "@/server/services/katalog"; import { distributionenListe, profileListe } from "@/server/services/katalog";
import { templatesListe, type TemplateSicht } from "@/server/services/templates"; import { templatesListe, type TemplateSicht } from "@/server/services/templates";
import { TemplateBauen } from "./template-bauen"; import { TemplateBauen } from "./template-bauen";
@@ -48,6 +48,7 @@ export default async function TemplatesSeite() {
const distributionen = (await distributionenListe({ typ: "vm", nurAktive: true })).map((d) => ({ const distributionen = (await distributionenListe({ typ: "vm", nurAktive: true })).map((d) => ({
id: d.id, id: d.id,
name: d.name, name: d.name,
familie: d.familie,
})); }));
return ( return (
@@ -83,6 +84,7 @@ export default async function TemplatesSeite() {
{" "} {" "}
· {t.knoten} · {t.knoten}
{t.distributionId ? ` · ${t.distributionId}` : ""} {t.distributionId ? ` · ${t.distributionId}` : ""}
{t.profile.length > 0 ? ` · mit ${t.profile.join(", ")}` : ""}
</span> </span>
</span> </span>
<span className="text-muted text-sm"> <span className="text-muted text-sm">
@@ -110,6 +112,14 @@ export default async function TemplatesSeite() {
bridges: k.bridges.map((b) => b.name), bridges: k.bridges.map((b) => b.name),
}))} }))}
faehigkeit={bericht.faehigkeiten.templateBau} faehigkeit={bericht.faehigkeiten.templateBau}
profile={(await profileListe({ nurAktive: true }))
.filter((p) => !p.definition.variables.some((v) => v.required && v.default === undefined))
.map((p) => ({
id: p.id,
name: p.name,
requires: p.definition.requires,
familien: p.familien,
}))}
/> />
</Karte> </Karte>
) : ( ) : (
+41 -1
View File
@@ -14,9 +14,11 @@ export function TemplateBauen({
distributionen, distributionen,
knoten, knoten,
faehigkeit, faehigkeit,
profile,
}: { }: {
server: string; server: string;
distributionen: Array<{ id: string; name: string }>; distributionen: Array<{ id: string; name: string; familie: string }>;
profile: Array<{ id: string; name: string; requires: string[]; familien: string[] }>;
knoten: Knoten[]; knoten: Knoten[];
faehigkeit: { weg: "api" | "ssh" | "nicht_moeglich"; grund: string }; faehigkeit: { weg: "api" | "ssh" | "nicht_moeglich"; grund: string };
}) { }) {
@@ -26,6 +28,7 @@ export function TemplateBauen({
knoten: knoten[0]?.name ?? "", knoten: knoten[0]?.name ?? "",
storage: knoten[0]?.storages[0] ?? "", storage: knoten[0]?.storages[0] ?? "",
bridge: knoten[0]?.bridges.includes("vmbr0") ? "vmbr0" : (knoten[0]?.bridges[0] ?? ""), bridge: knoten[0]?.bridges.includes("vmbr0") ? "vmbr0" : (knoten[0]?.bridges[0] ?? ""),
profile: [] as string[],
}); });
const [fehler, setFehler] = useState<{ text: string; hinweis?: string } | null>(null); const [fehler, setFehler] = useState<{ text: string; hinweis?: string } | null>(null);
const [sendet, setSendet] = useState(false); const [sendet, setSendet] = useState(false);
@@ -118,6 +121,43 @@ export function TemplateBauen({
</select> </select>
</Feld> </Feld>
</div> </div>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Profile einbacken (optional)</legend>
<p className="text-muted text-sm">
Was hier gewählt ist, ist in jedem Klon schon installiert – Deployments mit diesen Profilen sind
schneller fertig. Profile mit Pflichtangaben (z. B. Zabbix-Server) laufen beim Ausrollen.
</p>
<div className="flex flex-wrap gap-x-6 gap-y-2">
{profile
.filter((p) =>
p.familien.includes(distributionen.find((d) => d.id === werte.distribution)?.familie ?? ""),
)
.map((p) => {
const benoetigt = profile.some(
(x) => werte.profile.includes(x.id) && x.requires.includes(p.id),
);
return (
<label key={p.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="accent-akzent h-4 w-4"
checked={werte.profile.includes(p.id) || benoetigt}
disabled={benoetigt && !werte.profile.includes(p.id)}
onChange={(e) =>
setWerte({
...werte,
profile: e.target.checked
? [...werte.profile, p.id]
: werte.profile.filter((x) => x !== p.id),
})
}
/>
{p.name}
</label>
);
})}
</div>
</fieldset>
<p className="text-muted text-sm"> <p className="text-muted text-sm">
Der Vorbereitungsstart braucht DHCP und Internetzugang auf der Bridge, um qemu-guest-agent zu Der Vorbereitungsstart braucht DHCP und Internetzugang auf der Bridge, um qemu-guest-agent zu
installieren. installieren.
+68
View File
@@ -0,0 +1,68 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { vorlagenListe } from "@/server/services/vorlagen";
import { VorlageLoeschen } from "./vorlage-loeschen";
export const metadata: Metadata = { title: "Vorlagen" };
export default async function Vorlagen() {
const benutzer = await seiteSchuetzen("lesen", "/vorlagen");
const liste = await vorlagenListe();
const pflegen = darf(benutzer.rolle, "vorlagenPflegen");
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Vorlagen</h1>
<p className="text-muted text-sm">
Gespeicherte Kombinationen für den Assistenten und die CLI (
<span className="font-mono">--preset</span>). Anlegen: im Assistenten auf der Zusammenfassung „Als
Vorlage speichern“.
</p>
</header>
{liste.length === 0 ? (
<Karte>
<Status art="offen">Noch keine Vorlage.</Status>
</Karte>
) : (
<ul className="grid gap-4 md:grid-cols-2">
{liste.map((v) => {
const p = v.parameter;
const eckdaten = [
p.typ?.toUpperCase(),
p.distribution,
p.cores ? `${p.cores} CPU` : null,
p.speicherMb ? `${p.speicherMb / 1024} GB RAM` : null,
p.diskGb ? `${p.diskGb} GB Disk` : null,
].filter(Boolean);
return (
<li key={v.name} className="bg-panel border-linie rounded-karte flex flex-col gap-3 border p-5">
<div>
<p className="font-semibold">{v.titel}</p>
<p className="text-muted font-mono text-xs">--preset {v.name}</p>
</div>
{v.beschreibung ? <p className="text-muted text-sm">{v.beschreibung}</p> : null}
<p className="font-mono text-xs">{eckdaten.join(" · ")}</p>
{p.profile && p.profile.length > 0 ? (
<p className="text-muted text-xs">Profile: {p.profile.join(", ")}</p>
) : null}
<div className="mt-auto flex flex-wrap items-center gap-3 pt-2">
<Link
href={`/ausrollen?vorlage=${v.name}`}
className="bg-akzent text-akzent-text inline-flex h-11 items-center rounded-lg px-4 text-sm font-semibold sm:h-10"
>
Ausrollen mit dieser Vorlage
</Link>
{pflegen ? <VorlageLoeschen name={v.name} /> : null}
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -0,0 +1,21 @@
"use client";
import { useRouter } from "next/navigation";
import { Knopf } from "@/components/knopf";
import { apiAufruf } from "@/lib/api-client";
export function VorlageLoeschen({ name }: { name: string }) {
const router = useRouter();
return (
<Knopf
variante="gefahr"
onClick={async () => {
if (!window.confirm(`Vorlage „${name}“ löschen?`)) return;
await apiAufruf("DELETE", `/api/v1/vorlagen/${name}`);
router.refresh();
}}
>
Löschen
</Knopf>
);
}
+2 -2
View File
@@ -5,6 +5,6 @@ import { ausrollenPlanen } from "@/server/services/ausrollen";
/** Dry-Run: Phasen, geplante Proxmox-Aufrufe, Skript und CLI-Befehl – angelegt wird nichts. */ /** Dry-Run: Phasen, geplante Proxmox-Aufrufe, Skript und CLI-Befehl – angelegt wird nichts. */
export const POST = api(async (anfrage: Request) => { export const POST = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "lesen"); const aufrufer = await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage))); return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage), aufrufer));
}); });
+17 -1
View File
@@ -3,7 +3,7 @@ import { aufruferAdresse } from "@/lib/adressen";
import { z } from "@/lib/validierung"; import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api"; import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang"; import { rechtErforderlich } from "@/server/auth/zugang";
import { profilAktivSetzen } from "@/server/services/katalog"; import { profilAktivSetzen, profilLoeschen, profilSpeichern } from "@/server/services/katalog";
type Kontext = { params: Promise<{ id: string }> }; type Kontext = { params: Promise<{ id: string }> };
@@ -13,3 +13,19 @@ export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
await profilAktivSetzen((await params).id, aktiv, aufrufer, aufruferAdresse(anfrage.headers)); await profilAktivSetzen((await params).id, aktiv, aufrufer, aufruferAdresse(anfrage.headers));
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
}); });
export const PUT = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
const { quelltext } = z
.object({ quelltext: z.string().min(1).max(200_000) })
.parse(await jsonLesen(anfrage));
return NextResponse.json(
await profilSpeichern(quelltext, aufrufer, aufruferAdresse(anfrage.headers), (await params).id),
);
});
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
await profilLoeschen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return new NextResponse(null, { status: 204 });
});
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { profilPruefen } from "@/server/services/katalog";
/** Live-Prüfung im Editor: Fehlerliste oder Vorschau des Skripts. Speichert nichts. */
export const POST = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "katalogVerwalten");
const { quelltext } = z.object({ quelltext: z.string().max(200_000) }).parse(await jsonLesen(anfrage));
return NextResponse.json(await profilPruefen(quelltext));
});
+15 -2
View File
@@ -1,8 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { api } from "@/server/api"; import { aufruferAdresse } from "@/lib/adressen";
import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api";
import { darf } from "@/server/auth/rechte"; import { darf } from "@/server/auth/rechte";
import { rechtErforderlich } from "@/server/auth/zugang"; import { rechtErforderlich } from "@/server/auth/zugang";
import { profileListe } from "@/server/services/katalog"; import { profileListe, profilSpeichern } from "@/server/services/katalog";
export const GET = api(async (anfrage: Request) => { export const GET = api(async (anfrage: Request) => {
const { benutzer } = await rechtErforderlich(anfrage, "lesen"); const { benutzer } = await rechtErforderlich(anfrage, "lesen");
@@ -10,3 +12,14 @@ export const GET = api(async (anfrage: Request) => {
new URL(anfrage.url).searchParams.get("alle") === "1" && darf(benutzer.rolle, "katalogVerwalten"); new URL(anfrage.url).searchParams.get("alle") === "1" && darf(benutzer.rolle, "katalogVerwalten");
return NextResponse.json(await profileListe({ nurAktive: !alle })); return NextResponse.json(await profileListe({ nurAktive: !alle }));
}); });
/** Eigenes Profil anlegen (YAML im Feld `quelltext`). Nur Admins – Profile führen Befehle als root aus. */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
const { quelltext } = z
.object({ quelltext: z.string().min(1).max(200_000) })
.parse(await jsonLesen(anfrage));
return NextResponse.json(await profilSpeichern(quelltext, aufrufer, aufruferAdresse(anfrage.headers)), {
status: 201,
});
});
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { schluesselLoeschen } from "@/server/services/schluessel";
type Kontext = { params: Promise<{ id: string }> };
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
await schluesselLoeschen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return new NextResponse(null, { status: 204 });
});
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { schluesselAnlegen, schluesselFuer } from "@/server/services/schluessel";
/** Die eigenen und die Team-Schlüssel – das, was ohne Angabe in jeden Gast kommt. */
export const GET = api(async (anfrage: Request) => {
const { benutzer } = await rechtErforderlich(anfrage, "eigeneZugaenge");
return NextResponse.json(await schluesselFuer(benutzer));
});
/** `{ name, oeffentlich, team? }` – Team-Schlüssel nur für Admins. */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
return NextResponse.json(
await schluesselAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
{ status: 201 },
);
});
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { vorlageHolen, vorlageLoeschen, vorlageSpeichern } from "@/server/services/vorlagen";
type Kontext = { params: Promise<{ name: string }> };
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await vorlageHolen((await params).name));
});
export const PUT = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen");
return NextResponse.json(
await vorlageSpeichern(
await jsonLesen(anfrage),
aufrufer,
aufruferAdresse(anfrage.headers),
(await params).name,
),
);
});
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen");
await vorlageLoeschen((await params).name, aufrufer, aufruferAdresse(anfrage.headers));
return new NextResponse(null, { status: 204 });
});
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { vorlageSpeichern, vorlagenListe } from "@/server/services/vorlagen";
export const GET = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await vorlagenListe());
});
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen");
return NextResponse.json(
await vorlageSpeichern(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
{ status: 201 },
);
});
+129
View File
@@ -0,0 +1,129 @@
import { AblaufFehler } from "@/engine/ablauf/fehler";
import type { PhasenKontext } from "@/engine/ablauf/ablauf";
import { MARKE } from "@/engine/profile/rendern";
import type { Parameter, ProxmoxClient } from "@/engine/proxmox/client";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
/**
* Provisionierung über den QEMU-Guest-Agent – gemeinsam für Deployments (#008) und Templates mit
* eingebackenen Profilen (#022). Kein SSH nötig.
*
* Die Agent-Endpunkte antworten unterschiedlich: exec → {pid}, exec-status und file-read direkt,
* ping und network-get-interfaces → {result}.
*/
export const SKRIPT = "/root/proxdeploy-provisionierung.sh";
export const LOG = "/var/log/proxdeploy-provisionierung.log";
/**
* file-write nimmt höchstens 60 KiB je Aufruf (API-Schema, Feld content). ProxDeploy kodiert selbst
* als base64 (encode=0): Proxmox' eigene Kodierung scheitert an Nicht-ASCII („Wide character in
* subroutine entry“, gefunden beim ersten echten Deploy). 40 KiB Rohdaten werden ~54 KiB base64.
*/
const STUECK = 40 * 1024;
type ExecStatus = { exited: number | boolean; exitcode?: number };
export function agent(client: ProxmoxClient, vmPfad: string) {
return async <T>(befehl: string, methode: "GET" | "POST" = "POST", parameter?: Parameter) =>
await client.aufrufen<T>({ methode, pfad: `${vmPfad}/agent/${befehl}`, parameter });
}
/** Wartet, bis der Agent antwortet; liefert die Wartezeit in ms. */
export async function agentAbwarten(
client: ProxmoxClient,
vmPfad: string,
k: PhasenKontext,
optionen: { grenzeMs: number; warten: (ms: number) => Promise<void> },
): Promise<number> {
const rufen = agent(client, vmPfad);
const beginn = Date.now();
for (;;) {
await k.pruefeAbbruch();
try {
await rufen("ping");
return Date.now() - beginn;
} catch (fehler) {
if (fehler instanceof ProxmoxFehler && fehler.art === "recht") throw fehler;
if (Date.now() - beginn > optionen.grenzeMs) {
throw new AblaufFehler(
`Der Guest-Agent antwortet nach ${Math.round(optionen.grenzeMs / 1000)} s nicht.`,
"Bootet die VM? Konsole ansehen. Wurde das Template von ProxDeploy gebaut (Agent eingebaut)?",
);
}
}
await k.detail(`wartet … ${Math.round((Date.now() - beginn) / 1000)}s`);
await optionen.warten(3000);
}
}
/**
* Überträgt ein Skript in Stücken (file-write ersetzt, hängt nicht an), führt es aus und zeigt den
* Fortschritt aus der Logdatei im Gast (Marken `::proxdeploy:: profil <id>`).
*/
export async function skriptAusfuehren(
client: ProxmoxClient,
vmPfad: string,
skript: string,
k: PhasenKontext,
optionen: { grenzeMs: number; warten: (ms: number) => Promise<void> },
): Promise<void> {
const rufen = agent(client, vmPfad);
const bytes = Buffer.from(skript, "utf8");
const stuecke: Buffer[] = [];
for (let i = 0; i < bytes.length; i += STUECK) stuecke.push(bytes.subarray(i, i + STUECK));
for (const [nr, inhalt] of stuecke.entries()) {
await rufen("file-write", "POST", {
file: `${SKRIPT}.${nr}`,
content: inhalt.toString("base64"),
encode: "0",
});
}
const teile = stuecke.map((_, nr) => `${SKRIPT}.${nr}`).join(" ");
const { pid } = await rufen<{ pid: number }>("exec", "POST", {
command: [
"/bin/bash",
"-c",
`cat ${teile} > ${SKRIPT} && rm -f ${teile} && chmod 700 ${SKRIPT} && ${SKRIPT} > ${LOG} 2>&1`,
],
});
const beginn = Date.now();
let gelesen = 0;
let profil = "";
for (;;) {
await optionen.warten(3000);
const status = await rufen<ExecStatus>("exec-status", "GET", { pid: String(pid) });
try {
const datei = await rufen<{ content: string }>("file-read", "GET", { file: LOG });
const zeilen = datei.content.split("\n");
for (const zeile of zeilen.slice(gelesen, zeilen.length - 1)) {
const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile);
if (marke) profil = marke[1]!;
await k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug");
}
gelesen = Math.max(gelesen, zeilen.length - 1);
} catch {
// Logdatei noch nicht da – beim nächsten Mal.
}
if (status.exited) {
if (status.exitcode !== 0) {
throw new AblaufFehler(
`Provisionierung ${profil ? `im Profil ${profil} ` : ""}mit Code ${status.exitcode} abgebrochen.`,
`Detail-Log oder ${LOG} in der VM ansehen.`,
);
}
break;
}
if (Date.now() - beginn > optionen.grenzeMs) {
throw new AblaufFehler(
`Provisionierung läuft länger als ${Math.round(optionen.grenzeMs / 60000)} min.`,
`${LOG} in der VM ansehen.`,
);
}
const s = Math.round((Date.now() - beginn) / 1000);
await k.detail(
`${profil ? `Profil ${profil}` : "Vorbereitung"} läuft … ${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`,
);
}
await rufen("exec", "POST", { command: ["rm", "-f", SKRIPT] }).catch(() => undefined);
}
+444
View File
@@ -0,0 +1,444 @@
import { AblaufFehler } from "@/engine/ablauf/fehler";
import type { PhasenKontext, PhasenSchritt } from "@/engine/ablauf/ablauf";
import { adapterFuer } from "@/engine/adapter";
import { templateWaehlen } from "@/engine/katalog/lxc-template";
import type { Distribution } from "@/engine/katalog/schema";
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { MARKE, profileRendern } from "@/engine/profile/rendern";
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
import { q } from "@/engine/shell";
import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
import type { AusrollAnfrage } from "@/lib/schemas/ausrollen";
import type { NodeBefehl } from "./template-bau";
/**
* Einen LXC-Container ausrollen. Anlegen, Netz und Start gehen per API; Befehle im Container
* nicht – Proxmox hat dafür keine API (geprüft gegen das Schema von PVE 9.2). Provisioniert wird
* deshalb per SSH, auf einem von zwei Wegen:
*
* - **Node**: `pct exec <ctid> -- bash -s` über SSH zum Node. Braucht keinen Netzweg zum Container.
* - **Gast**: SSH als root direkt in den Container mit dem ProxDeploy-Bereitstellungsschlüssel, den
* das Anlegen per `ssh-public-keys` hinterlegt; am Ende wird er wieder entfernt.
*/
export type Bereitstellungsschluessel = { privat: string; oeffentlich: string };
export type LxcAusrollEingabe = {
client: ProxmoxClient;
anfrage: AusrollAnfrage;
distribution: Distribution;
profile: AufgeloestesProfil[];
/** Container-Features, die Profile verlangen (z. B. docker → nesting). */
profilFeatures: { nesting: boolean; keyctl: boolean };
vmidReservieren: (belegt: Set<number>, wunsch?: number) => Promise<number>;
sshFuer: (knoten: string) => NodeBefehl | null;
bereitstellung: () => Promise<Bereitstellungsschluessel>;
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
auftragKurzId: string;
warten?: (ms: number) => Promise<void>;
netzMs?: number;
/** Nur für Tests: SSH in den Gast ersetzen. */
gastSsh?: (
ziel: { host: string; schluessel: Bereitstellungsschluessel },
skript: string,
beiAusgabe: (t: string) => void,
warten: (ms: number) => Promise<void>,
) => Promise<number | null>;
};
export type LxcAusrollStand = {
knoten?: string;
vmid?: number;
ostemplate?: string;
vztmplStorage?: string;
ip?: string;
weg?: "node" | "gast";
};
type Ressource = {
vmid?: number;
node?: string;
type?: string;
status?: string;
maxmem?: number;
mem?: number;
};
type StorageEintrag = { storage: string; content: string; active?: number; enabled?: number };
type Aplinfo = { template: string; section?: string; package?: string };
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
/** Markenzeilen der Provisionierung in Detail und Log übersetzen – für beide Wege gleich. */
export function ausgabeVerfolgen(k: PhasenKontext) {
let rest = "";
let profil = "";
const beginn = Date.now();
return {
get profil() {
return profil;
},
weiter(text: string) {
rest += text;
const zeilen = rest.split("\n");
rest = zeilen.pop() ?? "";
for (const zeile of zeilen) {
const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile);
if (marke) {
profil = marke[1]!;
const s = Math.round((Date.now() - beginn) / 1000);
void k.detail(
`Profil ${profil} läuft … ${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`,
);
}
void k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug");
}
},
};
}
export function lxcNetz(netz: AusrollAnfrage["netz"]): string {
return [
"name=eth0",
`bridge=${netz.bridge}`,
...(netz.vlan ? [`tag=${netz.vlan}`] : []),
netz.modus === "dhcp" ? "ip=dhcp" : `ip=${netz.ip}`,
...(netz.modus === "statisch" && netz.gateway ? [`gw=${netz.gateway}`] : []),
"type=veth",
].join(",");
}
export function lxcAusrollSchritte(e: LxcAusrollEingabe, stand: LxcAusrollStand = {}): PhasenSchritt[] {
const { client, anfrage: a, distribution: d } = e;
const warten = e.warten ?? schlafen;
const lxc = d.lxc;
const ctPfad = () => pfad`/nodes/${stand.knoten!}/lxc/${stand.vmid!}`;
const features = {
nesting: a.features.nesting || e.profilFeatures.nesting,
keyctl: a.features.keyctl || e.profilFeatures.keyctl,
};
let bereitstellung: Bereitstellungsschluessel | undefined;
return [
{
schluessel: "pruefungen",
titel: "Prüfungen",
ausfuehren: async () => {
if (!lxc)
throw new AblaufFehler(
`${d.name} gibt es nicht als LXC-Container.`,
"Eine andere Distribution wählen.",
);
const ressourcen = await client.get<Ressource[]>("/cluster/resources");
const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online");
stand.knoten =
a.knoten ??
[...nodes].sort((x, y) => (y.maxmem ?? 0) - (y.mem ?? 0) - ((x.maxmem ?? 0) - (x.mem ?? 0)))[0]
?.node;
if (!stand.knoten || !nodes.some((n) => n.node === stand.knoten)) {
throw new AblaufFehler(`Node ${stand.knoten ?? "?"} ist nicht online.`, "Anderen Node wählen.");
}
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${stand.knoten}/storage`);
const aktiv = (s: StorageEintrag) => s.active === 1 && s.enabled !== 0;
const ziel = storages.find((s) => s.storage === a.storage);
if (!ziel || !aktiv(ziel) || !ziel.content.split(",").includes("rootdir")) {
throw new AblaufFehler(
`Storage ${a.storage} nimmt auf ${stand.knoten} keine Container auf.`,
`Geeignet sind: ${
storages
.filter((s) => aktiv(s) && s.content.includes("rootdir"))
.map((s) => s.storage)
.join(", ") || "keiner"
}.`,
);
}
stand.vztmplStorage = storages.find(
(s) => aktiv(s) && s.content.split(",").includes("vztmpl"),
)?.storage;
if (!stand.vztmplStorage)
throw new AblaufFehler(
"Kein Storage mit Inhaltstyp „Container-Templates“.",
"Einem Dir-Storage den Inhaltstyp vztmpl geben.",
);
const netze = await client.get<Array<{ iface: string; type: string; bridge_vlan_aware?: number }>>(
pfad`/nodes/${stand.knoten}/network`,
);
const bridge = netze.find(
(n) => n.iface === a.netz.bridge && (n.type === "bridge" || n.type === "OVSBridge"),
);
if (!bridge)
throw new AblaufFehler(
`Bridge ${a.netz.bridge} gibt es auf ${stand.knoten} nicht.`,
"Andere Bridge wählen.",
);
if (a.netz.vlan && bridge.type === "bridge" && bridge.bridge_vlan_aware !== 1) {
throw new AblaufFehler(
`Bridge ${a.netz.bridge} ist nicht VLAN-fähig.`,
"„VLAN aware“ an der Bridge einschalten oder ohne VLAN ausrollen.",
);
}
// Provisionierung braucht SSH: lieber zum Node (kein Netzweg zum Container nötig).
stand.weg = e.sshFuer(stand.knoten) ? "node" : "gast";
if (features.keyctl && stand.weg !== "node") {
throw new AblaufFehler(
"Das Feature keyctl darf per API-Token nicht gesetzt werden.",
"SSH zum Node einrichten (dann setzt ProxDeploy es per pct set) oder ohne keyctl ausrollen.",
);
}
const belegt = new Set(
ressourcen.map((r) => r.vmid).filter((v): v is number => typeof v === "number"),
);
stand.vmid = await e.vmidReservieren(belegt, a.vmid);
await e.angelegt({ knoten: stand.knoten, typ: "LXC" });
return {
detail: `CTID ${stand.vmid} frei · ${a.storage} · ${a.netz.bridge}${a.netz.vlan ? ` VLAN ${a.netz.vlan}` : ""} · Provisionierung über ${stand.weg === "node" ? "pct exec" : "SSH in den Container"}`,
};
},
},
{
schluessel: "lxc-template",
titel: "LXC-Template",
ausfuehren: async () => {
const vorhanden = await client.get<Array<{ volid: string }>>(
pfad`/nodes/${stand.knoten!}/storage/${stand.vztmplStorage!}/content`,
{
content: "vztmpl",
},
);
const dateien = vorhanden.map((v) => v.volid.split("/").pop()!);
const lokal = templateWaehlen(lxc!.template_pattern, dateien);
// Was es zum Herunterladen gibt – ist dort eine neuere Version, wird sie geladen.
const verfuegbar = (await client.get<Aplinfo[]>(pfad`/nodes/${stand.knoten!}/aplinfo`)).map(
(x) => x.template,
);
const neueste = templateWaehlen(lxc!.template_pattern, verfuegbar);
if (lokal && (!neueste || lokal === neueste)) {
stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${lokal}`;
return { uebersprungen: true, detail: `${lokal} vorhanden` };
}
if (!neueste) {
if (lokal) {
stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${lokal}`;
return { uebersprungen: true, detail: `${lokal} vorhanden` };
}
throw new AblaufFehler(
`Kein Container-Template passt zu „${lxc!.template_pattern}“.`,
"Mit „pveam update“ die Liste auf dem Node auffrischen oder das Muster im Katalog prüfen.",
);
}
await aufgabeAbwarten(
client,
await client.post<string>(pfad`/nodes/${stand.knoten!}/aplinfo`, {
storage: stand.vztmplStorage!,
template: neueste,
}),
{ zeitlimitMs: 30 * 60 * 1000 },
);
stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${neueste}`;
return { detail: `${neueste} geladen` };
},
},
{
schluessel: "anlegen",
titel: "Anlegen",
ausfuehren: async () => {
if (stand.weg === "gast") bereitstellung = await e.bereitstellung();
const featureListe = [features.nesting ? "nesting=1" : null].filter(Boolean).join(",");
await aufgabeAbwarten(
client,
await client.post<string>(pfad`/nodes/${stand.knoten!}/lxc`, {
vmid: stand.vmid!,
hostname: a.name,
ostemplate: stand.ostemplate!,
ostype: lxc!.ostype,
rootfs: `${a.storage}:${a.diskGb}`,
cores: a.cores,
memory: a.speicherMb,
swap: a.swapMb,
unprivileged: a.unprivilegiert,
...(featureListe ? { features: featureListe } : {}),
net0: lxcNetz(a.netz),
...(a.netz.dns.length > 0 ? { nameserver: a.netz.dns.join(" ") } : {}),
...(a.netz.suchdomain ? { searchdomain: a.netz.suchdomain } : {}),
// Nur der Bereitstellungsschlüssel kommt an root – der Benutzer bekommt seine eigenen Schlüssel.
...(bereitstellung ? { "ssh-public-keys": bereitstellung.oeffentlich } : {}),
onboot: a.startBeiBoot,
tags: ["proxdeploy", d.id, ...a.tags].join(";"),
description: `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`,
}),
{ zeitlimitMs: 15 * 60 * 1000 },
);
await e.angelegt({ vmid: stand.vmid });
if (features.keyctl) {
const ssh = e.sshFuer(stand.knoten!)!;
if (!client.trocken) {
const ergebnis = await ssh(
`pct set ${stand.vmid} --features ${q(`nesting=${features.nesting ? 1 : 0},keyctl=1`)}`,
);
if (ergebnis.code !== 0)
throw new AblaufFehler(
`keyctl ließ sich nicht setzen: ${ergebnis.stderr.trim()}`,
"Rechte des SSH-Benutzers prüfen.",
);
}
}
return {
detail: `${a.unprivilegiert ? "unprivilegiert" : "privilegiert"} · ${a.cores} CPU · ${a.speicherMb} MB · ${a.diskGb}G${
features.nesting || features.keyctl
? ` · ${[features.nesting && "nesting", features.keyctl && "keyctl"].filter(Boolean).join(", ")}`
: ""
}`,
};
},
},
{
schluessel: "start",
titel: "Start",
ausfuehren: async () => {
await aufgabeAbwarten(client, await client.post<string>(`${ctPfad()}/status/start`), {
zeitlimitMs: 5 * 60 * 1000,
});
return { detail: `CT ${stand.vmid} läuft` };
},
},
{
schluessel: "netzwerk",
titel: "Netzwerk",
ausfuehren: async (k) => {
if (a.netz.modus === "statisch") {
stand.ip = a.netz.ip!.split("/")[0];
return { detail: `IP ${stand.ip}` };
}
if (client.trocken) return { detail: "IP per DHCP" };
const beginn = Date.now();
const grenze = e.netzMs ?? 2 * 60 * 1000;
while (!stand.ip) {
await k.pruefeAbbruch();
const schnittstellen = await client.get<Array<{ name: string; inet?: string }>>(
`${ctPfad()}/interfaces`,
);
stand.ip = schnittstellen.find((s) => s.name === "eth0" && s.inet)?.inet?.split("/")[0];
if (stand.ip) break;
if (Date.now() - beginn > grenze) {
throw new AblaufFehler(
"Der Container hat per DHCP keine Adresse bekommen.",
"DHCP auf der Bridge bzw. im VLAN prüfen oder eine statische Adresse wählen.",
);
}
await k.detail(`wartet auf DHCP … ${Math.round((Date.now() - beginn) / 1000)}s`);
await warten(2000);
}
return { detail: `IP ${stand.ip} per DHCP` };
},
},
{
schluessel: "provisionierung",
titel: "Provisionierung",
ausfuehren: async (k) => {
const adapter = adapterFuer(d.family);
if (!adapter)
throw new AblaufFehler(
`Für die Familie „${d.family}“ gibt es keinen Adapter.`,
"Adapter ergänzen (docs/katalog.md).",
);
const nachspann =
stand.weg === "gast"
? [
`echo '${MARKE} bereitstellung-entfernen'\nsed -i '/proxdeploy-bereitstellung/d' /root/.ssh/authorized_keys\n`,
]
: [];
const { skript } = profileRendern(
e.profile,
adapter,
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "lxc" } },
[
adapter.benutzerAnlegen({
name: a.zugang.benutzer,
sshSchluessel: a.zugang.sshSchluessel,
sudo: true,
}),
],
);
const gesamt = skript + nachspann.join("");
if (client.trocken) {
await k.log(gesamt, "info");
return {
detail: `über ${stand.weg === "node" ? "pct exec" : "SSH"}${e.profile.length > 0 ? ` · Profile ${e.profile.map((p) => p.profil.id).join(", ")}` : ""}`,
};
}
const verfolgen = ausgabeVerfolgen(k);
let code: number | null;
if (stand.weg === "node") {
const ssh = e.sshFuer(stand.knoten!)!;
code = (
await ssh(`pct exec ${stand.vmid} -- bash -s`, {
eingabe: gesamt,
zeitlimitMs: 60 * 60 * 1000,
beiAusgabe: (t) => verfolgen.weiter(t),
})
).code;
} else {
const ziel = { host: stand.ip!, schluessel: bereitstellung! };
code = await (e.gastSsh ?? gastSshStandard)(ziel, gesamt, (t) => verfolgen.weiter(t), warten);
}
verfolgen.weiter("\n");
if (code !== 0) {
throw new AblaufFehler(
`Provisionierung ${verfolgen.profil ? `im Profil ${verfolgen.profil} ` : ""}mit Code ${code} abgebrochen.`,
"Detail-Log ansehen.",
);
}
return {
detail:
e.profile.length > 0
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet`
: "Benutzer eingerichtet",
};
},
},
];
}
/**
* SSH als root in den frischen Container. Seinen Host-Key kann ProxDeploy vorher nicht kennen –
* er entsteht beim ersten Start. Vertraut wird deshalb dem ersten Schlüssel (TOFU), und das nur für
* diese eine Verbindung; der Bereitstellungsschlüssel wird am Ende des Skripts wieder entfernt.
*/
async function gastSshStandard(
ziel: { host: string; schluessel: Bereitstellungsschluessel },
skript: string,
beiAusgabe: (t: string) => void,
warten: (ms: number) => Promise<void> = schlafen,
): Promise<number | null> {
const beginn = Date.now();
for (;;) {
try {
const ergebnis = await sshAusfuehren(
{
host: ziel.host,
port: 22,
benutzer: "root",
privatSchluessel: ziel.schluessel.privat,
hostSchluessel: null,
},
"bash -s",
{ erkunden: true, eingabe: skript, zeitlimitMs: 60 * 60 * 1000, beiAusgabe },
);
return ergebnis.code;
} catch (fehler) {
// sshd braucht nach dem Start ein paar Sekunden.
if (fehler instanceof SshFehler && fehler.art === "verbindung" && Date.now() - beginn < 90_000) {
await warten(3000);
continue;
}
if (fehler instanceof SshFehler) {
throw new AblaufFehler(
`SSH in den Container ${ziel.host} fehlgeschlagen: ${fehler.ursache}`,
"Ist der Container aus dem Netz von ProxDeploy erreichbar? Sonst SSH zum Node einrichten (pct exec).",
);
}
throw fehler;
}
}
}
+225 -15
View File
@@ -6,6 +6,13 @@ import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client"; import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten"; import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten";
import { q } from "@/engine/shell"; import { q } from "@/engine/shell";
import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
import type { BauNetz } from "@/lib/schemas/server";
import type { Bereitstellungsschluessel } from "./lxc-ausrollen";
import { adapterFuer } from "@/engine/adapter";
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { profileRendern } from "@/engine/profile/rendern";
import { agentAbwarten, skriptAusfuehren } from "./gastagent";
/** /**
* Template-Bau für VMs: Cloud-Image laden und prüfen, VM anlegen, Disk importieren, Guest-Agent * Template-Bau für VMs: Cloud-Image laden und prüfen, VM anlegen, Disk importieren, Guest-Agent
@@ -24,12 +31,22 @@ import { q } from "@/engine/shell";
/** Führt einen Befehl als root auf einem Node aus (ssh2, gepinnter Host-Key). */ /** Führt einen Befehl als root auf einem Node aus (ssh2, gepinnter Host-Key). */
export type NodeBefehl = ( export type NodeBefehl = (
befehl: string, befehl: string,
optionen?: { eingabe?: string; zeitlimitMs?: number }, optionen?: { eingabe?: string; zeitlimitMs?: number; beiAusgabe?: (text: string) => void },
) => Promise<{ code: number | null; stdout: string; stderr: string }>; ) => Promise<{ code: number | null; stdout: string; stderr: string }>;
export type TemplateBauEingabe = { export type TemplateBauEingabe = {
client: ProxmoxClient; client: ProxmoxClient;
distribution: Distribution; distribution: Distribution;
/** Eingebackene Profile (#022), aufgelöst mit Standardwerten; leer = nur Guest-Agent. */
profile?: AufgeloestesProfil[];
/** Bau-Adresse (#021): Guest-Agent per SSH in die VM einbauen, wenn es kein Node-SSH gibt. */
bauNetz?: BauNetz | null;
bereitstellung?: () => Promise<Bereitstellungsschluessel>;
/** Nur für Tests: SSH in die Vorbereitungs-VM ersetzen. */
gastSsh?: (
ziel: { host: string; benutzer: string; schluessel: Bereitstellungsschluessel },
skript: string,
) => Promise<number | null>;
knoten: string; knoten: string;
/** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */ /** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */
storage: string; storage: string;
@@ -50,6 +67,8 @@ export type TemplateBauEingabe = {
export type TemplateBauStand = { export type TemplateBauStand = {
vmid?: number; vmid?: number;
/** Wie der Guest-Agent hineinkommt: Snippet (Node-SSH) oder SSH in die VM über die Bau-Adresse. */
agentWeg?: "snippet" | "baunetz";
weg?: "api" | "ssh"; weg?: "api" | "ssh";
importStorage?: string; importStorage?: string;
snippetStorage?: string; snippetStorage?: string;
@@ -80,10 +99,28 @@ async function standardHolen(url: string): Promise<string> {
return await antwort.text(); return await antwort.text();
} }
export function templateName(distribution: Distribution): string { export function templateName(distribution: Distribution, profile: string[] = []): string {
return `tpl-${distribution.id.replace(/[^a-z0-9-]/g, "-")}`; return ["tpl", distribution.id, ...profile.filter((p) => p !== "base")]
.join("-")
.replace(/[^a-z0-9-]/g, "-");
} }
/**
* Aufräumen vor dem Template, wenn Profile eingebacken wurden – alles, was je Klon eindeutig sein
* muss oder nur Platz kostet. SSH-Host-Keys erzeugt cloud-init bei jedem Klon neu.
*/
export const TEMPLATE_AUFRAEUMEN = [
"set -e",
"truncate -s 0 /etc/machine-id",
"rm -f /var/lib/dbus/machine-id",
"rm -f /etc/ssh/ssh_host_*",
"rm -f /var/lib/docker/engine-id",
"apt-get clean || true",
"rm -f /var/log/proxdeploy-provisionierung.log",
"cloud-init clean --logs",
"sync",
].join("; ");
/** /**
* vendor-data für den Vorbereitungsstart: Guest-Agent installieren, Maschinen-ID leeren (sonst * 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. * teilen sich alle Klone DHCP-Client-ID und journald-ID), cloud-init zurücksetzen, ausschalten.
@@ -107,6 +144,126 @@ power_state:
`; `;
} }
/**
* Guest-Agent ohne Node-SSH (#021): Vorbereitungsstart mit fester Bau-Adresse und dem
* Bereitstellungsschlüssel für den Standardbenutzer des Images, dann SSH in die VM. Danach wird
* der Schlüssel entfernt und die cloud-init-Angaben zurückgesetzt – im Template bleibt nichts davon.
*/
export const BAUNETZ_SKRIPT = `set -e
export DEBIAN_FRONTEND=noninteractive
cloud-init status --wait >/dev/null 2>&1 || true
apt-get -q update
apt-get -y -q -o DPkg::Lock::Timeout=600 install qemu-guest-agent
systemctl enable qemu-guest-agent
rm -f /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys
truncate -s 0 /etc/machine-id
rm -f /var/lib/dbus/machine-id
cloud-init clean --logs
sync
`;
async function agentUeberBauNetz(
e: TemplateBauEingabe,
vmPfad: string,
benutzer: string,
k: PhasenKontext,
warten: (ms: number) => Promise<void>,
): Promise<{ detail: string }> {
const { client } = e;
const netz = e.bauNetz!;
const schluessel = await e.bereitstellung!();
const host = netz.ip.split("/")[0]!;
await client.put(`${vmPfad}/config`, {
ciuser: benutzer,
sshkeys: encodeURIComponent(schluessel.oeffentlich).replace(
/[!'()*]/g,
(z) => `%${z.charCodeAt(0).toString(16).toUpperCase()}`,
),
ipconfig0: `ip=${netz.ip},gw=${netz.gateway}`,
...(netz.dns.length > 0 ? { nameserver: netz.dns.join(" ") } : {}),
});
try {
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/start`), {
zeitlimitMs: 5 * 60 * 1000,
});
if (client.trocken) return { detail: `über Bau-Adresse ${host}` };
await k.detail(`Vorbereitungsstart über Bau-Adresse ${host} …`);
const code = await (e.gastSsh ?? gastSshBau)(
{ host, benutzer, schluessel },
BAUNETZ_SKRIPT,
warten,
(z) => void k.log(z, "debug"),
);
if (code !== 0) {
throw new AblaufFehler(
`Einbau des Guest-Agents über ${host} mit Code ${code} abgebrochen.`,
"Hat die Bau-Adresse Internetzugang (Gateway, DNS)? Detail-Log ansehen.",
);
}
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/shutdown`, { timeout: 180 }), {
zeitlimitMs: 5 * 60 * 1000,
});
for (let i = 0; i < 60; i += 1) {
if ((await client.get<{ status: string }>(`${vmPfad}/status/current`)).status === "stopped") break;
await warten(3000);
}
} finally {
// Bau-Adresse und Schlüssel gehören nicht ins Template.
await client
.put(`${vmPfad}/config`, { ipconfig0: "ip=dhcp", delete: "sshkeys,ciuser,nameserver" })
.catch(() => undefined);
}
return { detail: `qemu-guest-agent über Bau-Adresse ${host} installiert · Maschinen-ID geleert` };
}
async function gastSshBau(
ziel: { host: string; benutzer: string; schluessel: Bereitstellungsschluessel },
skript: string,
warten: (ms: number) => Promise<void>,
beiZeile: (z: string) => void,
): Promise<number | null> {
// Den Host-Key der frischen VM kann niemand vorher kennen (TOFU, nur für diese Verbindung).
const beginn = Date.now();
for (;;) {
try {
const ergebnis = await sshAusfuehren(
{
host: ziel.host,
port: 22,
benutzer: ziel.benutzer,
privatSchluessel: ziel.schluessel.privat,
hostSchluessel: null,
},
"sudo -n bash -s",
{
erkunden: true,
eingabe: skript,
zeitlimitMs: 20 * 60 * 1000,
beiAusgabe: (t) => t.split("\n").filter(Boolean).forEach(beiZeile),
},
);
return ergebnis.code;
} catch (fehler) {
if (
fehler instanceof SshFehler &&
(fehler.art === "verbindung" || fehler.art === "anmeldung" || fehler.art === "zeitueberschreitung") &&
Date.now() - beginn < 4 * 60 * 1000
) {
// Bootet noch oder cloud-init hat den Schlüssel noch nicht eingetragen.
await warten(5000);
continue;
}
if (fehler instanceof SshFehler) {
throw new AblaufFehler(
`SSH in die Vorbereitungs-VM (${ziel.host}) fehlgeschlagen: ${fehler.ursache}`,
"Ist die Bau-Adresse aus dem Netz von ProxDeploy erreichbar? Stimmen Bridge/VLAN?",
);
}
throw fehler;
}
}
}
export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauStand = {}): PhasenSchritt[] { export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauStand = {}): PhasenSchritt[] {
const { client, distribution: d, knoten } = e; const { client, distribution: d, knoten } = e;
const vm = d.vm; const vm = d.vm;
@@ -118,6 +275,8 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
const holen = e.holen ?? standardHolen; const holen = e.holen ?? standardHolen;
const warten = e.warten ?? schlafen; const warten = e.warten ?? schlafen;
const snippetDatei = `proxdeploy-vorbereitung-${e.auftragKurzId}.yaml`; const snippetDatei = `proxdeploy-vorbereitung-${e.auftragKurzId}.yaml`;
const profilIds = (e.profile ?? []).map((p) => p.profil.id);
const name = templateName(d, profilIds);
const ssh = async ( const ssh = async (
befehl: string, befehl: string,
@@ -189,13 +348,17 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
: "SSH zum Node einrichten oder auf PVE 8.4+ aktualisieren.", : "SSH zum Node einrichten oder auf PVE 8.4+ aktualisieren.",
); );
} }
if (!vm.guest_agent_preinstalled && !(e.ssh && stand.snippetStorage)) { if (!vm.guest_agent_preinstalled) {
throw new AblaufFehler( if (e.ssh && stand.snippetStorage) stand.agentWeg = "snippet";
"Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node und einen Storage mit Inhaltstyp „Snippets“.", else if (e.bauNetz && e.bereitstellung) stand.agentWeg = "baunetz";
e.ssh else {
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben." throw new AblaufFehler(
: "SSH in den Server-Einstellungen einrichten.", "Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node (mit einem Snippets-Storage) oder eine Bau-Adresse.",
); e.ssh
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben oder in den Server-Einstellungen eine Bau-Adresse eintragen."
: "SSH in den Server-Einstellungen einrichten oder dort eine Bau-Adresse (feste IP für die Vorbereitungs-VM) eintragen.",
);
}
} }
const netze = await client.get<Array<{ iface: string; type: string }>>( const netze = await client.get<Array<{ iface: string; type: string }>>(
pfad`/nodes/${knoten}/network`, pfad`/nodes/${knoten}/network`,
@@ -294,7 +457,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
const disk = `${e.storage}:0,import-from=${importVon},discard=on,ssd=1,iothread=1`; const disk = `${e.storage}:0,import-from=${importVon},discard=on,ssd=1,iothread=1`;
const parameter = { const parameter = {
vmid, vmid,
name: templateName(d), name,
ostype: "l26", ostype: "l26",
machine: "q35", machine: "q35",
bios: vm.bios, bios: vm.bios,
@@ -310,8 +473,10 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
ide2: `${e.storage}:cloudinit`, ide2: `${e.storage}:cloudinit`,
ipconfig0: "ip=dhcp", ipconfig0: "ip=dhcp",
ciupgrade: false, ciupgrade: false,
tags: `proxdeploy-template;${d.id}`, tags: ["proxdeploy-template", d.id, ...profilIds.map((p) => `profil-${p}`)].join(";"),
description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}\nGebaut: ${jetzt} (Auftrag ${e.auftragKurzId})`, description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}${
profilIds.length > 0 ? `\nEingebackene Profile: ${profilIds.join(", ")}` : ""
}\nGebaut: ${jetzt} (Auftrag ${e.auftragKurzId})`,
}; };
if (stand.weg === "api") { if (stand.weg === "api") {
await aufgabeAbwarten( await aufgabeAbwarten(
@@ -336,7 +501,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
}); });
} }
return { return {
detail: `VM ${vmid} (${templateName(d)}) · q35 · ${vm.bios === "ovmf" ? "UEFI" : "SeaBIOS"} · Disk importiert`, detail: `VM ${vmid} (${name}) · q35 · ${vm.bios === "ovmf" ? "UEFI" : "SeaBIOS"} · Disk importiert`,
}; };
}, },
}, },
@@ -347,6 +512,8 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
if (vm.guest_agent_preinstalled) return { uebersprungen: true, detail: "im Image enthalten" }; if (vm.guest_agent_preinstalled) return { uebersprungen: true, detail: "im Image enthalten" };
const vmid = stand.vmid!; const vmid = stand.vmid!;
const vmPfad = pfad`/nodes/${knoten}/qemu/${vmid}`; const vmPfad = pfad`/nodes/${knoten}/qemu/${vmid}`;
if (stand.agentWeg === "baunetz")
return await agentUeberBauNetz(e, vmPfad, vm.default_user, k, warten);
const snippet = `${stand.snippetStorage}:snippets/${snippetDatei}`; const snippet = `${stand.snippetStorage}:snippets/${snippetDatei}`;
await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, { await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, {
eingabe: vorbereitungsSnippet(), eingabe: vorbereitungsSnippet(),
@@ -383,6 +550,49 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
return { detail: "qemu-guest-agent installiert · Maschinen-ID geleert" }; return { detail: "qemu-guest-agent installiert · Maschinen-ID geleert" };
}, },
}, },
{
schluessel: "profile",
titel: "Profile",
ausfuehren: async (k) => {
if (profilIds.length === 0) return { uebersprungen: true, detail: "keine eingebacken" };
const adapter = adapterFuer(d.family);
if (!adapter)
throw new AblaufFehler(
`Für die Familie „${d.family}“ gibt es keinen Adapter.`,
"Adapter ergänzen (docs/katalog.md).",
);
const vmPfad = pfad`/nodes/${knoten}/qemu/${stand.vmid!}`;
const { skript } = profileRendern(e.profile!, adapter, {
os: d.os,
gast: { name, benutzer: vm.default_user, typ: "vm" },
});
if (client.trocken) {
await k.log(skript, "info");
return { detail: `${profilIds.join(", ")} würden eingebacken` };
}
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/start`), {
zeitlimitMs: 5 * 60 * 1000,
});
await agentAbwarten(client, vmPfad, k, { grenzeMs: 5 * 60 * 1000, warten });
await skriptAusfuehren(client, vmPfad, skript, k, { grenzeMs: 60 * 60 * 1000, warten });
await k.detail("aufräumen und herunterfahren …");
// Über denselben Weg wie die Profile: wartet aufs Ende, statt eine Pause zu raten.
await skriptAusfuehren(client, vmPfad, `#!/bin/bash\n${TEMPLATE_AUFRAEUMEN}\n`, k, {
grenzeMs: 5 * 60 * 1000,
warten,
});
await aufgabeAbwarten(
client,
await client.post<string>(`${vmPfad}/status/shutdown`, { timeout: 180 }),
{ zeitlimitMs: 5 * 60 * 1000 },
);
for (let i = 0; i < 60; i += 1) {
if ((await client.get<{ status: string }>(`${vmPfad}/status/current`)).status === "stopped") break;
await warten(3000);
}
return { detail: `${profilIds.join(", ")} eingebacken · Maschinen-ID und Host-Keys entfernt` };
},
},
{ {
schluessel: "template", schluessel: "template",
titel: "Template", titel: "Template",
@@ -392,7 +602,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
await client.post<string>(pfad`/nodes/${knoten}/qemu/${stand.vmid!}/template`), await client.post<string>(pfad`/nodes/${knoten}/qemu/${stand.vmid!}/template`),
{ zeitlimitMs: 10 * 60 * 1000 }, { zeitlimitMs: 10 * 60 * 1000 },
); );
return { detail: `VM ${stand.vmid} ist jetzt Template ${templateName(d)}` }; return { detail: `VM ${stand.vmid} ist jetzt Template ${name}` };
}, },
}, },
]; ];
+77 -133
View File
@@ -2,8 +2,9 @@ import { AblaufFehler } from "@/engine/ablauf/fehler";
import type { PhasenSchritt } from "@/engine/ablauf/ablauf"; import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
import { adapterFuer } from "@/engine/adapter"; import { adapterFuer } from "@/engine/adapter";
import type { Distribution } from "@/engine/katalog/schema"; import type { Distribution } from "@/engine/katalog/schema";
import { profileRendern, MARKE } from "@/engine/profile/rendern"; import { profileRendern } from "@/engine/profile/rendern";
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen"; import { agent, agentAbwarten, skriptAusfuehren } from "./gastagent";
import { gastunabhaengig, mitStandardwerten, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben"; import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client"; import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
import { ProxmoxFehler } from "@/engine/proxmox/fehler"; import { ProxmoxFehler } from "@/engine/proxmox/fehler";
@@ -41,7 +42,7 @@ export type VmAusrollEingabe = {
export type VmAusrollStand = { export type VmAusrollStand = {
knoten?: string; knoten?: string;
template?: { vmid: number; knoten: string }; template?: { vmid: number; knoten: string; eingebacken: string[] };
vmid?: number; vmid?: number;
ip?: string; ip?: string;
agentNachMs?: number; agentNachMs?: number;
@@ -81,17 +82,7 @@ export function ipconfig(netz: AusrollAnfrage["netz"]): string {
return `ip=${netz.ip}${netz.gateway ? `,gw=${netz.gateway}` : ""}`; 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 AgentAntwort<T> = { result: T };
type ExecStatus = { exited: number | boolean; exitcode?: number; "out-data"?: string; "err-data"?: string };
type Netzschnittstelle = { type Netzschnittstelle = {
name: string; name: string;
"ip-addresses"?: Array<{ "ip-address-type": string; "ip-address": string }>; "ip-addresses"?: Array<{ "ip-address-type": string; "ip-address": string }>;
@@ -101,13 +92,6 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
const { client, anfrage: a, distribution: d } = e; const { client, anfrage: a, distribution: d } = e;
const warten = e.warten ?? schlafen; const warten = e.warten ?? schlafen;
const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`; 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 templateStand: TemplateBauStand = {};
const schritte: PhasenSchritt[] = [ const schritte: PhasenSchritt[] = [
@@ -119,17 +103,31 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
throw new AblaufFehler(`${d.name} bietet kein VM-Image.`, "Eine andere Distribution wählen."); throw new AblaufFehler(`${d.name} bietet kein VM-Image.`, "Eine andere Distribution wählen.");
const ressourcen = await client.get<Ressource[]>("/cluster/resources"); const ressourcen = await client.get<Ressource[]>("/cluster/resources");
const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online"); const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online");
const templates = ressourcen.filter( const gewaehlt = new Set(e.profile.map((p) => p.profil.id));
(r) => const tags = (r: Ressource) => (r.tags ?? "").split(/[;, ]/);
r.type === "qemu" && // Nur Templates, deren eingebackene Profile alle gewählt sind (#022) – sonst hätte der Gast
r.template === 1 && // Software, die niemand bestellt hat.
(r.tags ?? "").split(/[;, ]/).includes("proxdeploy-template") && const templates = ressourcen
(r.tags ?? "").split(/[;, ]/).includes(d.id), .filter(
); (r) =>
// Node: gewünscht, sonst der mit dem Template, sonst der mit dem meisten freien RAM. r.type === "qemu" &&
r.template === 1 &&
tags(r).includes("proxdeploy-template") &&
tags(r).includes(d.id),
)
.map((r) => ({
r,
eingebacken: tags(r)
.filter((t) => t.startsWith("profil-"))
.map((t) => t.slice(7)),
}))
.filter((t) => t.eingebacken.every((p) => gewaehlt.has(p)))
// Am meisten eingebacken zuerst, bei Gleichstand die kleinere VMID.
.sort((x, y) => y.eingebacken.length - x.eingebacken.length || (x.r.vmid ?? 0) - (y.r.vmid ?? 0));
// Node: gewünscht, sonst der mit dem besten Template, sonst der mit dem meisten freien RAM.
stand.knoten = stand.knoten =
a.knoten ?? a.knoten ??
templates.find((t) => nodes.some((n) => n.node === t.node))?.node ?? templates.find((t) => nodes.some((n) => n.node === t.r.node))?.r.node ??
[...nodes].sort((x, y) => (y.maxmem ?? 0) - (y.mem ?? 0) - ((x.maxmem ?? 0) - (x.mem ?? 0)))[0] [...nodes].sort((x, y) => (y.maxmem ?? 0) - (y.mem ?? 0) - ((x.maxmem ?? 0) - (x.mem ?? 0)))[0]
?.node; ?.node;
if (!stand.knoten || !nodes.some((n) => n.node === stand.knoten)) { if (!stand.knoten || !nodes.some((n) => n.node === stand.knoten)) {
@@ -138,8 +136,10 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
"Anderen Node wählen oder Cluster-Status prüfen.", "Anderen Node wählen oder Cluster-Status prüfen.",
); );
} }
const tpl = templates.find((t) => t.node === stand.knoten) ?? templates[0]; const besten = templates.find((t) => t.r.node === stand.knoten) ?? templates[0];
if (tpl) stand.template = { vmid: tpl.vmid!, knoten: tpl.node! }; const tpl = besten?.r;
if (besten)
stand.template = { vmid: besten.r.vmid!, knoten: besten.r.node!, eingebacken: besten.eingebacken };
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${stand.knoten}/storage`); const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${stand.knoten}/storage`);
const ziel = storages.find((s) => s.storage === a.storage); const ziel = storages.find((s) => s.storage === a.storage);
@@ -193,8 +193,13 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
schluessel: "template", schluessel: "template",
titel: "Template", titel: "Template",
ausfuehren: async (k) => { ausfuehren: async (k) => {
if (stand.template) if (stand.template) {
return { uebersprungen: true, detail: `${stand.template.vmid} auf ${stand.template.knoten}` }; const t = stand.template;
return {
uebersprungen: true,
detail: `${t.vmid} auf ${t.knoten}${t.eingebacken.length > 0 ? ` · ${t.eingebacken.join(", ")} eingebacken` : ""}`,
};
}
if (!e.templateBau) throw new AblaufFehler("Template fehlt.", "Template bauen."); if (!e.templateBau) throw new AblaufFehler("Template fehlt.", "Template bauen.");
// Die Phasen des Template-Baus laufen hier als Unterschritte; ihr Fortschritt erscheint als Detail. // Die Phasen des Template-Baus laufen hier als Unterschritte; ihr Fortschritt erscheint als Detail.
const unter = templateBauSchritte( const unter = templateBauSchritte(
@@ -218,7 +223,7 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
}); });
await k.log(`Template · ${schritt.titel}: ${ergebnis?.detail ?? "erledigt"}`); await k.log(`Template · ${schritt.titel}: ${ergebnis?.detail ?? "erledigt"}`);
} }
stand.template = { vmid: templateStand.vmid!, knoten: stand.knoten! }; stand.template = { vmid: templateStand.vmid!, knoten: stand.knoten!, eingebacken: [] };
return { detail: `${templateStand.vmid} gebaut` }; return { detail: `${templateStand.vmid} gebaut` };
}, },
}, },
@@ -237,7 +242,19 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
target: stand.knoten!, target: stand.knoten!,
storage: a.storage, storage: a.storage,
}), }),
{ zeitlimitMs: 30 * 60 * 1000 }, {
zeitlimitMs: 60 * 60 * 1000,
intervallMs: 2000,
// „transferred 1.2 GiB of 3.5 GiB (35.16%)“ – auf langsamen Storages dauert ein Voll-Klon.
beiLog: (zeilen) => {
const letzte = zeilen
.map((z) => /transferred (.+) of (.+) \((\d+(?:\.\d+)?)%\)/.exec(z))
.filter(Boolean)
.at(-1);
if (letzte)
void k.detail(`${letzte[1]} von ${letzte[2]} (${Math.round(Number(letzte[3]))} %)`);
},
},
); );
try { try {
await klonen(stand.vmid!); await klonen(stand.vmid!);
@@ -314,33 +331,20 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
titel: "Guest-Agent", titel: "Guest-Agent",
ausfuehren: async (k) => { ausfuehren: async (k) => {
if (client.trocken) return { detail: "wartet auf Antwort (ping)" }; if (client.trocken) return { detail: "wartet auf Antwort (ping)" };
const beginn = Date.now(); stand.agentNachMs = await agentAbwarten(client, vmPfad(), k, {
const grenze = e.agentMs ?? 5 * 60 * 1000; grenzeMs: e.agentMs ?? 5 * 60 * 1000,
for (;;) { warten,
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). // IP: bei statischer Adresse bekannt, sonst aus dem Agent (erste IPv4 außer Loopback).
if (a.netz.modus === "statisch") { if (a.netz.modus === "statisch") {
stand.ip = a.netz.ip!.split("/")[0]; stand.ip = a.netz.ip!.split("/")[0];
} else { } else {
for (let versuch = 0; versuch < 20 && !stand.ip; versuch += 1) { for (let versuch = 0; versuch < 20 && !stand.ip; versuch += 1) {
const schnittstellen = ( const schnittstellen = (
await agent<AgentAntwort<Netzschnittstelle[]>>("network-get-interfaces", "GET") await agent(client, vmPfad())<AgentAntwort<Netzschnittstelle[]>>(
"network-get-interfaces",
"GET",
)
).result; ).result;
stand.ip = schnittstellen stand.ip = schnittstellen
.filter((s) => s.name !== "lo") .filter((s) => s.name !== "lo")
@@ -366,10 +370,18 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
`Für die Familie „${d.family}“ gibt es keinen Adapter.`, `Für die Familie „${d.family}“ gibt es keinen Adapter.`,
"Adapter ergänzen (docs/katalog.md).", "Adapter ergänzen (docs/katalog.md).",
); );
// Eingebacken und mit Standardwerten → schon im Template, nicht noch einmal (#022).
const eingebacken = new Set(stand.template?.eingebacken ?? []);
const uebersprungen = e.profile
.filter((p) => eingebacken.has(p.profil.id) && mitStandardwerten(p) && gastunabhaengig(p.profil))
.map((p) => p.profil.id);
const auszufuehren = e.profile.filter((p) => !uebersprungen.includes(p.profil.id));
if (uebersprungen.length > 0)
await k.log(`Im Template eingebacken, wird übersprungen: ${uebersprungen.join(", ")}`);
const { skript } = profileRendern( const { skript } = profileRendern(
e.profile, auszufuehren,
adapter, adapter,
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer } }, { os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "vm" } },
[ [
adapter.benutzerAnlegen({ adapter.benutzerAnlegen({
name: a.zugang.benutzer, name: a.zugang.benutzer,
@@ -382,85 +394,17 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
`Skript: ${skript.split("\n").length} Zeilen, ${Math.round(skript.length / 1024)} KiB`, `Skript: ${skript.split("\n").length} Zeilen, ${Math.round(skript.length / 1024)} KiB`,
"debug", "debug",
); );
const zusatz = uebersprungen.length > 0 ? ` (${uebersprungen.join(", ")} aus dem Template)` : "";
const liste = auszufuehren.map((p) => p.profil.id).join(", ");
if (client.trocken) { if (client.trocken) {
await k.log(skript, "info"); await k.log(skript, "info");
return { return { detail: `${liste ? `Profile ${liste}` : "nur Benutzer"}${zusatz}` };
detail:
e.profile.length > 0
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")}`
: "nur Benutzer",
};
} }
await skriptAusfuehren(client, vmPfad(), skript, k, {
// Hochladen in Stücken (file-write ersetzt die Datei, hängt nicht an) und zusammensetzen. grenzeMs: e.provisionierungMs ?? 60 * 60 * 1000,
const bytes = Buffer.from(skript, "utf8"); warten,
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`,
],
}); });
return { detail: `${liste ? `Profile ${liste} angewendet` : "Benutzer eingerichtet"}${zusatz}` };
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",
};
}, },
}, },
]; ];
+2
View File
@@ -147,6 +147,8 @@ KbdInteractiveAuthentication ${h.passwortAnmeldung ? "yes" : "no"}
PermitRootLogin ${h.rootAnmeldung ? "prohibit-password" : "no"} PermitRootLogin ${h.rootAnmeldung ? "prohibit-password" : "no"}
PROXDEPLOY_SSH PROXDEPLOY_SSH
if command -v sshd >/dev/null 2>&1; then if command -v sshd >/dev/null 2>&1; then
# Ubuntu 24.04 startet sshd per Socket – bis zur ersten Verbindung fehlt /run/sshd, und sshd -t bricht ab.
install -d -m 0755 /run/sshd
sshd -t sshd -t
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
fi fi
+4 -1
View File
@@ -56,7 +56,10 @@ export type Distribution = z.infer<typeof DistributionSchema>;
export const VariableSchema = z export const VariableSchema = z
.object({ .object({
name: z.string().regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich."), name: z
.string()
.regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich.")
.refine((n) => n !== "vm" && n !== "lxc", "vm und lxc sind feste Bedingungen, keine Variablennamen."),
label: z.string().min(1), label: z.string().min(1),
type: z.enum(["string", "number", "boolean", "select"]).default("string"), type: z.enum(["string", "number", "boolean", "select"]).default("string"),
required: z.boolean().default(false), required: z.boolean().default(false),
+34
View File
@@ -68,6 +68,40 @@ export function profileAufloesen(
return { ok: true, profile, automatisch: reihenfolge.filter((id) => !auswahl.includes(id)) }; return { ok: true, profile, automatisch: reihenfolge.filter((id) => !auswahl.includes(id)) };
} }
/** Werte eines Profils aus der Eingabe, mit Standardwerten und Typprüfung. */
export function variablenWerte(
profil: Profil,
eingabe: Record<string, unknown>,
): { werte: Record<string, string | number | boolean>; fehler: ProfilFehler[] } {
const werte: Record<string, string | number | boolean> = {};
const fehler: ProfilFehler[] = [];
for (const variable of profil.variables) {
const ergebnis = wertPruefen(variable, eingabe[variable.name]);
if ("fehler" in ergebnis)
fehler.push({ profil: profil.id, variable: variable.name, meldung: ergebnis.fehler });
else werte[variable.name] = ergebnis.wert;
}
return { werte, fehler };
}
/**
* Ein ins Template eingebackenes Profil muss beim Deploy nur dann nicht noch einmal laufen, wenn
* seine Variablen genau die Standardwerte haben – mit denen wurde es eingebacken.
*/
export function mitStandardwerten(eintrag: AufgeloestesProfil): boolean {
const standard = variablenWerte(eintrag.profil, {}).werte;
return JSON.stringify(standard) === JSON.stringify(eintrag.werte);
}
/**
* Verweist ein Profil auf `{{ guest.* }}` (Name, Benutzer), hängt es vom einzelnen Gast ab – im
* Template stand dort etwas anderes. Solche Profile laufen beim Deploy trotzdem noch einmal; dank
* Idempotenz ist das schnell (Fund: docker nahm sonst den Benutzer nicht in die Gruppe auf).
*/
export function gastunabhaengig(profil: Profil): boolean {
return !/\{\{[^}]*\bguest\./.test(JSON.stringify(profil.families));
}
function wertPruefen( function wertPruefen(
variable: ProfilVariable, variable: ProfilVariable,
eingabe: unknown, eingabe: unknown,
+13 -4
View File
@@ -14,17 +14,26 @@ export const MARKE = "::proxdeploy::";
export type RenderKontext = { export type RenderKontext = {
os: { id: string; version_id: string; codename?: string }; os: { id: string; version_id: string; codename?: string };
gast: { name: string; benutzer: string }; gast: { name: string; benutzer: string; typ?: "vm" | "lxc" };
}; };
/** Feste Bedingungen neben den Profil-Variablen: `if: vm` / `if: lxc` (bzw. `!vm`, `!lxc`). */
export const FESTE_BEDINGUNGEN = ["vm", "lxc"] as const;
export type Abschnitt = { profil: string; skript: string }; export type Abschnitt = { profil: string; skript: string };
export type Gerendert = { skript: string; abschnitte: Abschnitt[] }; export type Gerendert = { skript: string; abschnitte: Abschnitt[] };
function bedingungErfuellt(schritt: ProfilSchritt, werte: Record<string, unknown>): boolean { function bedingungErfuellt(
schritt: ProfilSchritt,
werte: Record<string, unknown>,
typ: "vm" | "lxc" | undefined,
): boolean {
if (!schritt.if) return true; if (!schritt.if) return true;
const verneint = schritt.if.startsWith("!"); const verneint = schritt.if.startsWith("!");
const wert = Boolean(werte[schritt.if.replace(/^!/, "")]); const name = schritt.if.replace(/^!/, "");
// Ohne bekannten Typ (Rendern zum Ansehen) laufen typabhängige Schritte mit.
const wert = name === "vm" || name === "lxc" ? (typ ? typ === name : !verneint) : Boolean(werte[name]);
return verneint ? !wert : wert; return verneint ? !wert : wert;
} }
@@ -87,7 +96,7 @@ export function profileRendern(
}; };
const teile: string[] = []; const teile: string[] = [];
for (const schritt of profil.families[adapter.familie] ?? []) { for (const schritt of profil.families[adapter.familie] ?? []) {
if (!bedingungErfuellt(schritt, werte)) continue; if (!bedingungErfuellt(schritt, werte, kontext.gast.typ)) continue;
const text = schrittRendern(schritt, adapter, vorlagenKontext); const text = schrittRendern(schritt, adapter, vorlagenKontext);
if ("ssh_harden" in schritt) zumSchluss.push(text); if ("ssh_harden" in schritt) zumSchluss.push(text);
else teile.push(text); else teile.push(text);
+2 -1
View File
@@ -119,7 +119,7 @@ async function versuchen<T>(warnungen: string[], text: string, aufruf: () => Pro
export async function erkunden( export async function erkunden(
client: ProxmoxClient, client: ProxmoxClient,
optionen: { serverHost: string; ssh: SshZugang | null; jetzt?: () => Date }, optionen: { serverHost: string; ssh: SshZugang | null; bauNetz?: boolean; jetzt?: () => Date },
): Promise<VerbindungsBericht> { ): Promise<VerbindungsBericht> {
const warnungen: string[] = []; const warnungen: string[] = [];
const version = await client.get<{ version: string; release: string }>("/version"); const version = await client.get<{ version: string; release: string }>("/version");
@@ -313,6 +313,7 @@ export async function erkunden(
storages: alleStorages, storages: alleStorages,
ssh: sshGesamt, ssh: sshGesamt,
rechteFehlen: fehlendeRechte.some((r) => r.pflicht), rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
bauNetz: optionen.bauNetz,
}), }),
warnungen, warnungen,
}; };
+13 -2
View File
@@ -230,6 +230,8 @@ export function faehigkeitenAbleiten(eingabe: {
storages: StorageInfo[]; storages: StorageInfo[];
ssh: SshZustand; ssh: SshZustand;
rechteFehlen: boolean; rechteFehlen: boolean;
/** Bau-Adresse gesetzt (#021) – Guest-Agent einbauen geht dann auch ohne SSH zum Node. */
bauNetz?: boolean;
}): Faehigkeiten { }): Faehigkeiten {
const { version, storages, ssh } = eingabe; const { version, storages, ssh } = eingabe;
const sshOk = ssh === "ok"; const sshOk = ssh === "ok";
@@ -237,10 +239,19 @@ export function faehigkeitenAbleiten(eingabe: {
const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets")); const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets"));
let templateBau: Strategie; let templateBau: Strategie;
if (mindestens(version, 8, 4) && importSpeicher.length > 0) { const agentEinbauen = (sshOk && snippetSpeicher.length > 0) || Boolean(eingabe.bauNetz);
if (mindestens(version, 8, 4) && importSpeicher.length > 0 && !agentEinbauen) {
templateBau = {
weg: "nicht_moeglich",
grund:
"Image-Import ginge per API, aber der Guest-Agent muss ins Template: SSH zum Node (mit Snippets-Storage) oder eine Bau-Adresse in den Server-Einstellungen.",
};
} else if (mindestens(version, 8, 4) && importSpeicher.length > 0) {
templateBau = { templateBau = {
weg: "api", weg: "api",
grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}`, grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}${
sshOk && snippetSpeicher.length > 0 ? "" : "; Guest-Agent über die Bau-Adresse"
}`,
}; };
} else if (sshOk) { } else if (sshOk) {
templateBau = { templateBau = {
+17 -3
View File
@@ -40,7 +40,13 @@ export function hostSchluesselFingerabdruck(schluessel: Buffer): string {
export function sshAusfuehren( export function sshAusfuehren(
ziel: SshZiel, ziel: SshZiel,
befehl: string, befehl: string,
optionen: { zeitlimitMs?: number; eingabe?: string; erkunden?: boolean } = {}, optionen: {
zeitlimitMs?: number;
eingabe?: string;
erkunden?: boolean;
/** Laufende Ausgabe (stdout und stderr), z. B. für Live-Fortschritt einer Provisionierung. */
beiAusgabe?: (text: string) => void;
} = {},
): Promise<SshErgebnis> { ): Promise<SshErgebnis> {
if (!ziel.hostSchluessel && !optionen.erkunden) { if (!ziel.hostSchluessel && !optionen.erkunden) {
return Promise.reject( return Promise.reject(
@@ -95,8 +101,16 @@ export function sshAusfuehren(
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
let code: number | null = null; let code: number | null = null;
kanal.on("data", (teil: Buffer) => (stdout += teil.toString("utf8"))); kanal.on("data", (teil: Buffer) => {
kanal.stderr.on("data", (teil: Buffer) => (stderr += teil.toString("utf8"))); const text = teil.toString("utf8");
stdout += text;
optionen.beiAusgabe?.(text);
});
kanal.stderr.on("data", (teil: Buffer) => {
const text = teil.toString("utf8");
stderr += text;
optionen.beiAusgabe?.(text);
});
kanal.on("exit", (exitCode: number | null) => (code = exitCode)); kanal.on("exit", (exitCode: number | null) => (code = exitCode));
kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen })); kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen }));
if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe); if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe);
+2
View File
@@ -8,10 +8,12 @@ export async function register(): Promise<void> {
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung"); const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
const { logger } = await import("@/lib/logger"); const { logger } = await import("@/lib/logger");
const { katalogSynchronisieren } = await import("@/server/services/katalog"); const { katalogSynchronisieren } = await import("@/server/services/katalog");
const { beispielVorlageAnlegen } = await import("@/server/services/vorlagen");
try { try {
schluesselPruefen(); schluesselPruefen();
await ersterAdminFallsLeer(); await ersterAdminFallsLeer();
const sync = await katalogSynchronisieren(); const sync = await katalogSynchronisieren();
await beispielVorlageAnlegen();
logger.info( logger.info(
{ distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length }, { distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length },
"Katalog abgeglichen", "Katalog abgeglichen",
+1 -1
View File
@@ -25,7 +25,7 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" }, { pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" }, { pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" }, { pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" },
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" }, { pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" }, { pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" }, { pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
{ {
+8 -1
View File
@@ -58,9 +58,13 @@ export const Zugang = z.object({
.trim() .trim()
.regex(/^[a-z_][a-z0-9_-]{0,31}$/, "Benutzername: Kleinbuchstaben, Ziffern, _ und -") .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."), .refine((name) => name !== "root", "root ist nicht erlaubt – ProxDeploy legt einen sudo-Benutzer an."),
/** Leer = die hinterlegten Schlüssel des Benutzers und die Team-Schlüssel (beim Anlegen ergänzt). */
sshSchluessel: z sshSchluessel: z
.array(z.string().trim().regex(SSH_SCHLUESSEL, "Kein gültiger öffentlicher SSH-Schlüssel.")) .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."), .min(
1,
"Mindestens ein SSH-Schlüssel – Passwörter vergibt ProxDeploy nicht. In den Einstellungen hinterlegen oder hier angeben.",
),
}); });
export const AusrollAnfrage = z export const AusrollAnfrage = z
@@ -105,6 +109,8 @@ export const AusrollAnfrage = z
/** Fehlt das Template, vorher bauen (sonst bricht die Prüfung mit Hinweis ab). */ /** Fehlt das Template, vorher bauen (sonst bricht die Prüfung mit Hinweis ab). */
templateBauen: z.boolean().default(false), templateBauen: z.boolean().default(false),
startBeiBoot: z.boolean().default(true), startBeiBoot: z.boolean().default(true),
/** Name der Vorlage, die angewendet wurde (nur zur Nachverfolgung; angewendet wird vorher). */
vorlage: z.string().optional(),
}) })
.refine((a) => a.typ === "lxc" || a.diskGb >= 4, { .refine((a) => a.typ === "lxc" || a.diskGb >= 4, {
message: "Mindestens 4 GB für eine VM.", message: "Mindestens 4 GB für eine VM.",
@@ -120,6 +126,7 @@ export type AusrollEingabe = z.input<typeof AusrollAnfrage>;
*/ */
export function cliBefehl(a: AusrollAnfrage): string { export function cliBefehl(a: AusrollAnfrage): string {
const teile = [`proxdeploy ${a.typ} ${a.name}`, `--server ${a.server}`]; const teile = [`proxdeploy ${a.typ} ${a.name}`, `--server ${a.server}`];
if (a.vorlage) teile.push(`--preset ${a.vorlage}`);
if (a.knoten) teile.push(`--node ${a.knoten}`); if (a.knoten) teile.push(`--node ${a.knoten}`);
teile.push(`--distro ${a.distribution}`); teile.push(`--distro ${a.distribution}`);
if (a.profile.length > 0 && !(a.profile.length === 1 && a.profile[0] === "base")) if (a.profile.length > 0 && !(a.profile.length === 1 && a.profile[0] === "base"))
+31
View File
@@ -30,6 +30,35 @@ export const ServerUrl = z
return `${url.protocol}//${url.host}`; return `${url.protocol}//${url.host}`;
}); });
/**
* Bau-Adresse (#021): eine feste Adresse für den Vorbereitungsstart beim Template-Bau, wenn es
* kein SSH zum Node gibt. ProxDeploy meldet sich darüber per SSH in der VM an.
*/
export const BauNetz = z
.object({
ip: z
.string()
.trim()
.regex(/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, "Format: 10.0.10.250/24"),
gateway: z
.string()
.trim()
.regex(/^(\d{1,3}\.){3}\d{1,3}$/, "Format: 10.0.10.1"),
dns: z
.array(
z
.string()
.trim()
.regex(/^(\d{1,3}\.){3}\d{1,3}$/),
)
.max(3)
.default([]),
})
.nullable()
.default(null);
export type BauNetz = NonNullable<z.infer<typeof BauNetz>>;
const Vmid = z.coerce.number().int("Ganze Zahl erwartet.").min(100, "Mindestens 100.").max(999_999_999); const Vmid = z.coerce.number().int("Ganze Zahl erwartet.").min(100, "Mindestens 100.").max(999_999_999);
const Basis = z.object({ const Basis = z.object({
@@ -52,6 +81,7 @@ const Basis = z.object({
templateVmidVon: Vmid.default(9000), templateVmidVon: Vmid.default(9000),
templateVmidBis: Vmid.default(9999), templateVmidBis: Vmid.default(9999),
gastVmidVon: Vmid.default(100), gastVmidVon: Vmid.default(100),
bauNetz: BauNetz,
}); });
function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) { function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) {
@@ -112,6 +142,7 @@ export type ServerSicht = {
templateVmidVon: number; templateVmidVon: number;
templateVmidBis: number; templateVmidBis: number;
gastVmidVon: number; gastVmidVon: number;
bauNetz: BauNetz | null;
erreichbar: boolean | null; erreichbar: boolean | null;
pveVersion: string | null; pveVersion: string | null;
geprueftAm: string | null; geprueftAm: string | null;
+2
View File
@@ -7,6 +7,8 @@ export const TemplateBauAnfrage = z.object({
storage: z.string().min(1, "Storage fehlt."), storage: z.string().min(1, "Storage fehlt."),
bridge: z.string().min(1).default("vmbr0"), bridge: z.string().min(1).default("vmbr0"),
vlan: z.coerce.number().int().min(1).max(4094).optional(), vlan: z.coerce.number().int().min(1).max(4094).optional(),
/** Profile, die ins Template eingebacken werden (#022) – nur solche ohne Pflichtvariablen. */
profile: z.array(z.string()).default([]),
}); });
export type TemplateBauAnfrage = z.infer<typeof TemplateBauAnfrage>; export type TemplateBauAnfrage = z.infer<typeof TemplateBauAnfrage>;
+78
View File
@@ -0,0 +1,78 @@
import { z } from "@/lib/validierung";
/**
* Was eine Vorlage festlegen darf. Bewusst ohne Name, VMID, IP und Schlüssel – das ist je Gast
* verschieden. Server und Storage dürfen drin stehen, sind aber optional.
*/
export const VorlagenParameter = z
.object({
typ: z.enum(["vm", "lxc"]).optional(),
server: z.string().trim().optional(),
distribution: z.string().trim().optional(),
cores: z.coerce.number().int().min(1).max(128).optional(),
speicherMb: z.coerce.number().int().min(256).optional(),
diskGb: z.coerce.number().int().min(1).optional(),
storage: z.string().trim().optional(),
swapMb: z.coerce.number().int().min(0).optional(),
unprivilegiert: z.boolean().optional(),
features: z.object({ nesting: z.boolean(), keyctl: z.boolean() }).partial().optional(),
netz: z
.object({ bridge: z.string().trim(), vlan: z.coerce.number().int().min(1).max(4094) })
.partial()
.optional(),
profile: z.array(z.string()).optional(),
variablen: z
.record(z.string(), z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])))
.optional(),
tags: z.array(z.string()).optional(),
})
.strict();
export type VorlagenParameter = z.infer<typeof VorlagenParameter>;
export const VorlageEingabe = z.object({
name: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9][a-z0-9-]{0,47}$/, "Kurzname: Kleinbuchstaben, Ziffern und Bindestrich."),
titel: z.string().trim().min(1, "Titel fehlt.").max(80),
beschreibung: z.string().trim().max(500).optional(),
parameter: VorlagenParameter,
});
/**
* Legt eine Vorlage unter eine Anfrage: was die Anfrage selbst angibt, gewinnt. Objekte (netz,
* features, variablen) werden feldweise gemischt, Listen ersetzt.
*/
export function vorlageAnwenden(
vorlage: VorlagenParameter,
anfrage: Record<string, unknown>,
): Record<string, unknown> {
const ergebnis: Record<string, unknown> = { ...vorlage };
for (const [schluessel, wert] of Object.entries(anfrage)) {
if (wert === undefined) continue;
const basis = ergebnis[schluessel];
if (
basis &&
typeof basis === "object" &&
!Array.isArray(basis) &&
wert &&
typeof wert === "object" &&
!Array.isArray(wert)
) {
ergebnis[schluessel] =
schluessel === "variablen"
? Object.fromEntries(
[...new Set([...Object.keys(basis), ...Object.keys(wert)])].map((p) => [
p,
{ ...(basis as Record<string, object>)[p], ...(wert as Record<string, object>)[p] },
]),
)
: { ...basis, ...wert };
} else {
ergebnis[schluessel] = wert;
}
}
return ergebnis;
}
+21
View File
@@ -0,0 +1,21 @@
import { prisma } from "@/lib/db";
import type { GastSchluessel, SchluesselQuelle } from "./quelle";
/** Schlüssel aus der eigenen Datenbank: die des Benutzers und die Team-Schlüssel. */
export const lokaleQuelle: SchluesselQuelle = {
name: "lokal",
async schluesselFuer(benutzer) {
const zeilen = await prisma.sshSchluessel.findMany({
where: { OR: [{ benutzerId: benutzer.id }, { benutzerId: null }] },
orderBy: [{ benutzerId: "asc" }, { name: "asc" }],
});
return zeilen.map((z): GastSchluessel => ({
id: z.id,
name: z.name,
oeffentlich: z.oeffentlich,
fingerabdruck: z.fingerabdruck,
bereich: z.benutzerId ? "persoenlich" : "team",
quelle: z.quelle,
}));
},
};
+33
View File
@@ -0,0 +1,33 @@
import { createHash } from "node:crypto";
import { SSH_SCHLUESSEL } from "@/lib/schemas/ausrollen";
/**
* Woher die SSH-Schlüssel eines Benutzers kommen. Heute aus der eigenen Datenbank („lokal“); eine
* externe Schlüsselverwaltung ist eine weitere Umsetzung dieses Interfaces, die in
* `schluesselQuellen()` eingetragen wird – Assistent, API und CLI merken davon nichts.
*/
export type GastSchluessel = {
id: string;
name: string;
oeffentlich: string;
fingerabdruck: string;
/** Persönlich oder für alle (Team). */
bereich: "persoenlich" | "team";
quelle: string;
};
export interface SchluesselQuelle {
readonly name: string;
/** Schlüssel, die standardmäßig in Gäste dieses Benutzers kommen. */
schluesselFuer(benutzer: { id: string; benutzername: string }): Promise<GastSchluessel[]>;
}
/** Fingerprint wie `ssh-keygen -lf`: SHA256 über die Base64-dekodierten Schlüsseldaten. */
export function fingerabdruck(oeffentlich: string): string {
const daten = oeffentlich.trim().split(/\s+/)[1] ?? "";
return `SHA256:${createHash("sha256").update(Buffer.from(daten, "base64")).digest("base64").replace(/=+$/, "")}`;
}
export function istOeffentlicherSchluessel(text: string): boolean {
return SSH_SCHLUESSEL.test(text.trim());
}
+110 -27
View File
@@ -1,16 +1,23 @@
import type { Server } from "@/generated/prisma/client"; import type { Server } from "@/generated/prisma/client";
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf"; import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
import { lxcAusrollSchritte, type LxcAusrollStand } from "@/engine/ablaeufe/lxc-ausrollen";
import { vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen"; import { vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen";
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
import type { Distribution, Profil } from "@/engine/katalog/schema"; import type { Distribution, Profil } from "@/engine/katalog/schema";
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen"; import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client"; import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen"; import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen";
import type { BauNetz } from "@/lib/schemas/server";
import { vorlageAnwenden } from "@/lib/schemas/vorlage";
import { vorlageHolen } from "@/server/services/vorlagen";
import type { Aufrufer } from "@/server/auth/zugang"; import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler } from "@/server/fehler"; import { AnwendungsFehler } from "@/server/fehler";
import { auftragAnlegen } from "@/server/services/auftraege"; import { auftragAnlegen } from "@/server/services/auftraege";
import { serverHolen, verbindungAus } from "@/server/services/server"; import { serverHolen, verbindungAus } from "@/server/services/server";
import { nodeBefehlFuer } from "@/server/services/ssh-zugang"; import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
import { schluesselFuer } from "@/server/services/schluessel";
import { vmidVorschlagen } from "@/server/services/vmid"; import { vmidVorschlagen } from "@/server/services/vmid";
/** /**
@@ -26,7 +33,14 @@ export type Vorbereitet = {
}; };
export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> { export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> {
const anfrage = AusrollAnfrage.parse(roh); // Eine Vorlage (Preset) liegt unter der Anfrage: was die Anfrage selbst sagt, gewinnt.
const vorlageName =
roh && typeof roh === "object" && "vorlage" in roh ? (roh as { vorlage?: unknown }).vorlage : undefined;
const mitVorlage =
typeof vorlageName === "string" && vorlageName
? vorlageAnwenden((await vorlageHolen(vorlageName)).parameter, roh as Record<string, unknown>)
: roh;
const anfrage = AusrollAnfrage.parse(mitVorlage);
const server = await serverHolen(anfrage.server); const server = await serverHolen(anfrage.server);
const zeile = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } }); const zeile = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } });
if (!zeile || zeile.entferntAm || !zeile.aktiv) { if (!zeile || zeile.entferntAm || !zeile.aktiv) {
@@ -44,14 +58,6 @@ export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> {
`${distribution.name} gibt es nicht als ${anfrage.typ === "vm" ? "VM" : "LXC-Container"}.`, `${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( const verfuegbar = new Map(
(await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [ (await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [
p.id, p.id,
@@ -74,8 +80,22 @@ export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> {
return { anfrage, server, distribution, profile: aufloesung.profile, automatisch: aufloesung.automatisch }; return { anfrage, server, distribution, profile: aufloesung.profile, automatisch: aufloesung.automatisch };
} }
/**
* Gibt die Anfrage keine Schlüssel an, kommen die hinterlegten des Benutzers und die Team-Schlüssel
* hinein. Das passiert beim Anlegen, nicht im worker: dort ist bekannt, wer ausrollt, und die
* Anfrage im Verlauf zeigt, welche Schlüssel tatsächlich im Gast gelandet sind.
*/
export async function schluesselErgaenzen(roh: unknown, aufrufer: Aufrufer): Promise<unknown> {
if (!roh || typeof roh !== "object") return roh;
const anfrage = roh as { zugang?: { benutzer?: unknown; sshSchluessel?: unknown } };
const angegeben = Array.isArray(anfrage.zugang?.sshSchluessel) ? anfrage.zugang.sshSchluessel : [];
if (angegeben.length > 0) return roh;
const hinterlegt = (await schluesselFuer(aufrufer.benutzer)).map((s) => s.oeffentlich);
return { ...anfrage, zugang: { ...anfrage.zugang, sshSchluessel: hinterlegt } };
}
export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: string | null) { export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: string | null) {
const v = await ausrollenVorbereiten(roh); const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
return await auftragAnlegen( return await auftragAnlegen(
{ {
art: "AUSROLLEN", art: "AUSROLLEN",
@@ -92,6 +112,72 @@ export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: str
); );
} }
export type AusrollStand = VmAusrollStand & LxcAusrollStand;
/** Container-Features, die die gewählten Profile verlangen (z. B. docker → nesting). */
export function profilFeatures(profile: AufgeloestesProfil[]): { nesting: boolean; keyctl: boolean } {
return {
nesting: profile.some((p) => p.profil.lxc.features?.nesting),
keyctl: profile.some((p) => p.profil.lxc.features?.keyctl),
};
}
/**
* Die Phasen für VM oder LXC – dieselben im Trockenlauf und im worker. Was sich unterscheidet
* (reservieren oder nur vorschlagen, Auftrags-ID), kommt von außen.
*/
export function ausrollSchritte(
v: Vorbereitet,
o: {
client: ProxmoxClient;
vmidReservieren: (belegt: Set<number>, wunsch?: number) => Promise<number>;
templateVmidReservieren: (belegt: Set<number>) => Promise<number>;
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
auftragKurzId: string;
},
stand: AusrollStand,
): PhasenSchritt[] {
const sshFuer = (knoten: string) => nodeBefehlFuer(v.server, knoten);
if (v.anfrage.typ === "lxc") {
return lxcAusrollSchritte(
{
client: o.client,
anfrage: v.anfrage,
distribution: v.distribution,
profile: v.profile,
profilFeatures: profilFeatures(v.profile),
vmidReservieren: o.vmidReservieren,
sshFuer,
bereitstellung: bereitstellungsschluessel,
angelegt: o.angelegt,
auftragKurzId: o.auftragKurzId,
},
stand,
);
}
return vmAusrollSchritte(
{
client: o.client,
anfrage: v.anfrage,
distribution: v.distribution,
profile: v.profile,
vmidReservieren: o.vmidReservieren,
templateBau: v.anfrage.templateBauen
? {
sshFuer,
auftragKurzId: o.auftragKurzId,
vmidReservieren: o.templateVmidReservieren,
bauNetz: (v.server.bauNetz as BauNetz | null) ?? null,
bereitstellung: bereitstellungsschluessel,
}
: undefined,
angelegt: o.angelegt,
auftragKurzId: o.auftragKurzId,
},
stand,
);
}
export type Plan = { export type Plan = {
kopf: string; kopf: string;
phasen: Array<{ phasen: Array<{
@@ -114,8 +200,8 @@ export type Plan = {
* geht wirklich an Proxmox (VMID frei? Template da? Bridge VLAN-fähig?), Schreibendes wird nur * 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. * aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde.
*/ */
export async function ausrollenPlanen(roh: unknown): Promise<Plan> { export async function ausrollenPlanen(roh: unknown, aufrufer: Aufrufer): Promise<Plan> {
const v = await ausrollenVorbereiten(roh); const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
const client = new ProxmoxClient(verbindungAus(v.server), { trocken: true, zeitlimitMs: 15_000 }); const client = new ProxmoxClient(verbindungAus(v.server), { trocken: true, zeitlimitMs: 15_000 });
const phasen: Plan["phasen"] = []; const phasen: Plan["phasen"] = [];
let skript: string | null = null; let skript: string | null = null;
@@ -138,25 +224,17 @@ export async function ausrollenPlanen(roh: unknown): Promise<Plan> {
if (angaben?.phase === "provisionierung" && nachricht.startsWith("#!/bin/bash")) skript = nachricht; if (angaben?.phase === "provisionierung" && nachricht.startsWith("#!/bin/bash")) skript = nachricht;
}, },
}; };
const stand: VmAusrollStand = {}; const stand: AusrollStand = {};
try { try {
const ergebnis = await phasenAusfuehren( const ergebnis = await phasenAusfuehren(
vmAusrollSchritte( ausrollSchritte(
v,
{ {
client, client,
anfrage: v.anfrage,
distribution: v.distribution,
profile: v.profile,
vmidReservieren: (belegt, wunsch) => vmidReservieren: (belegt, wunsch) =>
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.gastVmidVon, wunsch }), vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.gastVmidVon, wunsch }),
templateBau: v.anfrage.templateBauen templateVmidReservieren: (belegt) =>
? { vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }),
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten),
auftragKurzId: "dry-run",
vmidReservieren: (belegt) =>
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }),
}
: undefined,
angelegt: async () => {}, angelegt: async () => {},
auftragKurzId: "dry-run", auftragKurzId: "dry-run",
}, },
@@ -178,11 +256,16 @@ export async function ausrollenPlanen(roh: unknown): Promise<Plan> {
} }
} }
export function kopfzeile(v: Pick<Vorbereitet, "server" | "distribution">, stand: VmAusrollStand): string { export function kopfzeile(
v: Pick<Vorbereitet, "server" | "distribution" | "anfrage">,
stand: AusrollStand,
): string {
return [ return [
`Server ${v.server.name}`, `Server ${v.server.name}`,
stand.knoten ? `Node ${stand.knoten}` : null, stand.knoten ? `Node ${stand.knoten}` : null,
`${v.distribution.name}${stand.template ? ` (Template ${stand.template.vmid})` : ""}`, v.anfrage.typ === "lxc"
? `${v.distribution.name} (LXC)`
: `${v.distribution.name}${stand.template ? ` (Template ${stand.template.vmid})` : ""}`,
] ]
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
+30
View File
@@ -0,0 +1,30 @@
import ssh2 from "ssh2";
import type { Bereitstellungsschluessel } from "@/engine/ablaeufe/lxc-ausrollen";
import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
/**
* Der Bereitstellungsschlüssel: damit meldet sich ProxDeploy als root in frisch angelegten
* Containern an, wenn es keinen SSH-Zugang zum Node hat. Er steht nur während der
* Provisionierung in /root/.ssh/authorized_keys und wird am Ende des Skripts entfernt (am
* Kommentar `proxdeploy-bereitstellung` erkennbar). Einmal erzeugt, privat nur verschlüsselt.
*/
const SCHLUESSEL = "bereitstellungsschluessel";
export const KOMMENTAR = "proxdeploy-bereitstellung";
export async function bereitstellungsschluessel(): Promise<Bereitstellungsschluessel> {
const vorhanden = await prisma.einstellung.findUnique({ where: { schluessel: SCHLUESSEL } });
if (vorhanden) {
const wert = vorhanden.wert as { privat: string; oeffentlich: string };
return { privat: entschluesseln(wert.privat), oeffentlich: wert.oeffentlich };
}
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: KOMMENTAR });
const neu = { privat: verschluesseln(paar.private), oeffentlich: paar.public.trim() };
// Zwei gleichzeitige Aufträge: der erste gewinnt, der zweite liest dessen Schlüssel.
await prisma.einstellung.upsert({
where: { schluessel: SCHLUESSEL },
create: { schluessel: SCHLUESSEL, wert: neu },
update: {},
});
return await bereitstellungsschluessel();
}
+163 -2
View File
@@ -1,11 +1,15 @@
import { join } from "node:path"; import { join } from "node:path";
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { katalogLaden, type KatalogFehler } from "@/engine/katalog/laden"; import { adapterFuer, bekannteFamilien } from "@/engine/adapter";
import { katalogLaden, yamlPruefen, type KatalogFehler } from "@/engine/katalog/laden";
import { ProfilSchema } from "@/engine/katalog/schema";
import { profileAufloesen } from "@/engine/profile/aufloesen";
import { profileRendern } from "@/engine/profile/rendern";
import type { Distribution, Profil } from "@/engine/katalog/schema"; import type { Distribution, Profil } from "@/engine/katalog/schema";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import type { Aufrufer } from "@/server/auth/zugang"; import type { Aufrufer } from "@/server/auth/zugang";
import { nichtGefunden } from "@/server/fehler"; import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll"; import { protokollieren } from "@/server/services/protokoll";
/** Wo der Katalog liegt: im Repo und im Image unter ./catalog, überschreibbar für Tests. */ /** Wo der Katalog liegt: im Repo und im Image unter ./catalog, überschreibbar für Tests. */
@@ -209,3 +213,160 @@ export async function profilAktivSetzen(id: string, aktiv: boolean, aufrufer: Au
); );
}); });
} }
// ── Eigene Profile (#011) ───────────────────────────────────────────────────────────────────────
export type ProfilPruefung =
{ ok: true; profil: Profil; vorschau: string | null } | { ok: false; fehler: string[] };
/**
* Prüft ein Profil aus dem Editor: YAML, Schema, Voraussetzungen. Mit Vorschau des erzeugten
* Skripts für die erste Distribution der Familie, damit man sieht, was im Gast ankommt.
*/
export async function profilPruefen(quelltext: string): Promise<ProfilPruefung> {
const geprueft = yamlPruefen(quelltext, ProfilSchema);
if (!geprueft.ok) return { ok: false, fehler: geprueft.fehler };
const profil = geprueft.eintrag;
const alle = new Map(
(await prisma.profil.findMany({ where: { entferntAm: null } })).map((p) => [
p.id,
p.definition as Profil,
]),
);
alle.set(profil.id, profil);
const fehler: string[] = [];
for (const familie of Object.keys(profil.families)) {
if (!adapterFuer(familie))
fehler.push(
`families.${familie}: Für diese Familie gibt es keinen Adapter (bekannt: ${bekannteFamilien().join(", ")}).`,
);
}
const familie = Object.keys(profil.families).find((f) => adapterFuer(f));
let vorschau: string | null = null;
if (familie) {
const aufgeloest = profileAufloesen([profil.id], alle, familie, {
// Für die Vorschau Pflichtvariablen mit einem Platzhalter füllen.
[profil.id]: Object.fromEntries(
profil.variables.filter((v) => v.required).map((v) => [v.name, `<${v.name}>`]),
),
});
if (!aufgeloest.ok) {
fehler.push(...aufgeloest.fehler.map((f) => `${f.profil}: ${f.meldung}`));
} else {
const distro = (await distributionenListe({ nurAktive: true })).find((d) => d.familie === familie);
try {
vorschau =
profileRendern(
aufgeloest.profile.filter((p) => p.profil.id === profil.id),
adapterFuer(familie)!,
{
os: distro?.definition.os ?? { id: familie, version_id: "0" },
gast: { name: "beispiel", benutzer: "benutzer", typ: "vm" },
},
).abschnitte[0]?.skript ?? null;
} catch (ursache) {
fehler.push(ursache instanceof Error ? ursache.message : String(ursache));
}
}
}
return fehler.length > 0 ? { ok: false, fehler } : { ok: true, profil, vorschau };
}
export async function profilSpeichern(
quelltext: string,
aufrufer: Aufrufer,
ip: string | null,
bisherigeId?: string,
): Promise<ProfilSicht> {
const pruefung = await profilPruefen(quelltext);
if (!pruefung.ok)
throw new AnwendungsFehler(
400,
"ungueltig",
pruefung.fehler.join(" "),
"Fehlerstellen im Editor beheben.",
);
const { profil } = pruefung;
const vorhanden = await prisma.profil.findUnique({ where: { id: profil.id } });
if (bisherigeId && bisherigeId !== profil.id) {
throw new AnwendungsFehler(
400,
"ungueltig",
"Die id eines Profils lässt sich nicht ändern.",
"Als neues Profil mit anderer id speichern.",
);
}
if (vorhanden && (vorhanden.herkunft === "KATALOG" || !bisherigeId) && !vorhanden.entferntAm) {
throw new AnwendungsFehler(
409,
"vergeben",
vorhanden.herkunft === "KATALOG"
? `„${profil.id}“ ist ein Katalogprofil.`
: `Ein Profil „${profil.id}“ gibt es schon.`,
vorhanden.herkunft === "KATALOG"
? "Für eine eigene Fassung eine andere id wählen, z. B. docker-eigen."
: undefined,
);
}
const daten = {
name: profil.name,
beschreibung: profil.description ?? null,
familien: Object.keys(profil.families),
herkunft: "EIGEN" as const,
definition: profil as unknown as Prisma.InputJsonValue,
quelltext,
entferntAm: null,
geaendertVonId: aufrufer.benutzer.id,
};
await prisma.$transaction(async (tx) => {
await tx.profil.upsert({ where: { id: profil.id }, create: { id: profil.id, ...daten }, update: daten });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: bisherigeId ? "Profil geändert" : "Profil angelegt",
ziel: `profil:${profil.id}`,
ip,
},
tx,
);
});
return (await profileListe()).find((p) => p.id === profil.id)!;
}
export async function profilLoeschen(id: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
const profil = await prisma.profil.findUnique({ where: { id } });
if (!profil || profil.entferntAm) throw nichtGefunden(`Das Profil „${id}“`);
if (profil.herkunft === "KATALOG") {
throw new AnwendungsFehler(
409,
"katalog",
"Katalogprofile lassen sich nicht löschen.",
"Deaktivieren oder die YAML-Datei aus catalog/profiles entfernen.",
);
}
const abhaengig = (await prisma.profil.findMany({ where: { entferntAm: null } })).filter((p) =>
(p.definition as Profil).requires?.includes(id),
);
if (abhaengig.length > 0) {
throw new AnwendungsFehler(
409,
"benutzt",
`${abhaengig.map((p) => p.id).join(", ")} setzt „${id}“ voraus.`,
);
}
await prisma.$transaction(async (tx) => {
// Aufträge nennen Profile nur beim Namen – löschen ist sicher, der Verlauf bleibt lesbar.
await tx.profil.delete({ where: { id } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Profil gelöscht",
ziel: `profil:${id}`,
ip,
},
tx,
);
});
}
+100
View File
@@ -0,0 +1,100 @@
import { prisma } from "@/lib/db";
import { z } from "@/lib/validierung";
import type { Aufrufer } from "@/server/auth/zugang";
import { darf } from "@/server/auth/rechte";
import { AnwendungsFehler, keinRecht, nichtGefunden } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll";
import { lokaleQuelle } from "@/server/schluessel/lokal";
import {
fingerabdruck,
istOeffentlicherSchluessel,
type GastSchluessel,
type SchluesselQuelle,
} from "@/server/schluessel/quelle";
/** Alle angebundenen Quellen. Eine externe Verwaltung kommt hier dazu. */
export function schluesselQuellen(): SchluesselQuelle[] {
return [lokaleQuelle];
}
export async function schluesselFuer(benutzer: {
id: string;
benutzername: string;
}): Promise<GastSchluessel[]> {
const listen = await Promise.all(schluesselQuellen().map((q) => q.schluesselFuer(benutzer)));
// Derselbe Schlüssel aus zwei Quellen nur einmal.
const gesehen = new Set<string>();
return listen.flat().filter((s) => !gesehen.has(s.fingerabdruck) && gesehen.add(s.fingerabdruck));
}
const Eingabe = z.object({
name: z.string().trim().min(1, "Name fehlt.").max(80),
oeffentlich: z
.string()
.trim()
.refine(
istOeffentlicherSchluessel,
"Kein öffentlicher SSH-Schlüssel (ssh-ed25519 …, ssh-rsa …, ecdsa-sha2-… ). Den .pub-Inhalt einfügen, nie den privaten Schlüssel.",
),
team: z.boolean().default(false),
});
export async function schluesselAnlegen(
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<GastSchluessel> {
const eingabe = Eingabe.parse(roh);
if (eingabe.team && !darf(aufrufer.benutzer.rolle, "benutzerVerwalten")) throw keinRecht();
const abdruck = fingerabdruck(eingabe.oeffentlich);
const benutzerId = eingabe.team ? null : aufrufer.benutzer.id;
if (await prisma.sshSchluessel.findFirst({ where: { benutzerId, fingerabdruck: abdruck } })) {
throw new AnwendungsFehler(409, "vergeben", "Diesen Schlüssel gibt es hier schon.");
}
const zeile = await prisma.$transaction(async (tx) => {
const neu = await tx.sshSchluessel.create({
data: { benutzerId, name: eingabe.name, oeffentlich: eingabe.oeffentlich, fingerabdruck: abdruck },
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: eingabe.team ? "Team-Schlüssel hinzugefügt" : "SSH-Schlüssel hinzugefügt",
ziel: `schluessel:${abdruck}`,
details: { name: eingabe.name },
ip,
},
tx,
);
return neu;
});
return {
id: zeile.id,
name: zeile.name,
oeffentlich: zeile.oeffentlich,
fingerabdruck: abdruck,
bereich: benutzerId ? "persoenlich" : "team",
quelle: "lokal",
};
}
export async function schluesselLoeschen(id: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
const zeile = await prisma.sshSchluessel.findUnique({ where: { id } });
if (!zeile) throw nichtGefunden("Den Schlüssel");
const eigener = zeile.benutzerId === aufrufer.benutzer.id;
if (!eigener && !(zeile.benutzerId === null && darf(aufrufer.benutzer.rolle, "benutzerVerwalten")))
throw keinRecht();
await prisma.$transaction(async (tx) => {
await tx.sshSchluessel.delete({ where: { id } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: zeile.benutzerId ? "SSH-Schlüssel entfernt" : "Team-Schlüssel entfernt",
ziel: `schluessel:${zeile.fingerabdruck}`,
ip,
},
tx,
);
});
}
+13 -2
View File
@@ -1,5 +1,5 @@
import ssh2 from "ssh2"; import ssh2 from "ssh2";
import type { Server } from "@/generated/prisma/client"; import { Prisma, type Server } from "@/generated/prisma/client";
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client"; import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung"; import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { ProxmoxFehler } from "@/engine/proxmox/fehler"; import { ProxmoxFehler } from "@/engine/proxmox/fehler";
@@ -11,6 +11,7 @@ import {
ServerAendern, ServerAendern,
ServerAnlegen, ServerAnlegen,
ServerPruefen, ServerPruefen,
type BauNetz,
type ServerSicht, type ServerSicht,
} from "@/lib/schemas/server"; } from "@/lib/schemas/server";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler"; import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
@@ -32,6 +33,7 @@ export function serverSicht(server: Server): ServerSicht {
templateVmidVon: server.templateVmidVon, templateVmidVon: server.templateVmidVon,
templateVmidBis: server.templateVmidBis, templateVmidBis: server.templateVmidBis,
gastVmidVon: server.gastVmidVon, gastVmidVon: server.gastVmidVon,
bauNetz: (server.bauNetz as BauNetz | null) ?? null,
erreichbar: server.erreichbar, erreichbar: server.erreichbar,
pveVersion: server.pveVersion, pveVersion: server.pveVersion,
geprueftAm: server.geprueftAm?.toISOString() ?? null, geprueftAm: server.geprueftAm?.toISOString() ?? null,
@@ -83,6 +85,7 @@ export async function serverAnlegen(
const angelegt = await tx.server.create({ const angelegt = await tx.server.create({
data: { data: {
...eingabe, ...eingabe,
bauNetz: eingabe.bauNetz ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck), fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis), tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis),
}, },
@@ -117,12 +120,19 @@ export async function serverAendern(
const { tokenGeheimnis, ...rest } = eingabe; const { tokenGeheimnis, ...rest } = eingabe;
const daten = { const daten = {
...rest, ...rest,
bauNetz: rest.bauNetz ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck), fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}), ...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}),
}; };
// Welche Felder sich geändert haben, steht im Protokoll – die Werte von Geheimnissen nie. // Welche Felder sich geändert haben, steht im Protokoll – die Werte von Geheimnissen nie.
// JSON-Felder (bauNetz) vergleichen nach Inhalt; Prisma.JsonNull steht für „leer“ wie null.
const vergleichbar = (wert: unknown) => JSON.stringify(wert === Prisma.JsonNull ? null : (wert ?? null));
const geaendert = Object.entries(daten) const geaendert = Object.entries(daten)
.filter(([feld, wert]) => feld === "tokenGeheimnis" || (bisher as Record<string, unknown>)[feld] !== wert) .filter(
([feld, wert]) =>
feld === "tokenGeheimnis" ||
vergleichbar((bisher as Record<string, unknown>)[feld]) !== vergleichbar(wert),
)
.map(([feld]) => feld); .map(([feld]) => feld);
const server = await prisma.$transaction(async (tx) => { const server = await prisma.$transaction(async (tx) => {
@@ -287,6 +297,7 @@ export async function serverTesten(
try { try {
const bericht = await erkunden(client, { const bericht = await erkunden(client, {
serverHost: new URL(server.url).hostname, serverHost: new URL(server.url).hostname,
bauNetz: Boolean(server.bauNetz),
ssh: server.sshAktiv ssh: server.sshAktiv
? { ? {
benutzer: server.sshBenutzer, benutzer: server.sshBenutzer,
+1 -1
View File
@@ -29,6 +29,6 @@ export function nodeBefehlFuer(server: Server, knoten: string): NodeBefehl | nul
hostSchluessel: pin, hostSchluessel: pin,
}, },
befehl, befehl,
{ eingabe: optionen.eingabe, zeitlimitMs: optionen.zeitlimitMs }, { eingabe: optionen.eingabe, zeitlimitMs: optionen.zeitlimitMs, beiAusgabe: optionen.beiAusgabe },
); );
} }
+36 -2
View File
@@ -1,4 +1,5 @@
import type { Distribution } from "@/engine/katalog/schema"; import type { Distribution, Profil } from "@/engine/katalog/schema";
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { ProxmoxClient } from "@/engine/proxmox/client"; import { ProxmoxClient } from "@/engine/proxmox/client";
import { ProxmoxFehler } from "@/engine/proxmox/fehler"; import { ProxmoxFehler } from "@/engine/proxmox/fehler";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -27,6 +28,7 @@ export async function templateBauStarten(
if (!(distribution.definition as Distribution).vm) { if (!(distribution.definition as Distribution).vm) {
throw new AnwendungsFehler(400, "ungueltig", `${distribution.name} bietet kein VM-Image.`); throw new AnwendungsFehler(400, "ungueltig", `${distribution.name} bietet kein VM-Image.`);
} }
await templateProfileAufloesen(anfrage.profile, (distribution.definition as Distribution).family);
return await auftragAnlegen( return await auftragAnlegen(
{ {
art: "TEMPLATE_BAUEN", art: "TEMPLATE_BAUEN",
@@ -42,6 +44,33 @@ export async function templateBauStarten(
); );
} }
/**
* Profile fürs Template: aufgelöst mit Standardwerten. Ein Profil mit Pflichtvariable (z. B. der
* Zabbix-Server) ist gerätespezifisch und gehört nicht ins Template.
*/
export async function templateProfileAufloesen(
auswahl: string[],
familie: string,
): Promise<AufgeloestesProfil[]> {
if (auswahl.length === 0) return [];
const verfuegbar = new Map(
(await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [
p.id,
p.definition as Profil,
]),
);
const aufgeloest = profileAufloesen(auswahl, verfuegbar, familie);
if (!aufgeloest.ok) {
throw new AnwendungsFehler(
400,
"ungueltig",
aufgeloest.fehler.map((f) => `${f.profil}: ${f.meldung}`).join(" "),
"Profile mit Pflichtvariablen gehören nicht ins Template – sie laufen beim Ausrollen.",
);
}
return aufgeloest.profile;
}
export type TemplateSicht = { export type TemplateSicht = {
vmid: number; vmid: number;
name: string; name: string;
@@ -49,6 +78,7 @@ export type TemplateSicht = {
distributionId: string | null; distributionId: string | null;
gebautAm: string | null; gebautAm: string | null;
imageDatei: string | null; imageDatei: string | null;
profile: string[];
}; };
/** /**
@@ -74,9 +104,13 @@ export async function templatesListe(serverName: string): Promise<TemplateSicht[
vmid: r.vmid, vmid: r.vmid,
name: r.name ?? `VM ${r.vmid}`, name: r.name ?? `VM ${r.vmid}`,
knoten: r.node, knoten: r.node,
distributionId: db?.distributionId ?? tags.find((t) => t !== "proxdeploy-template") ?? null, distributionId:
db?.distributionId ??
tags.find((t) => t !== "proxdeploy-template" && !t.startsWith("profil-")) ??
null,
gebautAm: db?.gebautAm.toISOString() ?? null, gebautAm: db?.gebautAm.toISOString() ?? null,
imageDatei: db?.imageDatei ?? null, imageDatei: db?.imageDatei ?? null,
profile: tags.filter((t) => t.startsWith("profil-")).map((t) => t.slice("profil-".length)),
}; };
}) })
.sort((a, b) => a.vmid - b.vmid); .sort((a, b) => a.vmid - b.vmid);
+128
View File
@@ -0,0 +1,128 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { VorlageEingabe, VorlagenParameter } from "@/lib/schemas/vorlage";
import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll";
export type VorlageSicht = {
name: string;
titel: string;
beschreibung: string | null;
parameter: VorlagenParameter;
geaendertAm: string;
};
const sicht = (v: {
name: string;
titel: string;
beschreibung: string | null;
parameter: unknown;
geaendertAm: Date;
}): VorlageSicht => ({
name: v.name,
titel: v.titel,
beschreibung: v.beschreibung,
parameter: v.parameter as VorlagenParameter,
geaendertAm: v.geaendertAm.toISOString(),
});
export async function vorlagenListe(): Promise<VorlageSicht[]> {
return (await prisma.vorlage.findMany({ orderBy: { titel: "asc" } })).map(sicht);
}
export async function vorlageHolen(name: string): Promise<VorlageSicht> {
const v = await prisma.vorlage.findUnique({ where: { name } });
if (!v) throw nichtGefunden(`Die Vorlage „${name}“`);
return sicht(v);
}
export async function vorlageSpeichern(
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
bisher?: string,
): Promise<VorlageSicht> {
const eingabe = VorlageEingabe.parse(roh);
if (bisher && bisher !== eingabe.name) {
throw new AnwendungsFehler(400, "ungueltig", "Der Kurzname einer Vorlage lässt sich nicht ändern.");
}
const vorhanden = await prisma.vorlage.findUnique({ where: { name: eingabe.name } });
if (vorhanden && !bisher)
throw new AnwendungsFehler(409, "vergeben", `Die Vorlage „${eingabe.name}“ gibt es schon.`);
if (!vorhanden && bisher) throw nichtGefunden(`Die Vorlage „${bisher}“`);
const daten = {
titel: eingabe.titel,
beschreibung: eingabe.beschreibung ?? null,
parameter: eingabe.parameter as Prisma.InputJsonValue,
};
const v = await prisma.$transaction(async (tx) => {
const gespeichert = await tx.vorlage.upsert({
where: { name: eingabe.name },
create: { name: eingabe.name, ...daten, erstelltVonId: aufrufer.benutzer.id },
update: daten,
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: bisher ? "Vorlage geändert" : "Vorlage angelegt",
ziel: `vorlage:${eingabe.name}`,
ip,
},
tx,
);
return gespeichert;
});
return sicht(v);
}
export async function vorlageLoeschen(name: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
await vorlageHolen(name);
await prisma.$transaction(async (tx) => {
await tx.vorlage.delete({ where: { name } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Vorlage gelöscht",
ziel: `vorlage:${name}`,
ip,
},
tx,
);
});
}
/**
* Beim ersten Start ein Beispiel aus der Vorgabe: „Docker-Host klein“. Nur einmal – wer es löscht,
* bekommt es nicht zurück (gemerkt in den Einstellungen).
*/
export async function beispielVorlageAnlegen(): Promise<void> {
const merker = "vorlagen.beispielAngelegt";
if (await prisma.einstellung.findUnique({ where: { schluessel: merker } })) return;
await prisma.$transaction([
prisma.vorlage.upsert({
where: { name: "docker-host-klein" },
create: {
name: "docker-host-klein",
titel: "Docker-Host klein",
beschreibung: "Debian 13 als VM mit 2 CPU, 4 GB RAM, 40 GB Disk – base und docker",
parameter: {
typ: "vm",
distribution: "debian-13",
cores: 2,
speicherMb: 4096,
diskGb: 40,
profile: ["base", "docker"],
},
},
update: {},
}),
prisma.einstellung.upsert({
where: { schluessel: merker },
create: { schluessel: merker, wert: true },
update: {},
}),
]);
}
+18 -22
View File
@@ -1,11 +1,15 @@
import { phasenAusfuehren } from "@/engine/ablauf/ablauf"; import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
import { sshBefehl, vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen"; import { sshBefehl } from "@/engine/ablaeufe/vm-ausrollen";
import { ProxmoxClient } from "@/engine/proxmox/client"; import { ProxmoxClient } from "@/engine/proxmox/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { melden } from "@/server/ereignisse/senke"; import { melden } from "@/server/ereignisse/senke";
import { ausrollenVorbereiten, kopfzeile } from "@/server/services/ausrollen"; import {
ausrollenVorbereiten,
ausrollSchritte,
kopfzeile,
type AusrollStand,
} from "@/server/services/ausrollen";
import { verbindungAus } from "@/server/services/server"; import { verbindungAus } from "@/server/services/server";
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
import { vmidReservieren } from "@/server/services/vmid"; import { vmidReservieren } from "@/server/services/vmid";
import { ablaufRegistrieren } from "../ausfuehren"; import { ablaufRegistrieren } from "../ausfuehren";
import { aufrufLogger } from "../aufruf-log"; import { aufrufLogger } from "../aufruf-log";
@@ -13,7 +17,7 @@ import { aufrufLogger } from "../aufruf-log";
ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, angelegt }) => { ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, angelegt }) => {
// Dieselbe Vorbereitung wie beim Anlegen – der Katalog könnte sich seitdem geändert haben. // Dieselbe Vorbereitung wie beim Anlegen – der Katalog könnte sich seitdem geändert haben.
const v = await ausrollenVorbereiten(auftrag.anfrage); const v = await ausrollenVorbereiten(auftrag.anfrage);
const stand: VmAusrollStand = {}; const stand: AusrollStand = {};
const kopfSetzen = async () => { const kopfSetzen = async () => {
await prisma.auftrag.update({ await prisma.auftrag.update({
where: { id: auftrag.id }, where: { id: auftrag.id },
@@ -26,12 +30,10 @@ ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, ange
const client = new ProxmoxClient(verbindungAus(v.server), { beobachter: aufrufLogger(senke) }); const client = new ProxmoxClient(verbindungAus(v.server), { beobachter: aufrufLogger(senke) });
try { try {
const ergebnis = await phasenAusfuehren( const ergebnis = await phasenAusfuehren(
vmAusrollSchritte( ausrollSchritte(
v,
{ {
client, client,
anfrage: v.anfrage,
distribution: v.distribution,
profile: v.profile,
vmidReservieren: async (belegt, wunsch) => { vmidReservieren: async (belegt, wunsch) => {
const vmid = await vmidReservieren({ const vmid = await vmidReservieren({
serverId: v.server.id, serverId: v.server.id,
@@ -44,20 +46,14 @@ ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, ange
await kopfSetzen(); await kopfSetzen();
return vmid; return vmid;
}, },
templateBau: v.anfrage.templateBauen templateVmidReservieren: (belegt) =>
? { vmidReservieren({
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten), serverId: v.server.id,
auftragKurzId: auftrag.kurzId, auftragId: auftrag.id,
vmidReservieren: (belegt) => belegt,
vmidReservieren({ von: v.server.templateVmidVon,
serverId: v.server.id, bis: v.server.templateVmidBis,
auftragId: auftrag.id, }),
belegt,
von: v.server.templateVmidVon,
bis: v.server.templateVmidBis,
}),
}
: undefined,
angelegt, angelegt,
auftragKurzId: auftrag.kurzId, auftragKurzId: auftrag.kurzId,
}, },
+21 -2
View File
@@ -3,10 +3,13 @@ import { templateBauSchritte, templateName, type TemplateBauStand } from "@/engi
import type { Distribution } from "@/engine/katalog/schema"; import type { Distribution } from "@/engine/katalog/schema";
import { ProxmoxClient } from "@/engine/proxmox/client"; import { ProxmoxClient } from "@/engine/proxmox/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import type { BauNetz } from "@/lib/schemas/server";
import { TemplateBauAnfrage } from "@/lib/schemas/template"; import { TemplateBauAnfrage } from "@/lib/schemas/template";
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
import { melden } from "@/server/ereignisse/senke"; import { melden } from "@/server/ereignisse/senke";
import { verbindungAus } from "@/server/services/server"; import { verbindungAus } from "@/server/services/server";
import { nodeBefehlFuer } from "@/server/services/ssh-zugang"; import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
import { templateProfileAufloesen } from "@/server/services/templates";
import { vmidReservieren } from "@/server/services/vmid"; import { vmidReservieren } from "@/server/services/vmid";
import { ablaufRegistrieren } from "../ausfuehren"; import { ablaufRegistrieren } from "../ausfuehren";
import { aufrufLogger } from "../aufruf-log"; import { aufrufLogger } from "../aufruf-log";
@@ -16,10 +19,15 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht,
const server = await prisma.server.findUniqueOrThrow({ where: { id: auftrag.serverId } }); const server = await prisma.server.findUniqueOrThrow({ where: { id: auftrag.serverId } });
const zeile = await prisma.distribution.findUniqueOrThrow({ where: { id: anfrage.distribution } }); const zeile = await prisma.distribution.findUniqueOrThrow({ where: { id: anfrage.distribution } });
const distribution = zeile.definition as Distribution; const distribution = zeile.definition as Distribution;
const profile = await templateProfileAufloesen(anfrage.profile, distribution.family);
await prisma.auftrag.update({ await prisma.auftrag.update({
where: { id: auftrag.id }, where: { id: auftrag.id },
data: { kopf: `Server ${server.name} · Node ${anfrage.knoten} · Template ${distribution.name}` }, data: {
kopf: `Server ${server.name} · Node ${anfrage.knoten} · Template ${distribution.name}${
anfrage.profile.length > 0 ? ` mit ${anfrage.profile.join(", ")}` : ""
}`,
},
}); });
await melden(auftrag.id); await melden(auftrag.id);
@@ -31,11 +39,14 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht,
{ {
client, client,
distribution, distribution,
profile,
knoten: anfrage.knoten, knoten: anfrage.knoten,
storage: anfrage.storage, storage: anfrage.storage,
bridge: anfrage.bridge, bridge: anfrage.bridge,
vlan: anfrage.vlan, vlan: anfrage.vlan,
ssh: nodeBefehlFuer(server, anfrage.knoten), ssh: nodeBefehlFuer(server, anfrage.knoten),
bauNetz: (server.bauNetz as BauNetz | null) ?? null,
bereitstellung: bereitstellungsschluessel,
auftragKurzId: auftrag.kurzId, auftragKurzId: auftrag.kurzId,
vmidReservieren: async (belegt) => { vmidReservieren: async (belegt) => {
const vmid = await vmidReservieren({ const vmid = await vmidReservieren({
@@ -65,12 +76,20 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht,
storage: anfrage.storage, storage: anfrage.storage,
imageDatei: stand.imageDatei!, imageDatei: stand.imageDatei!,
pruefsumme: stand.pruefsumme!, pruefsumme: stand.pruefsumme!,
profile: profile.map((p) => p.profil.id),
gebautVonAuftragId: auftrag.id, gebautVonAuftragId: auftrag.id,
}, },
}); });
return { return {
ok: true, ok: true,
ergebnis: { vmid: stand.vmid, template: templateName(distribution), knoten: anfrage.knoten }, ergebnis: {
vmid: stand.vmid,
template: templateName(
distribution,
profile.map((p) => p.profil.id),
),
knoten: anfrage.knoten,
},
}; };
} finally { } finally {
await client.schliessen(); await client.schliessen();
+49
View File
@@ -0,0 +1,49 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "@playwright/test";
import { anmelden, E2E_PRAEFIX, keinQuerscrollen } from "./helfer";
/** Je Lauf ein eigener Schlüssel – derselbe zweimal wäre für denselben Benutzer ein Duplikat. */
function neuerSchluessel(): string {
const ordner = mkdtempSync(join(tmpdir(), "pd-e2e-"));
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "e2e@test", "-f", join(ordner, "k")]);
return readFileSync(join(ordner, "k.pub"), "utf8").trim();
}
test("SSH-Schlüssel hinterlegen, im Assistenten vorausgewählt, wieder entfernen", async ({ page }) => {
const name = `${E2E_PRAEFIX}-${test.info().project.name}`;
await anmelden(page, "/einstellungen");
// Reste eines abgebrochenen Laufs wegräumen.
for (const alt of (await (await page.request.get("/api/v1/schluessel")).json()) as Array<{
id: string;
name: string;
}>) {
if (alt.name.startsWith(E2E_PRAEFIX)) await page.request.delete(`/api/v1/schluessel/${alt.id}`);
}
await page.reload();
await page.getByLabel("Name").fill(name);
await page.getByLabel("Öffentlicher Schlüssel").fill("-----BEGIN OPENSSH PRIVATE KEY-----");
await page.getByRole("button", { name: "Hinzufügen" }).click();
await expect(page.getByText(/nie den privaten Schlüssel/)).toBeVisible();
await page.getByLabel("Öffentlicher Schlüssel").fill(neuerSchluessel());
await page.getByRole("button", { name: "Hinzufügen" }).click();
await expect(page.getByText(name)).toBeVisible();
await keinQuerscrollen(page);
await page.goto("/ausrollen");
if (await page.getByText("Erst einen Server anlegen").isVisible())
test.skip(true, "Kein getesteter Server vorhanden");
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Weiter" }).click();
await page.getByLabel("Name (Hostname)").fill("pd-e2e");
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Weiter" }).click();
await expect(page.getByLabel(name)).toBeChecked();
// Mit hinterlegtem Schlüssel geht es ohne Eingabe weiter.
await expect(page.getByRole("button", { name: "Weiter" })).toBeEnabled();
await page.goto("/einstellungen");
page.once("dialog", (d) => void d.accept());
await page.getByRole("button", { name: `Schlüssel ${name} entfernen` }).click();
await expect(page.getByText(name)).toHaveCount(0);
});
+38
View File
@@ -0,0 +1,38 @@
import { expect, test } from "@playwright/test";
import { anmelden, E2E_PRAEFIX, keinQuerscrollen } from "./helfer";
test("Profil-Editor: Kopie eines Katalogprofils, Live-Prüfung, speichern, löschen", async ({ page }) => {
const id = `${E2E_PRAEFIX}-${test.info().project.name}`;
await anmelden(page, "/katalog/profile/docker");
await expect(page.getByText("Katalog (schreibgeschützt)")).toBeVisible();
await expect(page.getByLabel("Profil als YAML")).not.toBeEditable();
await page.getByRole("link", { name: "Als Kopie bearbeiten" }).click();
await expect(page).toHaveURL(/\/katalog\/profile\/neu\?von=docker/);
const editor = page.getByLabel("Profil als YAML");
await editor.fill(
(await editor.inputValue())
.replace("id: docker-eigen", `id: ${id}`)
.replace("- packages: [", "- pakete: ["),
);
await expect(page.getByText(/families\.debian\.1/)).toBeVisible();
await expect(page.getByRole("button", { name: "Speichern" })).toBeDisabled();
await editor.fill((await editor.inputValue()).replace("- pakete: [", "- packages: ["));
await expect(page.getByText("Gültig", { exact: true })).toBeVisible();
await page.getByText("Vorschau: erzeugtes Skript").click();
await expect(page.getByText(/docker-compose-plugin/).first()).toBeVisible();
await keinQuerscrollen(page);
await page.screenshot({
path: `test-results/profil-editor-${test.info().project.name}.png`,
fullPage: true,
});
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page).toHaveURL(new RegExp(`/katalog/profile/${id}$`));
await expect(page.getByText("eigenes Profil")).toBeVisible();
page.once("dialog", (d) => void d.accept());
await page.getByRole("button", { name: "Löschen" }).click();
await expect(page).toHaveURL(/\/katalog$/);
});
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test";
import { anmelden, E2E_PRAEFIX, keinQuerscrollen } from "./helfer";
/**
* Braucht einen Server mit Verbindungstest (der Assistent zeigt sonst nur einen Hinweis). Gibt es
* keinen, wird der Ablauf übersprungen und sagt das.
*/
test("Vorlage im Assistenten anwenden und als neue Vorlage speichern", async ({ page }) => {
const name = `${E2E_PRAEFIX}-${test.info().project.name}`;
await anmelden(page, "/vorlagen");
await expect(page.getByText("Docker-Host klein")).toBeVisible();
await keinQuerscrollen(page);
await page.getByRole("link", { name: "Ausrollen mit dieser Vorlage" }).first().click();
if (await page.getByText("Erst einen Server anlegen").isVisible())
test.skip(true, "Kein getesteter Server vorhanden");
await expect(page.getByLabel("Vorlage")).toHaveValue("docker-host-klein");
// Vorbelegung: Ressourcen aus der Vorlage
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Weiter" }).click();
await page.getByLabel("Name (Hostname)").fill("pd-vorlage");
await page.getByRole("button", { name: "Weiter" }).click();
await expect(page.getByLabel("RAM (MB)")).toHaveValue("4096");
await expect(page.getByLabel("Disk (GB)")).toHaveValue("40");
// Zur Zusammenfassung springen geht nicht ohne Schlüssel – Schritt 7 ausfüllen.
await page.getByRole("button", { name: /07 Zugang/ }).click();
await page
.getByLabel("Öffentliche SSH-Schlüssel")
.fill("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd e2e@test");
await page.getByRole("button", { name: "Weiter" }).click();
await expect(page.getByLabel("Docker CE")).toBeChecked();
await page.getByRole("button", { name: "Weiter" }).click();
await expect(page.getByText(/--preset docker-host-klein/)).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: "Als Vorlage speichern" }).click();
await page.getByLabel("Kurzname (für --preset)").fill(name);
await page.getByLabel("Titel").fill(`E2E ${test.info().project.name}`);
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page.getByText(/gespeichert/)).toBeVisible();
await page.goto("/vorlagen");
await expect(page.getByText(`--preset ${name}`)).toBeVisible();
page.once("dialog", (d) => void d.accept());
await page
.locator("li", { hasText: `--preset ${name}` })
.getByRole("button", { name: "Löschen" })
.click();
await expect(page.getByText(`--preset ${name}`)).toHaveCount(0);
});
+1 -1
View File
@@ -32,7 +32,7 @@ async function mitSzenario(szenario: Szenario) {
describe("Verbindungstest (Erkundung)", () => { describe("Verbindungstest (Erkundung)", () => {
it("PVE 9.2 Einzelhost: Nodes, Storages, Bridges, keine fehlenden Rechte, Template-Bau per API", async () => { it("PVE 9.2 Einzelhost: Nodes, Storages, Bridges, keine fehlenden Rechte, Template-Bau per API", async () => {
const { client } = await mitSzenario({ version: "9.2.1", importStorage: true }); const { client } = await mitSzenario({ version: "9.2.1", importStorage: true });
const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null }); const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null, bauNetz: true });
expect(bericht.version).toBe("9.2.1"); expect(bericht.version).toBe("9.2.1");
expect(bericht.cluster).toBeNull(); expect(bericht.cluster).toBeNull();
expect(bericht.knoten).toHaveLength(1); expect(bericht.knoten).toHaveLength(1);
+16 -6
View File
@@ -101,17 +101,27 @@ describe("Strategien", () => {
const mitImport = [storage("local", ["iso", "import", "snippets"])]; const mitImport = [storage("local", ["iso", "import", "snippets"])];
const ohneImport = [storage("local", ["iso", "snippets"])]; const ohneImport = [storage("local", ["iso", "snippets"])];
it("PVE 9.2 mit import-Storage: Template-Bau per API, auch ohne SSH", () => { it("PVE 9.2 mit import-Storage ohne SSH: Image ginge per API, der Guest-Agent aber nicht – außer mit Bau-Adresse", () => {
const f = faehigkeitenAbleiten({ const ohne = faehigkeitenAbleiten({
version: v("9.2.1"), version: v("9.2.1"),
storages: mitImport, storages: mitImport,
ssh: "aus", ssh: "aus",
rechteFehlen: false, rechteFehlen: false,
}); });
expect(f.templateBau.weg).toBe("api"); expect(ohne.templateBau.weg).toBe("nicht_moeglich");
expect(f.vmProvisionierung.weg).toBe("api"); expect(ohne.templateBau.grund).toMatch(/Bau-Adresse/);
expect(f.lxcProvisionierung).toMatchObject({ weg: "ssh", ueber: "gast" }); const mit = faehigkeitenAbleiten({
expect(f.snippets.weg).toBe("nicht_moeglich"); version: v("9.2.1"),
storages: mitImport,
ssh: "aus",
rechteFehlen: false,
bauNetz: true,
});
expect(mit.templateBau).toMatchObject({ weg: "api" });
expect(mit.templateBau.grund).toMatch(/Bau-Adresse/);
expect(mit.vmProvisionierung.weg).toBe("api");
expect(mit.lxcProvisionierung).toMatchObject({ weg: "ssh", ueber: "gast" });
expect(mit.snippets.weg).toBe("nicht_moeglich");
}); });
it("PVE 8.3: Template-Bau nur per SSH; ohne SSH mit Begründung unmöglich", () => { it("PVE 8.3: Template-Bau nur per SSH; ohne SSH mit Begründung unmöglich", () => {
+227
View File
@@ -0,0 +1,227 @@
import { afterEach, describe, expect, it } from "vitest";
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
import { lxcAusrollSchritte, lxcNetz, type LxcAusrollStand } from "@/engine/ablaeufe/lxc-ausrollen";
import type { NodeBefehl } from "@/engine/ablaeufe/template-bau";
import { katalogLaden } from "@/engine/katalog/laden";
import { profileAufloesen } from "@/engine/profile/aufloesen";
import { ProxmoxClient } from "@/engine/proxmox/client";
import { AusrollAnfrage } from "@/lib/schemas/ausrollen";
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
import { szenarioEinrichten } from "../proxmox-mock/szenario";
const UPID = (art: string) => `UPID:pve01:00001234:00ABCDEF:6710F00D:${art}:130:proxdeploy@pve!test:`;
const SCHLUESSEL =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@test";
const stumm: EreignisSenke = {
phasenAnlegen: async () => {},
phaseGestartet: async () => {},
phaseDetail: async () => {},
phaseBeendet: async () => {},
log: async () => {},
};
const BEREIT = { privat: "privat", oeffentlich: "ssh-ed25519 AAAAbereit proxdeploy-bereitstellung" };
const aufraeumen: Array<() => Promise<void>> = [];
afterEach(async () => {
while (aufraeumen.length) await aufraeumen.pop()!();
});
async function aufbauen(o: { templateLokal?: string[]; dhcpNachAbfragen?: number; trocken?: boolean } = {}) {
const mock = await new ProxmoxMock().starten();
szenarioEinrichten(mock, { version: "9.2.1" });
let interfaceAbfragen = 0;
mock
.route("GET", "/cluster/resources", () => ({
data: [{ type: "node", node: "pve01", status: "online", maxmem: 64e9, mem: 1e9 }, { vmid: 100 }],
}))
.route("GET", /^\/nodes\/pve01\/storage\/local\/content$/, () => ({
data: (o.templateLokal ?? []).map((t) => ({ volid: `local:vztmpl/${t}` })),
}))
.route("GET", "/nodes/pve01/aplinfo", () => ({
data: [
{ template: "debian-13-standard_13.1-2_amd64.tar.zst" },
{ template: "debian-13-standard_13.6-1_amd64.tar.zst" },
{ template: "ubuntu-24.04-standard_24.04-2_amd64.tar.zst" },
],
}))
.route("POST", "/nodes/pve01/aplinfo", () => ({ data: UPID("download") }))
.route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } }))
.route("POST", "/nodes/pve01/lxc", () => ({ data: UPID("vzcreate") }))
.route("POST", /^\/nodes\/pve01\/lxc\/\d+\/status\/start$/, () => ({ data: UPID("vzstart") }))
.route("GET", /^\/nodes\/pve01\/lxc\/\d+\/interfaces$/, () => ({
data: [
{ name: "lo", inet: "127.0.0.1/8" },
{
name: "eth0",
...(++interfaceAbfragen > (o.dhcpNachAbfragen ?? 0) ? { inet: "10.0.10.77/24" } : {}),
},
],
}));
const client = new ProxmoxClient(
{
url: mock.url,
tokenId: MOCK_TOKEN_ID,
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
tlsPruefen: false,
fingerabdruck: mock.fingerabdruck,
},
{ trocken: o.trocken },
);
aufraeumen.push(async () => {
await client.schliessen();
await mock.stoppen();
});
return { mock, client };
}
async function eingabe(
client: ProxmoxClient,
teil: Record<string, unknown> = {},
ssh: NodeBefehl | null = null,
) {
const katalog = await katalogLaden("catalog");
const anfrage = AusrollAnfrage.parse({
typ: "lxc",
server: "rz1",
distribution: "debian-13",
name: "ct01",
storage: "local-zfs",
diskGb: 8,
netz: { bridge: "vmbr0", modus: "dhcp" },
zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] },
profile: ["docker"],
...teil,
});
const distribution = katalog.distributionen.find((d) => d.eintrag.id === anfrage.distribution)!.eintrag;
const aufgeloest = profileAufloesen(
anfrage.profile,
new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])),
"debian",
);
if (!aufgeloest.ok) throw new Error("Profile");
const gast: Array<{ host: string; skript: string }> = [];
return {
gast,
e: {
client,
anfrage,
distribution,
profile: aufgeloest.profile,
profilFeatures: {
nesting: aufgeloest.profile.some((p) => p.profil.lxc.features?.nesting),
keyctl: false,
},
vmidReservieren: async () => 130,
sshFuer: () => ssh,
bereitstellung: async () => BEREIT,
angelegt: async () => {},
auftragKurzId: "d-ct",
warten: async () => {},
gastSsh: async (ziel: { host: string }, skript: string, beiAusgabe: (t: string) => void) => {
gast.push({ host: ziel.host, skript });
beiAusgabe("::proxdeploy:: profil base\n::proxdeploy:: fertig\n");
return 0;
},
},
};
}
describe("LXC ausrollen", () => {
it("mit Node-SSH: neuestes Template laden, anlegen mit nesting (docker), DHCP-IP, pct exec mit Skript auf stdin", async () => {
const { mock, client } = await aufbauen({ dhcpNachAbfragen: 2 });
const befehle: Array<{ befehl: string; eingabe?: string }> = [];
const ssh: NodeBefehl = async (befehl, o) => {
befehle.push({ befehl, eingabe: o?.eingabe });
o?.beiAusgabe?.("::proxdeploy:: profil base\n");
return { code: 0, stdout: "", stderr: "" };
};
const { e } = await eingabe(client, {}, ssh);
const stand: LxcAusrollStand = {};
expect(await phasenAusfuehren(lxcAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
expect(stand).toMatchObject({
weg: "node",
vmid: 130,
ip: "10.0.10.77",
ostemplate: "local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst",
});
expect(
Object.fromEntries(
mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/aplinfo")!.rumpf,
),
).toEqual({
storage: "local",
template: "debian-13-standard_13.6-1_amd64.tar.zst",
});
const anlegen = Object.fromEntries(
mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/lxc")!.rumpf,
);
expect(anlegen).toMatchObject({
vmid: "130",
hostname: "ct01",
rootfs: "local-zfs:8",
unprivileged: "1",
features: "nesting=1",
net0: "name=eth0,bridge=vmbr0,ip=dhcp,type=veth",
ostype: "debian",
});
expect(anlegen).not.toHaveProperty("ssh-public-keys");
expect(befehle).toHaveLength(1);
expect(befehle[0]!.befehl).toBe("pct exec 130 -- bash -s");
expect(befehle[0]!.eingabe).toContain("useradd -m -s /bin/bash florian");
// Zeitsync nur in VMs.
expect(befehle[0]!.eingabe).not.toContain("chrony");
});
it("vorhandenes neuestes Template wird nicht erneut geladen", async () => {
const { mock, client } = await aufbauen({ templateLokal: ["debian-13-standard_13.6-1_amd64.tar.zst"] });
const { e } = await eingabe(client, {}, async () => ({ code: 0, stdout: "", stderr: "" }));
await phasenAusfuehren(lxcAusrollSchritte(e), stumm);
expect(mock.anfragen.some((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/aplinfo")).toBe(false);
});
it("ohne Node-SSH: Bereitstellungsschlüssel an root, SSH in den Container, Schlüssel am Ende entfernen", async () => {
const { mock, client } = await aufbauen();
const { e, gast } = await eingabe(client, {
netz: { bridge: "vmbr0", modus: "statisch", ip: "10.0.20.5/24", gateway: "10.0.20.1" },
});
const stand: LxcAusrollStand = {};
expect(await phasenAusfuehren(lxcAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
expect(stand.weg).toBe("gast");
const anlegen = Object.fromEntries(
mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/lxc")!.rumpf,
);
expect(anlegen["ssh-public-keys"]).toBe(BEREIT.oeffentlich);
expect(anlegen["net0"]).toBe("name=eth0,bridge=vmbr0,ip=10.0.20.5/24,gw=10.0.20.1,type=veth");
expect(gast[0]!.host).toBe("10.0.20.5");
expect(
gast[0]!.skript.trimEnd().endsWith("sed -i '/proxdeploy-bereitstellung/d' /root/.ssh/authorized_keys"),
).toBe(true);
});
it("keyctl ohne Node-SSH wird vor dem Anlegen abgelehnt", async () => {
const { mock, client } = await aufbauen();
const { e } = await eingabe(client, { features: { nesting: true, keyctl: true } });
const ergebnis = await phasenAusfuehren(lxcAusrollSchritte(e), stumm);
expect(ergebnis.ok || ergebnis.fehler.ursache).toMatch(/keyctl/);
expect(mock.anfragen.some((a) => a.pfad === "/nodes/pve01/lxc")).toBe(false);
});
it("Netzkonfiguration mit VLAN", () => {
expect(lxcNetz({ bridge: "vmbr1", vlan: 30, modus: "dhcp", dns: [] })).toBe(
"name=eth0,bridge=vmbr1,tag=30,ip=dhcp,type=veth",
);
});
it("Trockenlauf: Plan ohne Schreibzugriffe", async () => {
const { mock, client } = await aufbauen({ trocken: true });
const { e } = await eingabe(client, {}, async () => ({ code: 0, stdout: "", stderr: "" }));
expect(await phasenAusfuehren(lxcAusrollSchritte(e), stumm)).toEqual({ ok: true });
expect(mock.anfragen.filter((a) => a.methode !== "GET")).toEqual([]);
expect(client.geplant.map((a) => `${a.methode} ${a.pfad}`)).toEqual([
"POST /nodes/pve01/aplinfo",
"POST /nodes/pve01/lxc",
"POST /nodes/pve01/lxc/130/status/start",
]);
});
});
+131
View File
@@ -300,3 +300,134 @@ describe("Template-Bau", () => {
]); ]);
}); });
}); });
describe("Profile einbacken (#022)", () => {
it("startet die VM, provisioniert über den Agent, räumt auf, fährt herunter – Name und Tags nennen die Profile", async () => {
const { mock, client } = await aufbauen({ version: "9.2.1" });
const dateien: string[] = [];
let exec = 0;
mock
.route("POST", /\/agent\/ping$/, () => ({ data: { result: {} } }))
.route("POST", /\/agent\/file-write$/, (a) => {
dateien.push(Buffer.from(a.rumpf.get("content")!, "base64").toString("utf8"));
return { data: null };
})
.route("POST", /\/agent\/exec$/, () => ({ data: { pid: ++exec } }))
.route("GET", /\/agent\/exec-status$/, () => ({ data: { exited: 1, exitcode: 0 } }))
.route("GET", /\/agent\/file-read$/, () => ({ data: { content: "" } }))
.route("POST", /\/status\/shutdown$/, () => ({ data: UPID("qmshutdown") }));
const katalog = await katalogLaden("catalog");
const { profileAufloesen } = await import("@/engine/profile/aufloesen");
const aufgeloest = profileAufloesen(
["docker"],
new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])),
"debian",
);
if (!aufgeloest.ok) throw new Error("Profile");
const ergebnis = await phasenAusfuehren(
templateBauSchritte({
client,
distribution: await debian13(),
profile: aufgeloest.profile,
knoten: "pve01",
storage: "local-zfs",
bridge: "vmbr0",
ssh: sshAufzeichnung().ssh,
auftragKurzId: "t",
holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`,
vmidReservieren: async () => 9002,
warten: async () => {},
}),
stumm,
);
expect(ergebnis).toEqual({ ok: true });
const anlegen = Object.fromEntries(
mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/qemu")!.rumpf,
);
expect(anlegen).toMatchObject({
name: "tpl-debian-13-docker",
tags: "proxdeploy-template;debian-13;profil-base;profil-docker",
});
expect(dateien.join("")).toContain("docker-compose-plugin");
expect(dateien.join("")).toContain("truncate -s 0 /etc/machine-id");
expect(dateien.join("")).toContain("rm -f /etc/ssh/ssh_host_*");
const reihenfolge = schreibend(mock.anfragen).filter((z) => /status\/(start|shutdown)|template$/.test(z));
expect(reihenfolge).toEqual([
"POST /nodes/pve01/qemu/9002/status/start",
"POST /nodes/pve01/qemu/9002/status/start",
"POST /nodes/pve01/qemu/9002/status/shutdown",
"POST /nodes/pve01/qemu/9002/template",
]);
});
});
describe("Guest-Agent über die Bau-Adresse (#021)", () => {
it("ohne Node-SSH: feste Adresse und Bereitstellungsschlüssel per cloud-init, SSH in die VM, danach zurückgesetzt", async () => {
const { mock, client } = await aufbauen({ version: "9.2.1" });
mock.route("POST", /\/status\/shutdown$/, () => ({ data: UPID("qmshutdown") }));
const gast: Array<{ host: string; benutzer: string; skript: string }> = [];
const ergebnis = await phasenAusfuehren(
templateBauSchritte({
client,
distribution: await debian13(),
knoten: "pve01",
storage: "local-zfs",
bridge: "vmbr0",
ssh: null,
auftragKurzId: "t",
holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`,
vmidReservieren: async () => 9000,
warten: async () => {},
bauNetz: { ip: "10.0.10.250/24", gateway: "10.0.10.1", dns: ["10.0.10.1"] },
bereitstellung: async () => ({
privat: "p",
oeffentlich: "ssh-ed25519 AAAAbau proxdeploy-bereitstellung",
}),
gastSsh: async (ziel, skript) => {
gast.push({ host: ziel.host, benutzer: ziel.benutzer, skript });
return 0;
},
}),
stumm,
);
expect(ergebnis).toEqual({ ok: true });
expect(gast).toEqual([
{
host: "10.0.10.250",
benutzer: "debian",
skript: expect.stringContaining("apt-get -y -q -o DPkg::Lock::Timeout=600 install qemu-guest-agent"),
},
]);
expect(gast[0]!.skript).toContain("rm -f /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys");
const configs = mock.anfragen.filter((a) => a.methode === "PUT").map((a) => Object.fromEntries(a.rumpf));
expect(configs[0]).toMatchObject({
ciuser: "debian",
ipconfig0: "ip=10.0.10.250/24,gw=10.0.10.1",
nameserver: "10.0.10.1",
});
expect(configs[0]!["sshkeys"]).toBe("ssh-ed25519%20AAAAbau%20proxdeploy-bereitstellung");
expect(configs.at(-1)).toEqual({ ipconfig0: "ip=dhcp", delete: "sshkeys,ciuser,nameserver" });
// Kein Snippet ohne Node-SSH.
expect(mock.anfragen.some((a) => a.rumpf.get("cicustom"))).toBe(false);
});
it("ohne Node-SSH und ohne Bau-Adresse sagt schon die Prüfung, was fehlt", async () => {
const { client } = await aufbauen({ version: "9.2.1" });
const ergebnis = await phasenAusfuehren(
templateBauSchritte({
client,
distribution: await debian13(),
knoten: "pve01",
storage: "local-zfs",
bridge: "vmbr0",
ssh: null,
auftragKurzId: "t",
holen: async () => "",
vmidReservieren: async () => 9000,
}),
stumm,
);
expect(ergebnis).toMatchObject({ ok: false, phase: "pruefungen" });
if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/Bau-Adresse/);
});
});
+51
View File
@@ -65,6 +65,9 @@ async function aufbauen(o: MockOptionen = {}) {
: { data: UPID("qmclone") }, : { data: UPID("qmclone") },
) )
.route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } })) .route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } }))
.route("GET", /\/tasks\/[^/]+\/log$/, () => ({
data: [{ n: 1, t: "transferred 1.2 GiB of 3.5 GiB (35.16%)" }],
}))
.route("PUT", /^\/nodes\/pve01\/qemu\/\d+\/(config|resize)$/, () => ({ data: null })) .route("PUT", /^\/nodes\/pve01\/qemu\/\d+\/(config|resize)$/, () => ({ data: null }))
.route("GET", /^\/nodes\/pve01\/qemu\/\d+\/config$/, () => ({ .route("GET", /^\/nodes\/pve01\/qemu\/\d+\/config$/, () => ({
data: { scsi0: "local-zfs:vm-101-disk-1,size=3G" }, data: { scsi0: "local-zfs:vm-101-disk-1,size=3G" },
@@ -144,6 +147,7 @@ async function eingabe(client: ProxmoxClient, anfrageTeil: Record<string, unknow
anfrage.profile, anfrage.profile,
new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])), new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])),
"debian", "debian",
anfrage.variablen,
); );
if (!aufgeloest.ok) throw new Error("Profile"); if (!aufgeloest.ok) throw new Error("Profile");
const angelegt: Array<Record<string, unknown>> = []; const angelegt: Array<Record<string, unknown>> = [];
@@ -289,3 +293,50 @@ describe("VM ausrollen", () => {
]); ]);
}); });
}); });
describe("Templates mit eingebackenen Profilen (#022)", () => {
async function mitTemplates(templates: Array<{ vmid: number; tags: string }>) {
const { mock, client, dateien } = await aufbauen();
mock.route("GET", "/cluster/resources", () => ({
data: [
{ type: "node", node: "pve01", status: "online", maxmem: 64e9, mem: 8e9 },
...templates.map((t) => ({ type: "qemu", vmid: t.vmid, node: "pve01", template: 1, tags: t.tags })),
],
}));
mock.route("POST", /^\/nodes\/pve01\/qemu\/\d+\/clone$/, () => ({ data: UPID("qmclone") }));
return { mock, client, dateien };
}
it("wählt das Template mit den meisten passenden Profilen, nie eins mit ungewählten", async () => {
const { client } = await mitTemplates([
{ vmid: 9000, tags: "proxdeploy-template;debian-13" },
{ vmid: 9002, tags: "proxdeploy-template;debian-13;profil-base;profil-docker" },
{ vmid: 9003, tags: "proxdeploy-template;debian-13;profil-base;profil-docker;profil-nginx" },
]);
const { e } = await eingabe(client);
const stand: VmAusrollStand = {};
expect(await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
expect(stand.template).toEqual({ vmid: 9002, knoten: "pve01", eingebacken: ["base", "docker"] });
});
it("überspringt gastunabhängige eingebackene Profile; docker (nutzt guest.user) läuft noch einmal", async () => {
const { client, dateien } = await mitTemplates([
{ vmid: 9002, tags: "proxdeploy-template;debian-13;profil-base;profil-docker" },
]);
const { e } = await eingabe(client);
await phasenAusfuehren(vmAusrollSchritte(e), stumm);
const skript = [...dateien.values()].join("");
expect(skript).not.toContain("::proxdeploy:: profil base");
expect(skript).toContain("::proxdeploy:: profil docker");
expect(skript).toContain("usermod -aG docker florian");
});
it("weicht eine Variable vom Standard ab, läuft auch ein eingebackenes Profil", async () => {
const { client, dateien } = await mitTemplates([
{ vmid: 9002, tags: "proxdeploy-template;debian-13;profil-base" },
]);
const { e } = await eingabe(client, { profile: ["base"], variablen: { base: { zeitzone: "UTC" } } });
await phasenAusfuehren(vmAusrollSchritte(e), stumm);
expect([...dateien.values()].join("")).toContain("::proxdeploy:: profil base");
});
});
+157
View File
@@ -0,0 +1,157 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import * as liste from "@/app/api/v1/schluessel/route";
import * as einzeln from "@/app/api/v1/schluessel/[id]/route";
import { verschluesseln } from "@/lib/geheimnisse";
import { schluesselErgaenzen, ausrollenVorbereiten } from "@/server/services/ausrollen";
import { katalogSynchronisieren } from "@/server/services/katalog";
import { fingerabdruck } from "@/server/schluessel/quelle";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
function neuerSchluessel(kommentar: string): string {
const ordner = mkdtempSync(join(tmpdir(), "pd-key-"));
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", kommentar, "-f", join(ordner, "k")]);
return readFileSync(join(ordner, "k.pub"), "utf8").trim();
}
describe("Fingerprint", () => {
it("entspricht ssh-keygen -lf", () => {
const ordner = mkdtempSync(join(tmpdir(), "pd-key-"));
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", join(ordner, "k")]);
const erwartet = execFileSync("ssh-keygen", ["-lf", join(ordner, "k.pub")])
.toString()
.split(" ")[1];
expect(fingerabdruck(readFileSync(join(ordner, "k.pub"), "utf8"))).toBe(erwartet);
});
});
describe.runIf(datenbankVerfuegbar)("SSH-Schlüssel", () => {
let admin: { cookie: string; benutzer: { id: string; benutzername: string } };
let operator: { cookie: string; benutzer: { id: string; benutzername: string } };
beforeEach(async () => {
await datenbankLeeren();
admin = await angemeldet("ADMIN");
operator = await angemeldet("OPERATOR");
});
const anlegen = async (cookie: string, rumpf: unknown) =>
await liste.POST(anfrage("POST", "/api/v1/schluessel", cookie, rumpf));
it("eigene Schlüssel für alle, Team-Schlüssel nur für Admins; jeder sieht eigene + Team", async () => {
expect(
(await anlegen(operator.cookie, { name: "Laptop", oeffentlich: neuerSchluessel("op") })).status,
).toBe(201);
expect(
(await anlegen(operator.cookie, { name: "Team?", oeffentlich: neuerSchluessel("x"), team: true }))
.status,
).toBe(403);
expect(
(await anlegen(admin.cookie, { name: "Notfall", oeffentlich: neuerSchluessel("team"), team: true }))
.status,
).toBe(201);
expect(
(await anlegen(admin.cookie, { name: "Admin", oeffentlich: neuerSchluessel("admin") })).status,
).toBe(201);
const sicht = await (await liste.GET(anfrage("GET", "/api/v1/schluessel", operator.cookie))).json();
expect(sicht.map((s: { name: string; bereich: string }) => `${s.name}:${s.bereich}`).sort()).toEqual([
"Laptop:persoenlich",
"Notfall:team",
]);
});
it("weist private Schlüssel und Doppelte ab", async () => {
const privat = await anlegen(operator.cookie, {
name: "x",
oeffentlich: "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----",
});
expect(privat.status).toBe(400);
expect((await privat.json()).fehler.felder.oeffentlich).toMatch(/nie den privaten/);
const schluessel = neuerSchluessel("doppelt");
await anlegen(operator.cookie, { name: "a", oeffentlich: schluessel });
expect((await anlegen(operator.cookie, { name: "b", oeffentlich: schluessel })).status).toBe(409);
});
it("fremde Schlüssel darf niemand löschen, Team-Schlüssel nur Admins", async () => {
const eigener = await (
await anlegen(admin.cookie, { name: "Admin", oeffentlich: neuerSchluessel("a") })
).json();
const team = await (
await anlegen(admin.cookie, { name: "Team", oeffentlich: neuerSchluessel("t"), team: true })
).json();
const loeschen = (id: string, cookie: string) =>
einzeln.DELETE(anfrage("DELETE", `/api/v1/schluessel/${id}`, cookie), kontext({ id }));
expect((await loeschen(eigener.id, operator.cookie)).status).toBe(403);
expect((await loeschen(team.id, operator.cookie)).status).toBe(403);
expect((await loeschen(team.id, admin.cookie)).status).toBe(204);
});
it("Ausrollen ohne Schlüssel nimmt die hinterlegten (eigene + Team); angegebene gewinnen", async () => {
await katalogSynchronisieren();
await testPrisma.server.create({
data: {
name: "rz1",
titel: "RZ 1",
url: "https://127.0.0.1:8006",
tokenId: "a@pve!b",
tokenGeheimnis: verschluesseln("x"),
fingerabdruck: "AA",
},
});
const eigen = neuerSchluessel("eigen");
const team = neuerSchluessel("team");
await anlegen(operator.cookie, { name: "eigen", oeffentlich: eigen });
await anlegen(admin.cookie, { name: "team", oeffentlich: team, team: true });
const roh = {
typ: "vm",
server: "rz1",
distribution: "debian-13",
name: "web01",
storage: "s",
netz: { bridge: "vmbr0" },
zugang: { benutzer: "florian" },
};
const aufrufer = { benutzer: { ...operator.benutzer } as never, quelle: "CLI" as const };
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
expect(v.anfrage.zugang.sshSchluessel.sort()).toEqual([eigen, team].sort());
const anders = neuerSchluessel("anders");
const nurAngegeben = await schluesselErgaenzen(
{ ...roh, zugang: { benutzer: "florian", sshSchluessel: [anders] } },
aufrufer,
);
expect((nurAngegeben as { zugang: { sshSchluessel: string[] } }).zugang.sshSchluessel).toEqual([anders]);
});
it("ohne hinterlegte und ohne angegebene Schlüssel: verständlicher Fehler", async () => {
await katalogSynchronisieren();
await testPrisma.server.create({
data: {
name: "rz1",
titel: "RZ 1",
url: "https://127.0.0.1:8006",
tokenId: "a@pve!b",
tokenGeheimnis: verschluesseln("x"),
fingerabdruck: "AA",
},
});
const roh = {
typ: "vm",
server: "rz1",
distribution: "debian-13",
name: "web01",
storage: "s",
netz: { bridge: "vmbr0" },
zugang: { benutzer: "florian" },
};
await expect(
ausrollenVorbereiten(
await schluesselErgaenzen(roh, { benutzer: operator.benutzer as never, quelle: "CLI" }),
),
).rejects.toThrow();
});
});
+130
View File
@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it } from "vitest";
import * as liste from "@/app/api/v1/vorlagen/route";
import * as einzeln from "@/app/api/v1/vorlagen/[name]/route";
import { verschluesseln } from "@/lib/geheimnisse";
import { vorlageAnwenden } from "@/lib/schemas/vorlage";
import { ausrollenVorbereiten } from "@/server/services/ausrollen";
import { katalogSynchronisieren } from "@/server/services/katalog";
import { beispielVorlageAnlegen } from "@/server/services/vorlagen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
const SCHLUESSEL =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@test";
describe("Vorlage anwenden", () => {
it("Anfrage gewinnt; netz und variablen werden feldweise gemischt, Listen ersetzt", () => {
const ergebnis = vorlageAnwenden(
{
cores: 2,
speicherMb: 4096,
profile: ["base", "docker"],
netz: { bridge: "vmbr1", vlan: 20 },
variablen: { "zabbix-agent": { server: "zbx" } },
},
{
cores: 4,
netz: { modus: "dhcp" },
profile: ["base"],
variablen: { "zabbix-agent": { server_active: "zbx2" } },
name: undefined,
},
);
expect(ergebnis).toEqual({
cores: 4,
speicherMb: 4096,
profile: ["base"],
netz: { bridge: "vmbr1", vlan: 20, modus: "dhcp" },
variablen: { "zabbix-agent": { server: "zbx", server_active: "zbx2" } },
});
});
});
describe.runIf(datenbankVerfuegbar)("Vorlagen", () => {
beforeEach(async () => {
await datenbankLeeren();
await katalogSynchronisieren();
});
it("Beispiel „Docker-Host klein“ wird einmal angelegt und kommt nach dem Löschen nicht wieder", async () => {
await beispielVorlageAnlegen();
expect(
(await testPrisma.vorlage.findUniqueOrThrow({ where: { name: "docker-host-klein" } })).parameter,
).toMatchObject({
distribution: "debian-13",
cores: 2,
speicherMb: 4096,
profile: ["base", "docker"],
});
await testPrisma.vorlage.delete({ where: { name: "docker-host-klein" } });
await beispielVorlageAnlegen();
expect(await testPrisma.vorlage.count()).toBe(0);
});
it("CRUD über die API; Operator darf pflegen; Name, IP und Schlüssel gehören nicht hinein", async () => {
const operator = (await angemeldet("OPERATOR")).cookie;
const neu = {
name: "Web-Klein",
titel: "Web klein",
parameter: { typ: "vm", distribution: "debian-13", cores: 1 },
};
const angelegt = await liste.POST(anfrage("POST", "/api/v1/vorlagen", operator, neu));
expect(angelegt.status).toBe(201);
expect((await angelegt.json()).name).toBe("web-klein");
expect((await liste.POST(anfrage("POST", "/api/v1/vorlagen", operator, neu))).status).toBe(409);
const mitName = await liste.POST(
anfrage("POST", "/api/v1/vorlagen", operator, { ...neu, name: "x", parameter: { name: "web01" } }),
);
expect(mitName.status).toBe(400);
const geaendert = await einzeln.PUT(
anfrage("PUT", "/api/v1/vorlagen/web-klein", operator, {
...neu,
titel: "Web, klein",
parameter: { cores: 2 },
}),
kontext({ name: "web-klein" }),
);
expect((await geaendert.json()).parameter).toEqual({ cores: 2 });
expect(
(
await einzeln.DELETE(
anfrage("DELETE", "/api/v1/vorlagen/web-klein", operator),
kontext({ name: "web-klein" }),
)
).status,
).toBe(204);
expect(await testPrisma.protokoll.count({ where: { ziel: "vorlage:web-klein" } })).toBe(3);
});
it("Ausrollen mit vorlage: Werte der Vorlage, Anfrage gewinnt, Name im CLI-Befehl", async () => {
await beispielVorlageAnlegen();
await testPrisma.server.create({
data: {
name: "rz1",
titel: "RZ 1",
url: "https://127.0.0.1:8006",
tokenId: "a@pve!b",
tokenGeheimnis: verschluesseln("x"),
fingerabdruck: "AA",
},
});
const v = await ausrollenVorbereiten({
vorlage: "docker-host-klein",
server: "rz1",
name: "web01",
storage: "local-zfs",
speicherMb: 8192,
netz: { bridge: "vmbr0" },
zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] },
});
expect(v.anfrage).toMatchObject({
typ: "vm",
distribution: "debian-13",
cores: 2,
speicherMb: 8192,
diskGb: 40,
vorlage: "docker-host-klein",
});
expect(v.profile.map((p) => p.profil.id)).toEqual(["base", "docker"]);
});
});