Compare commits
9
Commits
v0.2.0
...
1e195f9bca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e195f9bca | ||
|
|
8bfa89fb3b | ||
|
|
21edff2141 | ||
|
|
426d3ec95a | ||
|
|
00712dc6ce | ||
|
|
ebcf63100a | ||
|
|
1b1ee4b876 | ||
|
|
e4306a6a53 | ||
|
|
d25ec54ef9 |
@@ -17,6 +17,23 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Sicherheit
|
||||
|
||||
## [0.3.0] – 2026-10-12
|
||||
|
||||
### Hinzugefügt
|
||||
- Template-Bau ohne SSH zum Node: über eine Bau-Adresse installiert ProxDeploy den Guest-Agent per SSH in der Vorbereitungs-VM (#021)
|
||||
- SSH-Schlüssel in den Einstellungen hinterlegen (persönlich und, für Admins, Team-Schlüssel); sie kommen automatisch in jeden Gast – auch per API/CLI ohne Angabe – und sind im Assistenten abwählbar (#015)
|
||||
- Templates mit eingebackenen Profilen: beim Template-Bau Profile wählen; Ausrollen nimmt das passende Template und überspringt, was schon drin ist (#022)
|
||||
- Profil-Editor: eigene Profile als YAML mit Live-Prüfung und Skriptvorschau, Katalogprofile als Kopie bearbeiten (#011)
|
||||
- Vorlagen (Presets): im Assistenten wählen und speichern, Vorlagenseite, `vorlage` in der API; Beispiel „Docker-Host klein“ (#012)
|
||||
- Ubuntu 22.04/24.04/26.04 als VM und LXC; Fortschritt beim Klonen aus dem Task-Log (#010)
|
||||
- LXC-Container ausrollen: Container-Template prüfen und laden, anlegen (unprivilegiert, nesting für docker), Netz, Provisionierung per `pct exec` über Node-SSH oder per SSH in den Container mit einem Bereitstellungsschlüssel (#009)
|
||||
- Profile kennen die festen Bedingungen `if: vm` und `if: lxc` (#009)
|
||||
|
||||
### Behoben
|
||||
- Verbindungstest meldete „Template-Bau per API“ auch dann, wenn der Guest-Agent mangels SSH gar nicht ins Template käme (#021)
|
||||
- chrony wird in Containern nicht mehr installiert – ohne CAP_SYS_TIME scheiterte dort das Profil base (#009)
|
||||
- SSH-Härtung auf Ubuntu 24.04 scheiterte an fehlendem /run/sshd (#009)
|
||||
|
||||
## [0.2.0] – 2026-10-12
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 2 h 50 min (Sitzung 1)
|
||||
**Gesamt:** 4 h 38 min (Sitzung 1 und 2)
|
||||
|
||||
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
||||
|
||||
@@ -16,11 +16,43 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
||||
| #006 Aufträge und Live-Fortschritt | 0 h 15 min |
|
||||
| #007 Template-Bau | 0 h 12 min |
|
||||
| #008 VM ausrollen und Assistent | 0 h 36 min |
|
||||
| #009 LXC | 0 h 31 min |
|
||||
| #010 Ubuntu | 0 h 05 min |
|
||||
| #011 Profil-Editor | 0 h 10 min |
|
||||
| #012 Vorlagen | 0 h 11 min |
|
||||
| #022 Profile im Template | 0 h 22 min |
|
||||
| #015 (Teil) SSH-Schlüssel | 0 h 10 min |
|
||||
| #021 Template-Bau ohne SSH | 0 h 19 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
| v0.1.0 | 1 h 15 min | **abgeschlossen**
|
||||
| v0.2.0 | 1 h 32 min | **abgeschlossen**
|
||||
| v0.3.0 | 1 h 48 min | **abgeschlossen** (Abnahme #021 offen)
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-12 · Sitzung 2 · 12:48–laufend
|
||||
**Issue:** #009–#012, #015 (Teil), #021, #022
|
||||
**Version:** 0.3.0-dev
|
||||
|
||||
### Erledigt
|
||||
- #009: LXC per pct exec (pd-ct01, Debian 13, docker im unprivilegierten CT) und per SSH in den Container (pd-ct02, Ubuntu 24.04) auf pve-demo
|
||||
- #010: Ubuntu 24.04 als VM (pd-ubu01, Template 9001 im selben Auftrag gebaut)
|
||||
- #011: Profil-Editor mit Live-Prüfung und Skriptvorschau
|
||||
- #012: Vorlagen inkl. „Docker-Host klein“, im Assistenten und per API
|
||||
- #022 (Wunsch Florian): Profile ins Template einbacken – Template 9002 tpl-debian-13-docker; Provisionierung 141 s → ~30 s
|
||||
- #015 vorgezogen: SSH-Schlüssel hinterlegen (persönlich/Team), automatisch in jedem Gast
|
||||
- #021: Template-Bau ohne SSH über Bau-Adresse (Mock-getestet, Abnahme auf pve-demo braucht eine freie IP)
|
||||
- Florians Fragen beantwortet: was am Template anpassbar ist; Schlüssel/Anwendungen ins Template
|
||||
- 253 Tests, 18 Playwright-Abläufe; main gepusht
|
||||
|
||||
### Entscheidungen
|
||||
- **chrony nur in VMs** (`if: vm`): unprivilegierte Container dürfen die Uhr nicht stellen.
|
||||
- **Profile mit `{{ guest.* }}` laufen beim Deploy erneut**, auch wenn eingebacken – sonst fehlt z. B. der Benutzer in der docker-Gruppe (gefunden mit pd-test03).
|
||||
- **SSH-Schlüssel nicht ins Template**, sondern in die Verwaltung (`SchluesselQuelle`) – beim Schlüsselwechsel bleibt es eine Stelle.
|
||||
- **Fähigkeitserkennung korrigiert:** Template-Bau per API nur, wenn der Guest-Agent auch hineinkommt.
|
||||
- **Voll-Klon bleibt Standard**; der Testhost kopiert zurzeit nur ~3–8 MB/s (Klon 8 min) – Linked Clones als spätere Option notiert.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ In Entwicklung. Version und Fortschritt stehen im [CHANGELOG](CHANGELOG.md), der
|
||||
|---|---|---|
|
||||
| 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft |
|
||||
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt, Assistent | ✓ fertig, auf PVE 9.2 geprüft |
|
||||
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen | ◐ als Nächstes |
|
||||
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ○ geplant |
|
||||
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen, Profile im Template, SSH-Schlüssel | ✓ fertig, auf PVE 9.2 geprüft (Bau-Adresse #021: Abnahme offen) |
|
||||
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ◐ als Nächstes |
|
||||
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ geplant |
|
||||
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Grundausstattung für jeden Gast. Platzhalter: {{ var.<name> }} aus den Variablen unten,
|
||||
# {{ os.id }}, {{ os.version_id }}, {{ os.codename }} aus der Distribution,
|
||||
# {{ guest.name }}, {{ guest.user }} aus dem Deployment. `a ?? b` nimmt b, wenn a leer ist.
|
||||
# Bedingungen: `if: <variable>`, dazu fest `if: vm` / `if: lxc`.
|
||||
id: base
|
||||
name: Basis
|
||||
description: Updates, Guest-Agent, Zeitzone, Locale, Tastatur, automatische Sicherheitsupdates, Zeitsync, SSH-Härtung
|
||||
@@ -16,8 +17,12 @@ families:
|
||||
- timezone: "{{ var.zeitzone }}"
|
||||
- locale: "{{ var.locale }}"
|
||||
- keyboard: "{{ var.tastatur }}"
|
||||
- packages: [chrony, sudo, curl, ca-certificates]
|
||||
- packages: [sudo, curl, ca-certificates]
|
||||
# Zeitsync nur in VMs: ein unprivilegierter Container darf die Uhr nicht stellen, er nimmt die des Hosts.
|
||||
- packages: [chrony]
|
||||
if: vm
|
||||
- service: { name: chrony, enable: true, start: true }
|
||||
if: vm
|
||||
- unattended_upgrades: true
|
||||
- ssh_harden: { password_auth: false, root_login: false }
|
||||
- packages: [fail2ban]
|
||||
|
||||
+11
-6
@@ -1,21 +1,26 @@
|
||||
# #009 LXC-Container ausrollen
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature, proxmox
|
||||
**Geschätzt:** 8 h **Tatsächlich:** –
|
||||
**Geschätzt:** 8 h **Tatsächlich:** 0 h 30 min
|
||||
|
||||
## Ziel
|
||||
LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH)
|
||||
- [ ] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen)
|
||||
- [x] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH)
|
||||
- [x] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] LXC mit base auf echtem Proxmox, einmal mit und einmal ohne Node-SSH
|
||||
- [x] Auf pve-demo: pd-ct01 (Debian 13, base+docker, pct exec, docker hello-world im unprivilegierten CT mit nesting) und pd-ct02 (Ubuntu 24.04, base, SSH in den Container, Bereitstellungsschlüssel danach entfernt)
|
||||
|
||||
## Abhängigkeiten
|
||||
#008
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- **Fund:** chrony läuft in unprivilegierten Containern nicht (keine CAP_SYS_TIME). Profile kennen jetzt die festen Bedingungen `if: vm` / `if: lxc`; base installiert chrony nur in VMs.
|
||||
- **Fund:** Ubuntu 24.04 startet sshd per Socket, `/run/sshd` fehlt bis zur ersten Verbindung – `sshd -t` scheiterte. Der Adapter legt das Verzeichnis vorher an.
|
||||
- **Docker im unprivilegierten Container braucht nur nesting**, kein keyctl (geprüft auf PVE 9.2.2 mit Debian 13).
|
||||
- **keyctl** darf per Token nicht gesetzt werden; mit Node-SSH setzt ProxDeploy es per `pct set`, ohne lehnt die Prüfung ab.
|
||||
- **Bereitstellungsschlüssel** (eigener ed25519, verschlüsselt in den Einstellungen) nur für den Weg ohne Node-SSH; die Schlüssel des Benutzers gehen nie an root.
|
||||
- Bei mehreren passenden Container-Templates wird das neueste aus `aplinfo` geladen, wenn lokal nur ein älteres liegt.
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
# #010 Ubuntu 22.04, 24.04 und 26.04
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature, katalog
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 05 min
|
||||
|
||||
## Ziel
|
||||
Ubuntu als VM und LXC über den debian-Adapter.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades)
|
||||
- [ ] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12)
|
||||
- [x] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades)
|
||||
- [x] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Je eine Ubuntu-VM und ein Ubuntu-LXC ausgerollt
|
||||
- [x] Auf pve-demo: pd-ubu01 (Ubuntu 24.04 VM, Template 9001 im selben Auftrag gebaut) und pd-ct02 (Ubuntu 24.04 LXC)
|
||||
|
||||
## Abhängigkeiten
|
||||
#009
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- Der Adapter brauchte für Ubuntu nur eine Änderung: `/run/sshd` anlegen (sshd per Socket, #009). Locale-Erzeugung unterscheidet Debian/Ubuntu.
|
||||
- Ubuntu 22.04 und 26.04 laufen im Containertest (`pnpm test:container`); auf dem Testhost geprüft ist 24.04.
|
||||
- Der Voll-Klon dauerte auf pve-demo ~9 min (3,5 GB, Storage ~3 MB/s) – die Phase „Klonen“ zeigt jetzt den Fortschritt aus dem Task-Log.
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
# #011 Profile docker und zabbix-agent, Variablen, Editor
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature
|
||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
||||
**Geschätzt:** 6 h **Tatsächlich:** 0 h 10 min
|
||||
|
||||
## Ziel
|
||||
Kombinierbare Profile mit Variablen; eigene Profile im YAML-Editor.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive)
|
||||
- [ ] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie
|
||||
- [ ] YAML-Editor mit Validierung und Fehlerstelle
|
||||
- [x] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive)
|
||||
- [x] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie
|
||||
- [x] YAML-Editor mit Validierung und Fehlerstelle
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] docker run hello-world im ausgerollten Gast erfolgreich
|
||||
- [x] docker hello-world in pd-test01 (VM) und pd-ct01 (LXC); zabbix-agent2 aktiv in pd-test02
|
||||
|
||||
## Abhängigkeiten
|
||||
#005
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- Editor prüft beim Tippen (400 ms Pause) gegen dasselbe Schema wie der Katalog und zeigt das erzeugte Skript.
|
||||
- Katalogprofile sind schreibgeschützt; „Als Kopie bearbeiten“ legt `<id>-eigen` an. Eigene Profile mit Katalog-id werden abgewiesen.
|
||||
- Profile anlegen dürfen nur Admins – sie führen Befehle als root aus.
|
||||
- Löschen verweigert, solange ein anderes Profil es per `requires` braucht.
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
# #012 Vorlagen (Presets)
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 11 min
|
||||
|
||||
## Ziel
|
||||
Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] CRUD, „aus diesem Auftrag als Vorlage speichern“, Beispiel „Docker-Host klein“
|
||||
- [x] CRUD, „Als Vorlage speichern“ im Assistenten, Beispiel „Docker-Host klein“ (einmalig angelegt)
|
||||
- [x] Vorlagen-Auswahl im Assistenten, `?vorlage=` von der Vorlagenseite, `vorlage` in der API (für `--preset` der CLI)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] `--preset` in API und Assistent identisch aufgelöst
|
||||
- [x] `--preset` in API und Assistent identisch aufgelöst
|
||||
|
||||
## Abhängigkeiten
|
||||
#008
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- Eine Vorlage liegt **unter** der Anfrage: was die Anfrage sagt, gewinnt; netz/features/variablen feldweise, Listen ersetzt.
|
||||
- Name, VMID, IP und SSH-Schlüssel gehören nicht in eine Vorlage (Schema `strict`).
|
||||
- Operatoren dürfen Vorlagen pflegen (Recht `vorlagenPflegen`).
|
||||
|
||||
@@ -9,7 +9,8 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test)
|
||||
- [ ] Benutzerverwaltung, CLI-Tokens, SSH-Keys über `SchluesselQuelle`
|
||||
- [ ] Benutzerverwaltung, CLI-Tokens
|
||||
- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar
|
||||
- [ ] Audit-Log-Ansicht mit Filter
|
||||
|
||||
## Akzeptanzkriterien
|
||||
@@ -19,4 +20,4 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
||||
#001
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- SSH-Schlüssel auf Florians Frage nach Schlüsseln im Template vorgezogen (2026-10-12): Schlüssel gehören in die Verwaltung, nicht ins Template. `SchluesselQuelle` ist die Andockstelle für eine externe Verwaltung (z. B. sshkeyvault).
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# #021 Template-Bau ohne SSH (Bau-Adresse)
|
||||
**Status:** offen
|
||||
**Status:** umgesetzt, Abnahme auf pve-demo ausstehend
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature, proxmox
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 19 min
|
||||
|
||||
## Ziel
|
||||
Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet,
|
||||
sondern über SSH **in die Vorbereitungs-VM** eingebaut.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Server-Einstellung `bauNetz`: Bridge, VLAN, statische IP/CIDR, Gateway, DNS
|
||||
- [ ] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys`
|
||||
- [ ] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten
|
||||
- [ ] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
|
||||
- [x] Server-Einstellung `bauNetz`: statische IP/CIDR, Gateway, DNS (Bridge/VLAN wie beim Template-Bau gewählt)
|
||||
- [x] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys`
|
||||
- [x] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten
|
||||
- [x] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH
|
||||
@@ -21,4 +21,7 @@ sondern über SSH **in die Vorbereitungs-VM** eingebaut.
|
||||
#007
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Fähigkeitserkennung war zu optimistisch:** „Template-Bau per API“ stand auch ohne Node-SSH da, obwohl der Guest-Agent dann nicht hineinkam. Jetzt: per API nur mit Snippet-Weg oder Bau-Adresse.
|
||||
- Der Bereitstellungsschlüssel kommt per cloud-init an den Standardbenutzer des Images; danach werden alle authorized_keys, ciuser, sshkeys und die feste Adresse entfernt.
|
||||
- Host-Key der frischen VM ist vorher unbekannt (TOFU, nur für diese eine Verbindung).
|
||||
- Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# #022 Templates mit eingebackenen Profilen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature, proxmox
|
||||
**Geschätzt:** 4 h **Tatsächlich:** 0 h 22 min
|
||||
|
||||
## Ziel
|
||||
Beim Template-Bau Profile (z. B. base, docker) gleich mit installieren, damit Deployments schneller
|
||||
fertig sind. Wunsch von Florian am 2026-10-12.
|
||||
|
||||
## Aufgaben
|
||||
- [x] Template-Bau-Anfrage um `profile` erweitern (nur Profile ohne Pflichtvariablen bzw. mit Standardwerten)
|
||||
- [x] Nach dem Vorbereitungsstart: VM erneut starten, Profile über den Guest-Agent ausführen (gleicher Code wie beim Deployment)
|
||||
- [x] Aufräumen vor dem Template: Maschinen-ID leeren, `cloud-init clean`, SSH-Host-Keys löschen, `apt-get clean`, Logdatei entfernen
|
||||
- [x] Name/Tags: `tpl-debian-13-docker`, Tags `proxdeploy-template;debian-13;profil-docker`
|
||||
- [x] Ausrollen: passendes Template bevorzugen (eingebackene Profile ⊆ gewählte); eingebackene Profile beim Deploy überspringen, wenn ihre Variablen den Standardwerten entsprechen
|
||||
- [ ] Optional: zusätzlicher Admin-/Notfall-Schlüssel im Template – zurückgestellt; Standard-Schlüssel je Benutzer/Team kommen stattdessen (#015)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [x] Template 9002 `tpl-debian-13-docker` auf pve-demo (8 min). Provisionierung beim Deploy: 141 s ohne, 29–32 s mit eingebackenem base+docker
|
||||
|
||||
## Abhängigkeiten
|
||||
#007, #008
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- SSH-Schlüssel gehören grundsätzlich nicht ins Template: beim Wechsel müsste jedes Template und jeder Klon angefasst werden. Dafür kommen Standard-Schlüssel je Benutzer/Team (aus #015 vorgezogen).
|
||||
- **Fund:** Profile mit `{{ guest.* }}` hängen vom einzelnen Gast ab (docker nimmt den Benutzer in die Gruppe auf). Sie laufen beim Deploy trotzdem noch einmal – idempotent und schnell. Übersprungen wird nur, was gastunabhängig ist und Standardwerte hat.
|
||||
- Vor dem Template: Maschinen-ID leeren, SSH-Host-Keys und Docker-engine-id löschen, apt-Cache leeren, `cloud-init clean`; geprüft: Klone haben eigene Maschinen-ID und eigene Host-Keys.
|
||||
- Ausrollen wählt das Template mit den meisten eingebackenen Profilen, deren Profile alle gewählt sind – nie eins mit ungewählter Software.
|
||||
- Gemeinsames Modul `gastagent.ts` für Provisionierung über den Guest-Agent (Deploy und Template).
|
||||
@@ -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 |
|
||||
| [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min |
|
||||
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min |
|
||||
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – |
|
||||
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – |
|
||||
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – |
|
||||
| [#012](012-vorlagen.md) | Vorlagen (Presets) | offen | v0.3.0 | 3 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 | erledigt | v0.3.0 | 3 h | 0 h 05 min |
|
||||
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | erledigt | v0.3.0 | 6 h | 0 h 10 min |
|
||||
| [#012](012-vorlagen.md) | Vorlagen (Presets) | erledigt | v0.3.0 | 3 h | 0 h 11 min |
|
||||
| [#013](013-cli.md) | CLI proxdeploy (Go) | offen | v0.4.0 | 12 h | – |
|
||||
| [#014](014-verlauf.md) | Verlauf und Wiederholen | offen | v0.5.0 | 4 h | – |
|
||||
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | offen | v0.5.0 | 6 h | – |
|
||||
@@ -24,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 | – |
|
||||
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
||||
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
|
||||
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | offen | v0.3.0 | 3 h | – |
|
||||
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | umgesetzt, Abnahme offen | v0.3.0 | 3 h | 0 h 19 min |
|
||||
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 22 min |
|
||||
|
||||
+2
-1
@@ -99,7 +99,8 @@ families:
|
||||
| `run: <befehl>` | freier Shell-Befehl – nicht portabel, nur wenn nichts anderes passt |
|
||||
|
||||
Jeder Schritt kann `if: <variable>` (oder `if: "!<variable>"`) tragen und läuft dann nur, wenn die
|
||||
Variable wahr bzw. falsch ist.
|
||||
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
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxdeploy",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
||||
|
||||
@@ -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;
|
||||
@@ -39,6 +39,7 @@ model Benutzer {
|
||||
geaendertAm DateTime @updatedAt
|
||||
protokoll Protokoll[]
|
||||
auftraege Auftrag[]
|
||||
schluessel SshSchluessel[]
|
||||
}
|
||||
|
||||
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
|
||||
@@ -271,9 +272,40 @@ model Template {
|
||||
storage String
|
||||
imageDatei String
|
||||
pruefsumme String
|
||||
/// Eingebackene Profile (#022), aufgelöst inkl. Voraussetzungen.
|
||||
profile String[] @default([])
|
||||
gebautVonAuftragId String?
|
||||
gebautAm DateTime @default(now())
|
||||
|
||||
@@unique([serverId, vmid])
|
||||
@@index([serverId, distributionId])
|
||||
}
|
||||
|
||||
/// Vorlage (Preset): eine gespeicherte Kombination, die Assistent, CLI (--preset) und API vorbelegen.
|
||||
model Vorlage {
|
||||
/// Kurzname für die CLI: „docker-host-klein“
|
||||
name String @id
|
||||
titel String
|
||||
beschreibung String?
|
||||
/// Teilmenge der Ausroll-Anfrage (typ, distribution, cores, speicherMb, diskGb, profile, variablen, tags …).
|
||||
parameter Json
|
||||
erstelltVonId String?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Öffentliche SSH-Schlüssel, die in Gäste kommen. benutzerId leer = Team-Schlüssel (in jeden Gast).
|
||||
model SshSchluessel {
|
||||
id String @id @default(cuid())
|
||||
benutzerId String?
|
||||
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
oeffentlich String
|
||||
/// SHA256:… wie ssh-keygen -lf
|
||||
fingerabdruck String
|
||||
/// Kennung der SchluesselQuelle („lokal“; später z. B. eine externe Schlüsselverwaltung).
|
||||
quelle String @default("lokal")
|
||||
erstelltAm DateTime @default(now())
|
||||
|
||||
@@unique([benutzerId, fingerabdruck])
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Status } from "@/components/status";
|
||||
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
|
||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen";
|
||||
import type { VorlagenParameter } from "@/lib/schemas/vorlage";
|
||||
|
||||
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,6 +48,13 @@ export type AssistentDaten = {
|
||||
}>;
|
||||
benutzer: 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 = {
|
||||
@@ -92,9 +100,12 @@ type Werte = {
|
||||
suchdomain: string;
|
||||
benutzer: string;
|
||||
schluessel: string;
|
||||
/** Fingerprints hinterlegter Schlüssel, die für diesen Gast abgewählt sind. */
|
||||
ohneSchluessel: string[];
|
||||
profile: string[];
|
||||
variablen: Record<string, Record<string, string | boolean>>;
|
||||
templateBauen: boolean;
|
||||
vorlage: string;
|
||||
};
|
||||
|
||||
const SCHRITTE = [
|
||||
@@ -125,9 +136,7 @@ function dekodieren(text: string | null): Partial<Werte> {
|
||||
}
|
||||
}
|
||||
|
||||
const SCHLUESSEL_SPEICHER = "proxdeploy.ssh-schluessel";
|
||||
|
||||
function anfrageAus(w: Werte): AusrollEingabe {
|
||||
function anfrageAus(w: Werte, hinterlegt: AssistentDaten["schluessel"] = []): AusrollEingabe {
|
||||
const zahl = (text: string) => (text.trim() === "" ? undefined : Number(text));
|
||||
return {
|
||||
typ: w.typ,
|
||||
@@ -155,14 +164,63 @@ function anfrageAus(w: Werte): AusrollEingabe {
|
||||
},
|
||||
zugang: {
|
||||
benutzer: w.benutzer,
|
||||
sshSchluessel: w.schluessel
|
||||
.split("\n")
|
||||
.map((z) => z.trim())
|
||||
.filter(Boolean),
|
||||
sshSchluessel: [
|
||||
...hinterlegt.filter((s) => !w.ohneSchluessel.includes(s.fingerabdruck)).map((s) => s.oeffentlich),
|
||||
...w.schluessel
|
||||
.split("\n")
|
||||
.map((z) => z.trim())
|
||||
.filter(Boolean),
|
||||
],
|
||||
},
|
||||
profile: w.profile,
|
||||
variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))),
|
||||
templateBauen: w.templateBauen,
|
||||
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: "",
|
||||
benutzer: daten.benutzer.replace(/[^a-z0-9_-]/g, "") || "admin",
|
||||
schluessel: "",
|
||||
ohneSchluessel: [],
|
||||
profile: ["base"],
|
||||
variablen: {},
|
||||
templateBauen: false,
|
||||
vorlage: "",
|
||||
};
|
||||
}, [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 [fehler, setFehler] = useState<{
|
||||
text: string;
|
||||
@@ -237,19 +315,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
};
|
||||
|
||||
const gehe = (ziel: number) => {
|
||||
let naechste = werte;
|
||||
// Gespeicherte Schlüssel als Bequemlichkeit vorbelegen (bis die Schlüsselverwaltung kommt, #015).
|
||||
if (SCHRITTE[ziel] === "Zugang" && !werte.schluessel) {
|
||||
try {
|
||||
const gemerkt = localStorage.getItem(SCHLUESSEL_SPEICHER);
|
||||
if (gemerkt) {
|
||||
naechste = { ...werte, schluessel: gemerkt };
|
||||
setWerte(naechste);
|
||||
}
|
||||
} catch {
|
||||
// kein Speicher verfügbar – dann eben nicht
|
||||
}
|
||||
}
|
||||
const naechste = werte;
|
||||
if (SCHRITTE[ziel] === "Zusammenfassung") void planen(naechste);
|
||||
const neu = new URLSearchParams({ s: String(ziel), d: kodieren(naechste) });
|
||||
router.replace(`${pfad}?${neu.toString()}`, { scroll: false });
|
||||
@@ -289,7 +355,9 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
.filter(Boolean);
|
||||
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(werte.benutzer) || werte.benutzer === "root")
|
||||
return "Gültigen Benutzernamen angeben (nicht root).";
|
||||
if (schluessel.length === 0) return "Mindestens einen öffentlichen SSH-Schlüssel angeben.";
|
||||
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)))
|
||||
return "Eine Zeile ist kein öffentlicher SSH-Schlüssel.";
|
||||
return null;
|
||||
@@ -304,7 +372,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
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) {
|
||||
setFehler(
|
||||
ursache instanceof ApiFehler
|
||||
@@ -320,12 +388,11 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
try {
|
||||
localStorage.setItem(SCHLUESSEL_SPEICHER, werte.schluessel);
|
||||
} catch {
|
||||
// egal
|
||||
}
|
||||
const { kurzId } = await apiAufruf<{ kurzId: string }>("POST", "/api/v1/auftraege", anfrageAus(werte));
|
||||
const { kurzId } = await apiAufruf<{ kurzId: string }>(
|
||||
"POST",
|
||||
"/api/v1/auftraege",
|
||||
anfrageAus(werte, daten.schluessel),
|
||||
);
|
||||
router.push(`/auftraege/${kurzId}`);
|
||||
} catch (ursache) {
|
||||
setFehler(
|
||||
@@ -388,6 +455,34 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{SCHRITTE[schritt] === "Server" ? (
|
||||
<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">
|
||||
<Feld id="server" label="Server">
|
||||
<select
|
||||
@@ -465,7 +560,6 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
className={clsx(
|
||||
"border-linie flex cursor-pointer flex-col gap-1 rounded-lg border p-4",
|
||||
werte.typ === wert && "border-akzent",
|
||||
wert === "lxc" && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-semibold">
|
||||
@@ -555,7 +649,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
autoFocus
|
||||
/>
|
||||
</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
|
||||
id="vmid"
|
||||
inputMode="numeric"
|
||||
@@ -631,6 +725,40 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
</select>
|
||||
</Feld>
|
||||
</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>
|
||||
) : null}
|
||||
|
||||
@@ -742,10 +870,35 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
autoComplete="off"
|
||||
/>
|
||||
</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
|
||||
id="schluessel"
|
||||
label="Öffentliche SSH-Schlüssel"
|
||||
hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Gespeicherte Schlüssel folgen mit #015."
|
||||
label={
|
||||
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
|
||||
id="schluessel"
|
||||
@@ -944,6 +1097,73 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
</Knopf>
|
||||
</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}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||
import { versionText } from "@/lib/version";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
||||
import { vorlagenListe } from "@/server/services/vorlagen";
|
||||
import { schluesselFuer } from "@/server/services/schluessel";
|
||||
import { Assistent, type AssistentDaten } from "./assistent";
|
||||
|
||||
export const metadata: Metadata = { title: "Ausrollen" };
|
||||
@@ -73,6 +75,13 @@ export default async function Ausrollen() {
|
||||
})),
|
||||
benutzer: benutzer.benutzername,
|
||||
version: versionText(),
|
||||
vorlagen: await vorlagenListe(),
|
||||
schluessel: (await schluesselFuer(benutzer)).map((x) => ({
|
||||
name: x.name,
|
||||
oeffentlich: x.oeffentlich,
|
||||
fingerabdruck: x.fingerabdruck,
|
||||
bereich: x.bereich,
|
||||
})),
|
||||
};
|
||||
const vorauswahl = aktiverServer(
|
||||
daten.server.map((s) => s.name),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
@@ -55,7 +56,16 @@ export default async function KatalogSeite() {
|
||||
</ul>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Profile">
|
||||
<Karte
|
||||
titel="Profile"
|
||||
aktion={
|
||||
verwalten ? (
|
||||
<Link href="/katalog/profile/neu" className="text-akzent text-sm font-semibold">
|
||||
Profil anlegen
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{profile.map((p) => (
|
||||
<li
|
||||
@@ -64,7 +74,9 @@ export default async function KatalogSeite() {
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{p.name}{" "}
|
||||
<Link href={`/katalog/profile/${p.id}`} className="hover:underline">
|
||||
{p.name}
|
||||
</Link>{" "}
|
||||
<span className="text-muted font-mono text-xs">
|
||||
{p.id} · {p.herkunft === "KATALOG" ? "Katalog" : "eigenes"}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { ProfilEditor } from "../profil-editor";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
return { title: `Profil ${(await params).id}` };
|
||||
}
|
||||
|
||||
/** Katalogprofile nur ansehen (bearbeiten als Kopie), eigene bearbeiten. */
|
||||
export default async function ProfilSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const benutzer = await seiteSchuetzen("lesen", `/katalog/profile/${id}`);
|
||||
const profil = await prisma.profil.findUnique({ where: { id } });
|
||||
if (!profil || profil.entferntAm) notFound();
|
||||
const bearbeitbar = profil.herkunft === "EIGEN" && darf(benutzer.rolle, "katalogVerwalten");
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-muted font-mono text-sm">
|
||||
{profil.id} · {profil.herkunft === "KATALOG" ? "Katalog (schreibgeschützt)" : "eigenes Profil"}
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{profil.name}</h1>
|
||||
</div>
|
||||
{profil.herkunft === "KATALOG" && darf(benutzer.rolle, "katalogVerwalten") ? (
|
||||
<Link
|
||||
href={`/katalog/profile/neu?von=${profil.id}`}
|
||||
className="border-linie bg-panel-hoch hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
|
||||
>
|
||||
Als Kopie bearbeiten
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
<ProfilEditor
|
||||
id={bearbeitbar ? profil.id : undefined}
|
||||
anfang={profil.quelltext}
|
||||
schreibgeschuetzt={!bearbeitbar}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { ProfilEditor } from "../profil-editor";
|
||||
|
||||
export const metadata: Metadata = { title: "Profil anlegen" };
|
||||
|
||||
const VORLAGE = `# Eigenes Profil – Format und Schritte: docs/katalog.md
|
||||
id: mein-profil
|
||||
name: Mein Profil
|
||||
description: Was das Profil tut
|
||||
requires: [base]
|
||||
variables:
|
||||
- { name: beispiel, label: Beispielwert, default: hallo }
|
||||
families:
|
||||
debian:
|
||||
- packages: [htop]
|
||||
- file:
|
||||
path: /etc/motd
|
||||
content: |
|
||||
{{ guest.name }} – {{ var.beispiel }}
|
||||
`;
|
||||
|
||||
/** Neues Profil, leer oder als Kopie (`?von=<id>`). */
|
||||
export default async function ProfilNeu({ searchParams }: { searchParams: Promise<{ von?: string }> }) {
|
||||
await seiteSchuetzen("katalogVerwalten", "/katalog/profile/neu");
|
||||
const von = (await searchParams).von;
|
||||
const quelle = von ? await prisma.profil.findUnique({ where: { id: von } }) : null;
|
||||
const anfang = quelle
|
||||
? quelle.quelltext
|
||||
.replace(/^id:\s*.+$/m, `id: ${quelle.id}-eigen`)
|
||||
.replace(/^name:\s*(.+)$/m, "name: $1 (eigen)")
|
||||
: VORLAGE;
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{quelle ? `${quelle.name} als Kopie` : "Profil anlegen"}
|
||||
</h1>
|
||||
<ProfilEditor anfang={anfang} schreibgeschuetzt={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Knopf } from "@/components/knopf";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||
|
||||
type Pruefung = { ok: true; vorschau: string | null } | { ok: false; fehler: string[] };
|
||||
|
||||
/**
|
||||
* YAML-Editor für Profile mit Prüfung beim Tippen (nach kurzer Pause) und Vorschau des Skripts.
|
||||
* Gespeichert wird nur, was der Server für gültig hält.
|
||||
*/
|
||||
export function ProfilEditor({
|
||||
id,
|
||||
anfang,
|
||||
schreibgeschuetzt,
|
||||
}: {
|
||||
id?: string;
|
||||
anfang: string;
|
||||
schreibgeschuetzt: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [text, setText] = useState(anfang);
|
||||
const [pruefung, setPruefung] = useState<Pruefung | null>(null);
|
||||
const [meldung, setMeldung] = useState<{ text: string; hinweis?: string } | null>(null);
|
||||
const [speichert, setSpeichert] = useState(false);
|
||||
const zaehler = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const nummer = ++zaehler.current;
|
||||
const uhr = setTimeout(async () => {
|
||||
try {
|
||||
const ergebnis = await apiAufruf<Pruefung>("POST", "/api/v1/profile/pruefen", { quelltext: text });
|
||||
// Eine ältere Antwort darf eine neuere nicht überschreiben.
|
||||
if (nummer === zaehler.current) setPruefung(ergebnis);
|
||||
} catch {
|
||||
// Prüfung ist Komfort – beim Speichern prüft der Server ohnehin.
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(uhr);
|
||||
}, [text]);
|
||||
|
||||
async function speichern() {
|
||||
setSpeichert(true);
|
||||
setMeldung(null);
|
||||
try {
|
||||
const gespeichert = id
|
||||
? await apiAufruf<{ id: string }>("PUT", `/api/v1/profile/${id}`, { quelltext: text })
|
||||
: await apiAufruf<{ id: string }>("POST", "/api/v1/profile", { quelltext: text });
|
||||
router.push(`/katalog/profile/${gespeichert.id}`);
|
||||
router.refresh();
|
||||
} catch (fehler) {
|
||||
setMeldung(
|
||||
fehler instanceof ApiFehler
|
||||
? { text: fehler.message, hinweis: fehler.hinweis }
|
||||
: { text: "Unerwarteter Fehler." },
|
||||
);
|
||||
} finally {
|
||||
setSpeichert(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loeschen() {
|
||||
if (!id || !window.confirm(`Profil „${id}“ wirklich löschen?`)) return;
|
||||
try {
|
||||
await apiAufruf("DELETE", `/api/v1/profile/${id}`);
|
||||
router.push("/katalog");
|
||||
router.refresh();
|
||||
} catch (fehler) {
|
||||
setMeldung(
|
||||
fehler instanceof ApiFehler
|
||||
? { text: fehler.message, hinweis: fehler.hinweis }
|
||||
: { text: "Unerwarteter Fehler." },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Karte titel="YAML">
|
||||
<label htmlFor="quelltext" className="sr-only">
|
||||
Profil als YAML
|
||||
</label>
|
||||
<textarea
|
||||
id="quelltext"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
readOnly={schreibgeschuetzt}
|
||||
spellCheck={false}
|
||||
rows={Math.min(40, Math.max(14, text.split("\n").length + 2))}
|
||||
className="bg-grund border-linie text-text w-full rounded-lg border p-3 font-mono text-xs leading-5 sm:text-sm"
|
||||
aria-describedby="pruefung"
|
||||
/>
|
||||
<div id="pruefung" aria-live="polite" className="mt-3 text-sm">
|
||||
{pruefung === null ? (
|
||||
<Status art="laeuft">Prüft …</Status>
|
||||
) : pruefung.ok ? (
|
||||
<Status art="erledigt">Gültig</Status>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{pruefung.fehler.map((f) => (
|
||||
<li key={f}>
|
||||
<Status art="fehler">{f}</Status>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
{pruefung?.ok && pruefung.vorschau ? (
|
||||
<details className="bg-panel border-linie rounded-karte border">
|
||||
<summary className="cursor-pointer px-5 py-3.5 font-semibold">Vorschau: erzeugtes Skript</summary>
|
||||
<pre className="border-linie max-h-[50vh] overflow-auto border-t p-5 font-mono text-xs">
|
||||
{pruefung.vorschau}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{!schreibgeschuetzt ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div aria-live="polite" className="text-sm">
|
||||
{meldung ? (
|
||||
<div>
|
||||
<Status art="fehler">{meldung.text}</Status>
|
||||
{meldung.hinweis ? <p className="text-muted">{meldung.hinweis}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{id ? (
|
||||
<Knopf variante="gefahr" onClick={loeschen}>
|
||||
Löschen
|
||||
</Knopf>
|
||||
) : null}
|
||||
<Knopf onClick={speichern} disabled={speichert || !pruefung?.ok}>
|
||||
{speichert ? "Speichert …" : "Speichern"}
|
||||
</Knopf>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,9 @@ type Werte = {
|
||||
templateVmidVon: string;
|
||||
templateVmidBis: string;
|
||||
gastVmidVon: string;
|
||||
bauIp: string;
|
||||
bauGateway: string;
|
||||
bauDns: string;
|
||||
};
|
||||
|
||||
function anfangswerte(server?: ServerSicht): Werte {
|
||||
@@ -60,6 +63,9 @@ function anfangswerte(server?: ServerSicht): Werte {
|
||||
templateVmidVon: String(server?.templateVmidVon ?? 9000),
|
||||
templateVmidBis: String(server?.templateVmidBis ?? 9999),
|
||||
gastVmidVon: String(server?.gastVmidVon ?? 100),
|
||||
bauIp: server?.bauNetz?.ip ?? "",
|
||||
bauGateway: server?.bauNetz?.gateway ?? "",
|
||||
bauDns: server?.bauNetz?.dns.join(", ") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,12 +123,16 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
|
||||
setSpeichert(true);
|
||||
setFelder({});
|
||||
setMeldung(null);
|
||||
const { bauIp, bauGateway, bauDns, ...ohneBau } = werte;
|
||||
const rumpf = {
|
||||
...werte,
|
||||
...ohneBau,
|
||||
sshPort: Number(werte.sshPort),
|
||||
templateVmidVon: Number(werte.templateVmidVon),
|
||||
templateVmidBis: Number(werte.templateVmidBis),
|
||||
gastVmidVon: Number(werte.gastVmidVon),
|
||||
bauNetz: bauIp.trim()
|
||||
? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) }
|
||||
: null,
|
||||
};
|
||||
try {
|
||||
const gespeichert = bearbeiten
|
||||
@@ -361,6 +371,44 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="text-sm font-medium">Bau-Adresse (optional)</legend>
|
||||
<p className="text-muted text-sm">
|
||||
Feste Adresse für den Vorbereitungsstart beim Template-Bau. Nur nötig ohne SSH zum Node:
|
||||
ProxDeploy meldet sich darüber kurz in der VM an und installiert den Guest-Agent. Braucht
|
||||
Internetzugang.
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Feld id="bauIp" label="IP/Präfix" fehler={felder["bauNetz.ip"]}>
|
||||
<input
|
||||
id="bauIp"
|
||||
className={`${EINGABE_KLASSE} font-mono`}
|
||||
value={werte.bauIp}
|
||||
onChange={(e) => setze("bauIp", e.target.value)}
|
||||
placeholder="10.0.10.250/24"
|
||||
/>
|
||||
</Feld>
|
||||
<Feld id="bauGateway" label="Gateway" fehler={felder["bauNetz.gateway"]}>
|
||||
<input
|
||||
id="bauGateway"
|
||||
className={`${EINGABE_KLASSE} font-mono`}
|
||||
value={werte.bauGateway}
|
||||
onChange={(e) => setze("bauGateway", e.target.value)}
|
||||
placeholder="10.0.10.1"
|
||||
/>
|
||||
</Feld>
|
||||
<Feld id="bauDns" label="DNS" fehler={felder["bauNetz.dns"]}>
|
||||
<input
|
||||
id="bauDns"
|
||||
className={`${EINGABE_KLASSE} font-mono`}
|
||||
value={werte.bauDns}
|
||||
onChange={(e) => setze("bauDns", e.target.value)}
|
||||
placeholder="10.0.10.1"
|
||||
/>
|
||||
</Feld>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<Schalter
|
||||
id="sshAktiv"
|
||||
label="SSH zu den Nodes nutzen"
|
||||
|
||||
@@ -10,7 +10,7 @@ import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { AnwendungsFehler } from "@/server/fehler";
|
||||
import { distributionenListe } from "@/server/services/katalog";
|
||||
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
||||
import { templatesListe, type TemplateSicht } from "@/server/services/templates";
|
||||
import { TemplateBauen } from "./template-bauen";
|
||||
|
||||
@@ -48,6 +48,7 @@ export default async function TemplatesSeite() {
|
||||
const distributionen = (await distributionenListe({ typ: "vm", nurAktive: true })).map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
familie: d.familie,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -83,6 +84,7 @@ export default async function TemplatesSeite() {
|
||||
{" "}
|
||||
· {t.knoten}
|
||||
{t.distributionId ? ` · ${t.distributionId}` : ""}
|
||||
{t.profile.length > 0 ? ` · mit ${t.profile.join(", ")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted text-sm">
|
||||
@@ -110,6 +112,14 @@ export default async function TemplatesSeite() {
|
||||
bridges: k.bridges.map((b) => b.name),
|
||||
}))}
|
||||
faehigkeit={bericht.faehigkeiten.templateBau}
|
||||
profile={(await profileListe({ nurAktive: true }))
|
||||
.filter((p) => !p.definition.variables.some((v) => v.required && v.default === undefined))
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
requires: p.definition.requires,
|
||||
familien: p.familien,
|
||||
}))}
|
||||
/>
|
||||
</Karte>
|
||||
) : (
|
||||
|
||||
@@ -14,9 +14,11 @@ export function TemplateBauen({
|
||||
distributionen,
|
||||
knoten,
|
||||
faehigkeit,
|
||||
profile,
|
||||
}: {
|
||||
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[];
|
||||
faehigkeit: { weg: "api" | "ssh" | "nicht_moeglich"; grund: string };
|
||||
}) {
|
||||
@@ -26,6 +28,7 @@ export function TemplateBauen({
|
||||
knoten: knoten[0]?.name ?? "",
|
||||
storage: knoten[0]?.storages[0] ?? "",
|
||||
bridge: knoten[0]?.bridges.includes("vmbr0") ? "vmbr0" : (knoten[0]?.bridges[0] ?? ""),
|
||||
profile: [] as string[],
|
||||
});
|
||||
const [fehler, setFehler] = useState<{ text: string; hinweis?: string } | null>(null);
|
||||
const [sendet, setSendet] = useState(false);
|
||||
@@ -118,6 +121,43 @@ export function TemplateBauen({
|
||||
</select>
|
||||
</Feld>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-1 text-sm font-medium">Profile einbacken (optional)</legend>
|
||||
<p className="text-muted text-sm">
|
||||
Was hier gewählt ist, ist in jedem Klon schon installiert – Deployments mit diesen Profilen sind
|
||||
schneller fertig. Profile mit Pflichtangaben (z. B. Zabbix-Server) laufen beim Ausrollen.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
||||
{profile
|
||||
.filter((p) =>
|
||||
p.familien.includes(distributionen.find((d) => d.id === werte.distribution)?.familie ?? ""),
|
||||
)
|
||||
.map((p) => {
|
||||
const benoetigt = profile.some(
|
||||
(x) => werte.profile.includes(x.id) && x.requires.includes(p.id),
|
||||
);
|
||||
return (
|
||||
<label key={p.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-akzent h-4 w-4"
|
||||
checked={werte.profile.includes(p.id) || benoetigt}
|
||||
disabled={benoetigt && !werte.profile.includes(p.id)}
|
||||
onChange={(e) =>
|
||||
setWerte({
|
||||
...werte,
|
||||
profile: e.target.checked
|
||||
? [...werte.profile, p.id]
|
||||
: werte.profile.filter((x) => x !== p.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{p.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
<p className="text-muted text-sm">
|
||||
Der Vorbereitungsstart braucht DHCP und Internetzugang auf der Bridge, um qemu-guest-agent zu
|
||||
installieren.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,6 @@ import { ausrollenPlanen } from "@/server/services/ausrollen";
|
||||
|
||||
/** Dry-Run: Phasen, geplante Proxmox-Aufrufe, Skript und CLI-Befehl – angelegt wird nichts. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
await rechtErforderlich(anfrage, "lesen");
|
||||
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage)));
|
||||
const aufrufer = await rechtErforderlich(anfrage, "lesen");
|
||||
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage), aufrufer));
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { profilAktivSetzen } from "@/server/services/katalog";
|
||||
import { profilAktivSetzen, profilLoeschen, profilSpeichern } from "@/server/services/katalog";
|
||||
|
||||
type Kontext = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -13,3 +13,19 @@ export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
await profilAktivSetzen((await params).id, aktiv, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
|
||||
export const PUT = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
const { quelltext } = z
|
||||
.object({ quelltext: z.string().min(1).max(200_000) })
|
||||
.parse(await jsonLesen(anfrage));
|
||||
return NextResponse.json(
|
||||
await profilSpeichern(quelltext, aufrufer, aufruferAdresse(anfrage.headers), (await params).id),
|
||||
);
|
||||
});
|
||||
|
||||
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
await profilLoeschen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { api } from "@/server/api";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { profileListe } from "@/server/services/katalog";
|
||||
import { profileListe, profilSpeichern } from "@/server/services/katalog";
|
||||
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const { benutzer } = await rechtErforderlich(anfrage, "lesen");
|
||||
@@ -10,3 +12,14 @@ export const GET = api(async (anfrage: Request) => {
|
||||
new URL(anfrage.url).searchParams.get("alle") === "1" && darf(benutzer.rolle, "katalogVerwalten");
|
||||
return NextResponse.json(await profileListe({ nurAktive: !alle }));
|
||||
});
|
||||
|
||||
/** Eigenes Profil anlegen (YAML im Feld `quelltext`). Nur Admins – Profile führen Befehle als root aus. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
const { quelltext } = z
|
||||
.object({ quelltext: z.string().min(1).max(200_000) })
|
||||
.parse(await jsonLesen(anfrage));
|
||||
return NextResponse.json(await profilSpeichern(quelltext, aufrufer, aufruferAdresse(anfrage.headers)), {
|
||||
status: 201,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { schluesselAnlegen, schluesselFuer } from "@/server/services/schluessel";
|
||||
|
||||
/** Die eigenen und die Team-Schlüssel – das, was ohne Angabe in jeden Gast kommt. */
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const { benutzer } = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||
return NextResponse.json(await schluesselFuer(benutzer));
|
||||
});
|
||||
|
||||
/** `{ name, oeffentlich, team? }` – Team-Schlüssel nur für Admins. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||
return NextResponse.json(
|
||||
await schluesselAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
|
||||
{ status: 201 },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,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 });
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,13 @@ import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
|
||||
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||||
import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten";
|
||||
import { q } from "@/engine/shell";
|
||||
import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
|
||||
import type { BauNetz } from "@/lib/schemas/server";
|
||||
import type { Bereitstellungsschluessel } from "./lxc-ausrollen";
|
||||
import { adapterFuer } from "@/engine/adapter";
|
||||
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||
import { profileRendern } from "@/engine/profile/rendern";
|
||||
import { agentAbwarten, skriptAusfuehren } from "./gastagent";
|
||||
|
||||
/**
|
||||
* Template-Bau für VMs: Cloud-Image laden und prüfen, VM anlegen, Disk importieren, Guest-Agent
|
||||
@@ -24,12 +31,22 @@ import { q } from "@/engine/shell";
|
||||
/** Führt einen Befehl als root auf einem Node aus (ssh2, gepinnter Host-Key). */
|
||||
export type NodeBefehl = (
|
||||
befehl: string,
|
||||
optionen?: { eingabe?: string; zeitlimitMs?: number },
|
||||
optionen?: { eingabe?: string; zeitlimitMs?: number; beiAusgabe?: (text: string) => void },
|
||||
) => Promise<{ code: number | null; stdout: string; stderr: string }>;
|
||||
|
||||
export type TemplateBauEingabe = {
|
||||
client: ProxmoxClient;
|
||||
distribution: Distribution;
|
||||
/** Eingebackene Profile (#022), aufgelöst mit Standardwerten; leer = nur Guest-Agent. */
|
||||
profile?: AufgeloestesProfil[];
|
||||
/** Bau-Adresse (#021): Guest-Agent per SSH in die VM einbauen, wenn es kein Node-SSH gibt. */
|
||||
bauNetz?: BauNetz | null;
|
||||
bereitstellung?: () => Promise<Bereitstellungsschluessel>;
|
||||
/** Nur für Tests: SSH in die Vorbereitungs-VM ersetzen. */
|
||||
gastSsh?: (
|
||||
ziel: { host: string; benutzer: string; schluessel: Bereitstellungsschluessel },
|
||||
skript: string,
|
||||
) => Promise<number | null>;
|
||||
knoten: string;
|
||||
/** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */
|
||||
storage: string;
|
||||
@@ -50,6 +67,8 @@ export type TemplateBauEingabe = {
|
||||
|
||||
export type TemplateBauStand = {
|
||||
vmid?: number;
|
||||
/** Wie der Guest-Agent hineinkommt: Snippet (Node-SSH) oder SSH in die VM über die Bau-Adresse. */
|
||||
agentWeg?: "snippet" | "baunetz";
|
||||
weg?: "api" | "ssh";
|
||||
importStorage?: string;
|
||||
snippetStorage?: string;
|
||||
@@ -80,10 +99,28 @@ async function standardHolen(url: string): Promise<string> {
|
||||
return await antwort.text();
|
||||
}
|
||||
|
||||
export function templateName(distribution: Distribution): string {
|
||||
return `tpl-${distribution.id.replace(/[^a-z0-9-]/g, "-")}`;
|
||||
export function templateName(distribution: Distribution, profile: string[] = []): string {
|
||||
return ["tpl", distribution.id, ...profile.filter((p) => p !== "base")]
|
||||
.join("-")
|
||||
.replace(/[^a-z0-9-]/g, "-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufräumen vor dem Template, wenn Profile eingebacken wurden – alles, was je Klon eindeutig sein
|
||||
* muss oder nur Platz kostet. SSH-Host-Keys erzeugt cloud-init bei jedem Klon neu.
|
||||
*/
|
||||
export const TEMPLATE_AUFRAEUMEN = [
|
||||
"set -e",
|
||||
"truncate -s 0 /etc/machine-id",
|
||||
"rm -f /var/lib/dbus/machine-id",
|
||||
"rm -f /etc/ssh/ssh_host_*",
|
||||
"rm -f /var/lib/docker/engine-id",
|
||||
"apt-get clean || true",
|
||||
"rm -f /var/log/proxdeploy-provisionierung.log",
|
||||
"cloud-init clean --logs",
|
||||
"sync",
|
||||
].join("; ");
|
||||
|
||||
/**
|
||||
* vendor-data für den Vorbereitungsstart: Guest-Agent installieren, Maschinen-ID leeren (sonst
|
||||
* teilen sich alle Klone DHCP-Client-ID und journald-ID), cloud-init zurücksetzen, ausschalten.
|
||||
@@ -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[] {
|
||||
const { client, distribution: d, knoten } = e;
|
||||
const vm = d.vm;
|
||||
@@ -118,6 +275,8 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
const holen = e.holen ?? standardHolen;
|
||||
const warten = e.warten ?? schlafen;
|
||||
const snippetDatei = `proxdeploy-vorbereitung-${e.auftragKurzId}.yaml`;
|
||||
const profilIds = (e.profile ?? []).map((p) => p.profil.id);
|
||||
const name = templateName(d, profilIds);
|
||||
|
||||
const ssh = async (
|
||||
befehl: string,
|
||||
@@ -189,13 +348,17 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
: "SSH zum Node einrichten oder auf PVE 8.4+ aktualisieren.",
|
||||
);
|
||||
}
|
||||
if (!vm.guest_agent_preinstalled && !(e.ssh && stand.snippetStorage)) {
|
||||
throw new AblaufFehler(
|
||||
"Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node und einen Storage mit Inhaltstyp „Snippets“.",
|
||||
e.ssh
|
||||
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben."
|
||||
: "SSH in den Server-Einstellungen einrichten.",
|
||||
);
|
||||
if (!vm.guest_agent_preinstalled) {
|
||||
if (e.ssh && stand.snippetStorage) stand.agentWeg = "snippet";
|
||||
else if (e.bauNetz && e.bereitstellung) stand.agentWeg = "baunetz";
|
||||
else {
|
||||
throw new AblaufFehler(
|
||||
"Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node (mit einem Snippets-Storage) oder eine Bau-Adresse.",
|
||||
e.ssh
|
||||
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben oder in den Server-Einstellungen eine Bau-Adresse eintragen."
|
||||
: "SSH in den Server-Einstellungen einrichten oder dort eine Bau-Adresse (feste IP für die Vorbereitungs-VM) eintragen.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const netze = await client.get<Array<{ iface: string; type: string }>>(
|
||||
pfad`/nodes/${knoten}/network`,
|
||||
@@ -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 parameter = {
|
||||
vmid,
|
||||
name: templateName(d),
|
||||
name,
|
||||
ostype: "l26",
|
||||
machine: "q35",
|
||||
bios: vm.bios,
|
||||
@@ -310,8 +473,10 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
ide2: `${e.storage}:cloudinit`,
|
||||
ipconfig0: "ip=dhcp",
|
||||
ciupgrade: false,
|
||||
tags: `proxdeploy-template;${d.id}`,
|
||||
description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}\nGebaut: ${jetzt} (Auftrag ${e.auftragKurzId})`,
|
||||
tags: ["proxdeploy-template", d.id, ...profilIds.map((p) => `profil-${p}`)].join(";"),
|
||||
description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}${
|
||||
profilIds.length > 0 ? `\nEingebackene Profile: ${profilIds.join(", ")}` : ""
|
||||
}\nGebaut: ${jetzt} (Auftrag ${e.auftragKurzId})`,
|
||||
};
|
||||
if (stand.weg === "api") {
|
||||
await aufgabeAbwarten(
|
||||
@@ -336,7 +501,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
});
|
||||
}
|
||||
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" };
|
||||
const vmid = stand.vmid!;
|
||||
const vmPfad = pfad`/nodes/${knoten}/qemu/${vmid}`;
|
||||
if (stand.agentWeg === "baunetz")
|
||||
return await agentUeberBauNetz(e, vmPfad, vm.default_user, k, warten);
|
||||
const snippet = `${stand.snippetStorage}:snippets/${snippetDatei}`;
|
||||
await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, {
|
||||
eingabe: vorbereitungsSnippet(),
|
||||
@@ -383,6 +550,49 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
return { detail: "qemu-guest-agent installiert · Maschinen-ID geleert" };
|
||||
},
|
||||
},
|
||||
{
|
||||
schluessel: "profile",
|
||||
titel: "Profile",
|
||||
ausfuehren: async (k) => {
|
||||
if (profilIds.length === 0) return { uebersprungen: true, detail: "keine eingebacken" };
|
||||
const adapter = adapterFuer(d.family);
|
||||
if (!adapter)
|
||||
throw new AblaufFehler(
|
||||
`Für die Familie „${d.family}“ gibt es keinen Adapter.`,
|
||||
"Adapter ergänzen (docs/katalog.md).",
|
||||
);
|
||||
const vmPfad = pfad`/nodes/${knoten}/qemu/${stand.vmid!}`;
|
||||
const { skript } = profileRendern(e.profile!, adapter, {
|
||||
os: d.os,
|
||||
gast: { name, benutzer: vm.default_user, typ: "vm" },
|
||||
});
|
||||
if (client.trocken) {
|
||||
await k.log(skript, "info");
|
||||
return { detail: `${profilIds.join(", ")} würden eingebacken` };
|
||||
}
|
||||
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/start`), {
|
||||
zeitlimitMs: 5 * 60 * 1000,
|
||||
});
|
||||
await agentAbwarten(client, vmPfad, k, { grenzeMs: 5 * 60 * 1000, warten });
|
||||
await skriptAusfuehren(client, vmPfad, skript, k, { grenzeMs: 60 * 60 * 1000, warten });
|
||||
await k.detail("aufräumen und herunterfahren …");
|
||||
// Über denselben Weg wie die Profile: wartet aufs Ende, statt eine Pause zu raten.
|
||||
await skriptAusfuehren(client, vmPfad, `#!/bin/bash\n${TEMPLATE_AUFRAEUMEN}\n`, k, {
|
||||
grenzeMs: 5 * 60 * 1000,
|
||||
warten,
|
||||
});
|
||||
await aufgabeAbwarten(
|
||||
client,
|
||||
await client.post<string>(`${vmPfad}/status/shutdown`, { timeout: 180 }),
|
||||
{ zeitlimitMs: 5 * 60 * 1000 },
|
||||
);
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
if ((await client.get<{ status: string }>(`${vmPfad}/status/current`)).status === "stopped") break;
|
||||
await warten(3000);
|
||||
}
|
||||
return { detail: `${profilIds.join(", ")} eingebacken · Maschinen-ID und Host-Keys entfernt` };
|
||||
},
|
||||
},
|
||||
{
|
||||
schluessel: "template",
|
||||
titel: "Template",
|
||||
@@ -392,7 +602,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
await client.post<string>(pfad`/nodes/${knoten}/qemu/${stand.vmid!}/template`),
|
||||
{ zeitlimitMs: 10 * 60 * 1000 },
|
||||
);
|
||||
return { detail: `VM ${stand.vmid} ist jetzt Template ${templateName(d)}` };
|
||||
return { detail: `VM ${stand.vmid} ist jetzt Template ${name}` };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,8 +2,9 @@ import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
|
||||
import { adapterFuer } from "@/engine/adapter";
|
||||
import type { Distribution } from "@/engine/katalog/schema";
|
||||
import { profileRendern, MARKE } from "@/engine/profile/rendern";
|
||||
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||
import { profileRendern } from "@/engine/profile/rendern";
|
||||
import { agent, agentAbwarten, skriptAusfuehren } from "./gastagent";
|
||||
import { gastunabhaengig, mitStandardwerten, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
|
||||
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||
@@ -41,7 +42,7 @@ export type VmAusrollEingabe = {
|
||||
|
||||
export type VmAusrollStand = {
|
||||
knoten?: string;
|
||||
template?: { vmid: number; knoten: string };
|
||||
template?: { vmid: number; knoten: string; eingebacken: string[] };
|
||||
vmid?: number;
|
||||
ip?: string;
|
||||
agentNachMs?: number;
|
||||
@@ -81,17 +82,7 @@ export function ipconfig(netz: AusrollAnfrage["netz"]): string {
|
||||
return `ip=${netz.ip}${netz.gateway ? `,gw=${netz.gateway}` : ""}`;
|
||||
}
|
||||
|
||||
const SKRIPT = "/root/proxdeploy-provisionierung.sh";
|
||||
const LOG = "/var/log/proxdeploy-provisionierung.log";
|
||||
/**
|
||||
* file-write nimmt höchstens 60 KiB je Aufruf (API-Schema, Feld content). ProxDeploy kodiert selbst
|
||||
* als base64 (encode=0): Proxmox' eigene Kodierung scheitert an Nicht-ASCII („Wide character in
|
||||
* subroutine entry“, gefunden beim ersten echten Deploy). 40 KiB Rohdaten werden ~54 KiB base64.
|
||||
*/
|
||||
const STUECK = 40 * 1024;
|
||||
|
||||
type AgentAntwort<T> = { result: T };
|
||||
type ExecStatus = { exited: number | boolean; exitcode?: number; "out-data"?: string; "err-data"?: string };
|
||||
type Netzschnittstelle = {
|
||||
name: string;
|
||||
"ip-addresses"?: Array<{ "ip-address-type": string; "ip-address": string }>;
|
||||
@@ -101,13 +92,6 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
const { client, anfrage: a, distribution: d } = e;
|
||||
const warten = e.warten ?? schlafen;
|
||||
const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`;
|
||||
// Die Agent-Endpunkte antworten unterschiedlich: exec → {pid}, exec-status und file-read direkt,
|
||||
// ping und network-get-interfaces → {result}. Deshalb liefert der Helfer die Rohdaten.
|
||||
const agent = async <T>(
|
||||
befehl: string,
|
||||
methode: "GET" | "POST" = "POST",
|
||||
parameter?: Record<string, string | readonly string[]>,
|
||||
) => await client.aufrufen<T>({ methode, pfad: `${vmPfad()}/agent/${befehl}`, parameter });
|
||||
const templateStand: TemplateBauStand = {};
|
||||
|
||||
const schritte: PhasenSchritt[] = [
|
||||
@@ -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.");
|
||||
const ressourcen = await client.get<Ressource[]>("/cluster/resources");
|
||||
const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online");
|
||||
const templates = ressourcen.filter(
|
||||
(r) =>
|
||||
r.type === "qemu" &&
|
||||
r.template === 1 &&
|
||||
(r.tags ?? "").split(/[;, ]/).includes("proxdeploy-template") &&
|
||||
(r.tags ?? "").split(/[;, ]/).includes(d.id),
|
||||
);
|
||||
// Node: gewünscht, sonst der mit dem Template, sonst der mit dem meisten freien RAM.
|
||||
const gewaehlt = new Set(e.profile.map((p) => p.profil.id));
|
||||
const tags = (r: Ressource) => (r.tags ?? "").split(/[;, ]/);
|
||||
// Nur Templates, deren eingebackene Profile alle gewählt sind (#022) – sonst hätte der Gast
|
||||
// Software, die niemand bestellt hat.
|
||||
const templates = ressourcen
|
||||
.filter(
|
||||
(r) =>
|
||||
r.type === "qemu" &&
|
||||
r.template === 1 &&
|
||||
tags(r).includes("proxdeploy-template") &&
|
||||
tags(r).includes(d.id),
|
||||
)
|
||||
.map((r) => ({
|
||||
r,
|
||||
eingebacken: tags(r)
|
||||
.filter((t) => t.startsWith("profil-"))
|
||||
.map((t) => t.slice(7)),
|
||||
}))
|
||||
.filter((t) => t.eingebacken.every((p) => gewaehlt.has(p)))
|
||||
// Am meisten eingebacken zuerst, bei Gleichstand die kleinere VMID.
|
||||
.sort((x, y) => y.eingebacken.length - x.eingebacken.length || (x.r.vmid ?? 0) - (y.r.vmid ?? 0));
|
||||
// Node: gewünscht, sonst der mit dem besten Template, sonst der mit dem meisten freien RAM.
|
||||
stand.knoten =
|
||||
a.knoten ??
|
||||
templates.find((t) => nodes.some((n) => n.node === t.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]
|
||||
?.node;
|
||||
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.",
|
||||
);
|
||||
}
|
||||
const tpl = templates.find((t) => t.node === stand.knoten) ?? templates[0];
|
||||
if (tpl) stand.template = { vmid: tpl.vmid!, knoten: tpl.node! };
|
||||
const besten = templates.find((t) => t.r.node === stand.knoten) ?? templates[0];
|
||||
const tpl = besten?.r;
|
||||
if (besten)
|
||||
stand.template = { vmid: besten.r.vmid!, knoten: besten.r.node!, eingebacken: besten.eingebacken };
|
||||
|
||||
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${stand.knoten}/storage`);
|
||||
const ziel = storages.find((s) => s.storage === a.storage);
|
||||
@@ -193,8 +193,13 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
schluessel: "template",
|
||||
titel: "Template",
|
||||
ausfuehren: async (k) => {
|
||||
if (stand.template)
|
||||
return { uebersprungen: true, detail: `${stand.template.vmid} auf ${stand.template.knoten}` };
|
||||
if (stand.template) {
|
||||
const t = stand.template;
|
||||
return {
|
||||
uebersprungen: true,
|
||||
detail: `${t.vmid} auf ${t.knoten}${t.eingebacken.length > 0 ? ` · ${t.eingebacken.join(", ")} eingebacken` : ""}`,
|
||||
};
|
||||
}
|
||||
if (!e.templateBau) throw new AblaufFehler("Template fehlt.", "Template bauen.");
|
||||
// Die Phasen des Template-Baus laufen hier als Unterschritte; ihr Fortschritt erscheint als Detail.
|
||||
const unter = templateBauSchritte(
|
||||
@@ -218,7 +223,7 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
});
|
||||
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` };
|
||||
},
|
||||
},
|
||||
@@ -237,7 +242,19 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
target: stand.knoten!,
|
||||
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 {
|
||||
await klonen(stand.vmid!);
|
||||
@@ -314,33 +331,20 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
titel: "Guest-Agent",
|
||||
ausfuehren: async (k) => {
|
||||
if (client.trocken) return { detail: "wartet auf Antwort (ping)" };
|
||||
const beginn = Date.now();
|
||||
const grenze = e.agentMs ?? 5 * 60 * 1000;
|
||||
for (;;) {
|
||||
await k.pruefeAbbruch();
|
||||
try {
|
||||
await agent("ping");
|
||||
break;
|
||||
} catch (fehler) {
|
||||
if (Date.now() - beginn > grenze) {
|
||||
throw new AblaufFehler(
|
||||
`Der Guest-Agent antwortet nach ${Math.round(grenze / 1000)} s nicht.`,
|
||||
"Bootet die VM? Konsole ansehen. Wurde das Template von ProxDeploy gebaut (Agent eingebaut)?",
|
||||
);
|
||||
}
|
||||
if (fehler instanceof ProxmoxFehler && fehler.art === "recht") throw fehler;
|
||||
}
|
||||
await k.detail(`wartet … ${Math.round((Date.now() - beginn) / 1000)}s`);
|
||||
await warten(3000);
|
||||
}
|
||||
stand.agentNachMs = Date.now() - beginn;
|
||||
stand.agentNachMs = await agentAbwarten(client, vmPfad(), k, {
|
||||
grenzeMs: e.agentMs ?? 5 * 60 * 1000,
|
||||
warten,
|
||||
});
|
||||
// IP: bei statischer Adresse bekannt, sonst aus dem Agent (erste IPv4 außer Loopback).
|
||||
if (a.netz.modus === "statisch") {
|
||||
stand.ip = a.netz.ip!.split("/")[0];
|
||||
} else {
|
||||
for (let versuch = 0; versuch < 20 && !stand.ip; versuch += 1) {
|
||||
const schnittstellen = (
|
||||
await agent<AgentAntwort<Netzschnittstelle[]>>("network-get-interfaces", "GET")
|
||||
await agent(client, vmPfad())<AgentAntwort<Netzschnittstelle[]>>(
|
||||
"network-get-interfaces",
|
||||
"GET",
|
||||
)
|
||||
).result;
|
||||
stand.ip = schnittstellen
|
||||
.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.`,
|
||||
"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(
|
||||
e.profile,
|
||||
auszufuehren,
|
||||
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({
|
||||
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`,
|
||||
"debug",
|
||||
);
|
||||
const zusatz = uebersprungen.length > 0 ? ` (${uebersprungen.join(", ")} aus dem Template)` : "";
|
||||
const liste = auszufuehren.map((p) => p.profil.id).join(", ");
|
||||
if (client.trocken) {
|
||||
await k.log(skript, "info");
|
||||
return {
|
||||
detail:
|
||||
e.profile.length > 0
|
||||
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")}`
|
||||
: "nur Benutzer",
|
||||
};
|
||||
return { detail: `${liste ? `Profile ${liste}` : "nur Benutzer"}${zusatz}` };
|
||||
}
|
||||
|
||||
// Hochladen in Stücken (file-write ersetzt die Datei, hängt nicht an) und zusammensetzen.
|
||||
const bytes = Buffer.from(skript, "utf8");
|
||||
const stuecke: Buffer[] = [];
|
||||
for (let i = 0; i < bytes.length; i += STUECK) stuecke.push(bytes.subarray(i, i + STUECK));
|
||||
for (const [nr, inhalt] of stuecke.entries()) {
|
||||
await agent("file-write", "POST", {
|
||||
file: `${SKRIPT}.${nr}`,
|
||||
content: inhalt.toString("base64"),
|
||||
encode: "0",
|
||||
});
|
||||
}
|
||||
const zusammensetzen = stuecke.map((_, nr) => `${SKRIPT}.${nr}`).join(" ");
|
||||
const { pid } = await agent<{ pid: number }>("exec", "POST", {
|
||||
command: [
|
||||
"/bin/bash",
|
||||
"-c",
|
||||
`cat ${zusammensetzen} > ${SKRIPT} && rm -f ${zusammensetzen} && chmod 700 ${SKRIPT} && ${SKRIPT} > ${LOG} 2>&1`,
|
||||
],
|
||||
await skriptAusfuehren(client, vmPfad(), skript, k, {
|
||||
grenzeMs: e.provisionierungMs ?? 60 * 60 * 1000,
|
||||
warten,
|
||||
});
|
||||
|
||||
const beginn = Date.now();
|
||||
const grenze = e.provisionierungMs ?? 60 * 60 * 1000;
|
||||
let gelesen = 0;
|
||||
let profil = "";
|
||||
for (;;) {
|
||||
await warten(3000);
|
||||
const status = await agent<ExecStatus>("exec-status", "GET", { pid: String(pid) });
|
||||
// Fortschritt: neue Zeilen der Logdatei im Gast.
|
||||
try {
|
||||
const datei = await agent<{ content: string; truncated?: boolean }>("file-read", "GET", {
|
||||
file: LOG,
|
||||
});
|
||||
const zeilen = datei.content.split("\n");
|
||||
for (const zeile of zeilen.slice(gelesen, zeilen.length - 1)) {
|
||||
const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile);
|
||||
if (marke) profil = marke[1]!;
|
||||
await k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug");
|
||||
}
|
||||
gelesen = Math.max(gelesen, zeilen.length - 1);
|
||||
} catch {
|
||||
// Logdatei noch nicht da – beim nächsten Mal.
|
||||
}
|
||||
const sekunden = Math.round((Date.now() - beginn) / 1000);
|
||||
if (status.exited) {
|
||||
if (status.exitcode !== 0) {
|
||||
throw new AblaufFehler(
|
||||
`Provisionierung ${profil ? `im Profil ${profil} ` : ""}mit Code ${status.exitcode} abgebrochen.`,
|
||||
`Detail-Log oder ${LOG} in der VM ansehen.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (Date.now() - beginn > grenze) {
|
||||
throw new AblaufFehler(
|
||||
`Provisionierung läuft länger als ${Math.round(grenze / 60000)} min.`,
|
||||
`${LOG} in der VM ansehen.`,
|
||||
);
|
||||
}
|
||||
await k.detail(
|
||||
`${profil ? `Profil ${profil}` : "Vorbereitung"} läuft … ${Math.floor(sekunden / 60)}m ${String(sekunden % 60).padStart(2, "0")}s`,
|
||||
);
|
||||
}
|
||||
await agent("exec", "POST", { command: ["rm", "-f", SKRIPT] }).catch(() => undefined);
|
||||
return {
|
||||
detail:
|
||||
e.profile.length > 0
|
||||
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet`
|
||||
: "Benutzer eingerichtet",
|
||||
};
|
||||
return { detail: `${liste ? `Profile ${liste} angewendet` : "Benutzer eingerichtet"}${zusatz}` };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -147,6 +147,8 @@ KbdInteractiveAuthentication ${h.passwortAnmeldung ? "yes" : "no"}
|
||||
PermitRootLogin ${h.rootAnmeldung ? "prohibit-password" : "no"}
|
||||
PROXDEPLOY_SSH
|
||||
if command -v sshd >/dev/null 2>&1; then
|
||||
# Ubuntu 24.04 startet sshd per Socket – bis zur ersten Verbindung fehlt /run/sshd, und sshd -t bricht ab.
|
||||
install -d -m 0755 /run/sshd
|
||||
sshd -t
|
||||
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -56,7 +56,10 @@ export type Distribution = z.infer<typeof DistributionSchema>;
|
||||
|
||||
export const VariableSchema = z
|
||||
.object({
|
||||
name: z.string().regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich."),
|
||||
name: z
|
||||
.string()
|
||||
.regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich.")
|
||||
.refine((n) => n !== "vm" && n !== "lxc", "vm und lxc sind feste Bedingungen, keine Variablennamen."),
|
||||
label: z.string().min(1),
|
||||
type: z.enum(["string", "number", "boolean", "select"]).default("string"),
|
||||
required: z.boolean().default(false),
|
||||
|
||||
@@ -68,6 +68,40 @@ export function profileAufloesen(
|
||||
return { ok: true, profile, automatisch: reihenfolge.filter((id) => !auswahl.includes(id)) };
|
||||
}
|
||||
|
||||
/** Werte eines Profils aus der Eingabe, mit Standardwerten und Typprüfung. */
|
||||
export function variablenWerte(
|
||||
profil: Profil,
|
||||
eingabe: Record<string, unknown>,
|
||||
): { werte: Record<string, string | number | boolean>; fehler: ProfilFehler[] } {
|
||||
const werte: Record<string, string | number | boolean> = {};
|
||||
const fehler: ProfilFehler[] = [];
|
||||
for (const variable of profil.variables) {
|
||||
const ergebnis = wertPruefen(variable, eingabe[variable.name]);
|
||||
if ("fehler" in ergebnis)
|
||||
fehler.push({ profil: profil.id, variable: variable.name, meldung: ergebnis.fehler });
|
||||
else werte[variable.name] = ergebnis.wert;
|
||||
}
|
||||
return { werte, fehler };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein ins Template eingebackenes Profil muss beim Deploy nur dann nicht noch einmal laufen, wenn
|
||||
* seine Variablen genau die Standardwerte haben – mit denen wurde es eingebacken.
|
||||
*/
|
||||
export function mitStandardwerten(eintrag: AufgeloestesProfil): boolean {
|
||||
const standard = variablenWerte(eintrag.profil, {}).werte;
|
||||
return JSON.stringify(standard) === JSON.stringify(eintrag.werte);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verweist ein Profil auf `{{ guest.* }}` (Name, Benutzer), hängt es vom einzelnen Gast ab – im
|
||||
* Template stand dort etwas anderes. Solche Profile laufen beim Deploy trotzdem noch einmal; dank
|
||||
* Idempotenz ist das schnell (Fund: docker nahm sonst den Benutzer nicht in die Gruppe auf).
|
||||
*/
|
||||
export function gastunabhaengig(profil: Profil): boolean {
|
||||
return !/\{\{[^}]*\bguest\./.test(JSON.stringify(profil.families));
|
||||
}
|
||||
|
||||
function wertPruefen(
|
||||
variable: ProfilVariable,
|
||||
eingabe: unknown,
|
||||
|
||||
@@ -14,17 +14,26 @@ export const MARKE = "::proxdeploy::";
|
||||
|
||||
export type RenderKontext = {
|
||||
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 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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -87,7 +96,7 @@ export function profileRendern(
|
||||
};
|
||||
const teile: string[] = [];
|
||||
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);
|
||||
if ("ssh_harden" in schritt) zumSchluss.push(text);
|
||||
else teile.push(text);
|
||||
|
||||
@@ -119,7 +119,7 @@ async function versuchen<T>(warnungen: string[], text: string, aufruf: () => Pro
|
||||
|
||||
export async function erkunden(
|
||||
client: ProxmoxClient,
|
||||
optionen: { serverHost: string; ssh: SshZugang | null; jetzt?: () => Date },
|
||||
optionen: { serverHost: string; ssh: SshZugang | null; bauNetz?: boolean; jetzt?: () => Date },
|
||||
): Promise<VerbindungsBericht> {
|
||||
const warnungen: string[] = [];
|
||||
const version = await client.get<{ version: string; release: string }>("/version");
|
||||
@@ -313,6 +313,7 @@ export async function erkunden(
|
||||
storages: alleStorages,
|
||||
ssh: sshGesamt,
|
||||
rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
|
||||
bauNetz: optionen.bauNetz,
|
||||
}),
|
||||
warnungen,
|
||||
};
|
||||
|
||||
@@ -230,6 +230,8 @@ export function faehigkeitenAbleiten(eingabe: {
|
||||
storages: StorageInfo[];
|
||||
ssh: SshZustand;
|
||||
rechteFehlen: boolean;
|
||||
/** Bau-Adresse gesetzt (#021) – Guest-Agent einbauen geht dann auch ohne SSH zum Node. */
|
||||
bauNetz?: boolean;
|
||||
}): Faehigkeiten {
|
||||
const { version, storages, ssh } = eingabe;
|
||||
const sshOk = ssh === "ok";
|
||||
@@ -237,10 +239,19 @@ export function faehigkeitenAbleiten(eingabe: {
|
||||
const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets"));
|
||||
|
||||
let templateBau: Strategie;
|
||||
if (mindestens(version, 8, 4) && importSpeicher.length > 0) {
|
||||
const agentEinbauen = (sshOk && snippetSpeicher.length > 0) || Boolean(eingabe.bauNetz);
|
||||
if (mindestens(version, 8, 4) && importSpeicher.length > 0 && !agentEinbauen) {
|
||||
templateBau = {
|
||||
weg: "nicht_moeglich",
|
||||
grund:
|
||||
"Image-Import ginge per API, aber der Guest-Agent muss ins Template: SSH zum Node (mit Snippets-Storage) oder eine Bau-Adresse in den Server-Einstellungen.",
|
||||
};
|
||||
} else if (mindestens(version, 8, 4) && importSpeicher.length > 0) {
|
||||
templateBau = {
|
||||
weg: "api",
|
||||
grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}`,
|
||||
grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}${
|
||||
sshOk && snippetSpeicher.length > 0 ? "" : "; Guest-Agent über die Bau-Adresse"
|
||||
}`,
|
||||
};
|
||||
} else if (sshOk) {
|
||||
templateBau = {
|
||||
|
||||
@@ -40,7 +40,13 @@ export function hostSchluesselFingerabdruck(schluessel: Buffer): string {
|
||||
export function sshAusfuehren(
|
||||
ziel: SshZiel,
|
||||
befehl: string,
|
||||
optionen: { zeitlimitMs?: number; eingabe?: string; erkunden?: boolean } = {},
|
||||
optionen: {
|
||||
zeitlimitMs?: number;
|
||||
eingabe?: string;
|
||||
erkunden?: boolean;
|
||||
/** Laufende Ausgabe (stdout und stderr), z. B. für Live-Fortschritt einer Provisionierung. */
|
||||
beiAusgabe?: (text: string) => void;
|
||||
} = {},
|
||||
): Promise<SshErgebnis> {
|
||||
if (!ziel.hostSchluessel && !optionen.erkunden) {
|
||||
return Promise.reject(
|
||||
@@ -95,8 +101,16 @@ export function sshAusfuehren(
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let code: number | null = null;
|
||||
kanal.on("data", (teil: Buffer) => (stdout += teil.toString("utf8")));
|
||||
kanal.stderr.on("data", (teil: Buffer) => (stderr += teil.toString("utf8")));
|
||||
kanal.on("data", (teil: Buffer) => {
|
||||
const text = teil.toString("utf8");
|
||||
stdout += text;
|
||||
optionen.beiAusgabe?.(text);
|
||||
});
|
||||
kanal.stderr.on("data", (teil: Buffer) => {
|
||||
const text = teil.toString("utf8");
|
||||
stderr += text;
|
||||
optionen.beiAusgabe?.(text);
|
||||
});
|
||||
kanal.on("exit", (exitCode: number | null) => (code = exitCode));
|
||||
kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen }));
|
||||
if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe);
|
||||
|
||||
@@ -8,10 +8,12 @@ export async function register(): Promise<void> {
|
||||
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
|
||||
const { logger } = await import("@/lib/logger");
|
||||
const { katalogSynchronisieren } = await import("@/server/services/katalog");
|
||||
const { beispielVorlageAnlegen } = await import("@/server/services/vorlagen");
|
||||
try {
|
||||
schluesselPruefen();
|
||||
await ersterAdminFallsLeer();
|
||||
const sync = await katalogSynchronisieren();
|
||||
await beispielVorlageAnlegen();
|
||||
logger.info(
|
||||
{ distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length },
|
||||
"Katalog abgeglichen",
|
||||
|
||||
@@ -25,7 +25,7 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
|
||||
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" },
|
||||
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
|
||||
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" },
|
||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
|
||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
|
||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
|
||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
||||
{
|
||||
|
||||
@@ -58,9 +58,13 @@ export const Zugang = z.object({
|
||||
.trim()
|
||||
.regex(/^[a-z_][a-z0-9_-]{0,31}$/, "Benutzername: Kleinbuchstaben, Ziffern, _ und -")
|
||||
.refine((name) => name !== "root", "root ist nicht erlaubt – ProxDeploy legt einen sudo-Benutzer an."),
|
||||
/** Leer = die hinterlegten Schlüssel des Benutzers und die Team-Schlüssel (beim Anlegen ergänzt). */
|
||||
sshSchluessel: z
|
||||
.array(z.string().trim().regex(SSH_SCHLUESSEL, "Kein gültiger öffentlicher SSH-Schlüssel."))
|
||||
.min(1, "Mindestens ein SSH-Schlüssel – Passwörter vergibt ProxDeploy nicht."),
|
||||
.min(
|
||||
1,
|
||||
"Mindestens ein SSH-Schlüssel – Passwörter vergibt ProxDeploy nicht. In den Einstellungen hinterlegen oder hier angeben.",
|
||||
),
|
||||
});
|
||||
|
||||
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). */
|
||||
templateBauen: z.boolean().default(false),
|
||||
startBeiBoot: z.boolean().default(true),
|
||||
/** Name der Vorlage, die angewendet wurde (nur zur Nachverfolgung; angewendet wird vorher). */
|
||||
vorlage: z.string().optional(),
|
||||
})
|
||||
.refine((a) => a.typ === "lxc" || a.diskGb >= 4, {
|
||||
message: "Mindestens 4 GB für eine VM.",
|
||||
@@ -120,6 +126,7 @@ export type AusrollEingabe = z.input<typeof AusrollAnfrage>;
|
||||
*/
|
||||
export function cliBefehl(a: AusrollAnfrage): string {
|
||||
const teile = [`proxdeploy ${a.typ} ${a.name}`, `--server ${a.server}`];
|
||||
if (a.vorlage) teile.push(`--preset ${a.vorlage}`);
|
||||
if (a.knoten) teile.push(`--node ${a.knoten}`);
|
||||
teile.push(`--distro ${a.distribution}`);
|
||||
if (a.profile.length > 0 && !(a.profile.length === 1 && a.profile[0] === "base"))
|
||||
|
||||
@@ -30,6 +30,35 @@ export const ServerUrl = z
|
||||
return `${url.protocol}//${url.host}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Bau-Adresse (#021): eine feste Adresse für den Vorbereitungsstart beim Template-Bau, wenn es
|
||||
* kein SSH zum Node gibt. ProxDeploy meldet sich darüber per SSH in der VM an.
|
||||
*/
|
||||
export const BauNetz = z
|
||||
.object({
|
||||
ip: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, "Format: 10.0.10.250/24"),
|
||||
gateway: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(\d{1,3}\.){3}\d{1,3}$/, "Format: 10.0.10.1"),
|
||||
dns: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(\d{1,3}\.){3}\d{1,3}$/),
|
||||
)
|
||||
.max(3)
|
||||
.default([]),
|
||||
})
|
||||
.nullable()
|
||||
.default(null);
|
||||
|
||||
export type BauNetz = NonNullable<z.infer<typeof BauNetz>>;
|
||||
|
||||
const Vmid = z.coerce.number().int("Ganze Zahl erwartet.").min(100, "Mindestens 100.").max(999_999_999);
|
||||
|
||||
const Basis = z.object({
|
||||
@@ -52,6 +81,7 @@ const Basis = z.object({
|
||||
templateVmidVon: Vmid.default(9000),
|
||||
templateVmidBis: Vmid.default(9999),
|
||||
gastVmidVon: Vmid.default(100),
|
||||
bauNetz: BauNetz,
|
||||
});
|
||||
|
||||
function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) {
|
||||
@@ -112,6 +142,7 @@ export type ServerSicht = {
|
||||
templateVmidVon: number;
|
||||
templateVmidBis: number;
|
||||
gastVmidVon: number;
|
||||
bauNetz: BauNetz | null;
|
||||
erreichbar: boolean | null;
|
||||
pveVersion: string | null;
|
||||
geprueftAm: string | null;
|
||||
|
||||
@@ -7,6 +7,8 @@ export const TemplateBauAnfrage = z.object({
|
||||
storage: z.string().min(1, "Storage fehlt."),
|
||||
bridge: z.string().min(1).default("vmbr0"),
|
||||
vlan: z.coerce.number().int().min(1).max(4094).optional(),
|
||||
/** Profile, die ins Template eingebacken werden (#022) – nur solche ohne Pflichtvariablen. */
|
||||
profile: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export type TemplateBauAnfrage = z.infer<typeof TemplateBauAnfrage>;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { z } from "@/lib/validierung";
|
||||
|
||||
/**
|
||||
* Was eine Vorlage festlegen darf. Bewusst ohne Name, VMID, IP und Schlüssel – das ist je Gast
|
||||
* verschieden. Server und Storage dürfen drin stehen, sind aber optional.
|
||||
*/
|
||||
export const VorlagenParameter = z
|
||||
.object({
|
||||
typ: z.enum(["vm", "lxc"]).optional(),
|
||||
server: z.string().trim().optional(),
|
||||
distribution: z.string().trim().optional(),
|
||||
cores: z.coerce.number().int().min(1).max(128).optional(),
|
||||
speicherMb: z.coerce.number().int().min(256).optional(),
|
||||
diskGb: z.coerce.number().int().min(1).optional(),
|
||||
storage: z.string().trim().optional(),
|
||||
swapMb: z.coerce.number().int().min(0).optional(),
|
||||
unprivilegiert: z.boolean().optional(),
|
||||
features: z.object({ nesting: z.boolean(), keyctl: z.boolean() }).partial().optional(),
|
||||
netz: z
|
||||
.object({ bridge: z.string().trim(), vlan: z.coerce.number().int().min(1).max(4094) })
|
||||
.partial()
|
||||
.optional(),
|
||||
profile: z.array(z.string()).optional(),
|
||||
variablen: z
|
||||
.record(z.string(), z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])))
|
||||
.optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type VorlagenParameter = z.infer<typeof VorlagenParameter>;
|
||||
|
||||
export const VorlageEingabe = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(/^[a-z0-9][a-z0-9-]{0,47}$/, "Kurzname: Kleinbuchstaben, Ziffern und Bindestrich."),
|
||||
titel: z.string().trim().min(1, "Titel fehlt.").max(80),
|
||||
beschreibung: z.string().trim().max(500).optional(),
|
||||
parameter: VorlagenParameter,
|
||||
});
|
||||
|
||||
/**
|
||||
* Legt eine Vorlage unter eine Anfrage: was die Anfrage selbst angibt, gewinnt. Objekte (netz,
|
||||
* features, variablen) werden feldweise gemischt, Listen ersetzt.
|
||||
*/
|
||||
export function vorlageAnwenden(
|
||||
vorlage: VorlagenParameter,
|
||||
anfrage: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const ergebnis: Record<string, unknown> = { ...vorlage };
|
||||
for (const [schluessel, wert] of Object.entries(anfrage)) {
|
||||
if (wert === undefined) continue;
|
||||
const basis = ergebnis[schluessel];
|
||||
if (
|
||||
basis &&
|
||||
typeof basis === "object" &&
|
||||
!Array.isArray(basis) &&
|
||||
wert &&
|
||||
typeof wert === "object" &&
|
||||
!Array.isArray(wert)
|
||||
) {
|
||||
ergebnis[schluessel] =
|
||||
schluessel === "variablen"
|
||||
? Object.fromEntries(
|
||||
[...new Set([...Object.keys(basis), ...Object.keys(wert)])].map((p) => [
|
||||
p,
|
||||
{ ...(basis as Record<string, object>)[p], ...(wert as Record<string, object>)[p] },
|
||||
]),
|
||||
)
|
||||
: { ...basis, ...wert };
|
||||
} else {
|
||||
ergebnis[schluessel] = wert;
|
||||
}
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
||||
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
||||
*/
|
||||
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.2.0";
|
||||
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.3.0";
|
||||
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
||||
|
||||
export function versionText(): string {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
import type { Server } from "@/generated/prisma/client";
|
||||
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||
import { lxcAusrollSchritte, type LxcAusrollStand } from "@/engine/ablaeufe/lxc-ausrollen";
|
||||
import { vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen";
|
||||
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
|
||||
import type { Distribution, Profil } from "@/engine/katalog/schema";
|
||||
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen";
|
||||
import type { BauNetz } from "@/lib/schemas/server";
|
||||
import { vorlageAnwenden } from "@/lib/schemas/vorlage";
|
||||
import { vorlageHolen } from "@/server/services/vorlagen";
|
||||
import type { Aufrufer } from "@/server/auth/zugang";
|
||||
import { AnwendungsFehler } from "@/server/fehler";
|
||||
import { auftragAnlegen } from "@/server/services/auftraege";
|
||||
import { serverHolen, verbindungAus } from "@/server/services/server";
|
||||
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
|
||||
import { schluesselFuer } from "@/server/services/schluessel";
|
||||
import { vmidVorschlagen } from "@/server/services/vmid";
|
||||
|
||||
/**
|
||||
@@ -26,7 +33,14 @@ export type 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 zeile = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } });
|
||||
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"}.`,
|
||||
);
|
||||
}
|
||||
if (anfrage.typ === "lxc") {
|
||||
throw new AnwendungsFehler(
|
||||
400,
|
||||
"nicht_umgesetzt",
|
||||
"LXC-Container folgen mit #009.",
|
||||
"Vorerst als VM ausrollen.",
|
||||
);
|
||||
}
|
||||
const verfuegbar = new Map(
|
||||
(await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [
|
||||
p.id,
|
||||
@@ -74,8 +80,22 @@ export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> {
|
||||
return { anfrage, server, distribution, profile: aufloesung.profile, automatisch: aufloesung.automatisch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Anfrage keine Schlüssel an, kommen die hinterlegten des Benutzers und die Team-Schlüssel
|
||||
* hinein. Das passiert beim Anlegen, nicht im worker: dort ist bekannt, wer ausrollt, und die
|
||||
* Anfrage im Verlauf zeigt, welche Schlüssel tatsächlich im Gast gelandet sind.
|
||||
*/
|
||||
export async function schluesselErgaenzen(roh: unknown, aufrufer: Aufrufer): Promise<unknown> {
|
||||
if (!roh || typeof roh !== "object") return roh;
|
||||
const anfrage = roh as { zugang?: { benutzer?: unknown; sshSchluessel?: unknown } };
|
||||
const angegeben = Array.isArray(anfrage.zugang?.sshSchluessel) ? anfrage.zugang.sshSchluessel : [];
|
||||
if (angegeben.length > 0) return roh;
|
||||
const hinterlegt = (await schluesselFuer(aufrufer.benutzer)).map((s) => s.oeffentlich);
|
||||
return { ...anfrage, zugang: { ...anfrage.zugang, sshSchluessel: hinterlegt } };
|
||||
}
|
||||
|
||||
export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: string | null) {
|
||||
const v = await ausrollenVorbereiten(roh);
|
||||
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
|
||||
return await auftragAnlegen(
|
||||
{
|
||||
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 = {
|
||||
kopf: string;
|
||||
phasen: Array<{
|
||||
@@ -114,8 +200,8 @@ export type Plan = {
|
||||
* geht wirklich an Proxmox (VMID frei? Template da? Bridge VLAN-fähig?), Schreibendes wird nur
|
||||
* aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde.
|
||||
*/
|
||||
export async function ausrollenPlanen(roh: unknown): Promise<Plan> {
|
||||
const v = await ausrollenVorbereiten(roh);
|
||||
export async function ausrollenPlanen(roh: unknown, aufrufer: Aufrufer): Promise<Plan> {
|
||||
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
|
||||
const client = new ProxmoxClient(verbindungAus(v.server), { trocken: true, zeitlimitMs: 15_000 });
|
||||
const phasen: Plan["phasen"] = [];
|
||||
let skript: string | null = null;
|
||||
@@ -138,25 +224,17 @@ export async function ausrollenPlanen(roh: unknown): Promise<Plan> {
|
||||
if (angaben?.phase === "provisionierung" && nachricht.startsWith("#!/bin/bash")) skript = nachricht;
|
||||
},
|
||||
};
|
||||
const stand: VmAusrollStand = {};
|
||||
const stand: AusrollStand = {};
|
||||
try {
|
||||
const ergebnis = await phasenAusfuehren(
|
||||
vmAusrollSchritte(
|
||||
ausrollSchritte(
|
||||
v,
|
||||
{
|
||||
client,
|
||||
anfrage: v.anfrage,
|
||||
distribution: v.distribution,
|
||||
profile: v.profile,
|
||||
vmidReservieren: (belegt, wunsch) =>
|
||||
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.gastVmidVon, wunsch }),
|
||||
templateBau: v.anfrage.templateBauen
|
||||
? {
|
||||
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten),
|
||||
auftragKurzId: "dry-run",
|
||||
vmidReservieren: (belegt) =>
|
||||
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }),
|
||||
}
|
||||
: undefined,
|
||||
templateVmidReservieren: (belegt) =>
|
||||
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }),
|
||||
angelegt: async () => {},
|
||||
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 [
|
||||
`Server ${v.server.name}`,
|
||||
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)
|
||||
.join(" · ");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { join } from "node:path";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { katalogLaden, type KatalogFehler } from "@/engine/katalog/laden";
|
||||
import { adapterFuer, bekannteFamilien } from "@/engine/adapter";
|
||||
import { katalogLaden, yamlPruefen, type KatalogFehler } from "@/engine/katalog/laden";
|
||||
import { ProfilSchema } from "@/engine/katalog/schema";
|
||||
import { profileAufloesen } from "@/engine/profile/aufloesen";
|
||||
import { profileRendern } from "@/engine/profile/rendern";
|
||||
import type { Distribution, Profil } from "@/engine/katalog/schema";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { Aufrufer } from "@/server/auth/zugang";
|
||||
import { nichtGefunden } from "@/server/fehler";
|
||||
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
|
||||
/** Wo der Katalog liegt: im Repo und im Image unter ./catalog, überschreibbar für Tests. */
|
||||
@@ -209,3 +213,160 @@ export async function profilAktivSetzen(id: string, aktiv: boolean, aufrufer: Au
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Eigene Profile (#011) ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ProfilPruefung =
|
||||
{ ok: true; profil: Profil; vorschau: string | null } | { ok: false; fehler: string[] };
|
||||
|
||||
/**
|
||||
* Prüft ein Profil aus dem Editor: YAML, Schema, Voraussetzungen. Mit Vorschau des erzeugten
|
||||
* Skripts für die erste Distribution der Familie, damit man sieht, was im Gast ankommt.
|
||||
*/
|
||||
export async function profilPruefen(quelltext: string): Promise<ProfilPruefung> {
|
||||
const geprueft = yamlPruefen(quelltext, ProfilSchema);
|
||||
if (!geprueft.ok) return { ok: false, fehler: geprueft.fehler };
|
||||
const profil = geprueft.eintrag;
|
||||
const alle = new Map(
|
||||
(await prisma.profil.findMany({ where: { entferntAm: null } })).map((p) => [
|
||||
p.id,
|
||||
p.definition as Profil,
|
||||
]),
|
||||
);
|
||||
alle.set(profil.id, profil);
|
||||
const fehler: string[] = [];
|
||||
for (const familie of Object.keys(profil.families)) {
|
||||
if (!adapterFuer(familie))
|
||||
fehler.push(
|
||||
`families.${familie}: Für diese Familie gibt es keinen Adapter (bekannt: ${bekannteFamilien().join(", ")}).`,
|
||||
);
|
||||
}
|
||||
const familie = Object.keys(profil.families).find((f) => adapterFuer(f));
|
||||
let vorschau: string | null = null;
|
||||
if (familie) {
|
||||
const aufgeloest = profileAufloesen([profil.id], alle, familie, {
|
||||
// Für die Vorschau Pflichtvariablen mit einem Platzhalter füllen.
|
||||
[profil.id]: Object.fromEntries(
|
||||
profil.variables.filter((v) => v.required).map((v) => [v.name, `<${v.name}>`]),
|
||||
),
|
||||
});
|
||||
if (!aufgeloest.ok) {
|
||||
fehler.push(...aufgeloest.fehler.map((f) => `${f.profil}: ${f.meldung}`));
|
||||
} else {
|
||||
const distro = (await distributionenListe({ nurAktive: true })).find((d) => d.familie === familie);
|
||||
try {
|
||||
vorschau =
|
||||
profileRendern(
|
||||
aufgeloest.profile.filter((p) => p.profil.id === profil.id),
|
||||
adapterFuer(familie)!,
|
||||
{
|
||||
os: distro?.definition.os ?? { id: familie, version_id: "0" },
|
||||
gast: { name: "beispiel", benutzer: "benutzer", typ: "vm" },
|
||||
},
|
||||
).abschnitte[0]?.skript ?? null;
|
||||
} catch (ursache) {
|
||||
fehler.push(ursache instanceof Error ? ursache.message : String(ursache));
|
||||
}
|
||||
}
|
||||
}
|
||||
return fehler.length > 0 ? { ok: false, fehler } : { ok: true, profil, vorschau };
|
||||
}
|
||||
|
||||
export async function profilSpeichern(
|
||||
quelltext: string,
|
||||
aufrufer: Aufrufer,
|
||||
ip: string | null,
|
||||
bisherigeId?: string,
|
||||
): Promise<ProfilSicht> {
|
||||
const pruefung = await profilPruefen(quelltext);
|
||||
if (!pruefung.ok)
|
||||
throw new AnwendungsFehler(
|
||||
400,
|
||||
"ungueltig",
|
||||
pruefung.fehler.join(" "),
|
||||
"Fehlerstellen im Editor beheben.",
|
||||
);
|
||||
const { profil } = pruefung;
|
||||
const vorhanden = await prisma.profil.findUnique({ where: { id: profil.id } });
|
||||
if (bisherigeId && bisherigeId !== profil.id) {
|
||||
throw new AnwendungsFehler(
|
||||
400,
|
||||
"ungueltig",
|
||||
"Die id eines Profils lässt sich nicht ändern.",
|
||||
"Als neues Profil mit anderer id speichern.",
|
||||
);
|
||||
}
|
||||
if (vorhanden && (vorhanden.herkunft === "KATALOG" || !bisherigeId) && !vorhanden.entferntAm) {
|
||||
throw new AnwendungsFehler(
|
||||
409,
|
||||
"vergeben",
|
||||
vorhanden.herkunft === "KATALOG"
|
||||
? `„${profil.id}“ ist ein Katalogprofil.`
|
||||
: `Ein Profil „${profil.id}“ gibt es schon.`,
|
||||
vorhanden.herkunft === "KATALOG"
|
||||
? "Für eine eigene Fassung eine andere id wählen, z. B. docker-eigen."
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
const daten = {
|
||||
name: profil.name,
|
||||
beschreibung: profil.description ?? null,
|
||||
familien: Object.keys(profil.families),
|
||||
herkunft: "EIGEN" as const,
|
||||
definition: profil as unknown as Prisma.InputJsonValue,
|
||||
quelltext,
|
||||
entferntAm: null,
|
||||
geaendertVonId: aufrufer.benutzer.id,
|
||||
};
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.profil.upsert({ where: { id: profil.id }, create: { id: profil.id, ...daten }, update: daten });
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: bisherigeId ? "Profil geändert" : "Profil angelegt",
|
||||
ziel: `profil:${profil.id}`,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
return (await profileListe()).find((p) => p.id === profil.id)!;
|
||||
}
|
||||
|
||||
export async function profilLoeschen(id: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
|
||||
const profil = await prisma.profil.findUnique({ where: { id } });
|
||||
if (!profil || profil.entferntAm) throw nichtGefunden(`Das Profil „${id}“`);
|
||||
if (profil.herkunft === "KATALOG") {
|
||||
throw new AnwendungsFehler(
|
||||
409,
|
||||
"katalog",
|
||||
"Katalogprofile lassen sich nicht löschen.",
|
||||
"Deaktivieren oder die YAML-Datei aus catalog/profiles entfernen.",
|
||||
);
|
||||
}
|
||||
const abhaengig = (await prisma.profil.findMany({ where: { entferntAm: null } })).filter((p) =>
|
||||
(p.definition as Profil).requires?.includes(id),
|
||||
);
|
||||
if (abhaengig.length > 0) {
|
||||
throw new AnwendungsFehler(
|
||||
409,
|
||||
"benutzt",
|
||||
`${abhaengig.map((p) => p.id).join(", ")} setzt „${id}“ voraus.`,
|
||||
);
|
||||
}
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Aufträge nennen Profile nur beim Namen – löschen ist sicher, der Verlauf bleibt lesbar.
|
||||
await tx.profil.delete({ where: { id } });
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: "Profil gelöscht",
|
||||
ziel: `profil:${id}`,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ServerAendern,
|
||||
ServerAnlegen,
|
||||
ServerPruefen,
|
||||
type BauNetz,
|
||||
type ServerSicht,
|
||||
} from "@/lib/schemas/server";
|
||||
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
||||
@@ -32,6 +33,7 @@ export function serverSicht(server: Server): ServerSicht {
|
||||
templateVmidVon: server.templateVmidVon,
|
||||
templateVmidBis: server.templateVmidBis,
|
||||
gastVmidVon: server.gastVmidVon,
|
||||
bauNetz: (server.bauNetz as BauNetz | null) ?? null,
|
||||
erreichbar: server.erreichbar,
|
||||
pveVersion: server.pveVersion,
|
||||
geprueftAm: server.geprueftAm?.toISOString() ?? null,
|
||||
@@ -83,6 +85,7 @@ export async function serverAnlegen(
|
||||
const angelegt = await tx.server.create({
|
||||
data: {
|
||||
...eingabe,
|
||||
bauNetz: eingabe.bauNetz ?? Prisma.JsonNull,
|
||||
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
|
||||
tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis),
|
||||
},
|
||||
@@ -117,12 +120,19 @@ export async function serverAendern(
|
||||
const { tokenGeheimnis, ...rest } = eingabe;
|
||||
const daten = {
|
||||
...rest,
|
||||
bauNetz: rest.bauNetz ?? Prisma.JsonNull,
|
||||
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
|
||||
...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}),
|
||||
};
|
||||
// Welche Felder sich geändert haben, steht im Protokoll – die Werte von Geheimnissen nie.
|
||||
// JSON-Felder (bauNetz) vergleichen nach Inhalt; Prisma.JsonNull steht für „leer“ wie null.
|
||||
const vergleichbar = (wert: unknown) => JSON.stringify(wert === Prisma.JsonNull ? null : (wert ?? null));
|
||||
const geaendert = Object.entries(daten)
|
||||
.filter(([feld, wert]) => feld === "tokenGeheimnis" || (bisher as Record<string, unknown>)[feld] !== wert)
|
||||
.filter(
|
||||
([feld, wert]) =>
|
||||
feld === "tokenGeheimnis" ||
|
||||
vergleichbar((bisher as Record<string, unknown>)[feld]) !== vergleichbar(wert),
|
||||
)
|
||||
.map(([feld]) => feld);
|
||||
|
||||
const server = await prisma.$transaction(async (tx) => {
|
||||
@@ -287,6 +297,7 @@ export async function serverTesten(
|
||||
try {
|
||||
const bericht = await erkunden(client, {
|
||||
serverHost: new URL(server.url).hostname,
|
||||
bauNetz: Boolean(server.bauNetz),
|
||||
ssh: server.sshAktiv
|
||||
? {
|
||||
benutzer: server.sshBenutzer,
|
||||
|
||||
@@ -29,6 +29,6 @@ export function nodeBefehlFuer(server: Server, knoten: string): NodeBefehl | nul
|
||||
hostSchluessel: pin,
|
||||
},
|
||||
befehl,
|
||||
{ eingabe: optionen.eingabe, zeitlimitMs: optionen.zeitlimitMs },
|
||||
{ eingabe: optionen.eingabe, zeitlimitMs: optionen.zeitlimitMs, beiAusgabe: optionen.beiAusgabe },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -27,6 +28,7 @@ export async function templateBauStarten(
|
||||
if (!(distribution.definition as Distribution).vm) {
|
||||
throw new AnwendungsFehler(400, "ungueltig", `${distribution.name} bietet kein VM-Image.`);
|
||||
}
|
||||
await templateProfileAufloesen(anfrage.profile, (distribution.definition as Distribution).family);
|
||||
return await auftragAnlegen(
|
||||
{
|
||||
art: "TEMPLATE_BAUEN",
|
||||
@@ -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 = {
|
||||
vmid: number;
|
||||
name: string;
|
||||
@@ -49,6 +78,7 @@ export type TemplateSicht = {
|
||||
distributionId: string | null;
|
||||
gebautAm: string | null;
|
||||
imageDatei: string | null;
|
||||
profile: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -74,9 +104,13 @@ export async function templatesListe(serverName: string): Promise<TemplateSicht[
|
||||
vmid: r.vmid,
|
||||
name: r.name ?? `VM ${r.vmid}`,
|
||||
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,
|
||||
imageDatei: db?.imageDatei ?? null,
|
||||
profile: tags.filter((t) => t.startsWith("profil-")).map((t) => t.slice("profil-".length)),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.vmid - b.vmid);
|
||||
|
||||
@@ -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: {},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
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 { prisma } from "@/lib/db";
|
||||
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 { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||
import { vmidReservieren } from "@/server/services/vmid";
|
||||
import { ablaufRegistrieren } from "../ausfuehren";
|
||||
import { aufrufLogger } from "../aufruf-log";
|
||||
@@ -13,7 +17,7 @@ import { aufrufLogger } from "../aufruf-log";
|
||||
ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, angelegt }) => {
|
||||
// Dieselbe Vorbereitung wie beim Anlegen – der Katalog könnte sich seitdem geändert haben.
|
||||
const v = await ausrollenVorbereiten(auftrag.anfrage);
|
||||
const stand: VmAusrollStand = {};
|
||||
const stand: AusrollStand = {};
|
||||
const kopfSetzen = async () => {
|
||||
await prisma.auftrag.update({
|
||||
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) });
|
||||
try {
|
||||
const ergebnis = await phasenAusfuehren(
|
||||
vmAusrollSchritte(
|
||||
ausrollSchritte(
|
||||
v,
|
||||
{
|
||||
client,
|
||||
anfrage: v.anfrage,
|
||||
distribution: v.distribution,
|
||||
profile: v.profile,
|
||||
vmidReservieren: async (belegt, wunsch) => {
|
||||
const vmid = await vmidReservieren({
|
||||
serverId: v.server.id,
|
||||
@@ -44,20 +46,14 @@ ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, ange
|
||||
await kopfSetzen();
|
||||
return vmid;
|
||||
},
|
||||
templateBau: v.anfrage.templateBauen
|
||||
? {
|
||||
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten),
|
||||
auftragKurzId: auftrag.kurzId,
|
||||
vmidReservieren: (belegt) =>
|
||||
vmidReservieren({
|
||||
serverId: v.server.id,
|
||||
auftragId: auftrag.id,
|
||||
belegt,
|
||||
von: v.server.templateVmidVon,
|
||||
bis: v.server.templateVmidBis,
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
templateVmidReservieren: (belegt) =>
|
||||
vmidReservieren({
|
||||
serverId: v.server.id,
|
||||
auftragId: auftrag.id,
|
||||
belegt,
|
||||
von: v.server.templateVmidVon,
|
||||
bis: v.server.templateVmidBis,
|
||||
}),
|
||||
angelegt,
|
||||
auftragKurzId: auftrag.kurzId,
|
||||
},
|
||||
|
||||
@@ -3,10 +3,13 @@ import { templateBauSchritte, templateName, type TemplateBauStand } from "@/engi
|
||||
import type { Distribution } from "@/engine/katalog/schema";
|
||||
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { BauNetz } from "@/lib/schemas/server";
|
||||
import { TemplateBauAnfrage } from "@/lib/schemas/template";
|
||||
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
|
||||
import { melden } from "@/server/ereignisse/senke";
|
||||
import { verbindungAus } from "@/server/services/server";
|
||||
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||
import { templateProfileAufloesen } from "@/server/services/templates";
|
||||
import { vmidReservieren } from "@/server/services/vmid";
|
||||
import { ablaufRegistrieren } from "../ausfuehren";
|
||||
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 zeile = await prisma.distribution.findUniqueOrThrow({ where: { id: anfrage.distribution } });
|
||||
const distribution = zeile.definition as Distribution;
|
||||
const profile = await templateProfileAufloesen(anfrage.profile, distribution.family);
|
||||
|
||||
await prisma.auftrag.update({
|
||||
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);
|
||||
|
||||
@@ -31,11 +39,14 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht,
|
||||
{
|
||||
client,
|
||||
distribution,
|
||||
profile,
|
||||
knoten: anfrage.knoten,
|
||||
storage: anfrage.storage,
|
||||
bridge: anfrage.bridge,
|
||||
vlan: anfrage.vlan,
|
||||
ssh: nodeBefehlFuer(server, anfrage.knoten),
|
||||
bauNetz: (server.bauNetz as BauNetz | null) ?? null,
|
||||
bereitstellung: bereitstellungsschluessel,
|
||||
auftragKurzId: auftrag.kurzId,
|
||||
vmidReservieren: async (belegt) => {
|
||||
const vmid = await vmidReservieren({
|
||||
@@ -65,12 +76,20 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht,
|
||||
storage: anfrage.storage,
|
||||
imageDatei: stand.imageDatei!,
|
||||
pruefsumme: stand.pruefsumme!,
|
||||
profile: profile.map((p) => p.profil.id),
|
||||
gebautVonAuftragId: auftrag.id,
|
||||
},
|
||||
});
|
||||
return {
|
||||
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 {
|
||||
await client.schliessen();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -32,7 +32,7 @@ async function mitSzenario(szenario: Szenario) {
|
||||
describe("Verbindungstest (Erkundung)", () => {
|
||||
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 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.cluster).toBeNull();
|
||||
expect(bericht.knoten).toHaveLength(1);
|
||||
|
||||
@@ -101,17 +101,27 @@ describe("Strategien", () => {
|
||||
const mitImport = [storage("local", ["iso", "import", "snippets"])];
|
||||
const ohneImport = [storage("local", ["iso", "snippets"])];
|
||||
|
||||
it("PVE 9.2 mit import-Storage: Template-Bau per API, auch ohne SSH", () => {
|
||||
const f = faehigkeitenAbleiten({
|
||||
it("PVE 9.2 mit import-Storage ohne SSH: Image ginge per API, der Guest-Agent aber nicht – außer mit Bau-Adresse", () => {
|
||||
const ohne = faehigkeitenAbleiten({
|
||||
version: v("9.2.1"),
|
||||
storages: mitImport,
|
||||
ssh: "aus",
|
||||
rechteFehlen: false,
|
||||
});
|
||||
expect(f.templateBau.weg).toBe("api");
|
||||
expect(f.vmProvisionierung.weg).toBe("api");
|
||||
expect(f.lxcProvisionierung).toMatchObject({ weg: "ssh", ueber: "gast" });
|
||||
expect(f.snippets.weg).toBe("nicht_moeglich");
|
||||
expect(ohne.templateBau.weg).toBe("nicht_moeglich");
|
||||
expect(ohne.templateBau.grund).toMatch(/Bau-Adresse/);
|
||||
const mit = faehigkeitenAbleiten({
|
||||
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", () => {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,9 @@ async function aufbauen(o: MockOptionen = {}) {
|
||||
: { data: UPID("qmclone") },
|
||||
)
|
||||
.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("GET", /^\/nodes\/pve01\/qemu\/\d+\/config$/, () => ({
|
||||
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,
|
||||
new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])),
|
||||
"debian",
|
||||
anfrage.variablen,
|
||||
);
|
||||
if (!aufgeloest.ok) throw new Error("Profile");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user