feat(ausrollen): VM ausrollen mit Assistent, Dry-Run und CLI-Befehl
Eine VM aus dem ProxDeploy-Template ausrollen, vollständig per API: Klonen, Hardware, cloud-init, Start, Guest-Agent und Provisionierung der Profile über den Agent – ohne SSH. Der Fortschritt kommt aus der Logdatei im Gast, je Profil eine Marke. Der Dry-Run läuft durch dieselben Phasen mit einem Trocken-Client und zeigt jeden geplanten Proxmox-Aufruf und den passenden CLI-Befehl. Der Assistent hat 9 Schritte, den Zustand in der URL und bietet an, ein fehlendes Template im selben Auftrag zu bauen. Auf pve-demo abgenommen: pd-test01 (base, docker) per API, pd-test02 (base, zabbix-agent) über den Assistenten, beide per SSH geprüft. Dabei gefunden: file-write scheitert an Umlauten – Skripte gehen jetzt base64-kodiert hinüber. Closes #008 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+20
-3
@@ -8,9 +8,6 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
- Katalog als Daten: Debian 12/13 und Ubuntu 22.04/24.04/26.04 sowie die Profile base, docker und zabbix-agent als YAML unter `catalog/`, beim Start in die Datenbank abgeglichen (#004)
|
|
||||||
- Katalogseite mit Aktiv-Schaltern für Distributionen und Profile; `GET /api/v1/distributionen?typ=vm|lxc`, `GET /api/v1/profile` (#004)
|
|
||||||
- README mit Stand, Fähigkeiten und Entwicklungsumgebung (#018)
|
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
||||||
@@ -20,6 +17,26 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.2.0] – 2026-10-12
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Katalog als Daten: Debian 12/13 und Ubuntu 22.04/24.04/26.04 sowie die Profile base, docker und zabbix-agent als YAML unter `catalog/`, beim Start in die Datenbank abgeglichen (#004)
|
||||||
|
- Katalogseite mit Aktiv-Schaltern für Distributionen und Profile; `GET /api/v1/distributionen?typ=vm|lxc`, `GET /api/v1/profile` (#004)
|
||||||
|
- README mit Stand, Fähigkeiten und Entwicklungsumgebung (#018)
|
||||||
|
- Provisionierungs-Adapter `debian` und Profil-Engine: Profile werden zu einem idempotenten Skript; geprüft mit shellcheck und echt in Containern für alle fünf Distributionen (`pnpm test:container`) (#005)
|
||||||
|
- `docs/katalog.md`: neue Distribution, neuer Adapter, eigenes Profil (#005)
|
||||||
|
- Aufträge im worker (pg-boss) mit Phasen, Detail-Log und Live-Fortschritt per Server-Sent Events; Live-Ansicht im Aufbau der CLI-Ausgabe, Verlaufsseite (#006)
|
||||||
|
- Template-Bau: Cloud-Image mit Prüfsumme laden, VM mit q35/UEFI anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren; Templates-Seite (#007)
|
||||||
|
- VM ausrollen: Klonen, Hardware, cloud-init, Start, Guest-Agent, Provisionierung der Profile über den Guest-Agent – ohne SSH (#008)
|
||||||
|
- Deploy-Assistent mit 9 Schritten, Dry-Run mit allen geplanten Proxmox-Aufrufen und passendem CLI-Befehl (#008)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Provisionierungsskripte mit Umlauten scheiterten an Proxmox' file-write; sie werden jetzt base64-kodiert übertragen (#008)
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- Profil-Variablen werden in Shell-Befehlen immer gequotet; Zeitzone, Locale und Tastatur gegen strenge Muster geprüft (#005)
|
||||||
|
- VMIDs werden unter einer Sperre je Server vergeben – parallele Aufträge bekommen nie dieselbe ID (#006)
|
||||||
|
|
||||||
## [0.1.0] – 2026-10-12
|
## [0.1.0] – 2026-10-12
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 1 h 24 min (laufende Sitzung)
|
**Gesamt:** 2 h 50 min (Sitzung 1)
|
||||||
|
|
||||||
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
||||||
|
|
||||||
@@ -12,17 +12,21 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
| #002 Server-Verwaltung | 0 h 11 min |
|
| #002 Server-Verwaltung | 0 h 11 min |
|
||||||
| #003 Verbindungstest | 0 h 25 min |
|
| #003 Verbindungstest | 0 h 25 min |
|
||||||
| #004 Katalog | 0 h 09 min |
|
| #004 Katalog | 0 h 09 min |
|
||||||
|
| #005 Adapter und Profile | 0 h 20 min |
|
||||||
|
| #006 Aufträge und Live-Fortschritt | 0 h 15 min |
|
||||||
|
| #007 Template-Bau | 0 h 12 min |
|
||||||
|
| #008 VM ausrollen und Assistent | 0 h 36 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
| v0.1.0 | 1 h 15 min | **abgeschlossen**
|
| v0.1.0 | 1 h 15 min | **abgeschlossen**
|
||||||
| v0.2.0 | – | in Arbeit
|
| v0.2.0 | 1 h 32 min | **abgeschlossen**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-10-12 · Sitzung 1 · 08:45–laufend
|
## 2026-10-12 · Sitzung 1 · 08:45–11:35
|
||||||
**Issue:** #001
|
**Issue:** #001
|
||||||
**Version:** 0.1.0-dev
|
**Version:** 0.1.0, 0.2.0
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt: Reverse-Proxy (ja), OIDC (implementieren, vorerst nur lokal testen), Testumgebung (folgt), CLI (Go)
|
- Auftrag gelesen, Rückfragen gestellt: Reverse-Proxy (ja), OIDC (implementieren, vorerst nur lokal testen), Testumgebung (folgt), CLI (Go)
|
||||||
@@ -35,6 +39,11 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
- Feldtest auf dem Testhost `pve-demo` (PVE 9.2.2): Rolle/Token nach Doku per SSH angelegt, Fingerprint und Host-Key gegen den Node verglichen, Test mit und ohne SSH – alles wie erwartet
|
- Feldtest auf dem Testhost `pve-demo` (PVE 9.2.2): Rolle/Token nach Doku per SSH angelegt, Fingerprint und Host-Key gegen den Node verglichen, Test mit und ohne SSH – alles wie erwartet
|
||||||
- Meilenstein v0.1.0 abgeschlossen, getaggt und nach Gitea gepusht
|
- Meilenstein v0.1.0 abgeschlossen, getaggt und nach Gitea gepusht
|
||||||
- README als Zwischenstand angelegt (Florian: „readme im projekt fehlt“)
|
- README als Zwischenstand angelegt (Florian: „readme im projekt fehlt“)
|
||||||
|
- #005 abgeschlossen: Adapter debian, Profil-Engine; base+docker+zabbix-agent echt in Containern auf Debian 12/13 und Ubuntu 22.04/24.04/26.04 inkl. Idempotenz
|
||||||
|
- #006 abgeschlossen: worker, Phasen, SSE, Live-Ansicht, VMID-Sperre
|
||||||
|
- #007 abgeschlossen: erstes echtes Template (9000, Debian 13) auf pve-demo in 5 min 26 s
|
||||||
|
- #008 abgeschlossen: erste echte VMs auf pve-demo – pd-test01 per API (base, docker), pd-test02 über den Assistenten (base, zabbix-agent), per SSH geprüft; 219 Tests, 12 Playwright-Abläufe
|
||||||
|
- Meilenstein v0.2.0 abgeschlossen, getaggt und gepusht
|
||||||
- #004 abgeschlossen: Katalog mit fünf Distributionen und drei Profilen, Abgleich, Katalogseite; 150 Tests, 12 Playwright-Abläufe
|
- #004 abgeschlossen: Katalog mit fünf Distributionen und drei Profilen, Abgleich, Katalogseite; 150 Tests, 12 Playwright-Abläufe
|
||||||
- #002 abgeschlossen: Server-Verwaltung mit Fingerprint-Pinning, Proxmox-Client, Proxmox-Mock (HTTPS, selbst signiert); 99 Tests, 10 Playwright-Abläufe
|
- #002 abgeschlossen: Server-Verwaltung mit Fingerprint-Pinning, Proxmox-Client, Proxmox-Mock (HTTPS, selbst signiert); 99 Tests, 10 Playwright-Abläufe
|
||||||
- Testhost 10.0.10.228 von Florian bereitgestellt (Installation läuft)
|
- Testhost 10.0.10.228 von Florian bereitgestellt (Installation läuft)
|
||||||
@@ -52,4 +61,8 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
- **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1.
|
- **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1.
|
||||||
- **TLS-Pinning über einen eigenen undici-Connector** statt `rejectUnauthorized: false` + Nachprüfen: die Verbindung wird getrennt, bevor das Token gesendet wird. Sitzungs-Cache aus, sonst fehlt bei Wiederaufnahme das Zertifikat.
|
- **TLS-Pinning über einen eigenen undici-Connector** statt `rejectUnauthorized: false` + Nachprüfen: die Verbindung wird getrennt, bevor das Token gesendet wird. Sitzungs-Cache aus, sonst fehlt bei Wiederaufnahme das Zertifikat.
|
||||||
- **Zugang zum Testhost über einen eigenen SSH-Schlüssel** (`~/.ssh/proxdeploy-testhost`), nicht über das root-Passwort: Claude Code verweigert das Anlegen von Zugängen mit Passwort, und ein Schlüssel nur für diesen Host ist ohnehin der sauberere Weg.
|
- **Zugang zum Testhost über einen eigenen SSH-Schlüssel** (`~/.ssh/proxdeploy-testhost`), nicht über das root-Passwort: Claude Code verweigert das Anlegen von Zugängen mit Passwort, und ein Schlüssel nur für diesen Host ist ohnehin der sauberere Weg.
|
||||||
|
- **Trockenmodus im Proxmox-Client statt eigenem Planer:** Dry-Run und Ernstfall laufen durch dieselben Phasen; Lesendes geht echt an Proxmox, Schreibendes wird aufgezeichnet. So können Plan und Wirklichkeit nicht auseinanderlaufen.
|
||||||
|
- **Guest-Agent wird beim Template-Bau per Vorbereitungsstart eingebaut** (vendor-data-Snippet), nicht bei jedem Deployment – Deployments laufen danach ohne SSH.
|
||||||
|
- **Skripte base64 über file-write:** Proxmox' eigene Kodierung scheitert an Umlauten (Fund beim ersten echten Deploy).
|
||||||
|
- **Template-Bau ohne SSH (Bau-Adresse) ausgegliedert** als #021, damit Etappe 2 nicht wartet.
|
||||||
- **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe).
|
- **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe).
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ In Entwicklung. Version und Fortschritt stehen im [CHANGELOG](CHANGELOG.md), der
|
|||||||
| Etappe | Inhalt | Stand |
|
| Etappe | Inhalt | Stand |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft |
|
| 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 | ◐ in Arbeit |
|
| 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 | ○ geplant |
|
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen | ◐ als Nächstes |
|
||||||
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ○ geplant |
|
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ○ geplant |
|
||||||
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ geplant |
|
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ geplant |
|
||||||
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
|
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
**Status:** erledigt
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** feature, proxmox
|
**Labels:** feature, proxmox
|
||||||
**Geschätzt:** 8 h **Tatsächlich:** 0 h 20 min
|
**Geschätzt:** 8 h **Tatsächlich:** 0 h 12 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren.
|
Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren.
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
# #008 Deploy-Assistent und VM ausrollen
|
# #008 Deploy-Assistent und VM ausrollen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** feature
|
**Labels:** feature
|
||||||
**Geschätzt:** 10 h **Tatsächlich:** –
|
**Geschätzt:** 10 h **Tatsächlich:** 0 h 36 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Debian-13-VM aus Template vollständig unattended, Assistent mit Dry-Run und CLI-Befehl.
|
Debian-13-VM aus Template vollständig unattended, Assistent mit Dry-Run und CLI-Befehl.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Planer VM (Phasen + geplante API-Aufrufe), `POST /auftraege/plan`
|
- [x] Anfrageschema für Web, CLI, Batch und Vorlagen (`src/lib/schemas/ausrollen.ts`), CLI-Befehl daraus
|
||||||
- [ ] Ausführung: Klonen, Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Agent, Provisionierung per Agent
|
- [x] Ablauf VM: Prüfungen (Node, Template, Storage, Bridge/VLAN, VMID), Klonen (mit einem Wiederholversuch bei belegter VMID), Hardware + Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Guest-Agent (ping, IP), Provisionierung per Agent mit Live-Fortschritt
|
||||||
- [ ] Fehlendes Template → automatisch bauen anbieten
|
- [x] Dry-Run über denselben Ablauf mit Trocken-Client: `POST /api/v1/auftraege/plan`
|
||||||
- [ ] Assistent mit 9 Schritten, URL-Zustand, Handy-tauglich
|
- [x] Fehlendes Template → „Template vorher bauen“ im Assistenten, läuft als Phase im selben Auftrag
|
||||||
- [ ] Aufräumen nach Bestätigung
|
- [x] Assistent mit 9 Schritten, Zustand in der URL, jederzeit zurückspringbar, Handy-tauglich
|
||||||
|
- [ ] Aufräumen nach Bestätigung → folgt mit #014 (Verlauf); bis dahin werden angelegte VMIDs je Auftrag festgehalten
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] VM mit base-Profil per SSH erreichbar; Fehlerfall zeigt Ursache + Hinweis
|
- [x] VM mit base-Profil per SSH erreichbar: pd-test01 (base, docker) per API, pd-test02 (base, zabbix-agent) über den Assistenten auf pve-demo, 2026-10-12
|
||||||
|
- [x] Fehlerfall zeigt Ursache + Hinweis (Tests: VLAN auf nicht VLAN-fähiger Bridge, Agent antwortet nicht, Profil scheitert mit Code)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#007
|
#007
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Fund beim ersten echten Deploy:** `agent/file-write` scheitert an Nicht-ASCII („Wide character in subroutine entry“). ProxDeploy kodiert die Skriptstücke jetzt selbst als base64 (`encode=0`) und teilt nach Bytes.
|
||||||
|
- **Die Agent-Endpunkte antworten unterschiedlich** (exec → `{pid}`, exec-status/file-read direkt, ping/network-get-interfaces → `{result}`).
|
||||||
|
- **Fortschritt der Provisionierung** aus der Logdatei im Gast (`/var/log/proxdeploy-provisionierung.log`, per file-read), Marken je Profil.
|
||||||
|
- **Klon auf anderen Node** nur bei geteiltem Storage; sonst sagt die Prüfung, welchen Node man wählen soll.
|
||||||
|
- Zwei Felder hießen „Server“ (Umschalter und Assistent) – der Umschalter heißt jetzt „Aktiver Server“.
|
||||||
|
- Testgäste auf pve-demo: VM 100 `pd-test01`, VM 101 `pd-test02` (Tag `proxdeploy-test`).
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | erledigt | v0.2.0 | 5 h | 0 h 09 min |
|
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | erledigt | v0.2.0 | 5 h | 0 h 09 min |
|
||||||
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | erledigt | v0.2.0 | 6 h | 0 h 20 min |
|
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | erledigt | v0.2.0 | 6 h | 0 h 20 min |
|
||||||
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min |
|
| [#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 20 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 | offen | v0.2.0 | 10 h | – |
|
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min |
|
||||||
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – |
|
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – |
|
||||||
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – |
|
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – |
|
||||||
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – |
|
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – |
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxdeploy",
|
"name": "proxdeploy",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
||||||
|
|||||||
@@ -0,0 +1,982 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { ArrowLeft, ArrowRight, Copy } from "lucide-react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Code, EINGABE_KLASSE, Feld, Schalter } from "@/components/formular";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Knopf } from "@/components/knopf";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
|
||||||
|
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||||
|
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen";
|
||||||
|
|
||||||
|
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ProfilVariable = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: "string" | "number" | "boolean" | "select";
|
||||||
|
required: boolean;
|
||||||
|
default?: string | number | boolean;
|
||||||
|
options?: string[];
|
||||||
|
help?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssistentDaten = {
|
||||||
|
server: Array<{
|
||||||
|
name: string;
|
||||||
|
titel: string;
|
||||||
|
knoten: Array<{
|
||||||
|
name: string;
|
||||||
|
speicherFrei: number;
|
||||||
|
storages: Array<{ name: string; vm: boolean; lxc: boolean; frei: number | null }>;
|
||||||
|
bridges: Array<{ name: string; vlan: boolean }>;
|
||||||
|
}>;
|
||||||
|
templates: Array<{ distribution: string; knoten: string; vmid: number }>;
|
||||||
|
}>;
|
||||||
|
distributionen: Array<{ id: string; name: string; familie: string; vm: boolean; lxc: boolean }>;
|
||||||
|
profile: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
beschreibung: string | null;
|
||||||
|
familien: string[];
|
||||||
|
requires: string[];
|
||||||
|
variablen: ProfilVariable[];
|
||||||
|
}>;
|
||||||
|
benutzer: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Plan = {
|
||||||
|
kopf: string;
|
||||||
|
phasen: Array<{
|
||||||
|
schluessel: string;
|
||||||
|
titel: string;
|
||||||
|
status: string;
|
||||||
|
detail: string | null;
|
||||||
|
ursache: string | null;
|
||||||
|
hinweis: string | null;
|
||||||
|
}>;
|
||||||
|
aufrufe: Array<{ methode: string; pfad: string; parameter?: Record<string, unknown> }>;
|
||||||
|
skript: string | null;
|
||||||
|
cliBefehl: string;
|
||||||
|
automatisch: string[];
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Zustand ─────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Werte = {
|
||||||
|
server: string;
|
||||||
|
knoten: string;
|
||||||
|
typ: "vm" | "lxc";
|
||||||
|
distribution: string;
|
||||||
|
name: string;
|
||||||
|
vmid: string;
|
||||||
|
tags: string;
|
||||||
|
cores: string;
|
||||||
|
speicherMb: string;
|
||||||
|
diskGb: string;
|
||||||
|
storage: string;
|
||||||
|
swapMb: string;
|
||||||
|
unprivilegiert: boolean;
|
||||||
|
nesting: boolean;
|
||||||
|
bridge: string;
|
||||||
|
vlan: string;
|
||||||
|
modus: "dhcp" | "statisch";
|
||||||
|
ip: string;
|
||||||
|
gateway: string;
|
||||||
|
dns: string;
|
||||||
|
suchdomain: string;
|
||||||
|
benutzer: string;
|
||||||
|
schluessel: string;
|
||||||
|
profile: string[];
|
||||||
|
variablen: Record<string, Record<string, string | boolean>>;
|
||||||
|
templateBauen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHRITTE = [
|
||||||
|
"Server",
|
||||||
|
"Typ",
|
||||||
|
"Distribution",
|
||||||
|
"Name",
|
||||||
|
"Ressourcen",
|
||||||
|
"Netzwerk",
|
||||||
|
"Zugang",
|
||||||
|
"Profile",
|
||||||
|
"Zusammenfassung",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function kodieren(werte: Werte): string {
|
||||||
|
return btoa(unescape(encodeURIComponent(JSON.stringify(werte))))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dekodieren(text: string | null): Partial<Werte> {
|
||||||
|
if (!text) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(decodeURIComponent(escape(atob(text.replace(/-/g, "+").replace(/_/g, "/")))));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHLUESSEL_SPEICHER = "proxdeploy.ssh-schluessel";
|
||||||
|
|
||||||
|
function anfrageAus(w: Werte): AusrollEingabe {
|
||||||
|
const zahl = (text: string) => (text.trim() === "" ? undefined : Number(text));
|
||||||
|
return {
|
||||||
|
typ: w.typ,
|
||||||
|
server: w.server,
|
||||||
|
knoten: w.knoten || undefined,
|
||||||
|
distribution: w.distribution,
|
||||||
|
name: w.name,
|
||||||
|
vmid: zahl(w.vmid),
|
||||||
|
tags: w.tags.split(/[\s,;]+/).filter(Boolean),
|
||||||
|
cores: Number(w.cores),
|
||||||
|
speicherMb: Number(w.speicherMb),
|
||||||
|
diskGb: Number(w.diskGb),
|
||||||
|
storage: w.storage,
|
||||||
|
swapMb: Number(w.swapMb),
|
||||||
|
unprivilegiert: w.unprivilegiert,
|
||||||
|
features: { nesting: w.nesting, keyctl: false },
|
||||||
|
netz: {
|
||||||
|
bridge: w.bridge,
|
||||||
|
vlan: zahl(w.vlan),
|
||||||
|
modus: w.modus,
|
||||||
|
ip: w.modus === "statisch" ? w.ip : undefined,
|
||||||
|
gateway: w.modus === "statisch" && w.gateway ? w.gateway : undefined,
|
||||||
|
dns: w.dns.split(/[\s,;]+/).filter(Boolean),
|
||||||
|
suchdomain: w.suchdomain || undefined,
|
||||||
|
},
|
||||||
|
zugang: {
|
||||||
|
benutzer: w.benutzer,
|
||||||
|
sshSchluessel: w.schluessel
|
||||||
|
.split("\n")
|
||||||
|
.map((z) => z.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
},
|
||||||
|
profile: w.profile,
|
||||||
|
variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))),
|
||||||
|
templateBauen: w.templateBauen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Komponente ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Deploy-Assistent. Der Zustand steht in der URL (`?s=<schritt>&d=<werte>`): Zurückspringen,
|
||||||
|
* Neuladen und Weitergeben funktionieren damit ohne Server-Zwischenspeicher. Geprüft wird
|
||||||
|
* endgültig auf dem Server – hier nur so viel, dass niemand ins Leere klickt.
|
||||||
|
*/
|
||||||
|
export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; vorauswahl: string | null }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pfad = usePathname();
|
||||||
|
const parameter = useSearchParams();
|
||||||
|
const schritt = Math.min(Math.max(Number(parameter.get("s") ?? 0), 0), SCHRITTE.length - 1);
|
||||||
|
|
||||||
|
const anfang = useMemo<Werte>(() => {
|
||||||
|
const server = daten.server.find((s) => s.name === vorauswahl) ?? daten.server[0]!;
|
||||||
|
const knoten = server.knoten[0];
|
||||||
|
return {
|
||||||
|
server: server.name,
|
||||||
|
knoten: knoten?.name ?? "",
|
||||||
|
typ: "vm",
|
||||||
|
distribution:
|
||||||
|
daten.distributionen.find((d) => d.id === "debian-13")?.id ?? daten.distributionen[0]?.id ?? "",
|
||||||
|
name: "",
|
||||||
|
vmid: "",
|
||||||
|
tags: "",
|
||||||
|
cores: "2",
|
||||||
|
speicherMb: "2048",
|
||||||
|
diskGb: "20",
|
||||||
|
storage: knoten?.storages.find((s) => s.vm)?.name ?? "",
|
||||||
|
swapMb: "512",
|
||||||
|
unprivilegiert: true,
|
||||||
|
nesting: false,
|
||||||
|
bridge: knoten?.bridges.find((b) => b.name === "vmbr0")?.name ?? knoten?.bridges[0]?.name ?? "",
|
||||||
|
vlan: "",
|
||||||
|
modus: "dhcp",
|
||||||
|
ip: "",
|
||||||
|
gateway: "",
|
||||||
|
dns: "",
|
||||||
|
suchdomain: "",
|
||||||
|
benutzer: daten.benutzer.replace(/[^a-z0-9_-]/g, "") || "admin",
|
||||||
|
schluessel: "",
|
||||||
|
profile: ["base"],
|
||||||
|
variablen: {},
|
||||||
|
templateBauen: false,
|
||||||
|
};
|
||||||
|
}, [daten, vorauswahl]);
|
||||||
|
|
||||||
|
const [werte, setWerte] = useState<Werte>(() => ({ ...anfang, ...dekodieren(parameter.get("d")) }));
|
||||||
|
const [plan, setPlan] = useState<Plan | null>(null);
|
||||||
|
const [fehler, setFehler] = useState<{
|
||||||
|
text: string;
|
||||||
|
hinweis?: string;
|
||||||
|
felder?: Record<string, string>;
|
||||||
|
} | null>(null);
|
||||||
|
const [laeuft, setLaeuft] = useState(false);
|
||||||
|
const [kopiert, setKopiert] = useState(false);
|
||||||
|
|
||||||
|
const server = daten.server.find((s) => s.name === werte.server)!;
|
||||||
|
const knoten = server.knoten.find((k) => k.name === werte.knoten) ?? server.knoten[0];
|
||||||
|
const distribution = daten.distributionen.find((d) => d.id === werte.distribution);
|
||||||
|
const template = server.templates.find(
|
||||||
|
(t) => t.distribution === werte.distribution && (!werte.knoten || t.knoten === werte.knoten),
|
||||||
|
);
|
||||||
|
const bridge = knoten?.bridges.find((b) => b.name === werte.bridge);
|
||||||
|
|
||||||
|
const setze = (aenderung: Partial<Werte>) => {
|
||||||
|
setWerte((alt) => ({ ...alt, ...aenderung }));
|
||||||
|
setPlan(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gehe = (ziel: number) => {
|
||||||
|
let naechste = werte;
|
||||||
|
// Gespeicherte Schlüssel als Bequemlichkeit vorbelegen (bis die Schlüsselverwaltung kommt, #015).
|
||||||
|
if (SCHRITTE[ziel] === "Zugang" && !werte.schluessel) {
|
||||||
|
try {
|
||||||
|
const gemerkt = localStorage.getItem(SCHLUESSEL_SPEICHER);
|
||||||
|
if (gemerkt) {
|
||||||
|
naechste = { ...werte, schluessel: gemerkt };
|
||||||
|
setWerte(naechste);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// kein Speicher verfügbar – dann eben nicht
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (SCHRITTE[ziel] === "Zusammenfassung") void planen(naechste);
|
||||||
|
const neu = new URLSearchParams({ s: String(ziel), d: kodieren(naechste) });
|
||||||
|
router.replace(`${pfad}?${neu.toString()}`, { scroll: false });
|
||||||
|
setFehler(null);
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Was einen Schritt blockiert – als Text, damit „Weiter“ sagt, warum es nicht geht.
|
||||||
|
const sperre = (nr: number): string | null => {
|
||||||
|
switch (SCHRITTE[nr]) {
|
||||||
|
case "Server":
|
||||||
|
return !werte.server ? "Server wählen." : null;
|
||||||
|
case "Distribution":
|
||||||
|
if (!distribution) return "Distribution wählen.";
|
||||||
|
if (!distribution[werte.typ])
|
||||||
|
return `${distribution.name} gibt es nicht als ${werte.typ.toUpperCase()}.`;
|
||||||
|
if (werte.typ === "vm" && !template && !werte.templateBauen)
|
||||||
|
return "Template fehlt – „Template vorher bauen“ wählen.";
|
||||||
|
return null;
|
||||||
|
case "Name":
|
||||||
|
if (!GAST_NAME.test(werte.name)) return "Name: Kleinbuchstaben, Ziffern und Bindestrich.";
|
||||||
|
if (werte.vmid && (!/^\d+$/.test(werte.vmid) || Number(werte.vmid) < 100))
|
||||||
|
return "VMID ab 100 oder leer lassen.";
|
||||||
|
return null;
|
||||||
|
case "Ressourcen":
|
||||||
|
return !werte.storage ? "Storage wählen." : null;
|
||||||
|
case "Netzwerk":
|
||||||
|
if (!werte.bridge) return "Bridge wählen.";
|
||||||
|
if (werte.vlan && bridge && !bridge.vlan) return `Bridge ${bridge.name} ist nicht VLAN-fähig.`;
|
||||||
|
if (werte.modus === "statisch" && !/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(werte.ip))
|
||||||
|
return "IP mit Präfix angeben, z. B. 10.0.20.15/24.";
|
||||||
|
return null;
|
||||||
|
case "Zugang": {
|
||||||
|
const schluessel = werte.schluessel
|
||||||
|
.split("\n")
|
||||||
|
.map((z) => z.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(werte.benutzer) || werte.benutzer === "root")
|
||||||
|
return "Gültigen Benutzernamen angeben (nicht root).";
|
||||||
|
if (schluessel.length === 0) return "Mindestens einen öffentlichen SSH-Schlüssel angeben.";
|
||||||
|
if (schluessel.some((z) => !SSH_SCHLUESSEL.test(z)))
|
||||||
|
return "Eine Zeile ist kein öffentlicher SSH-Schlüssel.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ersteSperre = SCHRITTE.findIndex((_, nr) => nr < schritt && sperre(nr) !== null);
|
||||||
|
|
||||||
|
async function planen(stand: Werte = werte) {
|
||||||
|
setLaeuft(true);
|
||||||
|
setFehler(null);
|
||||||
|
try {
|
||||||
|
setPlan(await apiAufruf<Plan>("POST", "/api/v1/auftraege/plan", anfrageAus(stand)));
|
||||||
|
} catch (ursache) {
|
||||||
|
setFehler(
|
||||||
|
ursache instanceof ApiFehler
|
||||||
|
? { text: ursache.message, hinweis: ursache.hinweis, felder: ursache.felder }
|
||||||
|
: { text: "Unerwarteter Fehler." },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ausrollen() {
|
||||||
|
setLaeuft(true);
|
||||||
|
setFehler(null);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SCHLUESSEL_SPEICHER, werte.schluessel);
|
||||||
|
} catch {
|
||||||
|
// egal
|
||||||
|
}
|
||||||
|
const { kurzId } = await apiAufruf<{ kurzId: string }>("POST", "/api/v1/auftraege", anfrageAus(werte));
|
||||||
|
router.push(`/auftraege/${kurzId}`);
|
||||||
|
} catch (ursache) {
|
||||||
|
setFehler(
|
||||||
|
ursache instanceof ApiFehler
|
||||||
|
? { text: ursache.message, hinweis: ursache.hinweis, felder: ursache.felder }
|
||||||
|
: { text: "Unerwarteter Fehler." },
|
||||||
|
);
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direkt geöffneter Link auf die Zusammenfassung: nach dem ersten Rendern planen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (SCHRITTE[schritt] !== "Zusammenfassung" || ersteSperre >= 0) return;
|
||||||
|
const uhr = setTimeout(() => void planen(), 0);
|
||||||
|
return () => clearTimeout(uhr);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const aktuelleSperre = sperre(schritt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Ausrollen</h1>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
Schritt {schritt + 1} von {SCHRITTE.length} · {SCHRITTE[schritt]}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[14rem_1fr]">
|
||||||
|
{/* Schrittleiste: jederzeit zurückspringbar, nach vorne nur bis zum ersten offenen Schritt. */}
|
||||||
|
<nav aria-label="Schritte" className="-mx-4 overflow-x-auto px-4 lg:mx-0 lg:px-0">
|
||||||
|
<ol className="flex gap-2 lg:flex-col lg:gap-1">
|
||||||
|
{SCHRITTE.map((titel, nr) => {
|
||||||
|
const gesperrt = SCHRITTE.slice(0, nr).some((_, vorher) => sperre(vorher) !== null);
|
||||||
|
return (
|
||||||
|
<li key={titel} className="shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={gesperrt}
|
||||||
|
onClick={() => gehe(nr)}
|
||||||
|
aria-current={nr === schritt ? "step" : undefined}
|
||||||
|
className={clsx(
|
||||||
|
"flex h-10 items-center gap-2 rounded-lg px-3 text-sm whitespace-nowrap transition disabled:opacity-40 lg:w-full",
|
||||||
|
nr === schritt ? "bg-panel-hoch text-text font-semibold" : "text-muted hover:text-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={clsx("font-mono text-xs", nr === schritt && "text-akzent")}>
|
||||||
|
{String(nr + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
{titel}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
|
{SCHRITTE[schritt] === "Server" ? (
|
||||||
|
<Karte titel="Server und Node">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld id="server" label="Server">
|
||||||
|
<select
|
||||||
|
id="server"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.server}
|
||||||
|
onChange={(e) => {
|
||||||
|
const s = daten.server.find((x) => x.name === e.target.value)!;
|
||||||
|
const k = s.knoten[0];
|
||||||
|
setze({
|
||||||
|
server: s.name,
|
||||||
|
knoten: k?.name ?? "",
|
||||||
|
storage: k?.storages.find((x) => x.vm)?.name ?? "",
|
||||||
|
bridge: k?.bridges[0]?.name ?? "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{daten.server.map((s) => (
|
||||||
|
<option key={s.name} value={s.name}>
|
||||||
|
{s.name} · {s.titel}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="knoten"
|
||||||
|
label="Node"
|
||||||
|
hilfe="Leer = ProxDeploy wählt (Node mit dem Template, sonst der mit dem meisten freien RAM)."
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="knoten"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.knoten}
|
||||||
|
onChange={(e) => {
|
||||||
|
const k = server.knoten.find((x) => x.name === e.target.value);
|
||||||
|
setze({
|
||||||
|
knoten: e.target.value,
|
||||||
|
storage: k?.storages.find((x) => x.vm)?.name ?? werte.storage,
|
||||||
|
bridge: k?.bridges[0]?.name ?? werte.bridge,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">automatisch</option>
|
||||||
|
{server.knoten.map((k) => (
|
||||||
|
<option key={k.name} value={k.name}>
|
||||||
|
{k.name} · {Math.round(k.speicherFrei / 1024 ** 3)} GiB RAM frei
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Typ" ? (
|
||||||
|
<Karte titel="VM oder Container">
|
||||||
|
<fieldset className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<legend className="sr-only">Typ</legend>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"vm",
|
||||||
|
"VM",
|
||||||
|
"Eigener Kernel, aus Cloud-Image-Template, Provisionierung über den Guest-Agent.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lxc",
|
||||||
|
"LXC-Container",
|
||||||
|
"Leichtgewichtig, aus Proxmox-Container-Template. Folgt mit #009.",
|
||||||
|
],
|
||||||
|
] as const
|
||||||
|
).map(([wert, titel, text]) => (
|
||||||
|
<label
|
||||||
|
key={wert}
|
||||||
|
className={clsx(
|
||||||
|
"border-linie flex cursor-pointer flex-col gap-1 rounded-lg border p-4",
|
||||||
|
werte.typ === wert && "border-akzent",
|
||||||
|
wert === "lxc" && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-semibold">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="typ"
|
||||||
|
value={wert}
|
||||||
|
checked={werte.typ === wert}
|
||||||
|
disabled={wert === "lxc"}
|
||||||
|
onChange={() => setze({ typ: wert })}
|
||||||
|
className="accent-akzent h-4 w-4"
|
||||||
|
/>
|
||||||
|
{titel}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-sm">{text}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Distribution" ? (
|
||||||
|
<Karte titel="Distribution">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<fieldset className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<legend className="sr-only">Distribution</legend>
|
||||||
|
{daten.distributionen
|
||||||
|
.filter((d) => d[werte.typ])
|
||||||
|
.map((d) => {
|
||||||
|
const hatTemplate = server.templates.some((t) => t.distribution === d.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={d.id}
|
||||||
|
className={clsx(
|
||||||
|
"border-linie flex cursor-pointer items-start gap-3 rounded-lg border p-3",
|
||||||
|
werte.distribution === d.id && "border-akzent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="distribution"
|
||||||
|
checked={werte.distribution === d.id}
|
||||||
|
onChange={() => setze({ distribution: d.id, templateBauen: false })}
|
||||||
|
className="accent-akzent mt-1 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span className="font-medium">{d.name}</span>
|
||||||
|
{werte.typ === "vm" ? (
|
||||||
|
<Status art={hatTemplate ? "erledigt" : "offen"} className="text-xs">
|
||||||
|
{hatTemplate ? "Template vorhanden" : "noch kein Template"}
|
||||||
|
</Status>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</fieldset>
|
||||||
|
{werte.typ === "vm" && distribution && !template ? (
|
||||||
|
<Schalter
|
||||||
|
id="templateBauen"
|
||||||
|
label="Template vorher bauen"
|
||||||
|
hilfe={`Für ${distribution.name} gibt es auf ${server.name} noch kein Template. Der Bau dauert einige Minuten und läuft als Teil dieses Auftrags.`}
|
||||||
|
checked={werte.templateBauen}
|
||||||
|
onChange={(an) => setze({ templateBauen: an })}
|
||||||
|
/>
|
||||||
|
) : template ? (
|
||||||
|
<Status art="erledigt">
|
||||||
|
Template {template.vmid} auf {template.knoten}
|
||||||
|
</Status>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Name" ? (
|
||||||
|
<Karte titel="Name, ID und Tags">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Feld id="name" label="Name (Hostname)" fehler={fehler?.felder?.["name"]}>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.name}
|
||||||
|
onChange={(e) => setze({ name: e.target.value.toLowerCase() })}
|
||||||
|
placeholder="web01"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="vmid" label="VMID" hilfe="Leer = nächste freie">
|
||||||
|
<input
|
||||||
|
id="vmid"
|
||||||
|
inputMode="numeric"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.vmid}
|
||||||
|
onChange={(e) => setze({ vmid: e.target.value })}
|
||||||
|
placeholder="automatisch"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="tags" label="Tags" hilfe="Kommagetrennt">
|
||||||
|
<input
|
||||||
|
id="tags"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.tags}
|
||||||
|
onChange={(e) => setze({ tags: e.target.value })}
|
||||||
|
placeholder="web,produktion"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Ressourcen" ? (
|
||||||
|
<Karte titel="Ressourcen">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Feld id="cores" label="CPU-Kerne">
|
||||||
|
<input
|
||||||
|
id="cores"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={128}
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.cores}
|
||||||
|
onChange={(e) => setze({ cores: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="speicher" label="RAM (MB)">
|
||||||
|
<input
|
||||||
|
id="speicher"
|
||||||
|
type="number"
|
||||||
|
min={256}
|
||||||
|
step={256}
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.speicherMb}
|
||||||
|
onChange={(e) => setze({ speicherMb: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="disk" label="Disk (GB)">
|
||||||
|
<input
|
||||||
|
id="disk"
|
||||||
|
type="number"
|
||||||
|
min={4}
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.diskGb}
|
||||||
|
onChange={(e) => setze({ diskGb: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="storage" label="Storage">
|
||||||
|
<select
|
||||||
|
id="storage"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.storage}
|
||||||
|
onChange={(e) => setze({ storage: e.target.value })}
|
||||||
|
>
|
||||||
|
{(knoten?.storages ?? [])
|
||||||
|
.filter((s) => (werte.typ === "vm" ? s.vm : s.lxc))
|
||||||
|
.map((s) => (
|
||||||
|
<option key={s.name} value={s.name}>
|
||||||
|
{s.name}
|
||||||
|
{s.frei !== null ? ` · ${Math.round(s.frei / 1024 ** 3)} GiB frei` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Netzwerk" ? (
|
||||||
|
<Karte titel="Netzwerk">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld id="bridge" label="Bridge">
|
||||||
|
<select
|
||||||
|
id="bridge"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.bridge}
|
||||||
|
onChange={(e) => setze({ bridge: e.target.value })}
|
||||||
|
>
|
||||||
|
{(knoten?.bridges ?? []).map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>
|
||||||
|
{b.name}
|
||||||
|
{b.vlan ? " · VLAN-fähig" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="vlan"
|
||||||
|
label="VLAN"
|
||||||
|
hilfe={bridge && !bridge.vlan ? "Diese Bridge ist nicht VLAN-fähig." : "Leer = ohne Tag"}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="vlan"
|
||||||
|
inputMode="numeric"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.vlan}
|
||||||
|
onChange={(e) => setze({ vlan: e.target.value })}
|
||||||
|
placeholder="–"
|
||||||
|
disabled={Boolean(bridge && !bridge.vlan)}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
<fieldset className="flex gap-6">
|
||||||
|
<legend className="mb-2 text-sm font-medium">Adresse</legend>
|
||||||
|
{(["dhcp", "statisch"] as const).map((m) => (
|
||||||
|
<label key={m} className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="modus"
|
||||||
|
checked={werte.modus === m}
|
||||||
|
onChange={() => setze({ modus: m })}
|
||||||
|
className="accent-akzent h-4 w-4"
|
||||||
|
/>
|
||||||
|
{m === "dhcp" ? "DHCP" : "Statisch"}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
{werte.modus === "statisch" ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld id="ip" label="IP/Präfix">
|
||||||
|
<input
|
||||||
|
id="ip"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.ip}
|
||||||
|
onChange={(e) => setze({ ip: e.target.value })}
|
||||||
|
placeholder="10.0.20.15/24"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="gateway" label="Gateway">
|
||||||
|
<input
|
||||||
|
id="gateway"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.gateway}
|
||||||
|
onChange={(e) => setze({ gateway: e.target.value })}
|
||||||
|
placeholder="10.0.20.1"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld id="dns" label="DNS-Server" hilfe="Leer = vom Host übernehmen">
|
||||||
|
<input
|
||||||
|
id="dns"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.dns}
|
||||||
|
onChange={(e) => setze({ dns: e.target.value })}
|
||||||
|
placeholder="10.0.20.1"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="suchdomain" label="Suchdomain">
|
||||||
|
<input
|
||||||
|
id="suchdomain"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={werte.suchdomain}
|
||||||
|
onChange={(e) => setze({ suchdomain: e.target.value })}
|
||||||
|
placeholder="example.lan"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Zugang" ? (
|
||||||
|
<Karte titel="Zugang">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Feld id="benutzer" label="Benutzer" hilfe="Wird mit sudo angelegt. Passwort-Login ist aus.">
|
||||||
|
<input
|
||||||
|
id="benutzer"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono sm:max-w-xs`}
|
||||||
|
value={werte.benutzer}
|
||||||
|
onChange={(e) => setze({ benutzer: e.target.value })}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="schluessel"
|
||||||
|
label="Öffentliche SSH-Schlüssel"
|
||||||
|
hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Gespeicherte Schlüssel folgen mit #015."
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="schluessel"
|
||||||
|
rows={4}
|
||||||
|
className={`${EINGABE_KLASSE} h-auto py-2 font-mono text-xs`}
|
||||||
|
value={werte.schluessel}
|
||||||
|
onChange={(e) => setze({ schluessel: e.target.value })}
|
||||||
|
placeholder="ssh-ed25519 AAAA… name@rechner"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Profile" ? (
|
||||||
|
<Karte titel="Profile">
|
||||||
|
<ul className="flex flex-col gap-4">
|
||||||
|
{daten.profile.map((p) => {
|
||||||
|
const umsetzbar = distribution ? p.familien.includes(distribution.familie) : false;
|
||||||
|
const gewaehlt = werte.profile.includes(p.id);
|
||||||
|
const benoetigtVon = daten.profile
|
||||||
|
.filter((x) => werte.profile.includes(x.id) && x.requires.includes(p.id))
|
||||||
|
.map((x) => x.name);
|
||||||
|
return (
|
||||||
|
<li key={p.id} className="flex flex-col gap-3">
|
||||||
|
<Schalter
|
||||||
|
id={`profil-${p.id}`}
|
||||||
|
label={p.name}
|
||||||
|
hilfe={
|
||||||
|
!umsetzbar
|
||||||
|
? `Keine Umsetzung für ${distribution?.name ?? "diese Distribution"} (Familie ${distribution?.familie}).`
|
||||||
|
: benoetigtVon.length > 0 && !gewaehlt
|
||||||
|
? `Wird automatisch mit ausgeführt (Voraussetzung für ${benoetigtVon.join(", ")}).`
|
||||||
|
: (p.beschreibung ?? undefined)
|
||||||
|
}
|
||||||
|
checked={gewaehlt || benoetigtVon.length > 0}
|
||||||
|
disabled={!umsetzbar || (benoetigtVon.length > 0 && !gewaehlt)}
|
||||||
|
onChange={(an) =>
|
||||||
|
setze({
|
||||||
|
profile: an ? [...werte.profile, p.id] : werte.profile.filter((x) => x !== p.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{(gewaehlt || benoetigtVon.length > 0) && p.variablen.length > 0 ? (
|
||||||
|
<div className="border-linie ml-8 grid gap-4 border-l pl-4 sm:grid-cols-2">
|
||||||
|
{p.variablen.map((v) => {
|
||||||
|
const id = `var-${p.id}-${v.name}`;
|
||||||
|
const wert =
|
||||||
|
werte.variablen[p.id]?.[v.name] ??
|
||||||
|
v.default ??
|
||||||
|
(v.type === "boolean" ? false : "");
|
||||||
|
const setzeWert = (neu: string | boolean) =>
|
||||||
|
setze({
|
||||||
|
variablen: {
|
||||||
|
...werte.variablen,
|
||||||
|
[p.id]: { ...werte.variablen[p.id], [v.name]: neu },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return v.type === "boolean" ? (
|
||||||
|
<Schalter
|
||||||
|
key={v.name}
|
||||||
|
id={id}
|
||||||
|
label={v.label}
|
||||||
|
hilfe={v.help}
|
||||||
|
checked={Boolean(wert)}
|
||||||
|
onChange={setzeWert}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Feld
|
||||||
|
key={v.name}
|
||||||
|
id={id}
|
||||||
|
label={`${v.label}${v.required ? " *" : ""}`}
|
||||||
|
hilfe={v.help}
|
||||||
|
fehler={fehler?.felder?.[`variablen.${p.id}.${v.name}`]}
|
||||||
|
>
|
||||||
|
{v.type === "select" ? (
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={String(wert)}
|
||||||
|
onChange={(e) => setzeWert(e.target.value)}
|
||||||
|
>
|
||||||
|
{v.options!.map((o) => (
|
||||||
|
<option key={o}>{o}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
inputMode={v.type === "number" ? "numeric" : undefined}
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={String(wert)}
|
||||||
|
onChange={(e) => setzeWert(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Feld>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{SCHRITTE[schritt] === "Zusammenfassung" ? (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{ersteSperre >= 0 ? (
|
||||||
|
<Karte>
|
||||||
|
<Status art="fehler">
|
||||||
|
Schritt {ersteSperre + 1} ({SCHRITTE[ersteSperre]}) ist unvollständig:{" "}
|
||||||
|
{sperre(ersteSperre)}
|
||||||
|
</Status>
|
||||||
|
</Karte>
|
||||||
|
) : laeuft && !plan ? (
|
||||||
|
<Karte>
|
||||||
|
<Status art="laeuft">
|
||||||
|
Plane – prüft Template, VMID, Storage und Netz gegen den Server …
|
||||||
|
</Status>
|
||||||
|
</Karte>
|
||||||
|
) : plan ? (
|
||||||
|
<>
|
||||||
|
<TerminalKarte
|
||||||
|
version={daten.version}
|
||||||
|
kopf={plan.kopf}
|
||||||
|
trockenlauf
|
||||||
|
phasen={plan.phasen.map((p): TerminalPhase => ({
|
||||||
|
...p,
|
||||||
|
// Im Trockenlauf ist nichts erledigt – geplante Phasen erscheinen als offen.
|
||||||
|
status:
|
||||||
|
p.status === "FEHLER"
|
||||||
|
? "FEHLER"
|
||||||
|
: p.status === "UEBERSPRUNGEN"
|
||||||
|
? "UEBERSPRUNGEN"
|
||||||
|
: "OFFEN",
|
||||||
|
gestartetAm: null,
|
||||||
|
}))}
|
||||||
|
ergebnis={
|
||||||
|
plan.ok
|
||||||
|
? null
|
||||||
|
: { ok: false, titel: `${werte.name} würde fehlschlagen`, dauer: null, zeilen: [] }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Karte titel="Geplante Proxmox-Aufrufe">
|
||||||
|
<ol className="flex flex-col gap-1 font-mono text-xs sm:text-sm">
|
||||||
|
{plan.aufrufe.map((aufruf, nr) => (
|
||||||
|
<li key={nr} className="break-all">
|
||||||
|
<span className="text-laeuft">→ </span>
|
||||||
|
{aufruf.methode} {aufruf.pfad}
|
||||||
|
{aufruf.parameter && Object.keys(aufruf.parameter).length > 0 ? (
|
||||||
|
<span className="text-muted">
|
||||||
|
{" "}
|
||||||
|
{Object.entries(aufruf.parameter)
|
||||||
|
.map(
|
||||||
|
([k, v]) =>
|
||||||
|
`${k}=${String(v).length > 60 ? `${String(v).slice(0, 57)}…` : String(v)}`,
|
||||||
|
)
|
||||||
|
.join(" ")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="text-muted">
|
||||||
|
→ Guest-Agent: Provisionierungsskript übertragen und ausführen
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</Karte>
|
||||||
|
{plan.skript ? (
|
||||||
|
<details className="bg-panel border-linie rounded-karte border">
|
||||||
|
<summary className="cursor-pointer px-5 py-3.5 font-semibold">
|
||||||
|
Provisionierungsskript
|
||||||
|
</summary>
|
||||||
|
<pre className="border-linie max-h-[50vh] overflow-auto border-t p-5 font-mono text-xs">
|
||||||
|
{plan.skript}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm font-medium">Dasselbe per CLI</span>
|
||||||
|
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-start">
|
||||||
|
<Code className="flex-1">{plan.cliBefehl}</Code>
|
||||||
|
<Knopf
|
||||||
|
variante="sekundaer"
|
||||||
|
aria-label="CLI-Befehl kopieren"
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(plan.cliBefehl);
|
||||||
|
setKopiert(true);
|
||||||
|
setTimeout(() => setKopiert(false), 2000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy size={16} aria-hidden="true" />
|
||||||
|
{kopiert ? "Kopiert" : "Kopieren"}
|
||||||
|
</Knopf>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div aria-live="polite">
|
||||||
|
{fehler ? (
|
||||||
|
<div>
|
||||||
|
<Status art="fehler">{fehler.text}</Status>
|
||||||
|
{fehler.hinweis ? <p className="text-muted text-sm">{fehler.hinweis}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Knopf variante="sekundaer" onClick={() => gehe(schritt - 1)} disabled={schritt === 0}>
|
||||||
|
<ArrowLeft size={16} aria-hidden="true" /> Zurück
|
||||||
|
</Knopf>
|
||||||
|
{SCHRITTE[schritt] === "Zusammenfassung" ? (
|
||||||
|
<Knopf onClick={ausrollen} disabled={laeuft || !plan?.ok}>
|
||||||
|
{laeuft && plan ? "Startet …" : "Ausrollen"}
|
||||||
|
</Knopf>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{aktuelleSperre ? <span className="text-muted text-sm">{aktuelleSperre}</span> : null}
|
||||||
|
<Knopf onClick={() => gehe(schritt + 1)} disabled={aktuelleSperre !== null}>
|
||||||
|
Weiter <ArrowRight size={16} aria-hidden="true" />
|
||||||
|
</Knopf>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||||
|
import { versionText } from "@/lib/version";
|
||||||
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
||||||
|
import { Assistent, type AssistentDaten } from "./assistent";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Ausrollen" };
|
||||||
|
|
||||||
|
/** Der Deploy-Assistent. Alle Daten für die Auswahlfelder kommen hier einmal vom Server. */
|
||||||
|
export default async function Ausrollen() {
|
||||||
|
const benutzer = await seiteSchuetzen("ausrollen", "/ausrollen");
|
||||||
|
const server = await prisma.server.findMany({ orderBy: { name: "asc" } });
|
||||||
|
const mitTest = server.filter((s) => s.letzterTest);
|
||||||
|
if (mitTest.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Ausrollen</h1>
|
||||||
|
<Karte>
|
||||||
|
<Status art="offen">Erst einen Server anlegen und die Verbindung testen.</Status>
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const templates = await prisma.template.findMany({
|
||||||
|
select: { serverId: true, distributionId: true, knoten: true, vmid: true },
|
||||||
|
});
|
||||||
|
const daten: AssistentDaten = {
|
||||||
|
server: mitTest.map((s) => {
|
||||||
|
const bericht = s.letzterTest as VerbindungsBericht;
|
||||||
|
return {
|
||||||
|
name: s.name,
|
||||||
|
titel: s.titel,
|
||||||
|
knoten: bericht.knoten
|
||||||
|
.filter((k) => k.online)
|
||||||
|
.map((k) => ({
|
||||||
|
name: k.name,
|
||||||
|
speicherFrei: (k.speicherGesamt ?? 0) - (k.speicherBelegt ?? 0),
|
||||||
|
storages: k.storages
|
||||||
|
.filter((st) => st.aktiv && (st.inhalte.includes("images") || st.inhalte.includes("rootdir")))
|
||||||
|
.map((st) => ({
|
||||||
|
name: st.storage,
|
||||||
|
vm: st.inhalte.includes("images"),
|
||||||
|
lxc: st.inhalte.includes("rootdir"),
|
||||||
|
frei: st.frei,
|
||||||
|
})),
|
||||||
|
bridges: k.bridges.map((b) => ({ name: b.name, vlan: b.vlanBewusst })),
|
||||||
|
})),
|
||||||
|
templates: templates
|
||||||
|
.filter((t) => t.serverId === s.id)
|
||||||
|
.map((t) => ({ distribution: t.distributionId, knoten: t.knoten, vmid: t.vmid })),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
distributionen: (await distributionenListe({ nurAktive: true })).map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
familie: d.familie,
|
||||||
|
vm: d.vm,
|
||||||
|
lxc: d.lxc,
|
||||||
|
})),
|
||||||
|
profile: (await profileListe({ nurAktive: true })).map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
beschreibung: p.beschreibung,
|
||||||
|
familien: p.familien,
|
||||||
|
requires: p.definition.requires,
|
||||||
|
variablen: p.definition.variables,
|
||||||
|
})),
|
||||||
|
benutzer: benutzer.benutzername,
|
||||||
|
version: versionText(),
|
||||||
|
};
|
||||||
|
const vorauswahl = aktiverServer(
|
||||||
|
daten.server.map((s) => s.name),
|
||||||
|
(await cookies()).get(SERVER_COOKIE)?.value,
|
||||||
|
);
|
||||||
|
return <Assistent daten={daten} vorauswahl={vorauswahl} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { ausrollenPlanen } from "@/server/services/ausrollen";
|
||||||
|
|
||||||
|
/** Dry-Run: Phasen, geplante Proxmox-Aufrufe, Skript und CLI-Befehl – angelegt wird nichts. */
|
||||||
|
export const POST = api(async (anfrage: Request) => {
|
||||||
|
await rechtErforderlich(anfrage, "lesen");
|
||||||
|
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage)));
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { api } from "@/server/api";
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { ausrollenStarten } from "@/server/services/ausrollen";
|
||||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
import { auftraegeListe } from "@/server/services/auftraege";
|
import { auftraegeListe } from "@/server/services/auftraege";
|
||||||
|
|
||||||
@@ -8,3 +10,14 @@ export const GET = api(async (anfrage: Request) => {
|
|||||||
await rechtErforderlich(anfrage, "lesen");
|
await rechtErforderlich(anfrage, "lesen");
|
||||||
return NextResponse.json(await auftraegeListe());
|
return NextResponse.json(await auftraegeListe());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Ausrollen → 202 mit Kurz-ID; Fortschritt unter /api/v1/auftraege/<kurzId>/ereignisse. */
|
||||||
|
export const POST = api(async (anfrage: Request) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
|
||||||
|
const auftrag = await ausrollenStarten(
|
||||||
|
await jsonLesen(anfrage),
|
||||||
|
aufrufer,
|
||||||
|
aufruferAdresse(anfrage.headers),
|
||||||
|
);
|
||||||
|
return NextResponse.json({ id: auftrag.id, kurzId: auftrag.kurzId }, { status: 202 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function ServerUmschalter({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5 px-2">
|
<div className="flex flex-col gap-1.5 px-2">
|
||||||
<label htmlFor="server-umschalter" className="text-muted text-xs font-medium tracking-wide uppercase">
|
<label htmlFor="server-umschalter" className="text-muted text-xs font-medium tracking-wide uppercase">
|
||||||
Server
|
Aktiver Server
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="server-umschalter"
|
id="server-umschalter"
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||||
|
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
|
||||||
|
import { adapterFuer } from "@/engine/adapter";
|
||||||
|
import type { Distribution } from "@/engine/katalog/schema";
|
||||||
|
import { profileRendern, MARKE } from "@/engine/profile/rendern";
|
||||||
|
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||||
|
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
|
||||||
|
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||||
|
import type { AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||||
|
import { templateBauSchritte, type TemplateBauEingabe, type TemplateBauStand } from "./template-bau";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine VM aus einem ProxDeploy-Template ausrollen – vollständig per API, ohne SSH:
|
||||||
|
* Klonen, Hardware, cloud-init (Benutzer, Schlüssel, Netz), Start, Guest-Agent, Provisionierung
|
||||||
|
* der Profile über den Guest-Agent (Skript per file-write, Ausführung per exec, Fortschritt aus der
|
||||||
|
* Logdatei im Gast).
|
||||||
|
*/
|
||||||
|
export type VmAusrollEingabe = {
|
||||||
|
client: ProxmoxClient;
|
||||||
|
anfrage: AusrollAnfrage;
|
||||||
|
distribution: Distribution;
|
||||||
|
profile: AufgeloestesProfil[];
|
||||||
|
/** Gast-VMID reservieren (nach Prüfung, was belegt ist); im Trockenlauf nur ermitteln. */
|
||||||
|
vmidReservieren: (belegt: Set<number>, wunsch?: number) => Promise<number>;
|
||||||
|
/** Für ein fehlendes Template (nur mit anfrage.templateBauen). */
|
||||||
|
templateBau?: Omit<
|
||||||
|
TemplateBauEingabe,
|
||||||
|
"client" | "distribution" | "knoten" | "storage" | "bridge" | "vlan" | "ssh"
|
||||||
|
> & {
|
||||||
|
/** SSH zum Node – erst bekannt, wenn die Prüfung den Node gewählt hat. */
|
||||||
|
sshFuer: (knoten: string) => TemplateBauEingabe["ssh"];
|
||||||
|
};
|
||||||
|
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
|
||||||
|
auftragKurzId: string;
|
||||||
|
warten?: (ms: number) => Promise<void>;
|
||||||
|
/** Zeitlimits – im Test kürzer. */
|
||||||
|
agentMs?: number;
|
||||||
|
provisionierungMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VmAusrollStand = {
|
||||||
|
knoten?: string;
|
||||||
|
template?: { vmid: number; knoten: string };
|
||||||
|
vmid?: number;
|
||||||
|
ip?: string;
|
||||||
|
agentNachMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Ressource = {
|
||||||
|
vmid?: number;
|
||||||
|
node?: string;
|
||||||
|
template?: number;
|
||||||
|
tags?: string;
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
maxmem?: number;
|
||||||
|
mem?: number;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
type StorageEintrag = {
|
||||||
|
storage: string;
|
||||||
|
content: string;
|
||||||
|
active?: number;
|
||||||
|
enabled?: number;
|
||||||
|
shared?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
|
||||||
|
|
||||||
|
/** Proxmox erwartet die Schlüssel URI-kodiert – strenger als encodeURIComponent (auch ! ' ( ) *). */
|
||||||
|
export function sshkeysKodieren(schluessel: string[]): string {
|
||||||
|
return encodeURIComponent(schluessel.join("\n")).replace(
|
||||||
|
/[!'()*]/g,
|
||||||
|
(z) => `%${z.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ipconfig(netz: AusrollAnfrage["netz"]): string {
|
||||||
|
if (netz.modus === "dhcp") return "ip=dhcp";
|
||||||
|
return `ip=${netz.ip}${netz.gateway ? `,gw=${netz.gateway}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKRIPT = "/root/proxdeploy-provisionierung.sh";
|
||||||
|
const LOG = "/var/log/proxdeploy-provisionierung.log";
|
||||||
|
/**
|
||||||
|
* file-write nimmt höchstens 60 KiB je Aufruf (API-Schema, Feld content). ProxDeploy kodiert selbst
|
||||||
|
* als base64 (encode=0): Proxmox' eigene Kodierung scheitert an Nicht-ASCII („Wide character in
|
||||||
|
* subroutine entry“, gefunden beim ersten echten Deploy). 40 KiB Rohdaten werden ~54 KiB base64.
|
||||||
|
*/
|
||||||
|
const STUECK = 40 * 1024;
|
||||||
|
|
||||||
|
type AgentAntwort<T> = { result: T };
|
||||||
|
type ExecStatus = { exited: number | boolean; exitcode?: number; "out-data"?: string; "err-data"?: string };
|
||||||
|
type Netzschnittstelle = {
|
||||||
|
name: string;
|
||||||
|
"ip-addresses"?: Array<{ "ip-address-type": string; "ip-address": string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {}): PhasenSchritt[] {
|
||||||
|
const { client, anfrage: a, distribution: d } = e;
|
||||||
|
const warten = e.warten ?? schlafen;
|
||||||
|
const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`;
|
||||||
|
// Die Agent-Endpunkte antworten unterschiedlich: exec → {pid}, exec-status und file-read direkt,
|
||||||
|
// ping und network-get-interfaces → {result}. Deshalb liefert der Helfer die Rohdaten.
|
||||||
|
const agent = async <T>(
|
||||||
|
befehl: string,
|
||||||
|
methode: "GET" | "POST" = "POST",
|
||||||
|
parameter?: Record<string, string | readonly string[]>,
|
||||||
|
) => await client.aufrufen<T>({ methode, pfad: `${vmPfad()}/agent/${befehl}`, parameter });
|
||||||
|
const templateStand: TemplateBauStand = {};
|
||||||
|
|
||||||
|
const schritte: PhasenSchritt[] = [
|
||||||
|
{
|
||||||
|
schluessel: "pruefungen",
|
||||||
|
titel: "Prüfungen",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
if (!d.vm)
|
||||||
|
throw new AblaufFehler(`${d.name} bietet kein VM-Image.`, "Eine andere Distribution wählen.");
|
||||||
|
const ressourcen = await client.get<Ressource[]>("/cluster/resources");
|
||||||
|
const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online");
|
||||||
|
const templates = ressourcen.filter(
|
||||||
|
(r) =>
|
||||||
|
r.type === "qemu" &&
|
||||||
|
r.template === 1 &&
|
||||||
|
(r.tags ?? "").split(/[;, ]/).includes("proxdeploy-template") &&
|
||||||
|
(r.tags ?? "").split(/[;, ]/).includes(d.id),
|
||||||
|
);
|
||||||
|
// Node: gewünscht, sonst der mit dem Template, sonst der mit dem meisten freien RAM.
|
||||||
|
stand.knoten =
|
||||||
|
a.knoten ??
|
||||||
|
templates.find((t) => nodes.some((n) => n.node === t.node))?.node ??
|
||||||
|
[...nodes].sort((x, y) => (y.maxmem ?? 0) - (y.mem ?? 0) - ((x.maxmem ?? 0) - (x.mem ?? 0)))[0]
|
||||||
|
?.node;
|
||||||
|
if (!stand.knoten || !nodes.some((n) => n.node === stand.knoten)) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Node ${stand.knoten ?? "?"} ist nicht online.`,
|
||||||
|
"Anderen Node wählen oder Cluster-Status prüfen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tpl = templates.find((t) => t.node === stand.knoten) ?? templates[0];
|
||||||
|
if (tpl) stand.template = { vmid: tpl.vmid!, knoten: tpl.node! };
|
||||||
|
|
||||||
|
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${stand.knoten}/storage`);
|
||||||
|
const ziel = storages.find((s) => s.storage === a.storage);
|
||||||
|
if (!ziel || ziel.active !== 1 || !ziel.content.split(",").includes("images")) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Storage ${a.storage} nimmt auf ${stand.knoten} keine VM-Disks auf.`,
|
||||||
|
"Anderen Storage wählen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tpl && tpl.node !== stand.knoten && ziel.shared !== 1) {
|
||||||
|
// Ein Voll-Klon auf einen anderen Node geht nur, wenn das Template auf geteiltem Storage liegt.
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Das Template ${tpl.vmid} liegt auf ${tpl.node}, die VM soll auf ${stand.knoten}.`,
|
||||||
|
`Node ${tpl.node} wählen oder auf ${stand.knoten} ein eigenes Template bauen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const netze = await client.get<Array<{ iface: string; type: string; bridge_vlan_aware?: number }>>(
|
||||||
|
pfad`/nodes/${stand.knoten}/network`,
|
||||||
|
);
|
||||||
|
const bridge = netze.find(
|
||||||
|
(n) => n.iface === a.netz.bridge && (n.type === "bridge" || n.type === "OVSBridge"),
|
||||||
|
);
|
||||||
|
if (!bridge)
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Bridge ${a.netz.bridge} gibt es auf ${stand.knoten} nicht.`,
|
||||||
|
"Andere Bridge wählen.",
|
||||||
|
);
|
||||||
|
if (a.netz.vlan && bridge.type === "bridge" && bridge.bridge_vlan_aware !== 1) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Bridge ${a.netz.bridge} ist nicht VLAN-fähig – VLAN ${a.netz.vlan} würde nicht ankommen.`,
|
||||||
|
"In Proxmox „VLAN aware“ an der Bridge einschalten oder ohne VLAN ausrollen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!tpl && !a.templateBauen) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Auf diesem Server gibt es kein ProxDeploy-Template für ${d.name}.`,
|
||||||
|
"Unter „Templates“ eins bauen oder beim Ausrollen „Template vorher bauen“ wählen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const belegt = new Set(
|
||||||
|
ressourcen.map((r) => r.vmid).filter((v): v is number => typeof v === "number"),
|
||||||
|
);
|
||||||
|
stand.vmid = await e.vmidReservieren(belegt, a.vmid);
|
||||||
|
await e.angelegt({ knoten: stand.knoten, typ: "VM" });
|
||||||
|
return {
|
||||||
|
detail: `VMID ${stand.vmid} frei · ${a.storage} · ${a.netz.bridge}${a.netz.vlan ? ` VLAN ${a.netz.vlan}` : ""}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "template",
|
||||||
|
titel: "Template",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
if (stand.template)
|
||||||
|
return { uebersprungen: true, detail: `${stand.template.vmid} auf ${stand.template.knoten}` };
|
||||||
|
if (!e.templateBau) throw new AblaufFehler("Template fehlt.", "Template bauen.");
|
||||||
|
// Die Phasen des Template-Baus laufen hier als Unterschritte; ihr Fortschritt erscheint als Detail.
|
||||||
|
const unter = templateBauSchritte(
|
||||||
|
{
|
||||||
|
...e.templateBau,
|
||||||
|
ssh: e.templateBau.sshFuer(stand.knoten!),
|
||||||
|
client,
|
||||||
|
distribution: d,
|
||||||
|
knoten: stand.knoten!,
|
||||||
|
storage: a.storage,
|
||||||
|
bridge: a.netz.bridge,
|
||||||
|
vlan: a.netz.vlan,
|
||||||
|
},
|
||||||
|
templateStand,
|
||||||
|
);
|
||||||
|
for (const schritt of unter) {
|
||||||
|
await k.detail(`${schritt.titel} …`);
|
||||||
|
const ergebnis = await schritt.ausfuehren({
|
||||||
|
...k,
|
||||||
|
detail: (text) => k.detail(`${schritt.titel}: ${text}`),
|
||||||
|
});
|
||||||
|
await k.log(`Template · ${schritt.titel}: ${ergebnis?.detail ?? "erledigt"}`);
|
||||||
|
}
|
||||||
|
stand.template = { vmid: templateStand.vmid!, knoten: stand.knoten! };
|
||||||
|
return { detail: `${templateStand.vmid} gebaut` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "klonen",
|
||||||
|
titel: "Klonen",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
const tpl = stand.template!;
|
||||||
|
const klonen = async (vmid: number) =>
|
||||||
|
await aufgabeAbwarten(
|
||||||
|
client,
|
||||||
|
await client.post<string>(pfad`/nodes/${tpl.knoten}/qemu/${tpl.vmid}/clone`, {
|
||||||
|
newid: vmid,
|
||||||
|
name: a.name,
|
||||||
|
full: true,
|
||||||
|
target: stand.knoten!,
|
||||||
|
storage: a.storage,
|
||||||
|
}),
|
||||||
|
{ zeitlimitMs: 30 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await klonen(stand.vmid!);
|
||||||
|
} catch (fehler) {
|
||||||
|
// Gleichzeitig außerhalb von ProxDeploy vergeben? Einmal mit neuer ID versuchen.
|
||||||
|
if (!(fehler instanceof ProxmoxFehler) || !/already exists/i.test(fehler.ursache) || a.vmid)
|
||||||
|
throw fehler;
|
||||||
|
const belegt = new Set(
|
||||||
|
(await client.get<Ressource[]>("/cluster/resources", { type: "vm" })).map((r) => r.vmid!),
|
||||||
|
);
|
||||||
|
stand.vmid = await e.vmidReservieren(belegt);
|
||||||
|
await k.log(`VMID war inzwischen belegt – neuer Versuch mit ${stand.vmid}`, "warn");
|
||||||
|
await klonen(stand.vmid);
|
||||||
|
}
|
||||||
|
await e.angelegt({ vmid: stand.vmid });
|
||||||
|
return { detail: `full clone ${tpl.vmid} → ${stand.vmid}` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "hardware",
|
||||||
|
titel: "Hardware",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
await client.put(`${vmPfad()}/config`, {
|
||||||
|
cores: a.cores,
|
||||||
|
memory: a.speicherMb,
|
||||||
|
cpu: "host",
|
||||||
|
net0: `virtio,bridge=${a.netz.bridge}${a.netz.vlan ? `,tag=${a.netz.vlan}` : ""}`,
|
||||||
|
onboot: a.startBeiBoot,
|
||||||
|
tags: ["proxdeploy", d.id, ...a.tags].join(";"),
|
||||||
|
description: `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`,
|
||||||
|
});
|
||||||
|
// Vergrößern nur, wenn größer: Proxmox kann Disks nicht verkleinern.
|
||||||
|
const konfig = client.trocken ? {} : await client.get<Record<string, string>>(`${vmPfad()}/config`);
|
||||||
|
const groesse = /size=(\d+)G/.exec(konfig["scsi0"] ?? "")?.[1];
|
||||||
|
if (client.trocken || !groesse || Number(groesse) < a.diskGb) {
|
||||||
|
await client.put(`${vmPfad()}/resize`, { disk: "scsi0", size: `${a.diskGb}G` });
|
||||||
|
}
|
||||||
|
return { detail: `${a.cores} CPU · ${a.speicherMb} MB · Disk ${a.diskGb}G` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "cloudinit",
|
||||||
|
titel: "cloud-init",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
await client.put(`${vmPfad()}/config`, {
|
||||||
|
ciuser: a.zugang.benutzer,
|
||||||
|
sshkeys: sshkeysKodieren(a.zugang.sshSchluessel),
|
||||||
|
ipconfig0: ipconfig(a.netz),
|
||||||
|
...(a.netz.dns.length > 0 ? { nameserver: a.netz.dns.join(" ") } : {}),
|
||||||
|
...(a.netz.suchdomain ? { searchdomain: a.netz.suchdomain } : {}),
|
||||||
|
// Updates macht das Profil base als sichtbare Phase, nicht stumm beim ersten Boot.
|
||||||
|
ciupgrade: false,
|
||||||
|
});
|
||||||
|
const schluessel = a.zugang.sshSchluessel.length;
|
||||||
|
return {
|
||||||
|
detail: `user ${a.zugang.benutzer} · ${schluessel} SSH-Key${schluessel === 1 ? "" : "s"} · ${a.netz.modus === "dhcp" ? "DHCP" : a.netz.ip}${
|
||||||
|
e.profile.length > 0 ? ` · Profile ${e.profile.map((p) => p.profil.id).join(", ")}` : ""
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "start",
|
||||||
|
titel: "Start",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad()}/status/start`), {
|
||||||
|
zeitlimitMs: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
return { detail: `VM ${stand.vmid} läuft` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "agent",
|
||||||
|
titel: "Guest-Agent",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
if (client.trocken) return { detail: "wartet auf Antwort (ping)" };
|
||||||
|
const beginn = Date.now();
|
||||||
|
const grenze = e.agentMs ?? 5 * 60 * 1000;
|
||||||
|
for (;;) {
|
||||||
|
await k.pruefeAbbruch();
|
||||||
|
try {
|
||||||
|
await agent("ping");
|
||||||
|
break;
|
||||||
|
} catch (fehler) {
|
||||||
|
if (Date.now() - beginn > grenze) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Der Guest-Agent antwortet nach ${Math.round(grenze / 1000)} s nicht.`,
|
||||||
|
"Bootet die VM? Konsole ansehen. Wurde das Template von ProxDeploy gebaut (Agent eingebaut)?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fehler instanceof ProxmoxFehler && fehler.art === "recht") throw fehler;
|
||||||
|
}
|
||||||
|
await k.detail(`wartet … ${Math.round((Date.now() - beginn) / 1000)}s`);
|
||||||
|
await warten(3000);
|
||||||
|
}
|
||||||
|
stand.agentNachMs = Date.now() - beginn;
|
||||||
|
// IP: bei statischer Adresse bekannt, sonst aus dem Agent (erste IPv4 außer Loopback).
|
||||||
|
if (a.netz.modus === "statisch") {
|
||||||
|
stand.ip = a.netz.ip!.split("/")[0];
|
||||||
|
} else {
|
||||||
|
for (let versuch = 0; versuch < 20 && !stand.ip; versuch += 1) {
|
||||||
|
const schnittstellen = (
|
||||||
|
await agent<AgentAntwort<Netzschnittstelle[]>>("network-get-interfaces", "GET")
|
||||||
|
).result;
|
||||||
|
stand.ip = schnittstellen
|
||||||
|
.filter((s) => s.name !== "lo")
|
||||||
|
.flatMap((s) => s["ip-addresses"] ?? [])
|
||||||
|
.find((ip) => ip["ip-address-type"] === "ipv4" && !ip["ip-address"].startsWith("169.254."))?.[
|
||||||
|
"ip-address"
|
||||||
|
];
|
||||||
|
if (!stand.ip) await warten(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
detail: `antwortet nach ${Math.round(stand.agentNachMs / 1000)}s${stand.ip ? ` · IP ${stand.ip}` : ""}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "provisionierung",
|
||||||
|
titel: "Provisionierung",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
const adapter = adapterFuer(d.family);
|
||||||
|
if (!adapter)
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Für die Familie „${d.family}“ gibt es keinen Adapter.`,
|
||||||
|
"Adapter ergänzen (docs/katalog.md).",
|
||||||
|
);
|
||||||
|
const { skript } = profileRendern(
|
||||||
|
e.profile,
|
||||||
|
adapter,
|
||||||
|
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer } },
|
||||||
|
[
|
||||||
|
adapter.benutzerAnlegen({
|
||||||
|
name: a.zugang.benutzer,
|
||||||
|
sshSchluessel: a.zugang.sshSchluessel,
|
||||||
|
sudo: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await k.log(
|
||||||
|
`Skript: ${skript.split("\n").length} Zeilen, ${Math.round(skript.length / 1024)} KiB`,
|
||||||
|
"debug",
|
||||||
|
);
|
||||||
|
if (client.trocken) {
|
||||||
|
await k.log(skript, "info");
|
||||||
|
return {
|
||||||
|
detail:
|
||||||
|
e.profile.length > 0
|
||||||
|
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")}`
|
||||||
|
: "nur Benutzer",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hochladen in Stücken (file-write ersetzt die Datei, hängt nicht an) und zusammensetzen.
|
||||||
|
const bytes = Buffer.from(skript, "utf8");
|
||||||
|
const stuecke: Buffer[] = [];
|
||||||
|
for (let i = 0; i < bytes.length; i += STUECK) stuecke.push(bytes.subarray(i, i + STUECK));
|
||||||
|
for (const [nr, inhalt] of stuecke.entries()) {
|
||||||
|
await agent("file-write", "POST", {
|
||||||
|
file: `${SKRIPT}.${nr}`,
|
||||||
|
content: inhalt.toString("base64"),
|
||||||
|
encode: "0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const zusammensetzen = stuecke.map((_, nr) => `${SKRIPT}.${nr}`).join(" ");
|
||||||
|
const { pid } = await agent<{ pid: number }>("exec", "POST", {
|
||||||
|
command: [
|
||||||
|
"/bin/bash",
|
||||||
|
"-c",
|
||||||
|
`cat ${zusammensetzen} > ${SKRIPT} && rm -f ${zusammensetzen} && chmod 700 ${SKRIPT} && ${SKRIPT} > ${LOG} 2>&1`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const beginn = Date.now();
|
||||||
|
const grenze = e.provisionierungMs ?? 60 * 60 * 1000;
|
||||||
|
let gelesen = 0;
|
||||||
|
let profil = "";
|
||||||
|
for (;;) {
|
||||||
|
await warten(3000);
|
||||||
|
const status = await agent<ExecStatus>("exec-status", "GET", { pid: String(pid) });
|
||||||
|
// Fortschritt: neue Zeilen der Logdatei im Gast.
|
||||||
|
try {
|
||||||
|
const datei = await agent<{ content: string; truncated?: boolean }>("file-read", "GET", {
|
||||||
|
file: LOG,
|
||||||
|
});
|
||||||
|
const zeilen = datei.content.split("\n");
|
||||||
|
for (const zeile of zeilen.slice(gelesen, zeilen.length - 1)) {
|
||||||
|
const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile);
|
||||||
|
if (marke) profil = marke[1]!;
|
||||||
|
await k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug");
|
||||||
|
}
|
||||||
|
gelesen = Math.max(gelesen, zeilen.length - 1);
|
||||||
|
} catch {
|
||||||
|
// Logdatei noch nicht da – beim nächsten Mal.
|
||||||
|
}
|
||||||
|
const sekunden = Math.round((Date.now() - beginn) / 1000);
|
||||||
|
if (status.exited) {
|
||||||
|
if (status.exitcode !== 0) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Provisionierung ${profil ? `im Profil ${profil} ` : ""}mit Code ${status.exitcode} abgebrochen.`,
|
||||||
|
`Detail-Log oder ${LOG} in der VM ansehen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (Date.now() - beginn > grenze) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Provisionierung läuft länger als ${Math.round(grenze / 60000)} min.`,
|
||||||
|
`${LOG} in der VM ansehen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await k.detail(
|
||||||
|
`${profil ? `Profil ${profil}` : "Vorbereitung"} läuft … ${Math.floor(sekunden / 60)}m ${String(sekunden % 60).padStart(2, "0")}s`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await agent("exec", "POST", { command: ["rm", "-f", SKRIPT] }).catch(() => undefined);
|
||||||
|
return {
|
||||||
|
detail:
|
||||||
|
e.profile.length > 0
|
||||||
|
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet`
|
||||||
|
: "Benutzer eingerichtet",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return schritte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSH-Befehl für die Ergebnis-Karte. */
|
||||||
|
export function sshBefehl(benutzer: string, ip: string | undefined): string | undefined {
|
||||||
|
return ip ? `ssh ${benutzer}@${ip}` : undefined;
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export type NavigationsEintrag = {
|
|||||||
|
|
||||||
export const NAVIGATION: readonly NavigationsEintrag[] = [
|
export const NAVIGATION: readonly NavigationsEintrag[] = [
|
||||||
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
|
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
|
||||||
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" },
|
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" },
|
||||||
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
|
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
|
||||||
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" },
|
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" },
|
||||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
|
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Anfrage „Ausrollen“ – eine Form für Web-Assistent, CLI (`proxdeploy vm …`), Batch und
|
||||||
|
* Vorlagen. Sie liegt in `src/lib`, weil Formular (Client) und API (Server) dieselben Regeln
|
||||||
|
* brauchen. Felder sind deutsch; die CLI übersetzt ihre Flags darauf (`--memory` → `speicherMb`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Hostname nach RFC 1123, ein Label: so heißt der Gast in Proxmox und im Netz. */
|
||||||
|
export const GAST_NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||||
|
|
||||||
|
const Ipv4Cidr = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, "Format: 10.0.20.15/24")
|
||||||
|
.refine((wert) => {
|
||||||
|
const [ip, praefix] = wert.split("/");
|
||||||
|
return (
|
||||||
|
ip!.split(".").every((teil) => Number(teil) <= 255) && Number(praefix) >= 1 && Number(praefix) <= 32
|
||||||
|
);
|
||||||
|
}, "Ungültige Adresse oder Präfixlänge.");
|
||||||
|
|
||||||
|
const Ipv4 = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^(\d{1,3}\.){3}\d{1,3}$/, "Format: 10.0.20.1")
|
||||||
|
.refine((ip) => ip.split(".").every((teil) => Number(teil) <= 255), "Ungültige Adresse.");
|
||||||
|
|
||||||
|
export const SSH_SCHLUESSEL =
|
||||||
|
/^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com) [A-Za-z0-9+/=]+( [^\r\n]*)?$/;
|
||||||
|
|
||||||
|
export const Netz = z
|
||||||
|
.object({
|
||||||
|
bridge: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Bridge fehlt.")
|
||||||
|
.regex(/^[A-Za-z0-9_.-]+$/, "Ungültiger Bridge-Name."),
|
||||||
|
vlan: z.coerce.number().int().min(1).max(4094).optional(),
|
||||||
|
modus: z.enum(["dhcp", "statisch"]).default("dhcp"),
|
||||||
|
ip: Ipv4Cidr.optional(),
|
||||||
|
gateway: Ipv4.optional(),
|
||||||
|
dns: z.array(Ipv4).max(3).default([]),
|
||||||
|
suchdomain: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^[a-z0-9.-]*$/i, "Ungültige Suchdomain.")
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.refine((n) => n.modus === "dhcp" || Boolean(n.ip), {
|
||||||
|
message: "Bei statischer Adresse fehlt die IP.",
|
||||||
|
path: ["ip"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Zugang = z.object({
|
||||||
|
benutzer: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^[a-z_][a-z0-9_-]{0,31}$/, "Benutzername: Kleinbuchstaben, Ziffern, _ und -")
|
||||||
|
.refine((name) => name !== "root", "root ist nicht erlaubt – ProxDeploy legt einen sudo-Benutzer an."),
|
||||||
|
sshSchluessel: z
|
||||||
|
.array(z.string().trim().regex(SSH_SCHLUESSEL, "Kein gültiger öffentlicher SSH-Schlüssel."))
|
||||||
|
.min(1, "Mindestens ein SSH-Schlüssel – Passwörter vergibt ProxDeploy nicht."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AusrollAnfrage = z
|
||||||
|
.object({
|
||||||
|
typ: z.enum(["vm", "lxc"]),
|
||||||
|
server: z.string().trim().min(1, "Server fehlt."),
|
||||||
|
/** Leer = ProxDeploy wählt: Node mit dem Template, sonst der mit dem meisten freien RAM. */
|
||||||
|
knoten: z.string().trim().optional(),
|
||||||
|
distribution: z.string().trim().min(1, "Distribution fehlt."),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(GAST_NAME, "Name: Kleinbuchstaben, Ziffern und Bindestrich (Hostname)."),
|
||||||
|
/** Leer = nächste freie ID. */
|
||||||
|
vmid: z.coerce.number().int().min(100).max(999_999_999).optional(),
|
||||||
|
tags: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^[a-z0-9_+.-]+$/i, "Tags: Buchstaben, Ziffern, _ + . -"),
|
||||||
|
)
|
||||||
|
.max(10)
|
||||||
|
.default([]),
|
||||||
|
cores: z.coerce.number().int().min(1).max(128).default(2),
|
||||||
|
speicherMb: z.coerce.number().int().min(256).max(1_048_576).default(2048),
|
||||||
|
diskGb: z.coerce.number().int().min(1).max(65_536).default(20),
|
||||||
|
storage: z.string().trim().min(1, "Storage fehlt."),
|
||||||
|
/** LXC */
|
||||||
|
swapMb: z.coerce.number().int().min(0).max(1_048_576).default(512),
|
||||||
|
unprivilegiert: z.boolean().default(true),
|
||||||
|
features: z
|
||||||
|
.object({ nesting: z.boolean().default(false), keyctl: z.boolean().default(false) })
|
||||||
|
.default({ nesting: false, keyctl: false }),
|
||||||
|
netz: Netz,
|
||||||
|
zugang: Zugang,
|
||||||
|
profile: z.array(z.string()).default(["base"]),
|
||||||
|
variablen: z
|
||||||
|
.record(z.string(), z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])))
|
||||||
|
.default({}),
|
||||||
|
/** Fehlt das Template, vorher bauen (sonst bricht die Prüfung mit Hinweis ab). */
|
||||||
|
templateBauen: z.boolean().default(false),
|
||||||
|
startBeiBoot: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.refine((a) => a.typ === "lxc" || a.diskGb >= 4, {
|
||||||
|
message: "Mindestens 4 GB für eine VM.",
|
||||||
|
path: ["diskGb"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AusrollAnfrage = z.infer<typeof AusrollAnfrage>;
|
||||||
|
export type AusrollEingabe = z.input<typeof AusrollAnfrage>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der passende CLI-Befehl zu einer Anfrage – im Assistenten zum Kopieren angezeigt. Nur Flags, die
|
||||||
|
* vom Standard abweichen, damit der Befehl lesbar bleibt.
|
||||||
|
*/
|
||||||
|
export function cliBefehl(a: AusrollAnfrage): string {
|
||||||
|
const teile = [`proxdeploy ${a.typ} ${a.name}`, `--server ${a.server}`];
|
||||||
|
if (a.knoten) teile.push(`--node ${a.knoten}`);
|
||||||
|
teile.push(`--distro ${a.distribution}`);
|
||||||
|
if (a.profile.length > 0 && !(a.profile.length === 1 && a.profile[0] === "base"))
|
||||||
|
teile.push(`--profile ${a.profile.join(",")}`);
|
||||||
|
if (a.netz.modus === "statisch" && a.netz.ip) {
|
||||||
|
teile.push(`--ip ${a.netz.ip}`);
|
||||||
|
if (a.netz.gateway) teile.push(`--gw ${a.netz.gateway}`);
|
||||||
|
}
|
||||||
|
if (a.netz.dns.length > 0) teile.push(`--dns ${a.netz.dns.join(",")}`);
|
||||||
|
if (a.netz.suchdomain) teile.push(`--search-domain ${a.netz.suchdomain}`);
|
||||||
|
if (a.netz.bridge !== "vmbr0") teile.push(`--bridge ${a.netz.bridge}`);
|
||||||
|
if (a.netz.vlan) teile.push(`--vlan ${a.netz.vlan}`);
|
||||||
|
if (a.vmid) teile.push(`--vmid ${a.vmid}`);
|
||||||
|
if (a.cores !== 2) teile.push(`--cores ${a.cores}`);
|
||||||
|
if (a.speicherMb !== 2048) teile.push(`--memory ${a.speicherMb}`);
|
||||||
|
if (a.diskGb !== 20) teile.push(`--disk ${a.diskGb}G`);
|
||||||
|
teile.push(`--storage ${a.storage}`);
|
||||||
|
teile.push(`--user ${a.zugang.benutzer}`);
|
||||||
|
if (a.tags.length > 0) teile.push(`--tags ${a.tags.join(",")}`);
|
||||||
|
for (const [profil, werte] of Object.entries(a.variablen)) {
|
||||||
|
for (const [name, wert] of Object.entries(werte)) {
|
||||||
|
const text = String(wert);
|
||||||
|
teile.push(
|
||||||
|
`--var ${profil}.${name}=${/^[\w.,:/@-]+$/.test(text) ? text : `'${text.replace(/'/g, `'"'"'`)}'`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (a.typ === "lxc") {
|
||||||
|
if (a.swapMb !== 512) teile.push(`--swap ${a.swapMb}`);
|
||||||
|
if (!a.unprivilegiert) teile.push("--privileged");
|
||||||
|
}
|
||||||
|
if (a.templateBauen) teile.push("--build-template");
|
||||||
|
return teile.join(" ");
|
||||||
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
||||||
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
||||||
*/
|
*/
|
||||||
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.1.0";
|
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.2.0";
|
||||||
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
||||||
|
|
||||||
export function versionText(): string {
|
export function versionText(): string {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function fehlerAntwort(fehler: unknown): NextResponse<FehlerAntwort> {
|
|||||||
code: fehler.code,
|
code: fehler.code,
|
||||||
nachricht: fehler.message,
|
nachricht: fehler.message,
|
||||||
...(fehler.hinweis ? { hinweis: fehler.hinweis } : {}),
|
...(fehler.hinweis ? { hinweis: fehler.hinweis } : {}),
|
||||||
|
...(fehler.felder ? { felder: fehler.felder } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ status: fehler.status },
|
{ status: fehler.status },
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export class AnwendungsFehler extends Error {
|
|||||||
readonly code: string,
|
readonly code: string,
|
||||||
nachricht: string,
|
nachricht: string,
|
||||||
readonly hinweis?: string,
|
readonly hinweis?: string,
|
||||||
|
/** Feldfehler für Formulare, Schlüssel wie der Feldpfad (z. B. „variablen.zabbix-agent.server“). */
|
||||||
|
readonly felder?: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
super(nachricht);
|
super(nachricht);
|
||||||
this.name = "AnwendungsFehler";
|
this.name = "AnwendungsFehler";
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import type { Server } from "@/generated/prisma/client";
|
||||||
|
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||||
|
import { vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen";
|
||||||
|
import type { Distribution, Profil } from "@/engine/katalog/schema";
|
||||||
|
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||||
|
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen";
|
||||||
|
import type { Aufrufer } from "@/server/auth/zugang";
|
||||||
|
import { AnwendungsFehler } from "@/server/fehler";
|
||||||
|
import { auftragAnlegen } from "@/server/services/auftraege";
|
||||||
|
import { serverHolen, verbindungAus } from "@/server/services/server";
|
||||||
|
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||||
|
import { vmidVorschlagen } from "@/server/services/vmid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ausrollen vorbereiten: Anfrage prüfen, Distribution und Profile auflösen. Fehler kommen als
|
||||||
|
* Feldfehler zurück, damit der Assistent sie am richtigen Feld zeigt.
|
||||||
|
*/
|
||||||
|
export type Vorbereitet = {
|
||||||
|
anfrage: AusrollAnfrage;
|
||||||
|
server: Server;
|
||||||
|
distribution: Distribution;
|
||||||
|
profile: AufgeloestesProfil[];
|
||||||
|
automatisch: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> {
|
||||||
|
const anfrage = AusrollAnfrage.parse(roh);
|
||||||
|
const server = await serverHolen(anfrage.server);
|
||||||
|
const zeile = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } });
|
||||||
|
if (!zeile || zeile.entferntAm || !zeile.aktiv) {
|
||||||
|
throw new AnwendungsFehler(
|
||||||
|
400,
|
||||||
|
"ungueltig",
|
||||||
|
`Die Distribution „${anfrage.distribution}“ ist nicht verfügbar.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const distribution = zeile.definition as Distribution;
|
||||||
|
if (!distribution[anfrage.typ]) {
|
||||||
|
throw new AnwendungsFehler(
|
||||||
|
400,
|
||||||
|
"ungueltig",
|
||||||
|
`${distribution.name} gibt es nicht als ${anfrage.typ === "vm" ? "VM" : "LXC-Container"}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (anfrage.typ === "lxc") {
|
||||||
|
throw new AnwendungsFehler(
|
||||||
|
400,
|
||||||
|
"nicht_umgesetzt",
|
||||||
|
"LXC-Container folgen mit #009.",
|
||||||
|
"Vorerst als VM ausrollen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const verfuegbar = new Map(
|
||||||
|
(await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [
|
||||||
|
p.id,
|
||||||
|
p.definition as Profil,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const aufloesung = profileAufloesen(anfrage.profile, verfuegbar, distribution.family, anfrage.variablen);
|
||||||
|
if (!aufloesung.ok) {
|
||||||
|
const felder = Object.fromEntries(
|
||||||
|
aufloesung.fehler.map((f) => [`variablen.${f.profil}${f.variable ? `.${f.variable}` : ""}`, f.meldung]),
|
||||||
|
);
|
||||||
|
throw new AnwendungsFehler(
|
||||||
|
400,
|
||||||
|
"ungueltig",
|
||||||
|
aufloesung.fehler.map((f) => f.meldung).join(" "),
|
||||||
|
undefined,
|
||||||
|
felder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { anfrage, server, distribution, profile: aufloesung.profile, automatisch: aufloesung.automatisch };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: string | null) {
|
||||||
|
const v = await ausrollenVorbereiten(roh);
|
||||||
|
return await auftragAnlegen(
|
||||||
|
{
|
||||||
|
art: "AUSROLLEN",
|
||||||
|
serverId: v.server.id,
|
||||||
|
knoten: v.anfrage.knoten,
|
||||||
|
gastTyp: v.anfrage.typ === "vm" ? "VM" : "LXC",
|
||||||
|
name: v.anfrage.name,
|
||||||
|
distributionId: v.distribution.id,
|
||||||
|
profile: v.profile.map((p) => p.profil.id),
|
||||||
|
anfrage: v.anfrage,
|
||||||
|
},
|
||||||
|
aufrufer,
|
||||||
|
ip,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Plan = {
|
||||||
|
kopf: string;
|
||||||
|
phasen: Array<{
|
||||||
|
schluessel: string;
|
||||||
|
titel: string;
|
||||||
|
status: string;
|
||||||
|
detail: string | null;
|
||||||
|
ursache: string | null;
|
||||||
|
hinweis: string | null;
|
||||||
|
}>;
|
||||||
|
aufrufe: Aufruf[];
|
||||||
|
skript: string | null;
|
||||||
|
cliBefehl: string;
|
||||||
|
automatisch: string[];
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dry-Run: dieselben Phasen wie im Ernstfall, mit einem Proxmox-Client im Trockenmodus. Lesendes
|
||||||
|
* geht wirklich an Proxmox (VMID frei? Template da? Bridge VLAN-fähig?), Schreibendes wird nur
|
||||||
|
* aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde.
|
||||||
|
*/
|
||||||
|
export async function ausrollenPlanen(roh: unknown): Promise<Plan> {
|
||||||
|
const v = await ausrollenVorbereiten(roh);
|
||||||
|
const client = new ProxmoxClient(verbindungAus(v.server), { trocken: true, zeitlimitMs: 15_000 });
|
||||||
|
const phasen: Plan["phasen"] = [];
|
||||||
|
let skript: string | null = null;
|
||||||
|
const finde = (s: string) => phasen.find((p) => p.schluessel === s)!;
|
||||||
|
const senke: EreignisSenke = {
|
||||||
|
phasenAnlegen: async (liste) => {
|
||||||
|
for (const p of liste)
|
||||||
|
phasen.push({ ...p, status: "OFFEN", detail: null, ursache: null, hinweis: null });
|
||||||
|
},
|
||||||
|
phaseGestartet: async (s) => void (finde(s).status = "LAEUFT"),
|
||||||
|
phaseDetail: async (s, d) => void (finde(s).detail = d),
|
||||||
|
phaseBeendet: async (s, status, a) => {
|
||||||
|
Object.assign(finde(s), {
|
||||||
|
status,
|
||||||
|
...(a.detail !== undefined ? { detail: a.detail } : {}),
|
||||||
|
...(a.fehler ?? {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
log: async (_stufe, nachricht, angaben) => {
|
||||||
|
if (angaben?.phase === "provisionierung" && nachricht.startsWith("#!/bin/bash")) skript = nachricht;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const stand: VmAusrollStand = {};
|
||||||
|
try {
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
vmAusrollSchritte(
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
anfrage: v.anfrage,
|
||||||
|
distribution: v.distribution,
|
||||||
|
profile: v.profile,
|
||||||
|
vmidReservieren: (belegt, wunsch) =>
|
||||||
|
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.gastVmidVon, wunsch }),
|
||||||
|
templateBau: v.anfrage.templateBauen
|
||||||
|
? {
|
||||||
|
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten),
|
||||||
|
auftragKurzId: "dry-run",
|
||||||
|
vmidReservieren: (belegt) =>
|
||||||
|
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
angelegt: async () => {},
|
||||||
|
auftragKurzId: "dry-run",
|
||||||
|
},
|
||||||
|
stand,
|
||||||
|
),
|
||||||
|
senke,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kopf: kopfzeile(v, stand),
|
||||||
|
phasen,
|
||||||
|
aufrufe: client.geplant,
|
||||||
|
skript,
|
||||||
|
cliBefehl: cliBefehl(v.anfrage),
|
||||||
|
automatisch: v.automatisch,
|
||||||
|
ok: ergebnis.ok,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await client.schliessen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kopfzeile(v: Pick<Vorbereitet, "server" | "distribution">, stand: VmAusrollStand): string {
|
||||||
|
return [
|
||||||
|
`Server ${v.server.name}`,
|
||||||
|
stand.knoten ? `Node ${stand.knoten}` : null,
|
||||||
|
`${v.distribution.name}${stand.template ? ` (Template ${stand.template.vmid})` : ""}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
@@ -70,3 +70,32 @@ export async function vmidReservieren(eingabe: {
|
|||||||
export async function reservierungenFreigeben(auftragId: string): Promise<void> {
|
export async function reservierungenFreigeben(auftragId: string): Promise<void> {
|
||||||
await prisma.vmidReservierung.deleteMany({ where: { auftragId } });
|
await prisma.vmidReservierung.deleteMany({ where: { auftragId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Für den Trockenlauf: die nächste freie ID nennen, ohne sie zu reservieren. */
|
||||||
|
export async function vmidVorschlagen(eingabe: {
|
||||||
|
serverId: string;
|
||||||
|
belegt: ReadonlySet<number>;
|
||||||
|
von: number;
|
||||||
|
wunsch?: number;
|
||||||
|
}): Promise<number> {
|
||||||
|
const reserviert = new Set(
|
||||||
|
(
|
||||||
|
await prisma.vmidReservierung.findMany({
|
||||||
|
where: { serverId: eingabe.serverId, ablaufAm: { gt: new Date() } },
|
||||||
|
select: { vmid: true },
|
||||||
|
})
|
||||||
|
).map((r) => r.vmid),
|
||||||
|
);
|
||||||
|
if (eingabe.wunsch !== undefined) {
|
||||||
|
if (eingabe.belegt.has(eingabe.wunsch) || reserviert.has(eingabe.wunsch)) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`VMID ${eingabe.wunsch} ist bereits belegt oder reserviert.`,
|
||||||
|
"Eine andere ID wählen oder das Feld leer lassen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return eingabe.wunsch;
|
||||||
|
}
|
||||||
|
let id = eingabe.von;
|
||||||
|
while (eingabe.belegt.has(id) || reserviert.has(id)) id += 1;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
/** Meldet alle Abläufe beim worker an. Ausrollen (#008) und Aufräumen folgen. */
|
/** Meldet alle Abläufe beim worker an. Aufräumen folgt. */
|
||||||
|
import "./ablaeufe/ausrollen";
|
||||||
import "./ablaeufe/template";
|
import "./ablaeufe/template";
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
|
||||||
|
import { sshBefehl, vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen";
|
||||||
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { melden } from "@/server/ereignisse/senke";
|
||||||
|
import { ausrollenVorbereiten, kopfzeile } from "@/server/services/ausrollen";
|
||||||
|
import { verbindungAus } from "@/server/services/server";
|
||||||
|
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||||
|
import { vmidReservieren } from "@/server/services/vmid";
|
||||||
|
import { ablaufRegistrieren } from "../ausfuehren";
|
||||||
|
import { aufrufLogger } from "../aufruf-log";
|
||||||
|
|
||||||
|
ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, angelegt }) => {
|
||||||
|
// Dieselbe Vorbereitung wie beim Anlegen – der Katalog könnte sich seitdem geändert haben.
|
||||||
|
const v = await ausrollenVorbereiten(auftrag.anfrage);
|
||||||
|
const stand: VmAusrollStand = {};
|
||||||
|
const kopfSetzen = async () => {
|
||||||
|
await prisma.auftrag.update({
|
||||||
|
where: { id: auftrag.id },
|
||||||
|
data: { kopf: kopfzeile(v, stand), knoten: stand.knoten ?? null },
|
||||||
|
});
|
||||||
|
await melden(auftrag.id);
|
||||||
|
};
|
||||||
|
await kopfSetzen();
|
||||||
|
|
||||||
|
const client = new ProxmoxClient(verbindungAus(v.server), { beobachter: aufrufLogger(senke) });
|
||||||
|
try {
|
||||||
|
const ergebnis = await phasenAusfuehren(
|
||||||
|
vmAusrollSchritte(
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
anfrage: v.anfrage,
|
||||||
|
distribution: v.distribution,
|
||||||
|
profile: v.profile,
|
||||||
|
vmidReservieren: async (belegt, wunsch) => {
|
||||||
|
const vmid = await vmidReservieren({
|
||||||
|
serverId: v.server.id,
|
||||||
|
auftragId: auftrag.id,
|
||||||
|
belegt,
|
||||||
|
von: v.server.gastVmidVon,
|
||||||
|
wunsch,
|
||||||
|
});
|
||||||
|
await prisma.auftrag.update({ where: { id: auftrag.id }, data: { vmid } });
|
||||||
|
await kopfSetzen();
|
||||||
|
return vmid;
|
||||||
|
},
|
||||||
|
templateBau: v.anfrage.templateBauen
|
||||||
|
? {
|
||||||
|
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten),
|
||||||
|
auftragKurzId: auftrag.kurzId,
|
||||||
|
vmidReservieren: (belegt) =>
|
||||||
|
vmidReservieren({
|
||||||
|
serverId: v.server.id,
|
||||||
|
auftragId: auftrag.id,
|
||||||
|
belegt,
|
||||||
|
von: v.server.templateVmidVon,
|
||||||
|
bis: v.server.templateVmidBis,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
angelegt,
|
||||||
|
auftragKurzId: auftrag.kurzId,
|
||||||
|
},
|
||||||
|
stand,
|
||||||
|
),
|
||||||
|
senke,
|
||||||
|
{ abbruchGewuenscht },
|
||||||
|
);
|
||||||
|
await kopfSetzen();
|
||||||
|
if (!ergebnis.ok) return ergebnis;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
ergebnis: {
|
||||||
|
vmid: stand.vmid,
|
||||||
|
knoten: stand.knoten,
|
||||||
|
ip: stand.ip,
|
||||||
|
ssh: sshBefehl(v.anfrage.zugang.benutzer, stand.ip),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await client.schliessen();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { POST as ausrollenPost } from "@/app/api/v1/auftraege/route";
|
||||||
|
import { verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { cliBefehl, AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||||
|
import { katalogSynchronisieren } from "@/server/services/katalog";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
import { angemeldet, anfrage } from "./helpers/anfragen";
|
||||||
|
|
||||||
|
const SCHLUESSEL =
|
||||||
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@test";
|
||||||
|
const gueltig = {
|
||||||
|
typ: "vm",
|
||||||
|
server: "rz1",
|
||||||
|
distribution: "debian-13",
|
||||||
|
name: "web01",
|
||||||
|
storage: "local-zfs",
|
||||||
|
netz: { bridge: "vmbr0" },
|
||||||
|
zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] },
|
||||||
|
profile: ["docker"],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CLI-Befehl aus der Anfrage", () => {
|
||||||
|
it("nennt nur Abweichungen vom Standard und entspricht der Vorgabe", () => {
|
||||||
|
const a = AusrollAnfrage.parse({
|
||||||
|
...gueltig,
|
||||||
|
profile: ["base", "docker"],
|
||||||
|
netz: { bridge: "vmbr0", vlan: 20, modus: "statisch", ip: "10.0.20.15/24", gateway: "10.0.20.1" },
|
||||||
|
cores: 2,
|
||||||
|
speicherMb: 4096,
|
||||||
|
diskGb: 40,
|
||||||
|
});
|
||||||
|
expect(cliBefehl(a)).toBe(
|
||||||
|
"proxdeploy vm web01 --server rz1 --distro debian-13 --profile base,docker --ip 10.0.20.15/24 --gw 10.0.20.1 --vlan 20 --memory 4096 --disk 40G --storage local-zfs --user florian",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quotet Variablenwerte mit Leerzeichen", () => {
|
||||||
|
const a = AusrollAnfrage.parse({ ...gueltig, variablen: { "zabbix-agent": { server: "a b" } } });
|
||||||
|
expect(cliBefehl(a)).toContain("--var zabbix-agent.server='a b'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("POST /api/v1/auftraege", () => {
|
||||||
|
let cookie: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
await katalogSynchronisieren();
|
||||||
|
cookie = (await angemeldet("OPERATOR")).cookie;
|
||||||
|
await testPrisma.server.create({
|
||||||
|
data: {
|
||||||
|
name: "rz1",
|
||||||
|
titel: "RZ 1",
|
||||||
|
url: "https://127.0.0.1:8006",
|
||||||
|
tokenId: "a@pve!b",
|
||||||
|
tokenGeheimnis: verschluesseln("x"),
|
||||||
|
fingerabdruck: "AA",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const senden = async (rumpf: unknown) => {
|
||||||
|
const antwort = await ausrollenPost(anfrage("POST", "/api/v1/auftraege", cookie, rumpf));
|
||||||
|
return { status: antwort.status, inhalt: await antwort.json() };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("meldet Feldfehler einzeln: Name, Schlüssel, root", async () => {
|
||||||
|
const { status, inhalt } = await senden({
|
||||||
|
...gueltig,
|
||||||
|
name: "Web_01",
|
||||||
|
zugang: { benutzer: "root", sshSchluessel: ["kein schlüssel"] },
|
||||||
|
});
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(Object.keys(inhalt.fehler.felder)).toEqual(
|
||||||
|
expect.arrayContaining(["name", "zugang.benutzer", "zugang.sshSchluessel.0"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fehlende Profil-Variable landet am richtigen Feld", async () => {
|
||||||
|
const { status, inhalt } = await senden({ ...gueltig, profile: ["zabbix-agent"] });
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(inhalt.fehler.felder).toEqual({ "variablen.zabbix-agent.server": "Zabbix-Server fehlt." });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deaktivierte Distribution und unbekannter Server werden abgewiesen", async () => {
|
||||||
|
await testPrisma.distribution.update({ where: { id: "debian-13" }, data: { aktiv: false } });
|
||||||
|
expect((await senden(gueltig)).status).toBe(400);
|
||||||
|
expect((await senden({ ...gueltig, server: "gibtsnicht" })).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||||
|
import {
|
||||||
|
ipconfig,
|
||||||
|
sshkeysKodieren,
|
||||||
|
vmAusrollSchritte,
|
||||||
|
type VmAusrollStand,
|
||||||
|
} from "@/engine/ablaeufe/vm-ausrollen";
|
||||||
|
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}:120: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 aufraeumen: Array<() => Promise<void>> = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
while (aufraeumen.length) await aufraeumen.pop()!();
|
||||||
|
});
|
||||||
|
|
||||||
|
type MockOptionen = {
|
||||||
|
template?: boolean;
|
||||||
|
cloneBelegt?: boolean;
|
||||||
|
provisionierungCode?: number;
|
||||||
|
agentStumm?: boolean;
|
||||||
|
vlanBridge?: boolean;
|
||||||
|
trocken?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function aufbauen(o: MockOptionen = {}) {
|
||||||
|
const mock = await new ProxmoxMock().starten();
|
||||||
|
szenarioEinrichten(mock, { version: "9.2.1", importStorage: true });
|
||||||
|
let klonVersuche = 0;
|
||||||
|
const dateien = new Map<string, string>();
|
||||||
|
let execStatusAbfragen = 0;
|
||||||
|
mock
|
||||||
|
.route("GET", "/cluster/resources", () => ({
|
||||||
|
data: [
|
||||||
|
{ type: "node", node: "pve01", status: "online", maxmem: 64e9, mem: 8e9 },
|
||||||
|
...(o.template === false
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{ type: "qemu", vmid: 9000, node: "pve01", template: 1, tags: "proxdeploy-template;debian-13" },
|
||||||
|
]),
|
||||||
|
{ type: "qemu", vmid: 100, node: "pve01" },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
.route("GET", /^\/nodes\/pve01\/network$/, () => ({
|
||||||
|
data: [{ iface: "vmbr0", type: "bridge", bridge_vlan_aware: o.vlanBridge === false ? 0 : 1 }],
|
||||||
|
}))
|
||||||
|
.route("POST", "/nodes/pve01/qemu/9000/clone", () =>
|
||||||
|
o.cloneBelegt && ++klonVersuche === 1
|
||||||
|
? { status: 500, statusText: "unable to create VM 101 - VM 101 already exists on node 'pve02'" }
|
||||||
|
: { data: UPID("qmclone") },
|
||||||
|
)
|
||||||
|
.route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } }))
|
||||||
|
.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" },
|
||||||
|
}))
|
||||||
|
.route("POST", /^\/nodes\/pve01\/qemu\/\d+\/status\/start$/, () => ({ data: UPID("qmstart") }))
|
||||||
|
.route("POST", /\/agent\/ping$/, () =>
|
||||||
|
o.agentStumm
|
||||||
|
? { status: 500, statusText: "QEMU guest agent is not running" }
|
||||||
|
: { data: { result: {} } },
|
||||||
|
)
|
||||||
|
.route("GET", /\/agent\/network-get-interfaces$/, () => ({
|
||||||
|
data: {
|
||||||
|
result: [
|
||||||
|
{ name: "lo", "ip-addresses": [{ "ip-address-type": "ipv4", "ip-address": "127.0.0.1" }] },
|
||||||
|
{ name: "eth0", "ip-addresses": [{ "ip-address-type": "ipv4", "ip-address": "10.0.10.50" }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.route("POST", /\/agent\/file-write$/, (a) => {
|
||||||
|
dateien.set(
|
||||||
|
a.rumpf.get("file")!,
|
||||||
|
Buffer.from(a.rumpf.get("content")!, a.rumpf.get("encode") === "0" ? "base64" : "utf8").toString(
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { data: null };
|
||||||
|
})
|
||||||
|
.route("POST", /\/agent\/exec$/, () => ({ data: { pid: 4711 } }))
|
||||||
|
.route("GET", /\/agent\/exec-status$/, () => ({
|
||||||
|
data: ++execStatusAbfragen < 2 ? { exited: 0 } : { exited: 1, exitcode: o.provisionierungCode ?? 0 },
|
||||||
|
}))
|
||||||
|
.route("GET", /\/agent\/file-read$/, () => ({
|
||||||
|
data: {
|
||||||
|
content: "::proxdeploy:: profil base\nReading package lists...\n::proxdeploy:: profil docker\n",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
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, dateien };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eingabe(client: ProxmoxClient, anfrageTeil: Record<string, unknown> = {}) {
|
||||||
|
const katalog = await katalogLaden("catalog");
|
||||||
|
const distribution = katalog.distributionen.find((d) => d.eintrag.id === "debian-13")!.eintrag;
|
||||||
|
const anfrage = AusrollAnfrage.parse({
|
||||||
|
typ: "vm",
|
||||||
|
server: "rz1",
|
||||||
|
distribution: "debian-13",
|
||||||
|
name: "web01",
|
||||||
|
storage: "local-zfs",
|
||||||
|
diskGb: 40,
|
||||||
|
netz: {
|
||||||
|
bridge: "vmbr0",
|
||||||
|
vlan: 20,
|
||||||
|
modus: "statisch",
|
||||||
|
ip: "10.0.20.15/24",
|
||||||
|
gateway: "10.0.20.1",
|
||||||
|
dns: ["10.0.20.1"],
|
||||||
|
},
|
||||||
|
zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] },
|
||||||
|
profile: ["docker"],
|
||||||
|
...anfrageTeil,
|
||||||
|
});
|
||||||
|
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 angelegt: Array<Record<string, unknown>> = [];
|
||||||
|
return {
|
||||||
|
angelegt,
|
||||||
|
e: {
|
||||||
|
client,
|
||||||
|
anfrage,
|
||||||
|
distribution,
|
||||||
|
profile: aufgeloest.profile,
|
||||||
|
vmidReservieren: async (belegt: Set<number>, wunsch?: number) =>
|
||||||
|
wunsch ?? (belegt.has(101) ? 102 : 101),
|
||||||
|
angelegt: async (x: Record<string, unknown>) => void angelegt.push(x),
|
||||||
|
auftragKurzId: "d-test1",
|
||||||
|
warten: async () => {},
|
||||||
|
agentMs: 50,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VM ausrollen", () => {
|
||||||
|
it("klont, konfiguriert, setzt cloud-init, startet, wartet auf den Agent und provisioniert", async () => {
|
||||||
|
const { mock, client, dateien } = await aufbauen();
|
||||||
|
const { e, angelegt } = await eingabe(client);
|
||||||
|
const stand: VmAusrollStand = {};
|
||||||
|
expect(await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
|
||||||
|
expect(stand).toMatchObject({
|
||||||
|
knoten: "pve01",
|
||||||
|
template: { vmid: 9000, knoten: "pve01" },
|
||||||
|
vmid: 101,
|
||||||
|
ip: "10.0.20.15",
|
||||||
|
});
|
||||||
|
expect(angelegt).toContainEqual({ vmid: 101 });
|
||||||
|
|
||||||
|
const klon = Object.fromEntries(mock.anfragen.find((a) => a.pfad.endsWith("/clone"))!.rumpf);
|
||||||
|
expect(klon).toEqual({ newid: "101", name: "web01", full: "1", target: "pve01", storage: "local-zfs" });
|
||||||
|
|
||||||
|
const configs = mock.anfragen
|
||||||
|
.filter((a) => a.methode === "PUT" && a.pfad.endsWith("/config"))
|
||||||
|
.map((a) => Object.fromEntries(a.rumpf));
|
||||||
|
expect(configs[0]).toMatchObject({
|
||||||
|
cores: "2",
|
||||||
|
memory: "2048",
|
||||||
|
cpu: "host",
|
||||||
|
net0: "virtio,bridge=vmbr0,tag=20",
|
||||||
|
tags: "proxdeploy;debian-13",
|
||||||
|
});
|
||||||
|
expect(configs[1]).toMatchObject({
|
||||||
|
ciuser: "florian",
|
||||||
|
ipconfig0: "ip=10.0.20.15/24,gw=10.0.20.1",
|
||||||
|
nameserver: "10.0.20.1",
|
||||||
|
ciupgrade: "0",
|
||||||
|
sshkeys: sshkeysKodieren([SCHLUESSEL]),
|
||||||
|
});
|
||||||
|
expect(Object.fromEntries(mock.anfragen.find((a) => a.pfad.endsWith("/resize"))!.rumpf)).toEqual({
|
||||||
|
disk: "scsi0",
|
||||||
|
size: "40G",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Das Skript kommt base64-kodiert an (Umlaute!) und enthält Benutzer und beide Profile.
|
||||||
|
const skript = [...dateien.values()].join("");
|
||||||
|
expect(skript).toContain("::proxdeploy:: profil base");
|
||||||
|
expect(skript).toContain("::proxdeploy:: profil docker");
|
||||||
|
expect(skript).toContain("useradd -m -s /bin/bash florian");
|
||||||
|
expect(skript).toContain("Frische Images");
|
||||||
|
const exec = mock.anfragen.find((a) => a.pfad.endsWith("/agent/exec"))!.rumpf.getAll("command");
|
||||||
|
expect(exec[0]).toBe("/bin/bash");
|
||||||
|
expect(exec[2]).toMatch(/> \/var\/log\/proxdeploy-provisionierung\.log 2>&1$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kodiert SSH-Schlüssel so, wie Proxmox sie erwartet (auch / + und Leerzeichen)", () => {
|
||||||
|
expect(sshkeysKodieren(["ssh-ed25519 AA/B+C= x (y)"])).toBe("ssh-ed25519%20AA%2FB%2BC%3D%20x%20%28y%29");
|
||||||
|
expect(ipconfig({ bridge: "vmbr0", modus: "dhcp", dns: [] })).toBe("ip=dhcp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ohne Template: klare Meldung – mit templateBauen würde gebaut", async () => {
|
||||||
|
const { client } = await aufbauen({ template: false });
|
||||||
|
const { e } = await eingabe(client);
|
||||||
|
const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm);
|
||||||
|
expect(ergebnis).toMatchObject({ ok: false, phase: "pruefungen" });
|
||||||
|
if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/Template vorher bauen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("VLAN auf einer nicht VLAN-fähigen Bridge wird vor dem Klonen abgelehnt", async () => {
|
||||||
|
const { mock, client } = await aufbauen({ vlanBridge: false });
|
||||||
|
const { e } = await eingabe(client);
|
||||||
|
const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm);
|
||||||
|
expect(ergebnis.ok || ergebnis.fehler.ursache).toMatch(/nicht VLAN-fähig/);
|
||||||
|
expect(mock.anfragen.some((a) => a.pfad.endsWith("/clone"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ist die VMID beim Klonen inzwischen belegt, wird einmal mit neuer ID geklont", async () => {
|
||||||
|
const { mock, client } = await aufbauen({ cloneBelegt: true });
|
||||||
|
const { e } = await eingabe(client);
|
||||||
|
const stand: VmAusrollStand = {};
|
||||||
|
let aufrufe = 0;
|
||||||
|
e.vmidReservieren = async () => (++aufrufe === 1 ? 101 : 102);
|
||||||
|
expect(await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
|
||||||
|
expect(stand.vmid).toBe(102);
|
||||||
|
expect(mock.anfragen.filter((a) => a.pfad.endsWith("/clone")).map((a) => a.rumpf.get("newid"))).toEqual([
|
||||||
|
"101",
|
||||||
|
"102",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ein scheiternder Profillauf nennt das Profil und den Exit-Code", async () => {
|
||||||
|
const { client } = await aufbauen({ provisionierungCode: 100 });
|
||||||
|
const { e } = await eingabe(client);
|
||||||
|
const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm);
|
||||||
|
expect(ergebnis).toMatchObject({ ok: false, phase: "provisionierung" });
|
||||||
|
if (!ergebnis.ok)
|
||||||
|
expect(ergebnis.fehler.ursache).toBe("Provisionierung im Profil docker mit Code 100 abgebrochen.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("antwortet der Agent nicht, sagt die Phase, woran es liegen kann", async () => {
|
||||||
|
const { client } = await aufbauen({ agentStumm: true });
|
||||||
|
const { e } = await eingabe(client);
|
||||||
|
const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm);
|
||||||
|
expect(ergebnis).toMatchObject({ ok: false, phase: "agent" });
|
||||||
|
if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/Konsole/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DHCP: die IP kommt vom Guest-Agent (ohne Loopback)", async () => {
|
||||||
|
const { client } = await aufbauen();
|
||||||
|
const { e } = await eingabe(client, { netz: { bridge: "vmbr0", modus: "dhcp" } });
|
||||||
|
const stand: VmAusrollStand = {};
|
||||||
|
await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm);
|
||||||
|
expect(stand.ip).toBe("10.0.10.50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Trockenlauf: nichts Schreibendes erreicht Proxmox, kein Agent-Aufruf", async () => {
|
||||||
|
const { mock, client } = await aufbauen({ trocken: true });
|
||||||
|
const { e } = await eingabe(client);
|
||||||
|
expect(await phasenAusfuehren(vmAusrollSchritte(e), stumm)).toEqual({ ok: true });
|
||||||
|
expect(mock.anfragen.filter((a) => a.methode !== "GET")).toEqual([]);
|
||||||
|
expect(mock.anfragen.some((a) => a.pfad.includes("/agent/"))).toBe(false);
|
||||||
|
expect(client.geplant.map((a) => `${a.methode} ${a.pfad}`)).toEqual([
|
||||||
|
"POST /nodes/pve01/qemu/9000/clone",
|
||||||
|
"PUT /nodes/pve01/qemu/101/config",
|
||||||
|
"PUT /nodes/pve01/qemu/101/resize",
|
||||||
|
"PUT /nodes/pve01/qemu/101/config",
|
||||||
|
"POST /nodes/pve01/qemu/101/status/start",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user