17 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5.5 543fc1c8b4 chore(release): v0.4.0
Etappe 4: CLI proxdeploy in Go, API-Tokens, kurze Anfragen, OpenAPI. Abnahme auf pve-demo per batch (VM 107, CT 108).

Closes #013

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:18:34 +02:00
florian.wildeandClaude Opus 5.5 4ae08abd7c docs: CLI in README, Architektur und Konventionen
Refs #013

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:08:42 +02:00
florian.wildeandClaude Opus 5.5 bd4bca5399 feat(cli): proxdeploy in Go
Befehle login, servers, distros, profiles, presets, vm, lxc, template list|build, batch, status, logs --follow, cancel. Renderer im Aufbau der Vorgabe (zeichengenau per Golden-File), 24-bit/256/ohne Farbe, --ascii, --quiet, --json, --dry-run; live im Terminal, zeilenweise ohne. SSE mit Wiederverbinden und Polling als Rückfall. Typen aus openapi.json per oapi-codegen; Release-Builds per cli/bauen.sh.

Refs #013

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:08:41 +02:00
florian.wildeandClaude Opus 5.5 9fdced62db feat(api): API-Tokens, kurze Anfragen und OpenAPI für die CLI
- ApiToken (nur SHA-256), Bearer-Anmeldung, POST /auth/token für proxdeploy login, Verwaltung in den Einstellungen
- anfrageErgaenzen: Storage, Bridge, Node, Benutzer und Schlüssel aus Server-Standards, Verbindungstest und Anmeldung – auch beim Template-Bau
- Abschlussbox serverseitig (abschluss), Web und CLI zeigen dieselben Zeilen
- Antwortschemas als zod, daraus /api/v1/openapi.json und cli/api/openapi.json (pnpm openapi)

Refs #013

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:08:27 +02:00
florian.wildeandClaude Opus 5.5 1e195f9bca chore(release): v0.3.0
Etappe 3: LXC, Ubuntu, Profil-Editor, Vorlagen, Profile im Template, SSH-Schlüssel, Template-Bau ohne SSH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:39:08 +02:00
florian.wildeandClaude Opus 5.5 8bfa89fb3b docs(devlog): Sitzung 2 und Zeiten für Etappe 3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:37:19 +02:00
florian.wildeandClaude Opus 5.5 21edff2141 feat(templates): Template-Bau ohne SSH über eine Bau-Adresse
Ohne SSH zum Node kommt der Guest-Agent jetzt über eine feste
Bau-Adresse ins Template: cloud-init setzt Adresse und den
Bereitstellungsschlüssel für den Standardbenutzer, ProxDeploy meldet
sich per SSH in der VM an, installiert den Agent und entfernt danach
Schlüssel, Adresse und cloud-init-Angaben.

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

Refs #021

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

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

Refs #015

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

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

Closes #022

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

Closes #010

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

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

Closes #012

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

Closes #011

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

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

Closes #009

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:19:13 +02:00
florian.wildeandClaude Opus 5.5 9310164bc2 feat(ausrollen): VM ausrollen mit Assistent, Dry-Run und CLI-Befehl
Eine VM aus dem ProxDeploy-Template ausrollen, vollständig per API:
Klonen, Hardware, cloud-init, Start, Guest-Agent und Provisionierung der
Profile über den Agent – ohne SSH. Der Fortschritt kommt aus der
Logdatei im Gast, je Profil eine Marke.

Der Dry-Run läuft durch dieselben Phasen mit einem Trocken-Client und
zeigt jeden geplanten Proxmox-Aufruf und den passenden CLI-Befehl. Der
Assistent hat 9 Schritte, den Zustand in der URL und bietet an, ein
fehlendes Template im selben Auftrag zu bauen.

Auf pve-demo abgenommen: pd-test01 (base, docker) per API, pd-test02
(base, zabbix-agent) über den Assistenten, beide per SSH geprüft. Dabei
gefunden: file-write scheitert an Umlauten – Skripte gehen jetzt
base64-kodiert hinüber.

Closes #008

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:34:15 +02:00
florian.wildeandClaude Opus 5.5 4285785a4c feat(templates): Template-Bau für VMs mit eingebautem Guest-Agent
Cloud-Image laden (download-url mit Prüfsumme, ab PVE 8.4 per API, davor
per SSH), VM mit q35/UEFI/virtio-scsi-single anlegen, Disk per
import-from importieren, Guest-Agent mit einem einmaligen
Vorbereitungsstart einbauen und als Template markieren.

Auf pve-demo (PVE 9.2.2) gebaut: Template 9000 aus Debian 13 in
5 min 26 s, Snippet und cicustom danach entfernt. Der Weg ohne SSH über
eine Bau-Adresse ist als #021 ausgegliedert.

Templates-Seite mit Liste (Proxmox ist maßgeblich, Tag
proxdeploy-template) und Formular; Abfrageschleifen erscheinen im
Detail-Log nur noch bei Fehlern.

Closes #007

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:57:11 +02:00
florian.wildeandClaude Opus 5.5 76bc0e4269 feat(auftraege): worker, Phasen und Live-Fortschritt per SSE
Aufträge laufen als pg-boss-Jobs im worker. Der Phasen-Runner der Engine
kennt weder Proxmox noch Datenbank; eine Senke schreibt Phasen und Log
in die DB und meldet per NOTIFY, die app streamt per SSE an Web und CLI
(Last-Event-ID, Herzschlag, Polling-Fallback im Browser).

VMIDs vergibt eine Advisory-Sperre je Server zusammen mit einer
Reservierungstabelle; zehn parallele Reservierungen im Test bekommen
zehn verschiedene IDs. Der Proxmox-Client hat einen Trockenmodus für
den Dry-Run: Lesendes echt, Schreibendes nur aufgezeichnet.

Live-Ansicht im Aufbau der CLI-Ausgabe. Im echten Betrieb aufgefallen:
ssh2 und pg sind CommonJS und brauchen im worker Default-Importe.

Closes #006

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:54:41 +02:00
florian.wildeandClaude Opus 5.5 01d22aac3a feat(engine): Adapter debian und Profil-Engine
Profile sind deklarative Schritte je Distributionsfamilie; der Adapter
übersetzt sie in ein idempotentes Shell-Skript. Ein neuer Adapter (rhel,
alpine) ist eine Datei und ein Eintrag in der Registry.

Variablen werden typisiert geprüft, in run-Schritten jeder Wert für die
Shell gequotet. SSH-Härtung läuft zuletzt, damit sich eine Provisionierung
per SSH nicht selbst aussperrt.

Geprüft mit shellcheck und echt in Containern (Debian 12/13, Ubuntu
22.04/24.04/26.04) inklusive zweitem Lauf. Dabei aufgefallen: frische
Images haben keine Paketlisten – der Vorspann macht jetzt apt-get update.

Closes #005

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:53:38 +02:00
183 changed files with 18000 additions and 160 deletions
+51 -3
View File
@@ -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,57 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.4.0] – 2026-10-12
### Hinzugefügt
- CLI `proxdeploy` (Go, ein Binary für Linux/macOS/Windows × amd64/arm64): `login`, `servers`, `distros`, `profiles`, `presets`, `vm`, `lxc`, `template list|build`, `batch`, `status`, `logs --follow`, `cancel`; Ausgabe im Aufbau der Vorgabe mit 24-bit-/256-Farben, `--ascii`, `--quiet`, `--json`, `--dry-run` (#013)
- API-Tokens für die CLI: anlegen und widerrufen in den Einstellungen, `proxdeploy login` tauscht Benutzername und Passwort gegen ein Token; Anmeldung per `Authorization: Bearer` (#013)
- Kurze Anfragen: fehlen Storage, Bridge, Node, Benutzer oder SSH-Schlüssel, nimmt der Server die Standards (Server-Einstellungen, Verbindungstest, eigener Benutzername, hinterlegte Schlüssel) – auch beim Template-Bau (#013)
- OpenAPI-Beschreibung unter `/api/v1/openapi.json`, aus denselben zod-Schemas wie die API (#013)
### Geändert
- Die Abschlussbox (Name, ID, IP, Profile, SSH bzw. Phase, Ursache, Hinweis) berechnet der Server; Web-GUI und CLI zeigen dieselben Zeilen (#013)
### Sicherheit
- API-Tokens werden nur als SHA-256 gespeichert und genau einmal angezeigt; ein ungültiges Token gilt als nicht angemeldet, auch wenn zusätzlich ein Sitzungscookie mitkommt (#013)
## [0.3.0] – 2026-10-12
### Hinzugefügt
- Template-Bau ohne SSH zum Node: über eine Bau-Adresse installiert ProxDeploy den Guest-Agent per SSH in der Vorbereitungs-VM (#021)
- SSH-Schlüssel in den Einstellungen hinterlegen (persönlich und, für Admins, Team-Schlüssel); sie kommen automatisch in jeden Gast – auch per API/CLI ohne Angabe – und sind im Assistenten abwählbar (#015)
- Templates mit eingebackenen Profilen: beim Template-Bau Profile wählen; Ausrollen nimmt das passende Template und überspringt, was schon drin ist (#022)
- Profil-Editor: eigene Profile als YAML mit Live-Prüfung und Skriptvorschau, Katalogprofile als Kopie bearbeiten (#011)
- Vorlagen (Presets): im Assistenten wählen und speichern, Vorlagenseite, `vorlage` in der API; Beispiel „Docker-Host klein“ (#012)
- Ubuntu 22.04/24.04/26.04 als VM und LXC; Fortschritt beim Klonen aus dem Task-Log (#010)
- LXC-Container ausrollen: Container-Template prüfen und laden, anlegen (unprivilegiert, nesting für docker), Netz, Provisionierung per `pct exec` über Node-SSH oder per SSH in den Container mit einem Bereitstellungsschlüssel (#009)
- Profile kennen die festen Bedingungen `if: vm` und `if: lxc` (#009)
### Behoben
- Verbindungstest meldete „Template-Bau per API“ auch dann, wenn der Guest-Agent mangels SSH gar nicht ins Template käme (#021)
- chrony wird in Containern nicht mehr installiert – ohne CAP_SYS_TIME scheiterte dort das Profil base (#009)
- SSH-Härtung auf Ubuntu 24.04 scheiterte an fehlendem /run/sshd (#009)
## [0.2.0] – 2026-10-12
### Hinzugefügt
- 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
+7
View File
@@ -38,6 +38,7 @@ src/app/api/v1/* Route Handler: Recht prüfen, zod, Service. Sonst nichts
src/components/* Darstellung, keine Geschäftslogik. src/components/* Darstellung, keine Geschäftslogik.
src/lib/* Framework-freie Helfer; was Client und Server teilen, liegt hier. src/lib/* Framework-freie Helfer; was Client und Server teilen, liegt hier.
src/worker/* Hintergrundprozess (pg-boss). src/worker/* Hintergrundprozess (pg-boss).
cli/ CLI in Go; nur REST-API. Typen aus cli/api/openapi.json (oapi-codegen).
``` ```
- **Web-GUI und CLI nutzen dieselbe REST-API.** Keine Logik nur für eine Oberfläche. - **Web-GUI und CLI nutzen dieselbe REST-API.** Keine Logik nur für eine Oberfläche.
@@ -61,6 +62,11 @@ Node 24 über nvm (`export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"`), pnpm 12.
`pino@10.4.0`. Obergrenzen: **eslint 9.x** (eslint-plugin-react aus eslint-config-next kann 10 nicht), `pino@10.4.0`. Obergrenzen: **eslint 9.x** (eslint-plugin-react aus eslint-config-next kann 10 nicht),
**typescript ~6.0.3** (typescript-eslint 8 erlaubt < 6.1). **typescript ~6.0.3** (typescript-eslint 8 erlaubt < 6.1).
CLI: **Go 1.27.2** ohne sudo unter `~/.local/go` (`export PATH="$HOME/.local/go/bin:$PATH"`),
`cobra 1.10.2`, `BurntSushi/toml 1.6.0`, `x/term 0.46.0`, `go-runewidth 0.0.30`,
`go.yaml.in/yaml/v3 3.0.5`, `oapi-codegen 2.8.0` (als `go tool`). Antwortschemas der API stehen in
`src/lib/schemas/api.ts`; nach einer Änderung `pnpm openapi` und `go generate ./...` in `cli/`.
Next 16: `proxy.ts` statt middleware; `cookies()`, `headers()`, `params`, `searchParams` sind async. Next 16: `proxy.ts` statt middleware; `cookies()`, `headers()`, `params`, `searchParams` sind async.
Prisma 7: Client aus `@/generated/prisma/client`, Enum-Werte aus `@/generated/prisma/enums`, Zugriff Prisma 7: Client aus `@/generated/prisma/client`, Enum-Werte aus `@/generated/prisma/enums`, Zugriff
über den Lazy-Proxy `src/lib/db.ts`. über den Lazy-Proxy `src/lib/db.ts`.
@@ -70,6 +76,7 @@ Prisma 7: Client aus `@/generated/prisma/client`, Enum-Werte aus `@/generated/pr
```bash ```bash
pnpm db:up pnpm db:up
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test
pnpm cli:pruefen # wenn API-Schemas oder cli/ betroffen sind
``` ```
Ohne laufende Datenbank überspringt `pnpm test` die Datenbanktests und sagt es auf stderr. Ohne laufende Datenbank überspringt `pnpm test` die Datenbanktests und sagt es auf stderr.
+73 -4
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 1 h 24 min (laufende Sitzung) **Gesamt:** 5 h 20 min (Sitzung 1–3)
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,77 @@ 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 |
| #009 LXC | 0 h 31 min |
| #010 Ubuntu | 0 h 05 min |
| #011 Profil-Editor | 0 h 10 min |
| #012 Vorlagen | 0 h 11 min |
| #022 Profile im Template | 0 h 22 min |
| #015 (Teil) SSH-Schlüssel | 0 h 10 min |
| #021 Template-Bau ohne SSH | 0 h 19 min |
| #013 CLI und API-Tokens | 0 h 42 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**
| v0.3.0 | 1 h 48 min | **abgeschlossen** (Abnahme #021 offen)
| v0.4.0 | 0 h 42 min | **abgeschlossen**
--- ---
## 2026-10-12 · Sitzung 1 · 08:45–laufend ## 2026-10-12 · Sitzung 3 · 14:39–15:21
**Issue:** #013
**Version:** 0.4.0
### Erledigt
- v0.3.0 freigegeben (Abnahme #021 bleibt offen, braucht eine freie IP)
- API-Tokens (`pd_…`, nur SHA-256), Bearer-Anmeldung, `POST /auth/token` für `proxdeploy login`, Verwaltung in den Einstellungen
- Kurze Anfragen: Server füllt Storage, Bridge, Node, Benutzer, Schlüssel; Abschlussbox serverseitig
- OpenAPI aus zod, Go-Typen per oapi-codegen, `pnpm cli:pruefen`
- CLI `proxdeploy` in Go 1.27.2 mit allen Befehlen der Vorgabe plus presets, template list, cancel; Ausgabe zeichengenau zum Mockup (Golden-File)
- Abnahme auf pve-demo per `batch`: pd-cli01 (VM 107, Debian 13 aus Template 9002, docker läuft) und pd-cli02 (CT 108, Ubuntu 24.04) parallel; das Verfolgen überstand einen App-Neustart mitten im Lauf
- 267 Vitest, 20 Playwright-Abläufe, Go-Tests grün
### Entscheidungen
- **Anfragen als Map, Antworten typisiert:** Go-Structs aus oapi-codegen schickten leere Pflichtfelder und überschrieben Vorlagenwerte.
- **`[instances.<name>]`** in der Konfiguration statt `profiles` – „profile“ ist schon `--profile`.
- **Strg+C hängt nur ab**, Abbruch nur mit `proxdeploy cancel`.
- **Ohne Terminal zeilenweise** (je Phase eine Zeile), damit CI-Logs lesbar bleiben.
- Gefunden durch Tests: SSE-Daten zeigten in einen wiederverwendeten Puffer (jetzt kopiert); mehrzeilige Proxmox-Parameter brachen die Dry-Run-Zeile (jetzt `⏎`).
---
## 2026-10-12 · Sitzung 2 · 12:48–14:39
**Issue:** #009–#012, #015 (Teil), #021, #022
**Version:** 0.3.0-dev
### Erledigt
- #009: LXC per pct exec (pd-ct01, Debian 13, docker im unprivilegierten CT) und per SSH in den Container (pd-ct02, Ubuntu 24.04) auf pve-demo
- #010: Ubuntu 24.04 als VM (pd-ubu01, Template 9001 im selben Auftrag gebaut)
- #011: Profil-Editor mit Live-Prüfung und Skriptvorschau
- #012: Vorlagen inkl. „Docker-Host klein“, im Assistenten und per API
- #022 (Wunsch Florian): Profile ins Template einbacken – Template 9002 tpl-debian-13-docker; Provisionierung 141 s → ~30 s
- #015 vorgezogen: SSH-Schlüssel hinterlegen (persönlich/Team), automatisch in jedem Gast
- #021: Template-Bau ohne SSH über Bau-Adresse (Mock-getestet, Abnahme auf pve-demo braucht eine freie IP)
- Florians Fragen beantwortet: was am Template anpassbar ist; Schlüssel/Anwendungen ins Template
- 253 Tests, 18 Playwright-Abläufe; main gepusht
### Entscheidungen
- **chrony nur in VMs** (`if: vm`): unprivilegierte Container dürfen die Uhr nicht stellen.
- **Profile mit `{{ guest.* }}` laufen beim Deploy erneut**, auch wenn eingebacken – sonst fehlt z. B. der Benutzer in der docker-Gruppe (gefunden mit pd-test03).
- **SSH-Schlüssel nicht ins Template**, sondern in die Verwaltung (`SchluesselQuelle`) – beim Schlüsselwechsel bleibt es eine Stelle.
- **Fähigkeitserkennung korrigiert:** Template-Bau per API nur, wenn der Guest-Agent auch hineinkommt.
- **Voll-Klon bleibt Standard**; der Testhost kopiert zurzeit nur ~3–8 MB/s (Klon 8 min) – Linked Clones als spätere Option notiert.
---
## 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 +95,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 +117,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).
+74 -4
View File
@@ -18,10 +18,10 @@ 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, Profile im Template, SSH-Schlüssel | ✓ fertig, auf PVE 9.2 geprüft (Bau-Adresse #021: Abnahme offen) |
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ○ geplant | | 4 · v0.4.0 | CLI `proxdeploy` (Go), API-Tokens | ✓ fertig, auf PVE 9.2 geprüft |
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ geplant | | 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ◐ als Nächstes |
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant | | 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
## Was ProxDeploy auf einem Server kann ## Was ProxDeploy auf einem Server kann
@@ -37,6 +37,72 @@ Maßgeblich ist die Proxmox-API (geprüft gegen PVE 8.x und 9.2):
Das API-Token braucht eine eigene Rolle; die Befehle stehen in Das API-Token braucht eine eigene Rolle; die Befehle stehen in
[docs/proxmox-rechte.md](docs/proxmox-rechte.md). [docs/proxmox-rechte.md](docs/proxmox-rechte.md).
## CLI
`proxdeploy` ist ein einzelnes Binary (Linux, macOS, Windows; amd64 und arm64) und spricht nur die
REST-API – dieselbe wie die Web-GUI, mit denselben Phasentexten.
```bash
proxdeploy login https://proxdeploy.example # fragt Benutzer und Passwort, speichert ein Token
proxdeploy servers
proxdeploy distros --type lxc
proxdeploy profiles
proxdeploy vm web01 --server rz1 --distro debian-13 --profile base,docker --ip 10.0.20.15/24 --gw 10.0.20.1 --vlan 20
proxdeploy lxc dns01 --server rz1 --distro ubuntu-24.04 --dry-run
proxdeploy template build --server rz1 --distro debian-13 --profile base,docker
proxdeploy batch gaeste.yaml
proxdeploy status a-7f3k
proxdeploy logs a-7f3k --follow
```
```
⬡ ProxDeploy v0.4 · Server rz1 · Node pve01 · Debian 13 (Template 9013)
✓ Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
✓ Klonen full clone 9013 → 120 · Disk auf 40G
✓ cloud-init user florian · SSH-Key · Profile base, docker
✓ Start VM 120 läuft
✓ Guest-Agent antwortet nach 18s
◐ Provisionierung cloud-init läuft … 1m 12s
```
- Was nicht angegeben ist, füllt der Server: Storage, Bridge und Node aus den Server-Einstellungen
bzw. dem Verbindungstest, Benutzer = eigener Benutzername, SSH-Schlüssel = die in den Einstellungen
hinterlegten. `--preset` belegt aus einer Vorlage vor; Flags gehen vor.
- Konfiguration in `~/.config/proxdeploy/config.toml` (Rechte 0600): `url`, `token`, optional
`server` als Standard für `--server` und `[instances.<name>]` für weitere Instanzen
(`--instance`). `PROXDEPLOY_URL`, `PROXDEPLOY_TOKEN`, `PROXDEPLOY_INSTANCE` gehen vor.
- Tokens für Skripte und CI: Web-GUI → Einstellungen → API-Tokens, dann `proxdeploy login <url> --token pd_…`.
- Ausgabe: Farben (24-bit, sonst 256) nur im Terminal und ohne `NO_COLOR`/`--no-color`; `--ascii`
ersetzt Symbole durch `[OK]`, `[..]`, `[!!]`; `--quiet` zeigt nur die Abschlussbox; `--json` ist
maschinenlesbar. Ohne Terminal (Pipe, CI) erscheint jede Phase als eine Zeile, sobald sie fertig ist.
- Exit-Codes: `0` Erfolg, `1` Auftrag fehlgeschlagen, `2` Bedienfehler, `3` Verbindung/Anmeldung.
- `Strg+C` hängt nur ab – der Auftrag läuft weiter (`proxdeploy status <id>`); abbrechen mit
`proxdeploy cancel <id>`.
`batch` rollt mehrere Gäste aus; die Schlüssel heißen wie die Flags. Die Datei wird vollständig
geprüft, bevor der erste Auftrag startet:
```yaml
defaults:
server: rz1
distro: debian-13
profile: [base]
guests:
- name: web01
type: vm
profile: [base, docker]
ip: 10.0.20.15/24
gw: 10.0.20.1
memory: 4096
- name: mon01
type: lxc
profile: [base, zabbix-agent]
vars: { zabbix-agent: { server: zbx.example } }
```
Bauen: `cli/bauen.sh 0.4.0` legt die sechs Binaries und `SHA256SUMS` unter `cli/bin/` ab (Go 1.27).
## Entwicklung ## Entwicklung
Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL. Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL.
@@ -54,8 +120,12 @@ Prüfen vor jedem Commit:
```bash ```bash
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test
pnpm test:e2e # Browser-Abläufe gegen den gebauten Stand (Desktop und 390 px) pnpm test:e2e # Browser-Abläufe gegen den gebauten Stand (Desktop und 390 px)
pnpm cli:pruefen # openapi.json und Go-Typen aktuell, go vet, go test (Golden-Files)
``` ```
Ändert sich ein Schema der API, erst `pnpm openapi`, dann in `cli/` `go generate ./...` – der
Go-Client nutzt die aus `cli/api/openapi.json` erzeugten Typen.
Konventionen (deutsche Bezeichner, Schichtung, Versionen) stehen in [CLAUDE.md](CLAUDE.md). Konventionen (deutsche Bezeichner, Schichtung, Versionen) stehen in [CLAUDE.md](CLAUDE.md).
## Betrieb ## Betrieb
+6 -1
View File
@@ -1,6 +1,7 @@
# Grundausstattung für jeden Gast. Platzhalter: {{ var.<name> }} aus den Variablen unten, # Grundausstattung für jeden Gast. Platzhalter: {{ var.<name> }} aus den Variablen unten,
# {{ os.id }}, {{ os.version_id }}, {{ os.codename }} aus der Distribution, # {{ os.id }}, {{ os.version_id }}, {{ os.codename }} aus der Distribution,
# {{ guest.name }}, {{ guest.user }} aus dem Deployment. `a ?? b` nimmt b, wenn a leer ist. # {{ guest.name }}, {{ guest.user }} aus dem Deployment. `a ?? b` nimmt b, wenn a leer ist.
# Bedingungen: `if: <variable>`, dazu fest `if: vm` / `if: lxc`.
id: base id: base
name: Basis name: Basis
description: Updates, Guest-Agent, Zeitzone, Locale, Tastatur, automatische Sicherheitsupdates, Zeitsync, SSH-Härtung description: Updates, Guest-Agent, Zeitzone, Locale, Tastatur, automatische Sicherheitsupdates, Zeitsync, SSH-Härtung
@@ -16,8 +17,12 @@ families:
- timezone: "{{ var.zeitzone }}" - timezone: "{{ var.zeitzone }}"
- locale: "{{ var.locale }}" - locale: "{{ var.locale }}"
- keyboard: "{{ var.tastatur }}" - keyboard: "{{ var.tastatur }}"
- packages: [chrony, sudo, curl, ca-certificates] - packages: [sudo, curl, ca-certificates]
# Zeitsync nur in VMs: ein unprivilegierter Container darf die Uhr nicht stellen, er nimmt die des Hosts.
- packages: [chrony]
if: vm
- service: { name: chrony, enable: true, start: true } - service: { name: chrony, enable: true, start: true }
if: vm
- unattended_upgrades: true - unattended_upgrades: true
- ssh_harden: { password_auth: false, root_login: false } - ssh_harden: { password_auth: false, root_login: false }
- packages: [fail2ban] - packages: [fail2ban]
+7
View File
@@ -0,0 +1,7 @@
# Nur Typen – den HTTP-Teil (Bearer, Fehlerformat, SSE) schreibt die CLI selbst in client.go.
package: api
output: typen.gen.go
generate:
models: true
output-options:
skip-prune: true
+1594
View File
File diff suppressed because it is too large Load Diff
Executable
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Release-Builds der CLI: linux/darwin/windows × amd64/arm64, statisch (CGO aus), nach cli/bin/.
# cli/bauen.sh 0.4.0
set -euo pipefail
cd "$(dirname "$0")"
version="${1:?Version angeben, z. B. 0.4.0}"
commit="$(git rev-parse --short HEAD 2>/dev/null || echo unbekannt)"
mkdir -p bin
rm -f bin/proxdeploy-* bin/SHA256SUMS
for ziel in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
os="${ziel%/*}"; arch="${ziel#*/}"
datei="bin/proxdeploy-${version}-${os}-${arch}"
[ "$os" = windows ] && datei="${datei}.exe"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
-ldflags "-s -w -X git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/befehle.Version=${version}+${commit}" \
-o "$datei" .
echo "$datei"
done
(cd bin && sha256sum proxdeploy-* > SHA256SUMS)
+39
View File
@@ -0,0 +1,39 @@
module git.wilde-server.de/florian.wilde/proxdeploy/cli
go 1.27
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
require (
github.com/BurntSushi/toml v1.6.0
github.com/mattn/go-runewidth v0.0.30
github.com/oapi-codegen/runtime v1.7.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/term v0.46.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/getkin/kin-openapi v0.142.0 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect
github.com/oasdiff/yaml v0.1.1 // indirect
github.com/oasdiff/yaml3 v0.0.14 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
github.com/speakeasy-api/openapi v1.24.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.48.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+201
View File
@@ -0,0 +1,201 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w=
github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM=
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo=
github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k=
github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU=
github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI=
github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04=
github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+287
View File
@@ -0,0 +1,287 @@
package api
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Client spricht die REST-API von ProxDeploy. Die Typen der Antworten sind aus openapi.json
// erzeugt (typen.gen.go); Anfragen gehen als Map hinaus, damit nur gesetzte Flags mitkommen und
// Vorlage und Server-Standards den Rest füllen.
type Client struct {
// Basis-URL der Instanz, z. B. https://proxdeploy.example (ohne /api/v1).
URL string
Token string
Version string
HTTP *http.Client
}
func Neu(basis, token, version string) *Client {
return &Client{
URL: strings.TrimRight(basis, "/"),
Token: token,
Version: version,
HTTP: &http.Client{Timeout: 60 * time.Second},
}
}
// Fehler ist eine Fehlerantwort der API (`{ fehler: { code, nachricht, hinweis?, felder? } }`).
type Fehler struct {
Status int
Code string
Nachricht string
Hinweis string
Felder map[string]string
}
func (f *Fehler) Error() string { return f.Nachricht }
// VerbindungsFehler: die Instanz war nicht erreichbar oder hat keine API-Antwort geliefert.
type VerbindungsFehler struct{ Ursache error }
func (f *VerbindungsFehler) Error() string {
return fmt.Sprintf("ProxDeploy nicht erreichbar: %v", f.Ursache)
}
func (f *VerbindungsFehler) Unwrap() error { return f.Ursache }
// IstAnmeldefehler: Token fehlt, ist abgelaufen oder widerrufen.
func IstAnmeldefehler(err error) bool {
var f *Fehler
return errors.As(err, &f) && f.Status == http.StatusUnauthorized
}
func (c *Client) anfrage(ctx context.Context, methode, pfad string, rumpf any) (*http.Request, error) {
var koerper io.Reader
if rumpf != nil {
daten, err := json.Marshal(rumpf)
if err != nil {
return nil, err
}
koerper = bytes.NewReader(daten)
}
req, err := http.NewRequestWithContext(ctx, methode, c.URL+"/api/v1"+pfad, koerper)
if err != nil {
return nil, err
}
if rumpf != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "proxdeploy-cli/"+c.Version)
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
return req, nil
}
func fehlerLesen(antwort *http.Response) error {
daten, _ := io.ReadAll(io.LimitReader(antwort.Body, 1<<20))
var huelle struct {
Fehler *struct {
Code string `json:"code"`
Nachricht string `json:"nachricht"`
Hinweis string `json:"hinweis"`
Felder map[string]string `json:"felder"`
} `json:"fehler"`
}
if json.Unmarshal(daten, &huelle) != nil || huelle.Fehler == nil {
// Kein API-Format: falsche URL, Reverse-Proxy-Fehlerseite o. Ä.
return &VerbindungsFehler{Ursache: fmt.Errorf("HTTP %d ohne API-Antwort – stimmt die URL?", antwort.StatusCode)}
}
return &Fehler{
Status: antwort.StatusCode,
Code: huelle.Fehler.Code,
Nachricht: huelle.Fehler.Nachricht,
Hinweis: huelle.Fehler.Hinweis,
Felder: huelle.Fehler.Felder,
}
}
// Aufrufen schickt eine Anfrage und liest die JSON-Antwort nach `ziel` (nil = verwerfen).
func (c *Client) Aufrufen(ctx context.Context, methode, pfad string, rumpf, ziel any) error {
req, err := c.anfrage(ctx, methode, pfad, rumpf)
if err != nil {
return err
}
antwort, err := c.HTTP.Do(req)
if err != nil {
return &VerbindungsFehler{Ursache: err}
}
defer antwort.Body.Close()
if antwort.StatusCode >= 400 {
return fehlerLesen(antwort)
}
if ziel == nil || antwort.StatusCode == http.StatusNoContent {
return nil
}
if !strings.HasPrefix(antwort.Header.Get("Content-Type"), "application/json") {
return &VerbindungsFehler{Ursache: errors.New("Antwort ist kein JSON – stimmt die URL?")}
}
if err := json.NewDecoder(antwort.Body).Decode(ziel); err != nil {
return fmt.Errorf("Antwort nicht lesbar: %w", err)
}
return nil
}
func (c *Client) Ich(ctx context.Context) (Ich, error) {
var ich Ich
return ich, c.Aufrufen(ctx, http.MethodGet, "/ich", nil, &ich)
}
func (c *Client) TokenAnmelden(ctx context.Context, anmeldung TokenAnmeldung) (NeuesToken, error) {
var token NeuesToken
return token, c.Aufrufen(ctx, http.MethodPost, "/auth/token", anmeldung, &token)
}
func (c *Client) ServerListe(ctx context.Context) ([]Server, error) {
var liste []Server
return liste, c.Aufrufen(ctx, http.MethodGet, "/server", nil, &liste)
}
func (c *Client) DistributionenListe(ctx context.Context, typ string) ([]Distribution, error) {
pfad := "/distributionen"
if typ != "" {
pfad += "?typ=" + url.QueryEscape(typ)
}
var liste []Distribution
return liste, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &liste)
}
func (c *Client) ProfileListe(ctx context.Context) ([]Profil, error) {
var liste []Profil
return liste, c.Aufrufen(ctx, http.MethodGet, "/profile", nil, &liste)
}
func (c *Client) VorlagenListe(ctx context.Context) ([]Vorlage, error) {
var liste []Vorlage
return liste, c.Aufrufen(ctx, http.MethodGet, "/vorlagen", nil, &liste)
}
func (c *Client) TemplatesListe(ctx context.Context, server string) ([]Template, error) {
var liste []Template
return liste, c.Aufrufen(ctx, http.MethodGet, "/server/"+url.PathEscape(server)+"/templates", nil, &liste)
}
func (c *Client) Ausrollen(ctx context.Context, anfrage map[string]any) (AuftragAngenommen, error) {
var a AuftragAngenommen
return a, c.Aufrufen(ctx, http.MethodPost, "/auftraege", anfrage, &a)
}
func (c *Client) Planen(ctx context.Context, anfrage map[string]any) (Plan, error) {
var p Plan
return p, c.Aufrufen(ctx, http.MethodPost, "/auftraege/plan", anfrage, &p)
}
func (c *Client) TemplateBauen(ctx context.Context, server string, anfrage map[string]any) (AuftragAngenommen, error) {
var a AuftragAngenommen
return a, c.Aufrufen(ctx, http.MethodPost, "/server/"+url.PathEscape(server)+"/templates", anfrage, &a)
}
func (c *Client) Auftrag(ctx context.Context, id string) (Auftrag, error) {
var a Auftrag
return a, c.Aufrufen(ctx, http.MethodGet, "/auftraege/"+url.PathEscape(id), nil, &a)
}
func (c *Client) Log(ctx context.Context, id, ab string) ([]LogZeile, error) {
var zeilen []LogZeile
pfad := "/auftraege/" + url.PathEscape(id) + "/log"
if ab != "" {
pfad += "?ab=" + url.QueryEscape(ab)
}
return zeilen, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &zeilen)
}
func (c *Client) Abbrechen(ctx context.Context, id string) error {
return c.Aufrufen(ctx, http.MethodPost, "/auftraege/"+url.PathEscape(id)+"/abbrechen", nil, nil)
}
// Ereignis aus dem SSE-Strom eines Auftrags.
type Ereignis struct {
ID string
Art string // stand, log, ende
Daten []byte
}
// Ereignisse liest den SSE-Strom bis zum Ende oder Abbruch. `ab` ist die letzte gesehene Event-ID
// (Log-Zeile); ein Wiederverbinden mit ihr setzt genau dort fort.
func (c *Client) Ereignisse(ctx context.Context, id, ab string, beiEreignis func(Ereignis) error) error {
req, err := c.anfrage(ctx, http.MethodGet, "/auftraege/"+url.PathEscape(id)+"/ereignisse", nil)
if err != nil {
return err
}
req.Header.Set("Accept", "text/event-stream")
if ab != "" {
req.Header.Set("Last-Event-ID", ab)
}
// Ohne Zeitlimit: der Strom läuft, solange der Auftrag läuft (Herzschlag alle 15 s).
strom := &http.Client{Transport: c.HTTP.Transport}
antwort, err := strom.Do(req)
if err != nil {
return &VerbindungsFehler{Ursache: err}
}
defer antwort.Body.Close()
if antwort.StatusCode >= 400 {
return fehlerLesen(antwort)
}
if !strings.HasPrefix(antwort.Header.Get("Content-Type"), "text/event-stream") {
return &VerbindungsFehler{Ursache: errors.New("kein Ereignisstrom (puffert ein Proxy?)")}
}
return sseLesen(antwort.Body, beiEreignis)
}
func sseLesen(r io.Reader, beiEreignis func(Ereignis) error) error {
leser := bufio.NewReaderSize(r, 64*1024)
var e Ereignis
var daten bytes.Buffer
for {
zeile, err := leser.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) {
return io.ErrUnexpectedEOF
}
return &VerbindungsFehler{Ursache: err}
}
zeile = strings.TrimRight(zeile, "\r\n")
switch {
case zeile == "":
if e.Art != "" || daten.Len() > 0 {
if e.Art == "" {
e.Art = "message"
}
// Kopie: der Puffer wird für das nächste Ereignis wiederverwendet.
e.Daten = bytes.Clone(bytes.TrimSuffix(daten.Bytes(), []byte("\n")))
if err := beiEreignis(e); err != nil {
return err
}
}
e = Ereignis{}
daten.Reset()
case strings.HasPrefix(zeile, ":"):
// Kommentar (Herzschlag)
default:
feld, wert, _ := strings.Cut(zeile, ":")
wert = strings.TrimPrefix(wert, " ")
switch feld {
case "id":
e.ID = wert
case "event":
e.Art = wert
case "data":
daten.WriteString(wert)
daten.WriteByte('\n')
}
}
}
}
// Ende signalisiert, dass der Strom regulär geschlossen wurde (Ereignis `ende`).
var Ende = errors.New("ende")
+31
View File
@@ -0,0 +1,31 @@
package api
import (
"errors"
"io"
"strings"
"testing"
)
func TestSseLesen(t *testing.T) {
strom := ": ping\r\n\r\nid: 41\r\nevent: log\r\ndata: [1,\r\ndata: 2]\r\n\r\nevent: stand\ndata: {}\n\n: ping\n\nevent: ende\ndata: {}\n\n"
var gesehen []Ereignis
err := sseLesen(strings.NewReader(strom), func(e Ereignis) error {
gesehen = append(gesehen, e)
if e.Art == "ende" {
return Ende
}
return nil
})
if !errors.Is(err, Ende) {
t.Fatalf("Fehler %v", err)
}
if len(gesehen) != 3 || gesehen[0].ID != "41" || string(gesehen[0].Daten) != "[1,\n2]" || gesehen[1].Art != "stand" {
t.Errorf("gelesen: %+v", gesehen)
}
// Bricht der Strom vor `ende` ab, merkt der Aufrufer es (und verbindet neu).
err = sseLesen(strings.NewReader("event: stand\ndata: {}\n\n"), func(Ereignis) error { return nil })
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Errorf("Abbruch: %v", err)
}
}
+4
View File
@@ -0,0 +1,4 @@
// Package api enthält die aus cli/api/openapi.json erzeugten Typen und den HTTP-Client der CLI.
package api
//go:generate go tool oapi-codegen -config ../../api/oapi-codegen.yaml ../../api/openapi.json
+751
View File
@@ -0,0 +1,751 @@
// Package api provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT.
package api
import (
"encoding/json"
"github.com/oapi-codegen/runtime"
)
// Defines values for AuftragArt.
const (
AUFRAEUMEN AuftragArt = "AUFRAEUMEN"
AUSROLLEN AuftragArt = "AUSROLLEN"
TEMPLATEBAUEN AuftragArt = "TEMPLATE_BAUEN"
)
// Valid indicates whether the value is a known member of the AuftragArt enum.
func (e AuftragArt) Valid() bool {
switch e {
case AUFRAEUMEN:
return true
case AUSROLLEN:
return true
case TEMPLATEBAUEN:
return true
default:
return false
}
}
// Defines values for AuftragGastTyp.
const (
LXC AuftragGastTyp = "LXC"
VM AuftragGastTyp = "VM"
)
// Valid indicates whether the value is a known member of the AuftragGastTyp enum.
func (e AuftragGastTyp) Valid() bool {
switch e {
case LXC:
return true
case VM:
return true
default:
return false
}
}
// Defines values for AuftragStatus.
const (
AuftragStatusABGEBROCHEN AuftragStatus = "ABGEBROCHEN"
AuftragStatusAUFGERAEUMT AuftragStatus = "AUFGERAEUMT"
AuftragStatusERFOLGREICH AuftragStatus = "ERFOLGREICH"
AuftragStatusFEHLGESCHLAGEN AuftragStatus = "FEHLGESCHLAGEN"
AuftragStatusLAEUFT AuftragStatus = "LAEUFT"
AuftragStatusWARTEND AuftragStatus = "WARTEND"
)
// Valid indicates whether the value is a known member of the AuftragStatus enum.
func (e AuftragStatus) Valid() bool {
switch e {
case AuftragStatusABGEBROCHEN:
return true
case AuftragStatusAUFGERAEUMT:
return true
case AuftragStatusERFOLGREICH:
return true
case AuftragStatusFEHLGESCHLAGEN:
return true
case AuftragStatusLAEUFT:
return true
case AuftragStatusWARTEND:
return true
default:
return false
}
}
// Defines values for AusrollAnfrageNetzModus.
const (
Dhcp AusrollAnfrageNetzModus = "dhcp"
Statisch AusrollAnfrageNetzModus = "statisch"
)
// Valid indicates whether the value is a known member of the AusrollAnfrageNetzModus enum.
func (e AusrollAnfrageNetzModus) Valid() bool {
switch e {
case Dhcp:
return true
case Statisch:
return true
default:
return false
}
}
// Defines values for AusrollAnfrageTyp.
const (
AusrollAnfrageTypLxc AusrollAnfrageTyp = "lxc"
AusrollAnfrageTypVm AusrollAnfrageTyp = "vm"
)
// Valid indicates whether the value is a known member of the AusrollAnfrageTyp enum.
func (e AusrollAnfrageTyp) Valid() bool {
switch e {
case AusrollAnfrageTypLxc:
return true
case AusrollAnfrageTypVm:
return true
default:
return false
}
}
// Defines values for IchRolle.
const (
ADMIN IchRolle = "ADMIN"
OPERATOR IchRolle = "OPERATOR"
)
// Valid indicates whether the value is a known member of the IchRolle enum.
func (e IchRolle) Valid() bool {
switch e {
case ADMIN:
return true
case OPERATOR:
return true
default:
return false
}
}
// Defines values for PhaseStatus.
const (
PhaseStatusERLEDIGT PhaseStatus = "ERLEDIGT"
PhaseStatusFEHLER PhaseStatus = "FEHLER"
PhaseStatusLAEUFT PhaseStatus = "LAEUFT"
PhaseStatusOFFEN PhaseStatus = "OFFEN"
PhaseStatusUEBERSPRUNGEN PhaseStatus = "UEBERSPRUNGEN"
)
// Valid indicates whether the value is a known member of the PhaseStatus enum.
func (e PhaseStatus) Valid() bool {
switch e {
case PhaseStatusERLEDIGT:
return true
case PhaseStatusFEHLER:
return true
case PhaseStatusLAEUFT:
return true
case PhaseStatusOFFEN:
return true
case PhaseStatusUEBERSPRUNGEN:
return true
default:
return false
}
}
// Defines values for ProfilHerkunft.
const (
EIGEN ProfilHerkunft = "EIGEN"
KATALOG ProfilHerkunft = "KATALOG"
)
// Valid indicates whether the value is a known member of the ProfilHerkunft enum.
func (e ProfilHerkunft) Valid() bool {
switch e {
case EIGEN:
return true
case KATALOG:
return true
default:
return false
}
}
// Defines values for ProfilVariableType.
const (
Boolean ProfilVariableType = "boolean"
Number ProfilVariableType = "number"
Select ProfilVariableType = "select"
String ProfilVariableType = "string"
)
// Valid indicates whether the value is a known member of the ProfilVariableType enum.
func (e ProfilVariableType) Valid() bool {
switch e {
case Boolean:
return true
case Number:
return true
case Select:
return true
case String:
return true
default:
return false
}
}
// Defines values for DistributionenListeParamsTyp.
const (
DistributionenListeParamsTypLxc DistributionenListeParamsTyp = "lxc"
DistributionenListeParamsTypVm DistributionenListeParamsTyp = "vm"
)
// Valid indicates whether the value is a known member of the DistributionenListeParamsTyp enum.
func (e DistributionenListeParamsTyp) Valid() bool {
switch e {
case DistributionenListeParamsTypLxc:
return true
case DistributionenListeParamsTypVm:
return true
default:
return false
}
}
// Abschluss defines model for Abschluss.
type Abschluss struct {
Dauer *string `json:"dauer"`
Ok bool `json:"ok"`
Titel string `json:"titel"`
Zeilen [][]string `json:"zeilen"`
}
// Auftrag defines model for Auftrag.
type Auftrag struct {
Abschluss *Abschluss `json:"abschluss"`
Art AuftragArt `json:"art"`
// BeendetAm ISO 8601
BeendetAm *string `json:"beendetAm"`
DistributionId *string `json:"distributionId"`
Ergebnis *AuftragErgebnis `json:"ergebnis"`
// ErstelltAm ISO 8601
ErstelltAm string `json:"erstelltAm"`
ErstelltVon string `json:"erstelltVon"`
Fehler *struct {
Hinweis *string `json:"hinweis,omitempty"`
Phase *string `json:"phase,omitempty"`
Ursache string `json:"ursache"`
} `json:"fehler"`
GastTyp *AuftragGastTyp `json:"gastTyp"`
// GestartetAm ISO 8601
GestartetAm *string `json:"gestartetAm"`
Id string `json:"id"`
Knoten *string `json:"knoten"`
Kopf string `json:"kopf"`
KurzId string `json:"kurzId"`
Name *string `json:"name"`
Phasen []Phase `json:"phasen"`
Profile []string `json:"profile"`
Server struct {
Name string `json:"name"`
Titel string `json:"titel"`
} `json:"server"`
Status AuftragStatus `json:"status"`
Vmid *int `json:"vmid"`
}
// AuftragArt defines model for Auftrag.Art.
type AuftragArt string
// AuftragGastTyp defines model for Auftrag.GastTyp.
type AuftragGastTyp string
// AuftragStatus defines model for Auftrag.Status.
type AuftragStatus string
// AuftragAngenommen defines model for AuftragAngenommen.
type AuftragAngenommen struct {
Id string `json:"id"`
KurzId string `json:"kurzId"`
}
// AuftragErgebnis defines model for AuftragErgebnis.
type AuftragErgebnis struct {
Ip *string `json:"ip,omitempty"`
Knoten *string `json:"knoten,omitempty"`
Ssh *string `json:"ssh,omitempty"`
Template *string `json:"template,omitempty"`
Vmid *int `json:"vmid,omitempty"`
}
// AusrollAnfrage defines model for AusrollAnfrage.
type AusrollAnfrage struct {
Cores *int `json:"cores,omitempty"`
DiskGb *int `json:"diskGb,omitempty"`
Distribution string `json:"distribution"`
Features *struct {
Keyctl *bool `json:"keyctl,omitempty"`
Nesting *bool `json:"nesting,omitempty"`
} `json:"features,omitempty"`
Knoten *string `json:"knoten,omitempty"`
Name string `json:"name"`
Netz struct {
Bridge string `json:"bridge"`
Dns *[]string `json:"dns,omitempty"`
Gateway *string `json:"gateway,omitempty"`
Ip *string `json:"ip,omitempty"`
Modus *AusrollAnfrageNetzModus `json:"modus,omitempty"`
Suchdomain *string `json:"suchdomain,omitempty"`
Vlan *int `json:"vlan,omitempty"`
} `json:"netz"`
Profile *[]string `json:"profile,omitempty"`
Server string `json:"server"`
SpeicherMb *int `json:"speicherMb,omitempty"`
StartBeiBoot *bool `json:"startBeiBoot,omitempty"`
Storage string `json:"storage"`
SwapMb *int `json:"swapMb,omitempty"`
Tags *[]string `json:"tags,omitempty"`
TemplateBauen *bool `json:"templateBauen,omitempty"`
Typ AusrollAnfrageTyp `json:"typ"`
Unprivilegiert *bool `json:"unprivilegiert,omitempty"`
Variablen *map[string]map[string]AusrollAnfrage_Variablen_AdditionalProperties `json:"variablen,omitempty"`
Vmid *int `json:"vmid,omitempty"`
Vorlage *string `json:"vorlage,omitempty"`
Zugang struct {
Benutzer string `json:"benutzer"`
SshSchluessel []string `json:"sshSchluessel"`
} `json:"zugang"`
}
// AusrollAnfrageNetzModus defines model for AusrollAnfrage.Netz.Modus.
type AusrollAnfrageNetzModus string
// AusrollAnfrageTyp defines model for AusrollAnfrage.Typ.
type AusrollAnfrageTyp string
// AusrollAnfrageVariablen0 defines model for AusrollAnfrage.Variablen.0.
type AusrollAnfrageVariablen0 = string
// AusrollAnfrageVariablen1 defines model for AusrollAnfrage.Variablen.1.
type AusrollAnfrageVariablen1 = float32
// AusrollAnfrageVariablen2 defines model for AusrollAnfrage.Variablen.2.
type AusrollAnfrageVariablen2 = bool
// AusrollAnfrage_Variablen_AdditionalProperties defines model for AusrollAnfrage.variablen.AdditionalProperties.
type AusrollAnfrage_Variablen_AdditionalProperties struct {
union json.RawMessage
}
// Distribution defines model for Distribution.
type Distribution struct {
Aktiv bool `json:"aktiv"`
Familie string `json:"familie"`
Id string `json:"id"`
Lxc bool `json:"lxc"`
Name string `json:"name"`
Vm bool `json:"vm"`
}
// FehlerAntwort defines model for FehlerAntwort.
type FehlerAntwort struct {
Fehler struct {
Code string `json:"code"`
Felder *map[string]string `json:"felder,omitempty"`
Hinweis *string `json:"hinweis,omitempty"`
Nachricht string `json:"nachricht"`
} `json:"fehler"`
}
// GeplanterAufruf defines model for GeplanterAufruf.
type GeplanterAufruf struct {
Methode string `json:"methode"`
Parameter *map[string]interface{} `json:"parameter,omitempty"`
Pfad string `json:"pfad"`
}
// Ich defines model for Ich.
type Ich struct {
Anzeigename string `json:"anzeigename"`
Benutzername string `json:"benutzername"`
Rechte []string `json:"rechte"`
Rolle IchRolle `json:"rolle"`
Version string `json:"version"`
}
// IchRolle defines model for Ich.Rolle.
type IchRolle string
// LogZeile defines model for LogZeile.
type LogZeile struct {
Id string `json:"id"`
Nachricht string `json:"nachricht"`
Phase *string `json:"phase"`
Stufe string `json:"stufe"`
// Zeit ISO 8601
Zeit string `json:"zeit"`
}
// NeuesToken defines model for NeuesToken.
type NeuesToken struct {
// AblaufAm ISO 8601
AblaufAm *string `json:"ablaufAm"`
// ErstelltAm ISO 8601
ErstelltAm string `json:"erstelltAm"`
Id string `json:"id"`
Name string `json:"name"`
Praefix string `json:"praefix"`
Token string `json:"token"`
}
// Phase defines model for Phase.
type Phase struct {
// BeendetAm ISO 8601
BeendetAm *string `json:"beendetAm,omitempty"`
Detail *string `json:"detail"`
// GestartetAm ISO 8601
GestartetAm *string `json:"gestartetAm,omitempty"`
Hinweis *string `json:"hinweis"`
Schluessel string `json:"schluessel"`
Status PhaseStatus `json:"status"`
Titel string `json:"titel"`
Ursache *string `json:"ursache"`
}
// PhaseStatus defines model for Phase.Status.
type PhaseStatus string
// Plan defines model for Plan.
type Plan struct {
Aufrufe []GeplanterAufruf `json:"aufrufe"`
Automatisch []string `json:"automatisch"`
CliBefehl string `json:"cliBefehl"`
Kopf string `json:"kopf"`
Ok bool `json:"ok"`
Phasen []Phase `json:"phasen"`
Skript *string `json:"skript"`
}
// Profil defines model for Profil.
type Profil struct {
Aktiv bool `json:"aktiv"`
Beschreibung *string `json:"beschreibung"`
Definition struct {
Variables *[]ProfilVariable `json:"variables,omitempty"`
} `json:"definition"`
Familien []string `json:"familien"`
Herkunft ProfilHerkunft `json:"herkunft"`
Id string `json:"id"`
Name string `json:"name"`
}
// ProfilHerkunft defines model for Profil.Herkunft.
type ProfilHerkunft string
// ProfilVariable defines model for ProfilVariable.
type ProfilVariable struct {
Default *ProfilVariable_Default `json:"default,omitempty"`
Help *string `json:"help,omitempty"`
Label string `json:"label"`
Name string `json:"name"`
Options *[]string `json:"options,omitempty"`
Required bool `json:"required"`
Type ProfilVariableType `json:"type"`
}
// ProfilVariableDefault0 defines model for ProfilVariable.Default.0.
type ProfilVariableDefault0 = string
// ProfilVariableDefault1 defines model for ProfilVariable.Default.1.
type ProfilVariableDefault1 = float32
// ProfilVariableDefault2 defines model for ProfilVariable.Default.2.
type ProfilVariableDefault2 = bool
// ProfilVariable_Default defines model for ProfilVariable.Default.
type ProfilVariable_Default struct {
union json.RawMessage
}
// ProfilVariableType defines model for ProfilVariable.Type.
type ProfilVariableType string
// Server defines model for Server.
type Server struct {
Erreichbar *bool `json:"erreichbar"`
// GeprueftAm ISO 8601
GeprueftAm *string `json:"geprueftAm"`
LetzterTest *struct {
Cluster *struct {
Name string `json:"name"`
} `json:"cluster"`
Knoten []struct {
Name string `json:"name"`
Online bool `json:"online"`
SpeicherBelegt *float32 `json:"speicherBelegt"`
SpeicherGesamt *float32 `json:"speicherGesamt"`
} `json:"knoten"`
} `json:"letzterTest"`
Name string `json:"name"`
PveVersion *string `json:"pveVersion"`
SshAktiv bool `json:"sshAktiv"`
Titel string `json:"titel"`
Url string `json:"url"`
}
// Template defines model for Template.
type Template struct {
DistributionId *string `json:"distributionId"`
// GebautAm ISO 8601
GebautAm *string `json:"gebautAm"`
Knoten string `json:"knoten"`
Name string `json:"name"`
Profile []string `json:"profile"`
Vmid int `json:"vmid"`
}
// TemplateBauAnfrage defines model for TemplateBauAnfrage.
type TemplateBauAnfrage struct {
Bridge *string `json:"bridge,omitempty"`
Distribution string `json:"distribution"`
Knoten string `json:"knoten"`
Profile *[]string `json:"profile,omitempty"`
Storage string `json:"storage"`
Vlan *int `json:"vlan,omitempty"`
}
// TokenAnmeldung defines model for TokenAnmeldung.
type TokenAnmeldung struct {
Benutzername string `json:"benutzername"`
Name string `json:"name"`
Passwort string `json:"passwort"`
Tage *int `json:"tage,omitempty"`
}
// Vorlage defines model for Vorlage.
type Vorlage struct {
Beschreibung *string `json:"beschreibung"`
Name string `json:"name"`
Titel string `json:"titel"`
}
// AuftragEreignisseParams defines parameters for AuftragEreignisse.
type AuftragEreignisseParams struct {
LastEventID *string `json:"Last-Event-ID,omitempty"`
}
// AuftragLogParams defines parameters for AuftragLog.
type AuftragLogParams struct {
Ab *string `form:"ab,omitempty" json:"ab,omitempty"`
}
// DistributionenListeParams defines parameters for DistributionenListe.
type DistributionenListeParams struct {
Typ *DistributionenListeParamsTyp `form:"typ,omitempty" json:"typ,omitempty"`
}
// DistributionenListeParamsTyp defines parameters for DistributionenListe.
type DistributionenListeParamsTyp string
// AusrollenJSONRequestBody defines body for Ausrollen for application/json ContentType.
type AusrollenJSONRequestBody = AusrollAnfrage
// AusrollenPlanenJSONRequestBody defines body for AusrollenPlanen for application/json ContentType.
type AusrollenPlanenJSONRequestBody = AusrollAnfrage
// TokenAnmeldenJSONRequestBody defines body for TokenAnmelden for application/json ContentType.
type TokenAnmeldenJSONRequestBody = TokenAnmeldung
// TemplateBauenJSONRequestBody defines body for TemplateBauen for application/json ContentType.
type TemplateBauenJSONRequestBody = TemplateBauAnfrage
// AsAusrollAnfrageVariablen0 returns the union data inside the AusrollAnfrage_Variablen_AdditionalProperties as a AusrollAnfrageVariablen0
func (t AusrollAnfrage_Variablen_AdditionalProperties) AsAusrollAnfrageVariablen0() (AusrollAnfrageVariablen0, error) {
var body AusrollAnfrageVariablen0
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromAusrollAnfrageVariablen0 overwrites any union data inside the AusrollAnfrage_Variablen_AdditionalProperties as the provided AusrollAnfrageVariablen0
func (t *AusrollAnfrage_Variablen_AdditionalProperties) FromAusrollAnfrageVariablen0(v AusrollAnfrageVariablen0) error {
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeAusrollAnfrageVariablen0 performs a merge with any union data inside the AusrollAnfrage_Variablen_AdditionalProperties, using the provided AusrollAnfrageVariablen0
func (t *AusrollAnfrage_Variablen_AdditionalProperties) MergeAusrollAnfrageVariablen0(v AusrollAnfrageVariablen0) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsAusrollAnfrageVariablen1 returns the union data inside the AusrollAnfrage_Variablen_AdditionalProperties as a AusrollAnfrageVariablen1
func (t AusrollAnfrage_Variablen_AdditionalProperties) AsAusrollAnfrageVariablen1() (AusrollAnfrageVariablen1, error) {
var body AusrollAnfrageVariablen1
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromAusrollAnfrageVariablen1 overwrites any union data inside the AusrollAnfrage_Variablen_AdditionalProperties as the provided AusrollAnfrageVariablen1
func (t *AusrollAnfrage_Variablen_AdditionalProperties) FromAusrollAnfrageVariablen1(v AusrollAnfrageVariablen1) error {
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeAusrollAnfrageVariablen1 performs a merge with any union data inside the AusrollAnfrage_Variablen_AdditionalProperties, using the provided AusrollAnfrageVariablen1
func (t *AusrollAnfrage_Variablen_AdditionalProperties) MergeAusrollAnfrageVariablen1(v AusrollAnfrageVariablen1) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsAusrollAnfrageVariablen2 returns the union data inside the AusrollAnfrage_Variablen_AdditionalProperties as a AusrollAnfrageVariablen2
func (t AusrollAnfrage_Variablen_AdditionalProperties) AsAusrollAnfrageVariablen2() (AusrollAnfrageVariablen2, error) {
var body AusrollAnfrageVariablen2
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromAusrollAnfrageVariablen2 overwrites any union data inside the AusrollAnfrage_Variablen_AdditionalProperties as the provided AusrollAnfrageVariablen2
func (t *AusrollAnfrage_Variablen_AdditionalProperties) FromAusrollAnfrageVariablen2(v AusrollAnfrageVariablen2) error {
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeAusrollAnfrageVariablen2 performs a merge with any union data inside the AusrollAnfrage_Variablen_AdditionalProperties, using the provided AusrollAnfrageVariablen2
func (t *AusrollAnfrage_Variablen_AdditionalProperties) MergeAusrollAnfrageVariablen2(v AusrollAnfrageVariablen2) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
func (t AusrollAnfrage_Variablen_AdditionalProperties) MarshalJSON() ([]byte, error) {
b, err := t.union.MarshalJSON()
return b, err
}
func (t *AusrollAnfrage_Variablen_AdditionalProperties) UnmarshalJSON(b []byte) error {
err := t.union.UnmarshalJSON(b)
return err
}
// AsProfilVariableDefault0 returns the union data inside the ProfilVariable_Default as a ProfilVariableDefault0
func (t ProfilVariable_Default) AsProfilVariableDefault0() (ProfilVariableDefault0, error) {
var body ProfilVariableDefault0
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromProfilVariableDefault0 overwrites any union data inside the ProfilVariable_Default as the provided ProfilVariableDefault0
func (t *ProfilVariable_Default) FromProfilVariableDefault0(v ProfilVariableDefault0) error {
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeProfilVariableDefault0 performs a merge with any union data inside the ProfilVariable_Default, using the provided ProfilVariableDefault0
func (t *ProfilVariable_Default) MergeProfilVariableDefault0(v ProfilVariableDefault0) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsProfilVariableDefault1 returns the union data inside the ProfilVariable_Default as a ProfilVariableDefault1
func (t ProfilVariable_Default) AsProfilVariableDefault1() (ProfilVariableDefault1, error) {
var body ProfilVariableDefault1
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromProfilVariableDefault1 overwrites any union data inside the ProfilVariable_Default as the provided ProfilVariableDefault1
func (t *ProfilVariable_Default) FromProfilVariableDefault1(v ProfilVariableDefault1) error {
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeProfilVariableDefault1 performs a merge with any union data inside the ProfilVariable_Default, using the provided ProfilVariableDefault1
func (t *ProfilVariable_Default) MergeProfilVariableDefault1(v ProfilVariableDefault1) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsProfilVariableDefault2 returns the union data inside the ProfilVariable_Default as a ProfilVariableDefault2
func (t ProfilVariable_Default) AsProfilVariableDefault2() (ProfilVariableDefault2, error) {
var body ProfilVariableDefault2
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromProfilVariableDefault2 overwrites any union data inside the ProfilVariable_Default as the provided ProfilVariableDefault2
func (t *ProfilVariable_Default) FromProfilVariableDefault2(v ProfilVariableDefault2) error {
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeProfilVariableDefault2 performs a merge with any union data inside the ProfilVariable_Default, using the provided ProfilVariableDefault2
func (t *ProfilVariable_Default) MergeProfilVariableDefault2(v ProfilVariableDefault2) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
func (t ProfilVariable_Default) MarshalJSON() ([]byte, error) {
b, err := t.union.MarshalJSON()
return b, err
}
func (t *ProfilVariable_Default) UnmarshalJSON(b []byte) error {
err := t.union.UnmarshalJSON(b)
return err
}
+231
View File
@@ -0,0 +1,231 @@
package ausgabe
import (
"fmt"
"sort"
"strings"
"time"
"github.com/mattn/go-runewidth"
)
// Phase, wie sie gezeigt wird – Felder kommen unverändert aus der API.
type Phase struct {
Titel string
Status string // OFFEN, LAEUFT, ERLEDIGT, FEHLER, UEBERSPRUNGEN
Detail string
Ursache string
Hinweis string
// Laufzeit der laufenden Phase (nur bei LAEUFT).
Laufzeit time.Duration
}
// Box ist die Abschlussbox; Zeilen sind [Bezeichnung, Wert].
type Box struct {
Ok bool
Titel string
Dauer string
Zeilen [][2]string
}
// Aufruf ist ein geplanter Proxmox-Aufruf im Dry-Run.
type Aufruf struct {
Methode string
Pfad string
Parameter map[string]any
}
// Dauer wie in Web-GUI und Engine: „18s“, „1m 12s“, „1h 03m“.
func Dauer(d time.Duration) string {
s := int((d + 500*time.Millisecond) / time.Second)
if s < 0 {
s = 0
}
switch {
case s < 60:
return fmt.Sprintf("%ds", s)
case s < 3600:
return fmt.Sprintf("%dm %02ds", s/60, s%60)
default:
return fmt.Sprintf("%dh %02dm", s/3600, (s%3600)/60)
}
}
// Kopf: „⬡ ProxDeploy v1.0 · Server rz1 · …“ – Prox in Paper, Deploy fett in Teal, Rest Muted.
func (s Stil) Kopf(version, kopf string, trocken bool) string {
var b strings.Builder
if !s.ASCII {
b.WriteString(s.Muted("⬡ "))
}
b.WriteString(s.Paper("Prox"))
b.WriteString(s.TealFett("Deploy"))
rest := " v" + version
if kopf != "" {
rest += " · " + kopf
}
b.WriteString(s.Muted(rest))
if trocken {
b.WriteString(s.AmberFett(" · DRY-RUN"))
}
return b.String()
}
// Spaltenbreite der Phasennamen: längster Name + 2, mindestens 14 (wie die Web-GUI).
func Spaltenbreite(phasen []Phase) int {
breite := 12
for _, p := range phasen {
if w := runewidth.StringWidth(p.Titel); w > breite {
breite = w
}
}
return breite + 2
}
// PhasenZeilen setzt jede Phase als Zeile: Symbol, Name in fester Breite, Detail in Muted. Bei
// Fehler Ursache in Rot und darunter der Hinweis. `takt` dreht den Spinner der laufenden Phase.
func (s Stil) PhasenZeilen(phasen []Phase, takt int) []string {
return s.PhasenZeilenMit(phasen, Spaltenbreite(phasen), takt)
}
// PhasenZeilenMit setzt mit fester Spaltenbreite – live bleibt sie so stabil, auch wenn noch nicht
// alle Phasen sichtbar sind.
func (s Stil) PhasenZeilenMit(phasen []Phase, breite, takt int) []string {
einzug := strings.Repeat(" ", runewidth.StringWidth(s.symbol("OFFEN"))+1+breite)
var zeilen []string
for _, p := range phasen {
symbol := s.symbol(p.Status)
if p.Status == "LAEUFT" && !s.ASCII {
symbol = spinner[takt%len(spinner)]
}
titel := p.Titel + strings.Repeat(" ", breite-runewidth.StringWidth(p.Titel))
var z strings.Builder
switch p.Status {
case "ERLEDIGT":
z.WriteString(s.Teal(symbol) + " " + s.Paper(titel))
case "LAEUFT":
z.WriteString(s.Amber(symbol) + " " + s.Paper(titel))
case "FEHLER":
z.WriteString(s.Rot(symbol) + " " + s.Paper(titel))
default:
z.WriteString(s.Muted(symbol) + " " + s.Muted(titel))
}
switch p.Status {
case "FEHLER":
z.WriteString(s.Rot(erste(p.Ursache, p.Detail)))
zeilen = append(zeilen, strings.TrimRight(z.String(), " "))
if p.Hinweis != "" {
zeilen = append(zeilen, einzug+s.Muted("→ "+p.Hinweis))
}
continue
case "LAEUFT":
text := Dauer(p.Laufzeit)
if p.Detail != "" {
text = p.Detail + " … " + text
}
z.WriteString(s.Muted(text))
default:
z.WriteString(s.Muted(p.Detail))
}
zeilen = append(zeilen, strings.TrimRight(z.String(), " "))
}
return zeilen
}
func erste(werte ...string) string {
for _, w := range werte {
if w != "" {
return w
}
}
return ""
}
// Mindestbreite der Box innen (zwischen den Rahmenstrichen) – wie im Mockup.
const boxMindestbreite = 38
// BoxZeilen setzt die Abschlussbox: Rahmen Teal (Erfolg) oder Rot, Breite aus dem Inhalt.
func (s Stil) BoxZeilen(b Box) []string {
rahmen := s.Teal
kopfSymbol := "●"
if !b.Ok {
rahmen = s.Rot
kopfSymbol = "✗"
}
if s.ASCII {
kopfSymbol = map[bool]string{true: "[OK]", false: "[!!]"}[b.Ok]
}
type zeile struct{ roh, farbig string }
kopf := kopfSymbol + " " + b.Titel
kopfFarbig := rahmen(kopfSymbol) + " " + s.Fett(b.Titel)
if b.Dauer != "" {
kopf += " · " + b.Dauer
kopfFarbig += s.Muted(" · " + b.Dauer)
}
inhalt := []zeile{{kopf, kopfFarbig}}
label := 7
for _, z := range b.Zeilen {
if w := runewidth.StringWidth(z[0]); w > label {
label = w
}
}
label += 2
for _, z := range b.Zeilen {
name := z[0] + strings.Repeat(" ", label-runewidth.StringWidth(z[0]))
inhalt = append(inhalt, zeile{name + z[1], s.Muted(name) + s.Paper(z[1])})
}
breite := boxMindestbreite
for _, z := range inhalt {
if w := runewidth.StringWidth(z.roh) + 4; w > breite {
breite = w
}
}
ol, or, ul, ur, h, v := "┌", "┐", "└", "┘", "─", "│"
if s.ASCII {
ol, or, ul, ur, h, v = "+", "+", "+", "+", "-", "|"
}
zeilen := []string{rahmen(ol + strings.Repeat(h, breite) + or)}
for _, z := range inhalt {
luecke := breite - 1 - runewidth.StringWidth(z.roh)
zeilen = append(zeilen, rahmen(v)+" "+z.farbig+strings.Repeat(" ", luecke)+rahmen(v))
}
zeilen = append(zeilen, rahmen(ul+strings.Repeat(h, breite)+ur))
return zeilen
}
// AufrufZeilen: jeder geplante Aufruf als „→ POST /pfad k=v …“, lange Werte gekürzt wie im Web.
func (s Stil) AufrufZeilen(aufrufe []Aufruf) []string {
pfeil := "→ "
if s.ASCII {
pfeil = "-> "
}
var zeilen []string
for _, a := range aufrufe {
z := s.Amber(pfeil) + s.Paper(a.Methode+" "+a.Pfad)
if len(a.Parameter) > 0 {
namen := make([]string, 0, len(a.Parameter))
for n := range a.Parameter {
namen = append(namen, n)
}
sort.Strings(namen)
teile := make([]string, 0, len(namen))
for _, n := range namen {
teile = append(teile, n+"="+kuerzen(fmt.Sprint(a.Parameter[n]), 60))
}
z += " " + s.Muted(strings.Join(teile, " "))
}
zeilen = append(zeilen, z)
}
return zeilen
}
func kuerzen(text string, hoechstens int) string {
// Eine Zeile je Aufruf: Umbrüche (z. B. in der Beschreibung) sichtbar, aber einzeilig.
text = strings.NewReplacer("\r\n", "⏎", "\n", "⏎").Replace(text)
r := []rune(text)
if len(r) <= hoechstens {
return text
}
return string(r[:hoechstens-3]) + "…"
}
+118
View File
@@ -0,0 +1,118 @@
package ausgabe
import (
"flag"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
var aktualisieren = flag.Bool("update", false, "Golden-Dateien neu schreiben")
// Die Beispielausgabe aus der Vorgabe.
var mockPhasen = []Phase{
{Titel: "Prüfungen", Status: "ERLEDIGT", Detail: "VMID 120 frei · local-zfs · vmbr0 VLAN 20"},
{Titel: "Klonen", Status: "ERLEDIGT", Detail: "full clone 9013 → 120 · Disk auf 40G"},
{Titel: "cloud-init", Status: "ERLEDIGT", Detail: "user florian · SSH-Key · Profile base, docker"},
{Titel: "Start", Status: "ERLEDIGT", Detail: "VM 120 läuft"},
{Titel: "Guest-Agent", Status: "ERLEDIGT", Detail: "antwortet nach 18s"},
{Titel: "Provisionierung", Status: "LAEUFT", Detail: "cloud-init läuft", Laufzeit: 72 * time.Second},
}
var mockBox = Box{
Ok: true,
Titel: "web01 bereit",
Dauer: "1m 54s",
Zeilen: [][2]string{
{"VMID", "120"},
{"IP", "10.0.20.15"},
{"Profile", "base, docker"},
{"SSH", "ssh florian@10.0.20.15"},
},
}
func mockAusgabe(s Stil) string {
zeilen := []string{s.Kopf("1.0", "Server rz1 · Node pve01 · Debian 13 (Template 9013)", false), ""}
zeilen = append(zeilen, s.PhasenZeilen(mockPhasen, 0)...)
zeilen = append(zeilen, "")
zeilen = append(zeilen, s.BoxZeilen(mockBox)...)
return strings.Join(zeilen, "\n") + "\n"
}
func golden(t *testing.T, name, ist string) {
t.Helper()
pfad := filepath.Join("testdata", name)
if *aktualisieren {
if err := os.WriteFile(pfad, []byte(ist), 0o644); err != nil {
t.Fatal(err)
}
}
soll, err := os.ReadFile(pfad)
if err != nil {
t.Fatal(err)
}
if ist != string(soll) {
t.Errorf("%s weicht ab.\n--- soll\n%s\n--- ist\n%s", name, soll, ist)
}
}
// Ohne Farbe muss die Ausgabe zeichengenau dem Mockup der Vorgabe entsprechen.
// mockup.txt ist von Hand aus der Vorgabe übernommen und wird nie mit -update überschrieben.
func TestMockupZeichengenau(t *testing.T) {
soll, err := os.ReadFile(filepath.Join("testdata", "mockup.txt"))
if err != nil {
t.Fatal(err)
}
if ist := mockAusgabe(Stil{}); ist != string(soll) {
t.Errorf("weicht vom Mockup ab.\n--- soll\n%s\n--- ist\n%s", soll, ist)
}
}
func TestFarbenUndAscii(t *testing.T) {
golden(t, "mockup-24bit.golden", mockAusgabe(Stil{Farbe: Echtfarben}))
golden(t, "mockup-256.golden", mockAusgabe(Stil{Farbe: Farben256}))
golden(t, "mockup-ascii.golden", mockAusgabe(Stil{ASCII: true}))
}
func TestFehlerUndDryRun(t *testing.T) {
s := Stil{}
zeilen := []string{s.Kopf("1.0", "Server rz1 · Node pve01 · Debian 13", true), ""}
zeilen = append(zeilen, s.PhasenZeilen([]Phase{
{Titel: "Prüfungen", Status: "ERLEDIGT", Detail: "VMID 120 frei"},
{Titel: "Template", Status: "UEBERSPRUNGEN", Detail: "vorhanden (9013)"},
{Titel: "Klonen", Status: "FEHLER", Ursache: "Storage local-zfs ist voll.", Hinweis: "Anderen Storage wählen."},
}, 0)...)
zeilen = append(zeilen, "")
zeilen = append(zeilen, s.AufrufZeilen([]Aufruf{
{Methode: "POST", Pfad: "/nodes/pve01/qemu/9013/clone", Parameter: map[string]any{"newid": 120, "full": 1, "name": "web01"}},
{Methode: "PUT", Pfad: "/nodes/pve01/qemu/120/config", Parameter: map[string]any{"sshkeys": strings.Repeat("A", 80), "description": "Zeile 1\nZeile 2"}},
})...)
zeilen = append(zeilen, "")
zeilen = append(zeilen, s.BoxZeilen(Box{
Titel: "web01 fehlgeschlagen", Dauer: "12s",
Zeilen: [][2]string{{"Phase", "Klonen"}, {"Ursache", "Storage local-zfs ist voll."}, {"Hinweis", "Anderen Storage wählen und die VM mit einem sehr langen Hinweis neu ausrollen."}},
})...)
golden(t, "fehler-dryrun.golden", strings.Join(zeilen, "\n")+"\n")
}
func TestDauer(t *testing.T) {
for d, soll := range map[time.Duration]string{
0: "0s", 18 * time.Second: "18s", 72 * time.Second: "1m 12s", 65 * time.Second: "1m 05s",
3780 * time.Second: "1h 03m", 1499 * time.Millisecond: "1s",
} {
if ist := Dauer(d); ist != soll {
t.Errorf("Dauer(%v) = %q, soll %q", d, ist, soll)
}
}
}
func TestXterm256(t *testing.T) {
// Teal #2DD4BF → 43 (#00d7af), Paper #E6EDF3 → 254 (Graustufe #e4e4e4), Rot #F87171 → 203 (#ff5f5f).
for f, soll := range map[rgb]int{teal: 43, paper: 254, rot: 203} {
if ist := xterm256(f); ist != soll {
t.Errorf("xterm256(%v) = %d, soll %d", f, ist, soll)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
// Package ausgabe rendert die CLI-Ausgabe im Aufbau der Vorgabe: Kopfzeile, Phasen, Abschlussbox.
// Alle Texte kommen aus der Engine; hier wird nur gesetzt und gefärbt.
package ausgabe
import (
"fmt"
"os"
"strings"
)
// Farbmodus: 24-bit, 256 Farben oder keine.
type Farbmodus int
const (
Aus Farbmodus = iota
Farben256
Echtfarben
)
// Die Markenfarben (siehe README, Abschnitt Branding).
type rgb struct{ r, g, b uint8 }
var (
paper = rgb{0xE6, 0xED, 0xF3}
muted = rgb{0x8B, 0x98, 0xA5}
teal = rgb{0x2D, 0xD4, 0xBF}
amber = rgb{0xF5, 0xB8, 0x41}
rot = rgb{0xF8, 0x71, 0x71}
)
// Stil legt fest, wie gesetzt wird: Farben und ob nur ASCII-Symbole erlaubt sind.
type Stil struct {
Farbe Farbmodus
ASCII bool
}
// FarbmodusErmitteln: aus bei NO_COLOR, --no-color oder ohne Terminal; sonst 24-bit, wenn das
// Terminal es meldet (COLORTERM), sonst 256 Farben.
func FarbmodusErmitteln(keineFarbe, istTerminal bool) Farbmodus {
if keineFarbe || !istTerminal {
return Aus
}
if _, gesetzt := os.LookupEnv("NO_COLOR"); gesetzt {
return Aus
}
if os.Getenv("TERM") == "dumb" {
return Aus
}
switch strings.ToLower(os.Getenv("COLORTERM")) {
case "truecolor", "24bit":
return Echtfarben
}
return Farben256
}
func (s Stil) faerben(f rgb, fett bool, text string) string {
if s.Farbe == Aus || text == "" {
return text
}
var code string
switch s.Farbe {
case Echtfarben:
code = fmt.Sprintf("38;2;%d;%d;%d", f.r, f.g, f.b)
default:
code = fmt.Sprintf("38;5;%d", xterm256(f))
}
if fett {
code = "1;" + code
}
return "\x1b[" + code + "m" + text + "\x1b[0m"
}
func (s Stil) Paper(t string) string { return s.faerben(paper, false, t) }
func (s Stil) Fett(t string) string { return s.faerben(paper, true, t) }
func (s Stil) Muted(t string) string { return s.faerben(muted, false, t) }
func (s Stil) Teal(t string) string { return s.faerben(teal, false, t) }
func (s Stil) TealFett(t string) string { return s.faerben(teal, true, t) }
func (s Stil) Amber(t string) string { return s.faerben(amber, false, t) }
func (s Stil) AmberFett(t string) string {
return s.faerben(amber, true, t)
}
func (s Stil) Rot(t string) string { return s.faerben(rot, false, t) }
// xterm256 wählt die nächste Farbe aus Würfel (16–231) oder Graustufen (232–255).
func xterm256(f rgb) int {
stufen := []int{0, 95, 135, 175, 215, 255}
naechste := func(wert uint8) int {
beste, abstand := 0, 1<<30
for i, s := range stufen {
d := int(wert) - s
if d*d < abstand {
beste, abstand = i, d*d
}
}
return beste
}
r, g, b := naechste(f.r), naechste(f.g), naechste(f.b)
wuerfel := 16 + 36*r + 6*g + b
dw := quadAbstand(f, stufen[r], stufen[g], stufen[b])
grau := (int(f.r) + int(f.g) + int(f.b)) / 3
gi := (grau - 8) / 10
if gi < 0 {
gi = 0
} else if gi > 23 {
gi = 23
}
gw := 8 + gi*10
if quadAbstand(f, gw, gw, gw) < dw {
return 232 + gi
}
return wuerfel
}
func quadAbstand(f rgb, r, g, b int) int {
dr, dg, db := int(f.r)-r, int(f.g)-g, int(f.b)-b
return dr*dr + dg*dg + db*db
}
// Symbole je Status, mit ASCII-Ersatz (--ascii).
func (s Stil) symbol(status string) string {
if s.ASCII {
switch status {
case "ERLEDIGT":
return "[OK]"
case "LAEUFT":
return "[..]"
case "FEHLER":
return "[!!]"
default:
return "[--]"
}
}
switch status {
case "ERLEDIGT":
return "✓"
case "LAEUFT":
return "◐"
case "FEHLER":
return "✗"
default:
return "○"
}
}
// Spinner für die laufende Phase: ◐◓◑◒.
var spinner = []string{"◐", "◓", "◑", "◒"}
+16
View File
@@ -0,0 +1,16 @@
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 · DRY-RUN
✓ Prüfungen VMID 120 frei
○ Template vorhanden (9013)
✗ Klonen Storage local-zfs ist voll.
→ Anderen Storage wählen.
→ POST /nodes/pve01/qemu/9013/clone full=1 name=web01 newid=120
→ PUT /nodes/pve01/qemu/120/config description=Zeile 1⏎Zeile 2 sshkeys=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA…
┌───────────────────────────────────────────────────────────────────────────────────────────┐
│ ✗ web01 fehlgeschlagen · 12s │
│ Phase Klonen │
│ Ursache Storage local-zfs ist voll. │
│ Hinweis Anderen Storage wählen und die VM mit einem sehr langen Hinweis neu ausrollen. │
└───────────────────────────────────────────────────────────────────────────────────────────┘
+16
View File
@@ -0,0 +1,16 @@
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013)
✓ Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
✓ Klonen full clone 9013 → 120 · Disk auf 40G
✓ cloud-init user florian · SSH-Key · Profile base, docker
✓ Start VM 120 läuft
✓ Guest-Agent antwortet nach 18s
◐ Provisionierung cloud-init läuft … 1m 12s
┌──────────────────────────────────────┐
│ ● web01 bereit · 1m 54s │
│ VMID 120 │
│ IP 10.0.20.15 │
│ Profile base, docker │
│ SSH ssh florian@10.0.20.15 │
└──────────────────────────────────────┘
+16
View File
@@ -0,0 +1,16 @@
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013)
✓ Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
✓ Klonen full clone 9013 → 120 · Disk auf 40G
✓ cloud-init user florian · SSH-Key · Profile base, docker
✓ Start VM 120 läuft
✓ Guest-Agent antwortet nach 18s
◐ Provisionierung cloud-init läuft … 1m 12s
┌──────────────────────────────────────┐
│ ● web01 bereit · 1m 54s │
│ VMID 120 │
│ IP 10.0.20.15 │
│ Profile base, docker │
│ SSH ssh florian@10.0.20.15 │
└──────────────────────────────────────┘
+16
View File
@@ -0,0 +1,16 @@
ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013)
[OK] Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
[OK] Klonen full clone 9013 → 120 · Disk auf 40G
[OK] cloud-init user florian · SSH-Key · Profile base, docker
[OK] Start VM 120 läuft
[OK] Guest-Agent antwortet nach 18s
[..] Provisionierung cloud-init läuft … 1m 12s
+--------------------------------------+
| [OK] web01 bereit · 1m 54s |
| VMID 120 |
| IP 10.0.20.15 |
| Profile base, docker |
| SSH ssh florian@10.0.20.15 |
+--------------------------------------+
+16
View File
@@ -0,0 +1,16 @@
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013)
✓ Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
✓ Klonen full clone 9013 → 120 · Disk auf 40G
✓ cloud-init user florian · SSH-Key · Profile base, docker
✓ Start VM 120 läuft
✓ Guest-Agent antwortet nach 18s
◐ Provisionierung cloud-init läuft … 1m 12s
┌──────────────────────────────────────┐
│ ● web01 bereit · 1m 54s │
│ VMID 120 │
│ IP 10.0.20.15 │
│ Profile base, docker │
│ SSH ssh florian@10.0.20.15 │
└──────────────────────────────────────┘
+174
View File
@@ -0,0 +1,174 @@
package befehle
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
)
func eineID(befehl string) cobra.PositionalArgs {
return func(_ *cobra.Command, argumente []string) error {
if len(argumente) != 1 {
return bedienfehler("Auftrags-ID fehlt: proxdeploy %s <job-id>", befehl)
}
return nil
}
}
func (u *umgebung) statusBefehl() *cobra.Command {
var folgen bool
befehl := &cobra.Command{
Use: "status <job-id>",
Short: "Stand eines Auftrags (mit --follow live bis zum Ende)",
Args: eineID("status"),
RunE: func(cmd *cobra.Command, argumente []string) error {
c, _, err := u.client()
if err != nil {
return err
}
if folgen {
_, err := u.folgen(cmd.Context(), c, argumente[0])
return err
}
a, err := c.Auftrag(cmd.Context(), argumente[0])
if err != nil {
return err
}
fertig := endzustaende[a.Status]
if u.opt.json {
if err := u.jsonAusgeben(a); err != nil {
return err
}
} else {
d := u.neuerDarsteller()
d.live = true // einmal vollständig zeichnen, ohne Neuzeichnen
d.zeichnen(a, fertig)
}
if fertig && a.Status != api.AuftragStatusERFOLGREICH {
return &exitFehler{code: ExitFehlschlag, gemeldet: true}
}
return nil
},
}
befehl.Flags().BoolVarP(&folgen, "follow", "f", false, "live verfolgen bis zum Ende")
return befehl
}
func (u *umgebung) logsBefehl() *cobra.Command {
var folgen bool
befehl := &cobra.Command{
Use: "logs <job-id>",
Short: "Detail-Log eines Auftrags (mit --follow live)",
Args: eineID("logs"),
RunE: func(cmd *cobra.Command, argumente []string) error {
c, _, err := u.client()
if err != nil {
return err
}
id := argumente[0]
zeilen, err := c.Log(cmd.Context(), id, "")
if err != nil {
return err
}
for _, z := range zeilen {
u.logZeile(z)
}
if !folgen {
return nil
}
letzte := ""
if len(zeilen) > 0 {
letzte = zeilen[len(zeilen)-1].Id
}
return u.logFolgen(cmd.Context(), c, id, letzte)
},
}
befehl.Flags().BoolVarP(&folgen, "follow", "f", false, "neue Zeilen live anhängen, bis der Auftrag fertig ist")
return befehl
}
func (u *umgebung) logZeile(z api.LogZeile) {
if u.opt.json {
daten, _ := json.Marshal(z)
fmt.Fprintln(u.aus, string(daten))
return
}
s := u.stil()
zeit := z.Zeit
if t, err := time.Parse(time.RFC3339Nano, z.Zeit); err == nil {
zeit = t.Local().Format("15:04:05")
}
phase := ""
if z.Phase != nil {
phase = *z.Phase
}
nachricht := z.Nachricht
switch z.Stufe {
case "error":
nachricht = s.Rot(nachricht)
case "warn":
nachricht = s.Amber(nachricht)
default:
nachricht = s.Paper(nachricht)
}
fmt.Fprintf(u.aus, "%s %s %s\n", s.Muted(zeit), s.Muted(fmt.Sprintf("%-16s", phase)), nachricht)
}
// logFolgen hängt neue Zeilen an (SSE-Ereignis `log`), bis der Auftrag fertig ist.
func (u *umgebung) logFolgen(ctx context.Context, c *api.Client, id, ab string) error {
for versuch := 0; versuch < 5; versuch++ {
err := c.Ereignisse(ctx, id, ab, func(e api.Ereignis) error {
switch e.Art {
case "log":
var zeilen []api.LogZeile
if err := json.Unmarshal(e.Daten, &zeilen); err != nil {
return err
}
for _, z := range zeilen {
u.logZeile(z)
ab = z.Id
}
case "ende":
return api.Ende
}
return nil
})
if errors.Is(err, api.Ende) || ctx.Err() != nil {
return nil
}
var apiFehler *api.Fehler
if errors.As(err, &apiFehler) {
return err
}
time.Sleep(time.Duration(versuch+1) * time.Second)
}
return &api.VerbindungsFehler{Ursache: errors.New("Ereignisstrom bricht wiederholt ab")}
}
func (u *umgebung) cancelBefehl() *cobra.Command {
return &cobra.Command{
Use: "cancel <job-id>",
Short: "Auftrag abbrechen (hält an der nächsten Phasengrenze)",
Args: eineID("cancel"),
RunE: func(cmd *cobra.Command, argumente []string) error {
c, _, err := u.client()
if err != nil {
return err
}
if err := c.Abbrechen(cmd.Context(), argumente[0]); err != nil {
return err
}
if !u.opt.json {
fmt.Fprintln(u.aus, u.stil().Amber("Abbruch angefordert – "+strings.TrimSpace(argumente[0])+" hält an der nächsten Phasengrenze."))
}
return nil
},
}
}
+317
View File
@@ -0,0 +1,317 @@
package befehle
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe"
)
// gastFlags sind die Angaben zu einem Gast – dieselben für `vm`/`lxc` und für Einträge in `batch`.
type gastFlags struct {
server, knoten, distro, preset, ip, gw, bridge, storage, benutzer, suchdomain string
vmid, vlan, cores, speicher, swap int
disk string
profile, dns, tags, schluessel, variablen []string
privilegiert, nesting, templateBauen bool
}
func (g *gastFlags) registrieren(f *pflag.FlagSet, typ string) {
f.StringVar(&g.server, "server", "", "Proxmox-Server (Kurzname, siehe „proxdeploy servers“)")
f.StringVar(&g.knoten, "node", "", "Node (Standard: wo das Template liegt bzw. der mit dem meisten freien RAM)")
f.StringVar(&g.distro, "distro", "", "Distribution, z. B. debian-13 (siehe „proxdeploy distros“)")
f.StringSliceVar(&g.profile, "profile", nil, "Profile, z. B. base,docker (Standard: base)")
f.StringVar(&g.preset, "preset", "", "Vorlage, deren Werte vorbelegt werden (Flags gehen vor)")
f.StringVar(&g.ip, "ip", "", "statische Adresse mit Präfix, z. B. 10.0.20.15/24 (sonst DHCP)")
f.StringVar(&g.gw, "gw", "", "Gateway")
f.StringSliceVar(&g.dns, "dns", nil, "DNS-Server, kommagetrennt")
f.StringVar(&g.suchdomain, "search-domain", "", "Suchdomain")
f.StringVar(&g.bridge, "bridge", "", "Bridge (Standard: vmbr0)")
f.IntVar(&g.vlan, "vlan", 0, "VLAN-Tag")
f.IntVar(&g.vmid, "vmid", 0, "VMID/CTID (Standard: nächste freie)")
f.IntVar(&g.cores, "cores", 0, "CPU-Kerne (Standard: 2)")
f.IntVar(&g.speicher, "memory", 0, "RAM in MB (Standard: 2048)")
f.StringVar(&g.disk, "disk", "", "Disk, z. B. 40G (Standard: 20G)")
f.StringVar(&g.storage, "storage", "", "Storage (Standard: aus den Server-Einstellungen)")
f.StringVar(&g.benutzer, "user", "", "Benutzer im Gast (Standard: dein Benutzername)")
f.StringArrayVar(&g.schluessel, "ssh-key", nil, "öffentlicher SSH-Schlüssel oder Pfad zur .pub (Standard: deine hinterlegten)")
f.StringSliceVar(&g.tags, "tags", nil, "Tags, kommagetrennt")
f.StringArrayVar(&g.variablen, "var", nil, "Profil-Variable profil.name=wert (siehe „proxdeploy profiles“)")
if typ == "lxc" {
f.IntVar(&g.swap, "swap", 0, "Swap in MB (Standard: 512)")
f.BoolVar(&g.privilegiert, "privileged", false, "privilegierter Container (Standard: unprivilegiert)")
f.BoolVar(&g.nesting, "nesting", false, "Feature nesting (das Profil docker setzt es selbst)")
} else {
f.BoolVar(&g.templateBauen, "build-template", false, "fehlt das Template, vorher bauen")
}
}
var diskMuster = regexp.MustCompile(`^(\d+)\s*([gG])?$`)
// anfrage baut die API-Anfrage. Nur gesetzte Werte kommen hinein; den Rest füllen Vorlage und
// Server (Storage, Bridge, Benutzer, Schlüssel).
func (g *gastFlags) anfrage(typ, name string, gesetzt func(string) bool) (map[string]any, error) {
a := map[string]any{"typ": typ, "name": name}
setze := func(flag, feld string, wert any) {
if gesetzt(flag) {
a[feld] = wert
}
}
setze("server", "server", g.server)
setze("node", "knoten", g.knoten)
setze("distro", "distribution", g.distro)
setze("profile", "profile", g.profile)
setze("preset", "vorlage", g.preset)
setze("vmid", "vmid", g.vmid)
setze("cores", "cores", g.cores)
setze("memory", "speicherMb", g.speicher)
setze("storage", "storage", g.storage)
setze("tags", "tags", g.tags)
setze("swap", "swapMb", g.swap)
setze("build-template", "templateBauen", g.templateBauen)
if gesetzt("privileged") {
a["unprivilegiert"] = !g.privilegiert
}
if gesetzt("nesting") {
a["features"] = map[string]any{"nesting": g.nesting}
}
if gesetzt("disk") {
treffer := diskMuster.FindStringSubmatch(strings.TrimSpace(g.disk))
if treffer == nil {
return nil, bedienfehler("--disk: Größe in GB, z. B. 40G")
}
groesse, _ := strconv.Atoi(treffer[1])
a["diskGb"] = groesse
}
netz := map[string]any{}
if gesetzt("ip") {
netz["modus"] = "statisch"
netz["ip"] = g.ip
}
if gesetzt("gw") {
netz["gateway"] = g.gw
}
if gesetzt("dns") {
netz["dns"] = g.dns
}
if gesetzt("search-domain") {
netz["suchdomain"] = g.suchdomain
}
if gesetzt("bridge") {
netz["bridge"] = g.bridge
}
if gesetzt("vlan") {
netz["vlan"] = g.vlan
}
if len(netz) > 0 {
a["netz"] = netz
}
zugang := map[string]any{}
if gesetzt("user") {
zugang["benutzer"] = g.benutzer
}
if gesetzt("ssh-key") {
schluessel := []string{}
for _, s := range g.schluessel {
text, err := schluesselLesen(s)
if err != nil {
return nil, err
}
schluessel = append(schluessel, text...)
}
zugang["sshSchluessel"] = schluessel
}
if len(zugang) > 0 {
a["zugang"] = zugang
}
if len(g.variablen) > 0 {
variablen := map[string]map[string]any{}
for _, v := range g.variablen {
schluessel, wert, ok := strings.Cut(v, "=")
profil, name, ok2 := strings.Cut(schluessel, ".")
if !ok || !ok2 || profil == "" || name == "" {
return nil, bedienfehler("--var %q: Format profil.name=wert", v)
}
if variablen[profil] == nil {
variablen[profil] = map[string]any{}
}
variablen[profil][name] = wert
}
a["variablen"] = variablen
}
return a, nil
}
// schluesselLesen nimmt einen Schlüssel als Text oder liest eine .pub-Datei (eine Zeile je Schlüssel).
func schluesselLesen(angabe string) ([]string, error) {
if strings.HasPrefix(angabe, "ssh-") || strings.HasPrefix(angabe, "ecdsa-") || strings.HasPrefix(angabe, "sk-") {
return []string{strings.TrimSpace(angabe)}, nil
}
pfad := angabe
if rest, ok := strings.CutPrefix(pfad, "~/"); ok {
if heim, err := os.UserHomeDir(); err == nil {
pfad = filepath.Join(heim, rest)
}
}
inhalt, err := os.ReadFile(pfad)
if err != nil {
return nil, bedienfehler("--ssh-key: %v", err)
}
var liste []string
for _, zeile := range strings.Split(string(inhalt), "\n") {
if z := strings.TrimSpace(zeile); z != "" && !strings.HasPrefix(z, "#") {
if strings.Contains(z, "PRIVATE KEY") {
return nil, bedienfehler("--ssh-key: %s ist ein privater Schlüssel – die .pub angeben", angabe)
}
liste = append(liste, z)
}
}
return liste, nil
}
func (u *umgebung) ausrollBefehl(typ string) *cobra.Command {
var g gastFlags
var trocken, nichtWarten bool
art := map[string]string{"vm": "VM", "lxc": "LXC-Container"}[typ]
befehl := &cobra.Command{
Use: typ + " <name>",
Short: art + " ausrollen",
Example: map[string]string{
"vm": " proxdeploy vm web01 --server rz1 --distro debian-13 --profile base,docker --ip 10.0.20.15/24 --gw 10.0.20.1 --vlan 20",
"lxc": " proxdeploy lxc dns01 --server rz1 --distro ubuntu-24.04 --profile base",
}[typ],
Args: func(cmd *cobra.Command, argumente []string) error {
if len(argumente) != 1 {
return bedienfehler("Name fehlt: proxdeploy %s <name> …", typ)
}
return nil
},
RunE: func(cmd *cobra.Command, argumente []string) error {
c, instanz, err := u.client()
if err != nil {
return err
}
gesetzt := cmd.Flags().Changed
if !gesetzt("server") && instanz.Server != "" {
g.server = instanz.Server
gesetzt = func(flag string) bool { return flag == "server" || cmd.Flags().Changed(flag) }
}
if !gesetzt("server") {
return bedienfehler("--server fehlt (oder `server = \"…\"` in der Konfiguration)")
}
if !gesetzt("distro") && !gesetzt("preset") {
return bedienfehler("--distro fehlt (siehe `proxdeploy distros`)")
}
anfrage, err := g.anfrage(typ, argumente[0], gesetzt)
if err != nil {
return err
}
if trocken {
return u.trockenlauf(cmd.Context(), c, anfrage, argumente[0])
}
angenommen, err := c.Ausrollen(cmd.Context(), anfrage)
if err != nil {
return err
}
return u.angenommen(cmd.Context(), c, angenommen, nichtWarten)
},
}
g.registrieren(befehl.Flags(), typ)
befehl.Flags().BoolVar(&trocken, "dry-run", false, "nur planen: Phasen und geplante API-Aufrufe zeigen, nichts anlegen")
befehl.Flags().BoolVar(&nichtWarten, "no-wait", false, "nicht verfolgen, nur die Auftrags-ID ausgeben")
return befehl
}
// angenommen: Auftrag läuft – verfolgen oder nur die ID ausgeben.
func (u *umgebung) angenommen(ctx context.Context, c *api.Client, a api.AuftragAngenommen, nichtWarten bool) error {
if nichtWarten {
if u.opt.json {
return u.jsonAusgeben(a)
}
fmt.Fprintln(u.aus, a.KurzId)
return nil
}
_, err := u.folgen(ctx, c, a.KurzId)
return err
}
// trockenlauf zeigt den Plan: Kopfzeile mit DRY-RUN, Phasen, jeden geplanten Aufruf als „→ …“.
func (u *umgebung) trockenlauf(ctx context.Context, c *api.Client, anfrage map[string]any, name string) error {
plan, err := c.Planen(ctx, anfrage)
if err != nil {
return err
}
if u.opt.json {
if err := u.jsonAusgeben(plan); err != nil {
return err
}
if !plan.Ok {
return &exitFehler{code: ExitFehlschlag, gemeldet: true}
}
return nil
}
s := u.stil()
phasen := make([]ausgabe.Phase, 0, len(plan.Phasen))
for _, p := range plan.Phasen {
phasen = append(phasen, ausgabe.Phase{
Titel: p.Titel, Status: string(p.Status), Detail: wert(p.Detail), Ursache: wert(p.Ursache), Hinweis: wert(p.Hinweis),
})
}
aufrufe := make([]ausgabe.Aufruf, 0, len(plan.Aufrufe))
for _, a := range plan.Aufrufe {
aufruf := ausgabe.Aufruf{Methode: a.Methode, Pfad: a.Pfad}
if a.Parameter != nil {
aufruf.Parameter = *a.Parameter
}
aufrufe = append(aufrufe, aufruf)
}
var zeilen []string
if !u.opt.still {
zeilen = append(zeilen, s.Kopf(Version, plan.Kopf, true), "")
zeilen = append(zeilen, s.PhasenZeilen(phasen, 0)...)
zeilen = append(zeilen, "")
zeilen = append(zeilen, s.AufrufZeilen(aufrufe)...)
if len(plan.Automatisch) > 0 {
zeilen = append(zeilen, "", s.Muted("Automatisch dazu: "+strings.Join(plan.Automatisch, ", ")))
}
}
if !plan.Ok {
box := ausgabe.Box{Titel: name + " würde fehlschlagen"}
for _, p := range plan.Phasen {
if p.Status == api.PhaseStatusFEHLER {
box.Zeilen = append(box.Zeilen, [2]string{"Phase", p.Titel})
if p.Ursache != nil {
box.Zeilen = append(box.Zeilen, [2]string{"Ursache", *p.Ursache})
}
if p.Hinweis != nil {
box.Zeilen = append(box.Zeilen, [2]string{"Hinweis", *p.Hinweis})
}
}
}
if len(zeilen) > 0 {
zeilen = append(zeilen, "")
}
zeilen = append(zeilen, s.BoxZeilen(box)...)
} else if !u.opt.still {
zeilen = append(zeilen, "", s.Muted("Nichts angelegt. Ohne --dry-run wird ausgerollt."))
}
if len(zeilen) > 0 {
fmt.Fprintln(u.aus, strings.Join(zeilen, "\n"))
}
if !plan.Ok {
return &exitFehler{code: ExitFehlschlag, gemeldet: true}
}
return nil
}
+266
View File
@@ -0,0 +1,266 @@
package befehle
import (
"errors"
"fmt"
"os"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"go.yaml.in/yaml/v3"
)
// Batch-Datei: Schlüssel heißen wie die Flags von `proxdeploy vm`/`lxc`.
//
// defaults:
// server: rz1
// distro: debian-13
// profile: [base]
// guests:
// - name: web01
// type: vm
// profile: [base, docker]
// ip: 10.0.20.15/24
// gw: 10.0.20.1
// - name: dns01
// type: lxc
// vars: { zabbix-agent: { server: zbx.example } }
type batchDatei struct {
Defaults map[string]any `yaml:"defaults"`
Guests []map[string]any `yaml:"guests"`
}
type batchEintrag struct {
name, typ string
anfrage map[string]any
}
// batchLesen prüft die ganze Datei, bevor irgendetwas ausgerollt wird.
func batchLesen(pfad string, standardServer string) ([]batchEintrag, error) {
inhalt, err := os.ReadFile(pfad)
if err != nil {
return nil, bedienfehler("%v", err)
}
var datei batchDatei
dekoder := yaml.NewDecoder(strings.NewReader(string(inhalt)))
dekoder.KnownFields(true)
if err := dekoder.Decode(&datei); err != nil {
return nil, bedienfehler("%s: %v", pfad, err)
}
if len(datei.Guests) == 0 {
return nil, bedienfehler("%s: keine Einträge unter guests", pfad)
}
var eintraege []batchEintrag
namen := map[string]bool{}
for nr, gast := range datei.Guests {
werte := map[string]any{}
for k, v := range datei.Defaults {
werte[k] = v
}
for k, v := range gast {
werte[k] = v
}
ort := fmt.Sprintf("%s, Eintrag %d", pfad, nr+1)
name, _ := werte["name"].(string)
typ, _ := werte["type"].(string)
if name == "" {
return nil, bedienfehler("%s: name fehlt", ort)
}
ort = fmt.Sprintf("%s (%s)", ort, name)
if typ != "vm" && typ != "lxc" {
return nil, bedienfehler("%s: type ist vm oder lxc", ort)
}
if namen[name] {
return nil, bedienfehler("%s: Name doppelt", ort)
}
namen[name] = true
delete(werte, "name")
delete(werte, "type")
if _, ok := werte["server"]; !ok && standardServer != "" {
werte["server"] = standardServer
}
var g gastFlags
flags := pflag.NewFlagSet(name, pflag.ContinueOnError)
g.registrieren(flags, typ)
if err := flagsSetzen(flags, werte); err != nil {
return nil, bedienfehler("%s: %v", ort, err)
}
if !flags.Changed("server") {
return nil, bedienfehler("%s: server fehlt", ort)
}
anfrage, err := g.anfrage(typ, name, flags.Changed)
if err != nil {
var ef *exitFehler
if errors.As(err, &ef) {
return nil, bedienfehler("%s: %v", ort, ef.err)
}
return nil, err
}
eintraege = append(eintraege, batchEintrag{name: name, typ: typ, anfrage: anfrage})
}
return eintraege, nil
}
// flagsSetzen überträgt YAML-Werte auf die Flags – so gelten dieselben Regeln wie auf der Kommandozeile.
func flagsSetzen(flags *pflag.FlagSet, werte map[string]any) error {
schluessel := make([]string, 0, len(werte))
for k := range werte {
schluessel = append(schluessel, k)
}
sort.Strings(schluessel)
for _, k := range schluessel {
w := werte[k]
if k == "vars" {
profile, ok := w.(map[string]any)
if !ok {
return errors.New("vars: profil: { name: wert }")
}
for profil, inhalt := range profile {
variablen, ok := inhalt.(map[string]any)
if !ok {
return fmt.Errorf("vars.%s: { name: wert }", profil)
}
for name, v := range variablen {
if err := flags.Set("var", fmt.Sprintf("%s.%s=%v", profil, name, v)); err != nil {
return err
}
}
}
continue
}
if flags.Lookup(k) == nil {
return fmt.Errorf("unbekannter Schlüssel %q", k)
}
switch liste := w.(type) {
case []any:
teile := make([]string, 0, len(liste))
for _, e := range liste {
teile = append(teile, fmt.Sprint(e))
}
if k == "ssh-key" {
for _, t := range teile {
if err := flags.Set(k, t); err != nil {
return fmt.Errorf("%s: %v", k, err)
}
}
continue
}
if err := flags.Set(k, strings.Join(teile, ",")); err != nil {
return fmt.Errorf("%s: %v", k, err)
}
default:
if err := flags.Set(k, fmt.Sprint(w)); err != nil {
return fmt.Errorf("%s: %v", k, err)
}
}
}
return nil
}
func (u *umgebung) batchBefehl() *cobra.Command {
var trocken bool
befehl := &cobra.Command{
Use: "batch <datei.yaml>",
Short: "Mehrere Gäste aus einer YAML-Datei ausrollen",
Long: "Rollt alle Gäste unter `guests` aus; `defaults` gilt für jeden Eintrag. Schlüssel heißen\n" +
"wie die Flags von `proxdeploy vm`/`lxc` (server, distro, profile, ip, gw, vlan, cores, …),\n" +
"dazu name, type (vm|lxc) und vars: { profil: { name: wert } }.\n" +
"Die Datei wird vollständig geprüft, bevor der erste Auftrag startet.",
Args: func(_ *cobra.Command, argumente []string) error {
if len(argumente) != 1 {
return bedienfehler("Datei fehlt: proxdeploy batch <datei.yaml>")
}
return nil
},
RunE: func(cmd *cobra.Command, argumente []string) error {
c, instanz, err := u.client()
if err != nil {
return err
}
eintraege, err := batchLesen(argumente[0], instanz.Server)
if err != nil {
return err
}
ctx := cmd.Context()
s := u.stil()
fehlgeschlagen := 0
if trocken {
for nr, e := range eintraege {
if nr > 0 && !u.opt.json {
fmt.Fprintln(u.aus)
}
if err := u.trockenlauf(ctx, c, e.anfrage, e.name); err != nil {
var ef *exitFehler
if !errors.As(err, &ef) || !ef.gemeldet {
u.fehlerMelden(err)
}
fehlgeschlagen++
}
}
} else {
type gestartet struct {
eintrag batchEintrag
id string
fehler error
}
var auftraege []gestartet
// Erst alle anlegen – der worker arbeitet sie parallel ab –, dann der Reihe nach verfolgen.
for _, e := range eintraege {
a, err := c.Ausrollen(ctx, e.anfrage)
auftraege = append(auftraege, gestartet{eintrag: e, id: a.KurzId, fehler: err})
}
zusammenfassung := [][]string{}
for nr, g := range auftraege {
if g.fehler != nil {
fmt.Fprintf(u.fehler, "%s\n", s.Rot(g.eintrag.name+": nicht gestartet"))
u.fehlerMelden(g.fehler)
fehlgeschlagen++
zusammenfassung = append(zusammenfassung, []string{g.eintrag.name, "–", "✗ nicht gestartet", ""})
continue
}
if nr > 0 && !u.opt.json && !u.opt.still {
fmt.Fprintln(u.aus)
}
a, err := u.folgen(ctx, c, g.id)
var ef *exitFehler
if err != nil && errors.As(err, &ef) && ef.code == 130 {
return err
}
status := "✓ bereit"
if err != nil {
fehlgeschlagen++
status = "✗ " + strings.ToLower(string(a.Status))
if a.Status == "" {
u.fehlerMelden(err)
status = "✗ Fehler"
}
}
ip := ""
if a.Ergebnis != nil {
ip = wert(a.Ergebnis.Ip)
}
zusammenfassung = append(zusammenfassung, []string{g.eintrag.name, g.id, status, ip})
}
if !u.opt.json && len(auftraege) > 1 {
fmt.Fprintln(u.aus)
if s.ASCII {
for _, z := range zusammenfassung {
z[2] = strings.NewReplacer("✓", "[OK]", "✗", "[!!]").Replace(z[2])
}
}
u.tabelle(s, []string{"NAME", "AUFTRAG", "STATUS", "IP"}, zusammenfassung)
}
}
if fehlgeschlagen > 0 {
return &exitFehler{code: ExitFehlschlag, gemeldet: true}
}
return nil
},
}
befehl.Flags().BoolVar(&trocken, "dry-run", false, "nur planen")
return befehl
}
+325
View File
@@ -0,0 +1,325 @@
package befehle
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
var aktualisieren = flag.Bool("update", false, "Golden-Dateien neu schreiben")
func golden(t *testing.T, name, ist string) {
t.Helper()
pfad := filepath.Join("testdata", name)
if *aktualisieren {
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(pfad, []byte(ist), 0o644); err != nil {
t.Fatal(err)
}
}
soll, err := os.ReadFile(pfad)
if err != nil {
t.Fatal(err)
}
if ist != string(soll) {
t.Errorf("%s weicht ab.\n--- soll\n%s\n--- ist\n%s", name, soll, ist)
}
}
// phase baut eine Phase der API.
func phase(schluessel, titel, status, detail string) map[string]any {
p := map[string]any{"schluessel": schluessel, "titel": titel, "status": status, "detail": nil, "ursache": nil, "hinweis": nil, "gestartetAm": nil, "beendetAm": nil}
if detail != "" {
p["detail"] = detail
}
if status == "LAEUFT" {
p["gestartetAm"] = "2026-10-12T10:00:00.000Z"
}
return p
}
func auftrag(status string, phasen []map[string]any, abschluss any) map[string]any {
return map[string]any{
"id": "c1", "kurzId": "a-7f3k", "art": "AUSROLLEN", "status": status,
"kopf": "Server rz1 · Node pve01 · Debian 13 (Template 9013)",
"server": map[string]any{"name": "rz1", "titel": "RZ 1"}, "knoten": "pve01", "gastTyp": "VM",
"vmid": 120, "name": "web01", "distributionId": "debian-13", "profile": []string{"base", "docker"},
"ergebnis": map[string]any{"vmid": 120, "ip": "10.0.20.15", "ssh": "ssh florian@10.0.20.15"},
"fehler": nil, "erstelltVon": "florian", "erstelltAm": "2026-10-12T10:00:00.000Z",
"gestartetAm": "2026-10-12T10:00:00.000Z", "beendetAm": nil, "abschluss": abschluss, "phasen": phasen,
}
}
var alle = []map[string]any{
phase("pruefungen", "Prüfungen", "ERLEDIGT", "VMID 120 frei · local-zfs · vmbr0 VLAN 20"),
phase("klonen", "Klonen", "ERLEDIGT", "full clone 9013 → 120 · Disk auf 40G"),
phase("cloudinit", "cloud-init", "ERLEDIGT", "user florian · SSH-Key · Profile base, docker"),
phase("start", "Start", "ERLEDIGT", "VM 120 läuft"),
phase("agent", "Guest-Agent", "ERLEDIGT", "antwortet nach 18s"),
phase("provisionierung", "Provisionierung", "ERLEDIGT", "base, docker"),
}
// fakeAPI spielt die REST-API nach; `anfragen` hält die Rümpfe fest.
type fakeAPI struct {
mu sync.Mutex
anfragen map[string][]map[string]any
staende []map[string]any
plan map[string]any
status int
antwort any
}
func (f *fakeAPI) handler(t *testing.T) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer pd_test" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
fmt.Fprint(w, `{"fehler":{"code":"nicht_angemeldet","nachricht":"Nicht angemeldet."}}`)
return
}
if r.Body != nil && r.Method == http.MethodPost {
var rumpf map[string]any
_ = json.NewDecoder(r.Body).Decode(&rumpf)
f.mu.Lock()
if f.anfragen == nil {
f.anfragen = map[string][]map[string]any{}
}
f.anfragen[r.URL.Path] = append(f.anfragen[r.URL.Path], rumpf)
f.mu.Unlock()
}
schreiben := func(status int, wert any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(wert)
}
if f.status != 0 {
schreiben(f.status, f.antwort)
return
}
switch {
case r.URL.Path == "/api/v1/ich":
schreiben(200, map[string]any{"benutzername": "florian", "anzeigename": "Florian", "rolle": "OPERATOR", "rechte": []string{}, "version": "0.4.0"})
case r.URL.Path == "/api/v1/auftraege" && r.Method == http.MethodPost:
schreiben(202, map[string]any{"id": "c1", "kurzId": "a-7f3k"})
case r.URL.Path == "/api/v1/auftraege/plan":
schreiben(200, f.plan)
case strings.HasSuffix(r.URL.Path, "/ereignisse"):
w.Header().Set("Content-Type", "text/event-stream")
for nr, s := range f.staende {
daten, _ := json.Marshal(s)
fmt.Fprintf(w, "id: %d\nevent: stand\ndata: %s\n\n: ping\n\n", nr+1, daten)
}
fmt.Fprint(w, "event: ende\ndata: {}\n\n")
case r.URL.Path == "/api/v1/distributionen":
schreiben(200, []map[string]any{
{"id": "debian-13", "name": "Debian 13 „Trixie“", "familie": "debian", "aktiv": true, "vm": true, "lxc": true},
{"id": "ubuntu-26.04", "name": "Ubuntu 26.04 LTS", "familie": "debian", "aktiv": true, "vm": true, "lxc": false},
})
default:
t.Errorf("unerwartete Anfrage %s %s", r.Method, r.URL.Path)
schreiben(404, map[string]any{"fehler": map[string]any{"code": "nicht_gefunden", "nachricht": "?"}})
}
})
}
func lauf(t *testing.T, f *fakeAPI, argumente ...string) (string, string, int) {
t.Helper()
server := httptest.NewServer(f.handler(t))
t.Cleanup(server.Close)
t.Setenv("PROXDEPLOY_CONFIG", filepath.Join(t.TempDir(), "config.toml"))
t.Setenv("PROXDEPLOY_URL", server.URL)
t.Setenv("PROXDEPLOY_TOKEN", "pd_test")
var aus, fehler bytes.Buffer
u := &umgebung{aus: &aus, fehler: &fehler, terminal: false, opt: &optionen{}}
code := u.ausfuehren(context.Background(), argumente)
return aus.String(), fehler.String(), code
}
func TestVmZeilenweiseBisZurBox(t *testing.T) {
Version = "1.0"
laeuft := append(append([]map[string]any{}, alle[:5]...), phase("provisionierung", "Provisionierung", "LAEUFT", "cloud-init läuft"))
f := &fakeAPI{staende: []map[string]any{
auftrag("LAEUFT", []map[string]any{phase("pruefungen", "Prüfungen", "LAEUFT", ""), phase("klonen", "Klonen", "OFFEN", "")}, nil),
auftrag("LAEUFT", laeuft, nil),
auftrag("ERFOLGREICH", alle, map[string]any{"ok": true, "titel": "web01 bereit", "dauer": "1m 54s", "zeilen": [][]string{
{"VMID", "120"}, {"IP", "10.0.20.15"}, {"Profile", "base, docker"}, {"SSH", "ssh florian@10.0.20.15"},
}}),
}}
aus, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--profile", "base,docker",
"--ip", "10.0.20.15/24", "--vlan", "20", "--disk", "40G", "--var", "zabbix-agent.server=zbx.local")
if code != 0 {
t.Fatalf("Exit %d, stderr: %s", code, fehler)
}
golden(t, "vm-zeilenweise.golden", aus)
// Nur gesetzte Flags gehen hinaus – Storage, Bridge, Benutzer und Schlüssel füllt der Server.
gesendet, _ := json.MarshalIndent(f.anfragen["/api/v1/auftraege"][0], "", " ")
golden(t, "vm-anfrage.golden", string(gesendet)+"\n")
}
func TestVmFehlgeschlagenExit1UndQuiet(t *testing.T) {
Version = "1.0"
fehlerPhase := phase("klonen", "Klonen", "FEHLER", "")
fehlerPhase["ursache"] = "Storage local-zfs ist voll."
fehlerPhase["hinweis"] = "Anderen Storage wählen (--storage)."
f := &fakeAPI{staende: []map[string]any{
auftrag("FEHLGESCHLAGEN", []map[string]any{alle[0], fehlerPhase, phase("cloudinit", "cloud-init", "OFFEN", "")},
map[string]any{"ok": false, "titel": "web01 fehlgeschlagen", "dauer": "12s", "zeilen": [][]string{
{"Phase", "Klonen"}, {"Ursache", "Storage local-zfs ist voll."}, {"Hinweis", "Anderen Storage wählen (--storage)."},
}}),
}}
aus, _, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--quiet", "--ascii")
if code != ExitFehlschlag {
t.Fatalf("Exit %d, erwartet 1", code)
}
golden(t, "vm-fehler-quiet-ascii.golden", aus)
}
func TestDryRun(t *testing.T) {
Version = "1.0"
f := &fakeAPI{plan: map[string]any{
"kopf": "Server rz1 · Node pve01 · Debian 13 (Template 9013)",
"phasen": []map[string]any{alle[0], alle[1], phase("provisionierung", "Provisionierung", "ERLEDIGT", "base, docker")},
"aufrufe": []map[string]any{
{"methode": "POST", "pfad": "/nodes/pve01/qemu/9013/clone", "parameter": map[string]any{"newid": 120, "full": 1, "name": "web01"}},
{"methode": "POST", "pfad": "/nodes/pve01/qemu/120/status/start"},
},
"skript": nil, "cliBefehl": "proxdeploy vm web01 --server rz1 --distro debian-13", "automatisch": []string{"base"}, "ok": true,
}}
aus, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--dry-run")
if code != 0 {
t.Fatalf("Exit %d: %s", code, fehler)
}
golden(t, "vm-dryrun.golden", aus)
if len(f.anfragen["/api/v1/auftraege"]) != 0 {
t.Error("Dry-Run hat einen Auftrag angelegt")
}
}
func TestFehlerUndExitCodes(t *testing.T) {
f := &fakeAPI{status: 400, antwort: map[string]any{"fehler": map[string]any{
"code": "ungueltig", "nachricht": "Die Eingaben sind nicht vollständig oder ungültig.",
"felder": map[string]string{"netz.ip": "Format: 10.0.20.15/24", "variablen.zabbix-agent.server": "Zabbix-Server fehlt."},
}}}
_, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--ip", "x")
if code != ExitBedienung {
t.Errorf("Exit %d, erwartet 2", code)
}
golden(t, "fehler-felder.golden", fehler)
f = &fakeAPI{status: 401, antwort: map[string]any{"fehler": map[string]any{"code": "nicht_angemeldet", "nachricht": "Nicht angemeldet."}}}
if _, _, code := lauf(t, f, "servers"); code != ExitVerbindung {
t.Errorf("401: Exit %d, erwartet 3", code)
}
t.Setenv("PROXDEPLOY_URL", "http://127.0.0.1:1")
t.Setenv("PROXDEPLOY_TOKEN", "pd_test")
var aus, fehlerAus bytes.Buffer
u := &umgebung{aus: &aus, fehler: &fehlerAus, opt: &optionen{}}
if code := u.ausfuehren(context.Background(), []string{"distros"}); code != ExitVerbindung {
t.Errorf("nicht erreichbar: Exit %d, erwartet 3", code)
}
if code := u.ausfuehren(context.Background(), []string{"vm"}); code != ExitBedienung {
t.Errorf("ohne Name: Exit %d, erwartet 2", code)
}
}
func TestDistrosTabelleUndJson(t *testing.T) {
aus, _, code := lauf(t, &fakeAPI{}, "distros")
if code != 0 {
t.Fatal(code)
}
golden(t, "distros.golden", aus)
aus, _, _ = lauf(t, &fakeAPI{}, "distros", "--json")
var liste []map[string]any
if err := json.Unmarshal([]byte(aus), &liste); err != nil || len(liste) != 2 {
t.Errorf("--json nicht lesbar: %v\n%s", err, aus)
}
}
func TestBatch(t *testing.T) {
Version = "1.0"
datei := filepath.Join(t.TempDir(), "gaeste.yaml")
if err := os.WriteFile(datei, []byte(`defaults:
server: rz1
distro: debian-13
profile: [base]
guests:
- name: web01
type: vm
profile: [base, docker]
ip: 10.0.20.15/24
gw: 10.0.20.1
memory: 4096
- name: mon01
type: lxc
profile: [base, zabbix-agent]
vars: { zabbix-agent: { server: zbx.example } }
nesting: true
`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeAPI{staende: []map[string]any{auftrag("ERFOLGREICH", alle, map[string]any{"ok": true, "titel": "web01 bereit", "dauer": "1m 54s", "zeilen": [][]string{}})}}
_, fehler, code := lauf(t, f, "batch", datei, "--quiet")
if code != 0 {
t.Fatalf("Exit %d: %s", code, fehler)
}
gesendet, _ := json.MarshalIndent(f.anfragen["/api/v1/auftraege"], "", " ")
golden(t, "batch-anfragen.golden", string(gesendet)+"\n")
falsch := filepath.Join(t.TempDir(), "falsch.yaml")
_ = os.WriteFile(falsch, []byte("guests:\n - name: a\n type: vm\n server: rz1\n ram: 4096\n"), 0o644)
_, fehler, code = lauf(t, &fakeAPI{}, "batch", falsch)
if code != ExitBedienung || !strings.Contains(fehler, `unbekannter Schlüssel "ram"`) {
t.Errorf("Exit %d, %s", code, fehler)
}
}
func TestLoginMitToken(t *testing.T) {
server := httptest.NewServer((&fakeAPI{}).handler(t))
defer server.Close()
pfad := filepath.Join(t.TempDir(), "proxdeploy", "config.toml")
t.Setenv("PROXDEPLOY_CONFIG", pfad)
t.Setenv("PROXDEPLOY_URL", "")
t.Setenv("PROXDEPLOY_TOKEN", "")
var aus, fehler bytes.Buffer
u := &umgebung{aus: &aus, fehler: &fehler, opt: &optionen{}}
if code := u.ausfuehren(context.Background(), []string{"login", server.URL, "--token", "pd_test", "--default-server", "rz1"}); code != 0 {
t.Fatalf("Exit %d: %s", code, fehler.String())
}
info, err := os.Stat(pfad)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("Rechte %v, erwartet 0600", info.Mode().Perm())
}
inhalt, _ := os.ReadFile(pfad)
if !strings.Contains(string(inhalt), `token = "pd_test"`) || !strings.Contains(string(inhalt), `server = "rz1"`) {
t.Errorf("Konfiguration:\n%s", inhalt)
}
if !strings.Contains(aus.String(), "Angemeldet als florian") {
t.Errorf("Ausgabe: %s", aus.String())
}
// Ein falsches Token wird nicht gespeichert.
aus.Reset()
code := u.ausfuehren(context.Background(), []string{"login", server.URL, "--token", "pd_falsch", "--instance", "labor"})
if code != ExitVerbindung {
t.Errorf("falsches Token: Exit %d", code)
}
inhalt2, _ := os.ReadFile(pfad)
if string(inhalt2) != string(inhalt) {
t.Error("falsches Token hat die Konfiguration verändert")
}
}
+328
View File
@@ -0,0 +1,328 @@
package befehle
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/mattn/go-runewidth"
"golang.org/x/term"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe"
)
var endzustaende = map[api.AuftragStatus]bool{
api.AuftragStatusERFOLGREICH: true,
api.AuftragStatusFEHLGESCHLAGEN: true,
api.AuftragStatusABGEBROCHEN: true,
api.AuftragStatusAUFGERAEUMT: true,
}
func wert(p *string) string {
if p == nil {
return ""
}
return *p
}
func zeitAus(p *string) (time.Time, bool) {
if p == nil {
return time.Time{}, false
}
t, err := time.Parse(time.RFC3339Nano, *p)
return t, err == nil
}
// phasenAus übernimmt die Phasen der API unverändert; nur die Laufzeit rechnet die CLI selbst.
func phasenAus(a api.Auftrag, jetzt time.Time) []ausgabe.Phase {
liste := make([]ausgabe.Phase, 0, len(a.Phasen))
for _, p := range a.Phasen {
phase := ausgabe.Phase{
Titel: p.Titel,
Status: string(p.Status),
Detail: wert(p.Detail),
Ursache: wert(p.Ursache),
Hinweis: wert(p.Hinweis),
}
if seit, ok := zeitAus(p.GestartetAm); ok && p.Status == api.PhaseStatusLAEUFT {
phase.Laufzeit = jetzt.Sub(seit)
}
liste = append(liste, phase)
}
return liste
}
func boxAus(a *api.Abschluss) ausgabe.Box {
box := ausgabe.Box{Ok: a.Ok, Titel: a.Titel, Dauer: wert(a.Dauer)}
for _, z := range a.Zeilen {
if len(z) == 2 {
box.Zeilen = append(box.Zeilen, [2]string{z[0], z[1]})
}
}
return box
}
// darsteller zeichnet einen Auftrag: live im Terminal (an Ort und Stelle neu), sonst zeilenweise.
type darsteller struct {
u *umgebung
s ausgabe.Stil
live bool
gezeichnet int // Bildschirmzeilen der letzten Zeichnung (live)
kopf bool
gedruckt int // Anzahl schon gedruckter Phasen (zeilenweise)
takt int
}
func (u *umgebung) neuerDarsteller() *darsteller {
return &darsteller{u: u, s: u.stil(), live: u.terminal && !u.opt.still && !u.opt.json}
}
func (d *darsteller) bildschirmzeilen(zeilen []string) int {
spalten := 0
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil {
spalten = w
}
n := 0
for _, z := range zeilen {
breite := runewidth.StringWidth(ohneFarben(z))
if spalten > 0 && breite > spalten {
n += (breite + spalten - 1) / spalten
} else {
n++
}
}
return n
}
func ohneFarben(text string) string {
var b strings.Builder
in := false
for _, r := range text {
switch {
case r == '\x1b':
in = true
case in && r == 'm':
in = false
case !in:
b.WriteRune(r)
}
}
return b.String()
}
// zeichnen gibt den Stand aus. `fertig`: der Auftrag ist beendet, die Box kommt dazu.
func (d *darsteller) zeichnen(a api.Auftrag, fertig bool) {
if d.u.opt.json {
return
}
phasen := phasenAus(a, time.Now())
if d.u.opt.still {
if fertig && a.Abschluss != nil {
fmt.Fprintln(d.u.aus, strings.Join(d.s.BoxZeilen(boxAus(a.Abschluss)), "\n"))
}
return
}
breite := ausgabe.Spaltenbreite(phasen)
if d.live {
sichtbar := make([]ausgabe.Phase, 0, len(phasen))
for _, p := range phasen {
if p.Status != "OFFEN" || fertig {
sichtbar = append(sichtbar, p)
}
}
zeilen := []string{d.s.Kopf(Version, a.Kopf, false), ""}
zeilen = append(zeilen, d.s.PhasenZeilenMit(sichtbar, breite, d.takt)...)
if fertig && a.Abschluss != nil {
zeilen = append(zeilen, "")
zeilen = append(zeilen, d.s.BoxZeilen(boxAus(a.Abschluss))...)
}
var b strings.Builder
if d.gezeichnet > 0 {
fmt.Fprintf(&b, "\x1b[%dF\x1b[J", d.gezeichnet)
}
b.WriteString(strings.Join(zeilen, "\n"))
b.WriteString("\n")
fmt.Fprint(d.u.aus, b.String())
d.gezeichnet = d.bildschirmzeilen(zeilen)
return
}
// Zeilenweise (Pipe, Datei, CI): jede Phase einmal, sobald sie abgeschlossen ist. Die Kopfzeile
// kommt nach der ersten Phase – dann stehen Node und Template fest.
abgeschlossen := 0
for _, p := range phasen {
if p.Status == "OFFEN" || p.Status == "LAEUFT" {
break
}
abgeschlossen++
}
if !d.kopf && (abgeschlossen > 0 || fertig) {
fmt.Fprintln(d.u.aus, d.s.Kopf(Version, a.Kopf, false))
fmt.Fprintln(d.u.aus)
d.kopf = true
}
if abgeschlossen > d.gedruckt {
for _, z := range d.s.PhasenZeilenMit(phasen[d.gedruckt:abgeschlossen], breite, 0) {
fmt.Fprintln(d.u.aus, z)
}
d.gedruckt = abgeschlossen
}
if fertig {
if rest := phasen[d.gedruckt:]; len(rest) > 0 {
for _, z := range d.s.PhasenZeilenMit(rest, breite, 0) {
fmt.Fprintln(d.u.aus, z)
}
}
if a.Abschluss != nil {
fmt.Fprintln(d.u.aus)
fmt.Fprintln(d.u.aus, strings.Join(d.s.BoxZeilen(boxAus(a.Abschluss)), "\n"))
}
}
}
// folgen hängt sich an einen Auftrag, bis er fertig ist. Fehlgeschlagen → Exit 1.
func (u *umgebung) folgen(ctx context.Context, c *api.Client, id string) (api.Auftrag, error) {
d := u.neuerDarsteller()
staende := make(chan api.Auftrag, 16)
stromFehler := make(chan error, 1)
strCtx, stopp := context.WithCancel(ctx)
defer stopp()
go func() {
defer close(staende)
stromFehler <- u.stroemen(strCtx, c, id, staende)
}()
var letzter *api.Auftrag
uhr := time.NewTicker(150 * time.Millisecond)
defer uhr.Stop()
for {
select {
case <-ctx.Done():
if d.live && letzter != nil {
d.zeichnen(*letzter, false)
}
fmt.Fprintln(u.fehler, d.s.Muted(fmt.Sprintf("\nAbgehängt – der Auftrag läuft weiter: proxdeploy status %s", id)))
return api.Auftrag{}, &exitFehler{code: 130, gemeldet: true}
case a, offen := <-staende:
if !offen {
if err := <-stromFehler; err != nil {
return api.Auftrag{}, err
}
if letzter == nil {
return api.Auftrag{}, errors.New("kein Stand erhalten")
}
return u.abschliessen(d, *letzter)
}
letzter = &a
if endzustaende[a.Status] {
return u.abschliessen(d, a)
}
d.zeichnen(a, false)
case <-uhr.C:
if d.live && letzter != nil {
d.takt++
d.zeichnen(*letzter, false)
}
}
}
}
func (u *umgebung) abschliessen(d *darsteller, a api.Auftrag) (api.Auftrag, error) {
d.zeichnen(a, true)
if u.opt.json {
if err := u.jsonAusgeben(a); err != nil {
return a, err
}
}
if a.Status != api.AuftragStatusERFOLGREICH {
return a, &exitFehler{code: ExitFehlschlag, gemeldet: true}
}
return a, nil
}
// stroemen liefert Stände per SSE; bricht der Strom ab, wird mit der letzten Event-ID neu
// verbunden. Klappt SSE dreimal hintereinander nicht (z. B. ein puffernder Proxy), wird alle 2 s
// abgefragt.
func (u *umgebung) stroemen(ctx context.Context, c *api.Client, id string, staende chan<- api.Auftrag) error {
letzteID := ""
fehlschlaege := 0
for fehlschlaege < 3 {
erhalten := false
err := c.Ereignisse(ctx, id, letzteID, func(e api.Ereignis) error {
if e.ID != "" {
letzteID = e.ID
}
switch e.Art {
case "stand":
var a api.Auftrag
if err := json.Unmarshal(e.Daten, &a); err != nil {
return fmt.Errorf("Stand nicht lesbar: %w", err)
}
erhalten = true
fehlschlaege = 0
select {
case staende <- a:
case <-ctx.Done():
return ctx.Err()
}
case "ende":
return api.Ende
}
return nil
})
switch {
case errors.Is(err, api.Ende):
return nil
case ctx.Err() != nil:
return nil
case api.IstAnmeldefehler(err):
return err
}
var apiFehler *api.Fehler
if errors.As(err, &apiFehler) {
return err
}
if !erhalten {
fehlschlaege++
}
select {
case <-time.After(time.Duration(fehlschlaege+1) * time.Second):
case <-ctx.Done():
return nil
}
}
for {
a, err := c.Auftrag(ctx, id)
if err != nil {
if ctx.Err() != nil {
return nil
}
var verbindung *api.VerbindungsFehler
if !errors.As(err, &verbindung) {
return err
}
} else {
select {
case staende <- a:
case <-ctx.Done():
return nil
}
if endzustaende[a.Status] {
return nil
}
}
select {
case <-time.After(2 * time.Second):
case <-ctx.Done():
return nil
}
}
}
+208
View File
@@ -0,0 +1,208 @@
package befehle
import (
"fmt"
"strings"
"github.com/mattn/go-runewidth"
"github.com/spf13/cobra"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe"
)
// tabelle setzt Spalten bündig; die Kopfzeile in Muted.
func (u *umgebung) tabelle(s ausgabe.Stil, kopf []string, zeilen [][]string) {
breiten := make([]int, len(kopf))
for i, k := range kopf {
breiten[i] = runewidth.StringWidth(k)
}
for _, z := range zeilen {
for i, zelle := range z {
if w := runewidth.StringWidth(zelle); w > breiten[i] {
breiten[i] = w
}
}
}
setzen := func(z []string, farbe func(string) string) string {
var b strings.Builder
for i, zelle := range z {
if i == len(z)-1 {
b.WriteString(farbe(zelle))
break
}
b.WriteString(farbe(zelle + strings.Repeat(" ", breiten[i]-runewidth.StringWidth(zelle)+2)))
}
return strings.TrimRight(b.String(), " ")
}
fmt.Fprintln(u.aus, setzen(kopf, s.Muted))
for _, z := range zeilen {
fmt.Fprintln(u.aus, setzen(z, s.Paper))
}
}
func janein(wert bool) string {
if wert {
return "ja"
}
return "–"
}
func (u *umgebung) serversBefehl() *cobra.Command {
return &cobra.Command{
Use: "servers",
Aliases: []string{"server"},
Short: "Proxmox-Server mit dem letzten Verbindungstest",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
c, _, err := u.client()
if err != nil {
return err
}
liste, err := c.ServerListe(cmd.Context())
if err != nil {
return err
}
if u.opt.json {
return u.jsonAusgeben(liste)
}
s := u.stil()
zeilen := [][]string{}
for _, sv := range liste {
status := "○ ungeprüft"
if sv.Erreichbar != nil {
status = map[bool]string{true: "✓ erreichbar", false: "✗ nicht erreichbar"}[*sv.Erreichbar]
}
if s.ASCII {
status = strings.NewReplacer("✓", "[OK]", "✗", "[!!]", "○", "[--]").Replace(status)
}
version, nodes := "–", "–"
if sv.PveVersion != nil {
version = *sv.PveVersion
}
if sv.LetzterTest != nil {
namen := []string{}
for _, k := range sv.LetzterTest.Knoten {
namen = append(namen, k.Name)
}
nodes = strings.Join(namen, ", ")
if sv.LetzterTest.Cluster != nil {
nodes = sv.LetzterTest.Cluster.Name + ": " + nodes
}
}
zeilen = append(zeilen, []string{sv.Name, sv.Titel, status, version, nodes})
}
if len(zeilen) == 0 {
fmt.Fprintln(u.aus, s.Muted("Noch keine Server – in der Web-GUI unter „Server“ anlegen."))
return nil
}
u.tabelle(s, []string{"NAME", "TITEL", "STATUS", "PVE", "NODES"}, zeilen)
return nil
},
}
}
func (u *umgebung) distrosBefehl() *cobra.Command {
var typ string
befehl := &cobra.Command{
Use: "distros",
Short: "Verfügbare Distributionen",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if typ != "" && typ != "vm" && typ != "lxc" {
return bedienfehler("--type ist vm oder lxc")
}
c, _, err := u.client()
if err != nil {
return err
}
liste, err := c.DistributionenListe(cmd.Context(), typ)
if err != nil {
return err
}
if u.opt.json {
return u.jsonAusgeben(liste)
}
zeilen := [][]string{}
for _, d := range liste {
zeilen = append(zeilen, []string{d.Id, d.Name, d.Familie, janein(d.Vm), janein(d.Lxc)})
}
u.tabelle(u.stil(), []string{"ID", "NAME", "FAMILIE", "VM", "LXC"}, zeilen)
return nil
},
}
befehl.Flags().StringVar(&typ, "type", "", "nur vm oder lxc")
return befehl
}
func (u *umgebung) profilesBefehl() *cobra.Command {
return &cobra.Command{
Use: "profiles",
Short: "Profile und ihre Variablen (für --profile und --var)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
c, _, err := u.client()
if err != nil {
return err
}
liste, err := c.ProfileListe(cmd.Context())
if err != nil {
return err
}
if u.opt.json {
return u.jsonAusgeben(liste)
}
zeilen := [][]string{}
for _, p := range liste {
variablen := []string{}
if p.Definition.Variables != nil {
for _, v := range *p.Definition.Variables {
name := p.Id + "." + v.Name
if v.Required {
name += "*"
}
variablen = append(variablen, name)
}
}
text := strings.Join(variablen, " ")
if text == "" {
text = "–"
}
zeilen = append(zeilen, []string{p.Id, p.Name, strings.Join(p.Familien, ","), text})
}
u.tabelle(u.stil(), []string{"ID", "NAME", "FAMILIEN", "VARIABLEN (* = Pflicht)"}, zeilen)
return nil
},
}
}
func (u *umgebung) presetsBefehl() *cobra.Command {
return &cobra.Command{
Use: "presets",
Aliases: []string{"vorlagen"},
Short: "Vorlagen (für --preset)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
c, _, err := u.client()
if err != nil {
return err
}
liste, err := c.VorlagenListe(cmd.Context())
if err != nil {
return err
}
if u.opt.json {
return u.jsonAusgeben(liste)
}
zeilen := [][]string{}
for _, v := range liste {
beschreibung := "–"
if v.Beschreibung != nil && *v.Beschreibung != "" {
beschreibung = *v.Beschreibung
}
zeilen = append(zeilen, []string{v.Name, v.Titel, beschreibung})
}
u.tabelle(u.stil(), []string{"NAME", "TITEL", "BESCHREIBUNG"}, zeilen)
return nil
},
}
}
+119
View File
@@ -0,0 +1,119 @@
package befehle
import (
"bufio"
"fmt"
"net/url"
"os"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/konfig"
)
func (u *umgebung) loginBefehl() *cobra.Command {
var token, benutzer, name, server string
var tage int
befehl := &cobra.Command{
Use: "login <url>",
Short: "An einer ProxDeploy-Instanz anmelden und das API-Token speichern",
Long: "Fragt Benutzername und Passwort ab, legt ein API-Token an und speichert es in\n" +
"~/.config/proxdeploy/config.toml (Rechte 0600). Mit --token wird ein vorhandenes\n" +
"Token (Web-GUI → Einstellungen → API-Tokens) geprüft und gespeichert.",
Example: " proxdeploy login https://proxdeploy.example\n proxdeploy login https://proxdeploy.labor --instance labor --token pd_…",
Args: func(_ *cobra.Command, argumente []string) error {
if len(argumente) != 1 {
return bedienfehler("URL fehlt: proxdeploy login <url>")
}
return nil
},
RunE: func(cmd *cobra.Command, argumente []string) error {
adresse := strings.TrimRight(strings.TrimSpace(argumente[0]), "/")
if !strings.Contains(adresse, "://") {
adresse = "https://" + adresse
}
geprueft, err := url.Parse(adresse)
if err != nil || geprueft.Host == "" || (geprueft.Scheme != "https" && geprueft.Scheme != "http") {
return bedienfehler("keine gültige URL: %s", argumente[0])
}
ctx := cmd.Context()
c := api.Neu(adresse, "", Version)
if token == "" {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return bedienfehler("ohne Terminal kein Passwort-Dialog – --token angeben")
}
leser := bufio.NewReader(os.Stdin)
if benutzer == "" {
fmt.Fprint(u.fehler, "Benutzername: ")
zeile, err := leser.ReadString('\n')
if err != nil {
return bedienfehler("Eingabe abgebrochen")
}
benutzer = strings.TrimSpace(zeile)
}
fmt.Fprint(u.fehler, "Passwort: ")
passwort, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(u.fehler)
if err != nil {
return bedienfehler("Eingabe abgebrochen")
}
if name == "" {
rechner, _ := os.Hostname()
name = "cli@" + rechner
}
anmeldung := api.TokenAnmeldung{Benutzername: benutzer, Passwort: string(passwort), Name: name}
if tage > 0 {
anmeldung.Tage = &tage
}
neu, err := c.TokenAnmelden(ctx, anmeldung)
if err != nil {
return err
}
token = neu.Token
}
c.Token = token
ich, err := c.Ich(ctx)
if err != nil {
return err
}
datei, pfad, err := konfig.Lesen()
if err != nil {
return err
}
instanz := konfig.Instanz{URL: adresse, Token: token, Server: server}
if alt, ok := datei.Instanzen[u.opt.instanz]; ok && server == "" {
instanz.Server = alt.Server
} else if u.opt.instanz == "" && server == "" {
instanz.Server = datei.Server
}
datei.Setzen(u.opt.instanz, instanz)
if err := konfig.Schreiben(pfad, datei); err != nil {
return fmt.Errorf("Konfiguration nicht geschrieben: %w", err)
}
if u.opt.json {
return u.jsonAusgeben(map[string]any{"benutzername": ich.Benutzername, "rolle": ich.Rolle, "version": ich.Version, "konfiguration": pfad})
}
s := u.stil()
symbol := "✓"
if s.ASCII {
symbol = "[OK]"
}
rolle := map[api.IchRolle]string{api.ADMIN: "Admin", api.OPERATOR: "Operator"}[ich.Rolle]
fmt.Fprintf(u.aus, "%s Angemeldet als %s %s\n", s.Teal(symbol), s.Fett(ich.Benutzername),
s.Muted(fmt.Sprintf("(%s) · ProxDeploy v%s · Token in %s", rolle, ich.Version, pfad)))
return nil
},
}
f := befehl.Flags()
f.StringVar(&token, "token", "", "vorhandenes API-Token (pd_…) statt Passwort")
f.StringVar(&benutzer, "user", "", "Benutzername (sonst Abfrage)")
f.StringVar(&name, "name", "", "Name des Tokens (Standard: cli@<rechner>)")
f.IntVar(&tage, "days", 0, "Token läuft nach so vielen Tagen ab (Standard: unbefristet)")
f.StringVar(&server, "default-server", "", "Standard für --server speichern")
return befehl
}
+109
View File
@@ -0,0 +1,109 @@
package befehle
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
func (u *umgebung) templateBefehl() *cobra.Command {
gruppe := &cobra.Command{Use: "template", Short: "Templates ansehen und bauen"}
var listeServer string
liste := &cobra.Command{
Use: "list",
Short: "ProxDeploy-Templates eines Servers",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
c, instanz, err := u.client()
if err != nil {
return err
}
if listeServer == "" {
listeServer = instanz.Server
}
if listeServer == "" {
return bedienfehler("--server fehlt")
}
templates, err := c.TemplatesListe(cmd.Context(), listeServer)
if err != nil {
return err
}
if u.opt.json {
return u.jsonAusgeben(templates)
}
zeilen := [][]string{}
for _, t := range templates {
profile := "–"
if len(t.Profile) > 0 {
profile = strings.Join(t.Profile, ", ")
}
zeilen = append(zeilen, []string{fmt.Sprint(t.Vmid), t.Name, t.Knoten, wert(t.DistributionId), profile})
}
u.tabelle(u.stil(), []string{"VMID", "NAME", "NODE", "DISTRIBUTION", "PROFILE"}, zeilen)
return nil
},
}
liste.Flags().StringVar(&listeServer, "server", "", "Proxmox-Server")
var server, distro, knoten, storage, bridge string
var vlan int
var profile []string
var nichtWarten bool
bauen := &cobra.Command{
Use: "build",
Short: "Template aus dem Cloud-Image bauen",
Example: " proxdeploy template build --server rz1 --distro debian-13\n proxdeploy template build --server rz1 --distro debian-13 --profile base,docker",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
c, instanz, err := u.client()
if err != nil {
return err
}
if server == "" {
server = instanz.Server
}
if server == "" {
return bedienfehler("--server fehlt")
}
if distro == "" {
return bedienfehler("--distro fehlt (siehe `proxdeploy distros --type vm`)")
}
anfrage := map[string]any{"distribution": distro}
gesetzt := cmd.Flags().Changed
if gesetzt("node") {
anfrage["knoten"] = knoten
}
if gesetzt("storage") {
anfrage["storage"] = storage
}
if gesetzt("bridge") {
anfrage["bridge"] = bridge
}
if gesetzt("vlan") {
anfrage["vlan"] = vlan
}
if gesetzt("profile") {
anfrage["profile"] = profile
}
angenommen, err := c.TemplateBauen(cmd.Context(), server, anfrage)
if err != nil {
return err
}
return u.angenommen(cmd.Context(), c, angenommen, nichtWarten)
},
}
f := bauen.Flags()
f.StringVar(&server, "server", "", "Proxmox-Server")
f.StringVar(&distro, "distro", "", "Distribution, z. B. debian-13")
f.StringVar(&knoten, "node", "", "Node (Standard: aus den Server-Einstellungen bzw. der erste)")
f.StringVar(&storage, "storage", "", "Storage für die Disk (Standard: aus den Server-Einstellungen)")
f.StringVar(&bridge, "bridge", "", "Bridge der Vorbereitungs-VM (Standard: vmbr0)")
f.IntVar(&vlan, "vlan", 0, "VLAN der Vorbereitungs-VM")
f.StringSliceVar(&profile, "profile", nil, "Profile einbacken, z. B. base,docker")
f.BoolVar(&nichtWarten, "no-wait", false, "nicht verfolgen, nur die Auftrags-ID ausgeben")
gruppe.AddCommand(liste, bauen)
return gruppe
}
+36
View File
@@ -0,0 +1,36 @@
[
{
"distribution": "debian-13",
"name": "web01",
"netz": {
"gateway": "10.0.20.1",
"ip": "10.0.20.15/24",
"modus": "statisch"
},
"profile": [
"base",
"docker"
],
"server": "rz1",
"speicherMb": 4096,
"typ": "vm"
},
{
"distribution": "debian-13",
"features": {
"nesting": true
},
"name": "mon01",
"profile": [
"base",
"zabbix-agent"
],
"server": "rz1",
"typ": "lxc",
"variablen": {
"zabbix-agent": {
"server": "zbx.example"
}
}
}
]
+3
View File
@@ -0,0 +1,3 @@
ID NAME FAMILIE VM LXC
debian-13 Debian 13 „Trixie“ debian ja ja
ubuntu-26.04 Ubuntu 26.04 LTS debian ja –
+3
View File
@@ -0,0 +1,3 @@
✗ Die Eingaben sind nicht vollständig oder ungültig.
--ip: Format: 10.0.20.15/24
--var zabbix-agent.server: Zabbix-Server fehlt.
+21
View File
@@ -0,0 +1,21 @@
{
"diskGb": 40,
"distribution": "debian-13",
"name": "web01",
"netz": {
"ip": "10.0.20.15/24",
"modus": "statisch",
"vlan": 20
},
"profile": [
"base",
"docker"
],
"server": "rz1",
"typ": "vm",
"variablen": {
"zabbix-agent": {
"server": "zbx.local"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013) · DRY-RUN
✓ Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
✓ Klonen full clone 9013 → 120 · Disk auf 40G
✓ Provisionierung base, docker
→ POST /nodes/pve01/qemu/9013/clone full=1 name=web01 newid=120
→ POST /nodes/pve01/qemu/120/status/start
Automatisch dazu: base
Nichts angelegt. Ohne --dry-run wird ausgerollt.
@@ -0,0 +1,6 @@
+------------------------------------------------+
| [!!] web01 fehlgeschlagen · 12s |
| Phase Klonen |
| Ursache Storage local-zfs ist voll. |
| Hinweis Anderen Storage wählen (--storage). |
+------------------------------------------------+
+16
View File
@@ -0,0 +1,16 @@
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013)
✓ Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
✓ Klonen full clone 9013 → 120 · Disk auf 40G
✓ cloud-init user florian · SSH-Key · Profile base, docker
✓ Start VM 120 läuft
✓ Guest-Agent antwortet nach 18s
✓ Provisionierung base, docker
┌──────────────────────────────────────┐
│ ● web01 bereit · 1m 54s │
│ VMID 120 │
│ IP 10.0.20.15 │
│ Profile base, docker │
│ SSH ssh florian@10.0.20.15 │
└──────────────────────────────────────┘
+314
View File
@@ -0,0 +1,314 @@
// Package befehle enthält die Befehle der CLI `proxdeploy`.
package befehle
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/signal"
"regexp"
"sort"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/konfig"
)
// Exit-Codes (docs/architektur.md, Abschnitt CLI).
const (
ExitOk = 0
ExitFehlschlag = 1 // Auftrag fehlgeschlagen oder abgebrochen
ExitBedienung = 2 // falscher Aufruf, ungültige Eingaben
ExitVerbindung = 3 // nicht erreichbar, nicht angemeldet
)
// Version wird beim Bauen gesetzt (-ldflags "-X …/befehle.Version=0.4.0").
var Version = "0.0.0-dev"
// Optionen, die für alle Befehle gelten.
type optionen struct {
keineFarbe bool
ascii bool
still bool
json bool
instanz string
}
// Umgebung eines Laufs – für Tests austauschbar.
type umgebung struct {
aus, fehler io.Writer
terminal bool
opt *optionen
}
func (u *umgebung) stil() ausgabe.Stil {
return ausgabe.Stil{Farbe: ausgabe.FarbmodusErmitteln(u.opt.keineFarbe, u.terminal), ASCII: u.opt.ascii}
}
// exitFehler trägt den Exit-Code; die Meldung ist schon ausgegeben, wenn `gemeldet` gesetzt ist.
type exitFehler struct {
code int
gemeldet bool
err error
}
func (e *exitFehler) Error() string {
if e.err != nil {
return e.err.Error()
}
return fmt.Sprintf("Exit %d", e.code)
}
func bedienfehler(format string, argumente ...any) error {
return &exitFehler{code: ExitBedienung, err: fmt.Errorf(format, argumente...)}
}
// Ausfuehren ist der Einstieg aus main.
func Ausfuehren() int {
u := &umgebung{
aus: os.Stdout,
fehler: os.Stderr,
terminal: term.IsTerminal(int(os.Stdout.Fd())),
opt: &optionen{},
}
ctx, stopp := signal.NotifyContext(context.Background(), os.Interrupt)
defer stopp()
return u.ausfuehren(ctx, os.Args[1:])
}
func (u *umgebung) ausfuehren(ctx context.Context, argumente []string) int {
wurzel := u.wurzel()
wurzel.SetArgs(argumente)
wurzel.SetOut(u.aus)
wurzel.SetErr(u.fehler)
err := wurzel.ExecuteContext(ctx)
if err == nil {
return ExitOk
}
return u.fehlerMelden(err)
}
func (u *umgebung) wurzel() *cobra.Command {
wurzel := &cobra.Command{
Use: "proxdeploy",
Short: "VMs und LXC-Container auf Proxmox VE ausrollen",
Long: "ProxDeploy – VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.\nEin unabhängiges Werkzeug, nicht von Proxmox Server Solutions.",
Version: Version,
SilenceUsage: true,
SilenceErrors: true,
}
wurzel.CompletionOptions.HiddenDefaultCmd = true
wurzel.SetUsageTemplate(hilfeVorlage)
wurzel.SetVersionTemplate("proxdeploy {{.Version}}\n")
wurzel.InitDefaultVersionFlag()
wurzel.Flags().Lookup("version").Usage = "Version zeigen"
wurzel.SetHelpCommand(&cobra.Command{Use: "help [befehl]", Short: "Hilfe zu einem Befehl", Hidden: true,
RunE: func(c *cobra.Command, argumente []string) error {
ziel, _, err := c.Root().Find(argumente)
if err != nil || ziel == nil {
ziel = c.Root()
}
return ziel.Help()
}})
wurzel.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
return &exitFehler{code: ExitBedienung, err: flagFehlerUebersetzen(err)}
})
f := wurzel.PersistentFlags()
f.BoolVar(&u.opt.keineFarbe, "no-color", false, "ohne Farben (auch mit NO_COLOR)")
f.BoolVar(&u.opt.ascii, "ascii", false, "Symbole als [OK], [..], [!!]")
f.BoolVarP(&u.opt.still, "quiet", "q", false, "nur Abschlussbox bzw. Fehler")
f.BoolVar(&u.opt.json, "json", false, "maschinenlesbare Ausgabe")
f.StringVar(&u.opt.instanz, "instance", "", "Instanz aus der Konfiguration ([instances.<name>])")
wurzel.AddCommand(
u.loginBefehl(),
u.serversBefehl(),
u.distrosBefehl(),
u.profilesBefehl(),
u.presetsBefehl(),
u.templateBefehl(),
u.ausrollBefehl("vm"),
u.ausrollBefehl("lxc"),
u.batchBefehl(),
u.statusBefehl(),
u.logsBefehl(),
u.cancelBefehl(),
)
hilfeEindeutschen(wurzel)
return wurzel
}
func hilfeEindeutschen(c *cobra.Command) {
c.InitDefaultHelpFlag()
if f := c.Flags().Lookup("help"); f != nil {
f.Usage = "Hilfe zu " + c.CommandPath()
}
for _, unter := range c.Commands() {
hilfeEindeutschen(unter)
}
}
// client baut den API-Client aus Konfiguration und Umgebung.
func (u *umgebung) client() (*api.Client, konfig.Instanz, error) {
datei, pfad, err := konfig.Lesen()
if err != nil {
return nil, konfig.Instanz{}, &exitFehler{code: ExitBedienung, err: err}
}
instanz, err := datei.Waehlen(u.opt.instanz)
if err != nil {
return nil, konfig.Instanz{}, &exitFehler{code: ExitBedienung, err: err}
}
if instanz.URL == "" || instanz.Token == "" {
return nil, instanz, &exitFehler{
code: ExitVerbindung,
err: fmt.Errorf("nicht angemeldet – zuerst `proxdeploy login <url>` (Konfiguration: %s)", pfad),
}
}
return api.Neu(instanz.URL, instanz.Token, Version), instanz, nil
}
// fehlerMelden gibt einen Fehler aus und liefert den Exit-Code.
func (u *umgebung) fehlerMelden(err error) int {
var ef *exitFehler
code := ExitBedienung
if errors.As(err, &ef) {
code = ef.code
if ef.gemeldet {
return code
}
if ef.err != nil {
err = ef.err
}
}
var apiFehler *api.Fehler
var verbindung *api.VerbindungsFehler
switch {
case errors.As(err, &apiFehler):
if apiFehler.Status == 401 || apiFehler.Status == 403 {
code = ExitVerbindung
} else if ef == nil {
code = ExitBedienung
}
case errors.As(err, &verbindung):
code = ExitVerbindung
case errors.Is(err, context.Canceled):
return 130
}
if u.opt.json {
inhalt := map[string]any{"nachricht": err.Error()}
if apiFehler != nil {
inhalt = map[string]any{"code": apiFehler.Code, "nachricht": apiFehler.Nachricht}
if apiFehler.Hinweis != "" {
inhalt["hinweis"] = apiFehler.Hinweis
}
if len(apiFehler.Felder) > 0 {
inhalt["felder"] = apiFehler.Felder
}
}
_ = json.NewEncoder(u.fehler).Encode(map[string]any{"fehler": inhalt})
return code
}
s := ausgabe.Stil{Farbe: ausgabe.FarbmodusErmitteln(u.opt.keineFarbe, term.IsTerminal(int(os.Stderr.Fd())) && u.terminal), ASCII: u.opt.ascii}
symbol := "✗"
if s.ASCII {
symbol = "[!!]"
}
fmt.Fprintln(u.fehler, s.Rot(symbol+" "+err.Error()))
if apiFehler != nil {
namen := make([]string, 0, len(apiFehler.Felder))
for n := range apiFehler.Felder {
namen = append(namen, n)
}
sort.Strings(namen)
for _, n := range namen {
fmt.Fprintln(u.fehler, " "+s.Paper(flagFuerFeld(n))+s.Muted(": "+apiFehler.Felder[n]))
}
if apiFehler.Hinweis != "" {
fmt.Fprintln(u.fehler, s.Muted(" → "+apiFehler.Hinweis))
}
}
return code
}
var (
ungueltigerWert = regexp.MustCompile(`^invalid argument "(.*)" for "(.*)" flag: .*$`)
unbekannteOption = regexp.MustCompile(`^unknown (?:shorthand )?flag: (.*)$`)
fehlenderWert = regexp.MustCompile(`^flag needs an argument: (.*)$`)
)
// flagFehlerUebersetzen: die Meldungen von pflag auf Deutsch.
func flagFehlerUebersetzen(err error) error {
text := err.Error()
if t := ungueltigerWert.FindStringSubmatch(text); t != nil {
return fmt.Errorf("ungültiger Wert „%s“ für %s", t[1], t[2])
}
if t := unbekannteOption.FindStringSubmatch(text); t != nil {
return fmt.Errorf("unbekannte Option %s – siehe --help", t[1])
}
if t := fehlenderWert.FindStringSubmatch(text); t != nil {
return fmt.Errorf("Option %s braucht einen Wert", t[1])
}
return err
}
// flagFuerFeld übersetzt ein Feld der API in das passende Flag, damit Feldfehler verständlich sind.
func flagFuerFeld(feld string) string {
zuordnung := map[string]string{
"server": "--server", "knoten": "--node", "distribution": "--distro", "name": "<name>",
"vmid": "--vmid", "tags": "--tags", "cores": "--cores", "speicherMb": "--memory",
"diskGb": "--disk", "storage": "--storage", "swapMb": "--swap", "netz.bridge": "--bridge",
"netz.vlan": "--vlan", "netz.ip": "--ip", "netz.gateway": "--gw", "netz.dns": "--dns",
"netz.suchdomain": "--search-domain", "zugang.benutzer": "--user",
"zugang.sshSchluessel": "--ssh-key", "profile": "--profile", "vorlage": "--preset",
}
if flag, ok := zuordnung[feld]; ok {
return flag
}
for praefix, flag := range zuordnung {
if strings.HasPrefix(feld, praefix+".") {
return flag
}
}
if rest, ok := strings.CutPrefix(feld, "variablen."); ok {
return "--var " + rest
}
return feld
}
// jsonAusgeben schreibt eingerücktes JSON (--json).
func (u *umgebung) jsonAusgeben(wert any) error {
kodierer := json.NewEncoder(u.aus)
kodierer.SetIndent("", " ")
kodierer.SetEscapeHTML(false)
return kodierer.Encode(wert)
}
// Deutsche Fassung der Cobra-Hilfe.
const hilfeVorlage = `Aufruf:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [befehl]{{end}}{{if gt (len .Aliases) 0}}
Auch: {{.NameAndAliases}}{{end}}{{if .HasExample}}
Beispiele:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}
Befehle:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Optionen:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Für alle Befehle:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableSubCommands}}
Mehr zu einem Befehl: {{.CommandPath}} [befehl] --help{{end}}
`
+131
View File
@@ -0,0 +1,131 @@
// Package konfig liest und schreibt ~/.config/proxdeploy/config.toml.
//
// url = "https://proxdeploy.example"
// token = "pd_…"
// server = "rz1" # optional: Standard für --server
//
// [instances.labor] # weitere Instanzen, Auswahl mit --instance labor
// url = "https://proxdeploy.labor"
// token = "pd_…"
//
// PROXDEPLOY_URL, PROXDEPLOY_TOKEN und PROXDEPLOY_INSTANCE gehen der Datei vor.
package konfig
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"github.com/BurntSushi/toml"
)
type Instanz struct {
URL string `toml:"url"`
Token string `toml:"token"`
Server string `toml:"server,omitempty"`
}
type Datei struct {
Instanz
Instanzen map[string]Instanz `toml:"instances,omitempty"`
}
// Pfad: $XDG_CONFIG_HOME/proxdeploy/config.toml, sonst ~/.config/…; unter Windows %AppData%.
func Pfad() (string, error) {
if eigener := os.Getenv("PROXDEPLOY_CONFIG"); eigener != "" {
return eigener, nil
}
basis := os.Getenv("XDG_CONFIG_HOME")
if basis == "" {
if runtime.GOOS == "windows" {
var err error
if basis, err = os.UserConfigDir(); err != nil {
return "", err
}
} else {
heim, err := os.UserHomeDir()
if err != nil {
return "", err
}
basis = filepath.Join(heim, ".config")
}
}
return filepath.Join(basis, "proxdeploy", "config.toml"), nil
}
// Lesen liefert die Datei oder eine leere, wenn es sie noch nicht gibt.
func Lesen() (Datei, string, error) {
pfad, err := Pfad()
if err != nil {
return Datei{}, "", err
}
var d Datei
if _, err := toml.DecodeFile(pfad, &d); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return Datei{}, pfad, nil
}
return Datei{}, pfad, fmt.Errorf("%s: %w", pfad, err)
}
return d, pfad, nil
}
// Waehlen: die gewünschte Instanz (leer = Hauptabschnitt), überlagert von der Umgebung.
func (d Datei) Waehlen(name string) (Instanz, error) {
if name == "" {
name = os.Getenv("PROXDEPLOY_INSTANCE")
}
i := d.Instanz
if name != "" {
gefunden, ok := d.Instanzen[name]
if !ok {
return Instanz{}, fmt.Errorf("Instanz %q steht nicht in der Konfiguration", name)
}
i = gefunden
}
if url := os.Getenv("PROXDEPLOY_URL"); url != "" {
i.URL = url
}
if token := os.Getenv("PROXDEPLOY_TOKEN"); token != "" {
i.Token = token
}
return i, nil
}
// Setzen trägt eine Instanz ein (leer = Hauptabschnitt).
func (d *Datei) Setzen(name string, i Instanz) {
if name == "" {
d.Instanz = i
return
}
if d.Instanzen == nil {
d.Instanzen = map[string]Instanz{}
}
d.Instanzen[name] = i
}
// Schreiben legt die Datei mit Rechten 0600 an – sie enthält das Token.
func Schreiben(pfad string, d Datei) error {
if err := os.MkdirAll(filepath.Dir(pfad), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(pfad), ".config-*.toml")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return err
}
if err := toml.NewEncoder(tmp).Encode(d); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), pfad)
}
+51
View File
@@ -0,0 +1,51 @@
package konfig
import (
"os"
"path/filepath"
"testing"
)
func TestWaehlenUndUmgebung(t *testing.T) {
pfad := filepath.Join(t.TempDir(), "proxdeploy", "config.toml")
t.Setenv("PROXDEPLOY_CONFIG", pfad)
t.Setenv("PROXDEPLOY_URL", "")
t.Setenv("PROXDEPLOY_TOKEN", "")
t.Setenv("PROXDEPLOY_INSTANCE", "")
var d Datei
d.Setzen("", Instanz{URL: "https://a", Token: "pd_a", Server: "rz1"})
d.Setzen("labor", Instanz{URL: "https://b", Token: "pd_b"})
if err := Schreiben(pfad, d); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(pfad); info.Mode().Perm() != 0o600 {
t.Errorf("Rechte %v", info.Mode().Perm())
}
gelesen, _, err := Lesen()
if err != nil {
t.Fatal(err)
}
if i, _ := gelesen.Waehlen(""); i.URL != "https://a" || i.Server != "rz1" {
t.Errorf("Hauptabschnitt: %+v", i)
}
if i, _ := gelesen.Waehlen("labor"); i.Token != "pd_b" {
t.Errorf("labor: %+v", i)
}
if _, err := gelesen.Waehlen("gibtsnicht"); err == nil {
t.Error("unbekannte Instanz ohne Fehler")
}
t.Setenv("PROXDEPLOY_INSTANCE", "labor")
t.Setenv("PROXDEPLOY_TOKEN", "pd_env")
if i, _ := gelesen.Waehlen(""); i.URL != "https://b" || i.Token != "pd_env" {
t.Errorf("Umgebung: %+v", i)
}
}
func TestLesenOhneDatei(t *testing.T) {
t.Setenv("PROXDEPLOY_CONFIG", filepath.Join(t.TempDir(), "fehlt.toml"))
d, _, err := Lesen()
if err != nil || d.URL != "" {
t.Errorf("%v %+v", err, d)
}
}
+12
View File
@@ -0,0 +1,12 @@
// proxdeploy – die CLI zu ProxDeploy. Spricht ausschließlich die REST-API der Instanz.
package main
import (
"os"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/befehle"
)
func main() {
os.Exit(befehle.Ausfuehren())
}
+13 -3
View File
@@ -500,9 +500,19 @@ TTY-Erkennung, 24-bit-Farben und SSE. Der Nachteil (keine geteilten TS-Typen) wi
`openapi.json` generierten Client (`oapi-codegen`) aufgefangen; ein CI-Schritt prüft, dass der `openapi.json` generierten Client (`oapi-codegen`) aufgefangen; ein CI-Schritt prüft, dass der
generierte Code aktuell ist. Bibliotheken: `cobra` (Befehle), `BurntSushi/toml`, `golang.org/x/term`. generierte Code aktuell ist. Bibliotheken: `cobra` (Befehle), `BurntSushi/toml`, `golang.org/x/term`.
- Konfig `~/.config/proxdeploy/config.toml` (`url`, `token`, optional `[profiles.<name>]` für mehrere - Konfig `~/.config/proxdeploy/config.toml` (`url`, `token`, optional `server` als Standard und
Instanzen), Rechte 0600; `PROXDEPLOY_URL`/`PROXDEPLOY_TOKEN` überschreiben. `[instances.<name>]` für mehrere Instanzen – nicht `profiles`, das hieße wie `--profile`), Rechte
- `login <url>` fragt Benutzername/Passwort ab, legt ein Token an und speichert es (oder `--token`). 0600; `PROXDEPLOY_URL`/`PROXDEPLOY_TOKEN`/`PROXDEPLOY_INSTANCE` überschreiben.
- **Kurze Anfragen:** Die CLI schickt nur gesetzte Flags. Der Server füllt nach der Vorlage Storage,
Bridge, Node (Server-Standards bzw. Verbindungstest), Benutzer (eigener Name) und SSH-Schlüssel
(hinterlegte) – `anfrageErgaenzen` in `src/server/services/ausrollen.ts`.
- **Abschlussbox** (`abschluss` im Auftrag) berechnet der Server; Web und CLI zeigen dieselben Zeilen.
- **Generierter Code:** Antwortschemas als zod in `src/lib/schemas/api.ts`, daraus `openapi.json`
(`pnpm openapi`) und daraus die Go-Typen (oapi-codegen, nur Modelle). Anfragen gehen als Map hinaus
(sonst schickte Go leere Pflichtfelder statt sie wegzulassen). `pnpm cli:pruefen` schlägt fehl,
wenn etwas veraltet ist; `tests/api-schemas.test.ts` prüft echte Antworten gegen die Schemas.
- `login <url>` fragt Benutzername/Passwort ab, tauscht sie über `POST /api/v1/auth/token` gegen ein
Token und speichert es (oder `--token`). Tokens: `pd_` + 256 Bit, gespeichert nur als SHA-256.
- Ausgabe exakt nach Vorgabe; Renderer in `cli/internal/ausgabe/` mit Golden-File-Tests für Farbe, - Ausgabe exakt nach Vorgabe; Renderer in `cli/internal/ausgabe/` mit Golden-File-Tests für Farbe,
`--no-color`, `--ascii`, `--quiet`, `--json`, `--dry-run`. Breite der Phasenspalte aus dem längsten `--no-color`, `--ascii`, `--quiet`, `--json`, `--dry-run`. Breite der Phasenspalte aus dem längsten
Phasennamen, Box-Breite aus dem Inhalt (Unicode-Breite beachtet). Phasennamen, Box-Breite aus dem Inhalt (Unicode-Breite beachtet).
+17 -9
View File
@@ -1,23 +1,31 @@
# #005 Adapter debian und Profil-Engine # #005 Adapter debian und Profil-Engine
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature, engine **Labels:** feature, engine
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min
## Ziel ## Ziel
Interface `FamilienAdapter`, Umsetzung `debian`, Profil `base` wird zu einem idempotenten Skript. Interface `FamilienAdapter`, Umsetzung `debian`, Profile werden zu einem idempotenten Skript.
## Aufgaben ## Aufgaben
- [ ] Interface + Registry; `debian`-Adapter (apt, systemd, deb822, locale, keyboard, sshd_config.d) - [x] Interface + Registry; `debian`-Adapter (apt mit Lock-Timeout, deb822, systemd, locale, keyboard, sshd_config.d, sudoers mit visudo-Prüfung)
- [ ] Profilauflösung (requires, Reihenfolge, Variablen, Shell-Quoting) - [x] Profilauflösung (requires transitiv, Zyklen, Variablen mit Typen und Standardwerten, Familien-Prüfung)
- [ ] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, sudo-User, SSH-Härtung, chrony, fail2ban optional) - [x] Platzhalter `{{ var.x }}`, `{{ os.* }}`, `{{ guest.* }}`, `a ?? b`; in `run` jeder Wert shell-gequotet
- [ ] Snapshot-Tests der erzeugten Skripte; shellcheck auf die Ausgabe - [x] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, chrony, SSH-Härtung, fail2ban optional)
- [x] Tests der erzeugten Skripte; shellcheck auf die Ausgabe aller Distributionen
- [x] `pnpm test:container`: Skripte wirklich ausführen (Debian 12/13, Ubuntu 22.04/24.04/26.04), Ergebnis prüfen, zweiter Lauf auf Idempotenz
- [x] `docs/katalog.md`: neue Distribution, neuer Adapter, eigenes Profil
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code - [x] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code
- [x] base + docker + zabbix-agent laufen auf allen fünf Distributionen durch und sind idempotent (Container, 2026-10-12)
## Abhängigkeiten ## Abhängigkeiten
#004 #004
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **Fund im Container:** frische Images haben keine Paketlisten – der Vorspann macht jetzt immer `apt-get update`, sonst scheiterte schon die Benutzeranlage an „Unable to locate package sudo“.
- **Zeitzone, Locale und Tastatur werden gegen strenge Muster geprüft**, bevor sie ins Skript kommen (shellcheck fand verschachteltes Quoting im sed-Ausdruck).
- **SSH-Härtung läuft immer zuletzt:** läuft die Provisionierung per SSH als root (LXC ohne Node-SSH), schlösse sie sich sonst selbst aus.
- **Marken `::proxdeploy:: profil <id>`** im Skript zeigen im Live-Log, welches Profil gerade läuft.
- Dateiinhalte gehen base64-kodiert ins Skript – beliebige Zeichen kommen unverändert an.
+17 -10
View File
@@ -1,25 +1,32 @@
# #006 Aufträge, Phasen, worker und Live-Fortschritt # #006 Aufträge, Phasen, worker und Live-Fortschritt
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature, engine **Labels:** feature, engine
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 15 min
## Ziel ## Ziel
Hintergrund-Jobs mit Phasen in der DB, live per SSE in der Web-GUI. Hintergrund-Jobs mit Phasen in der DB, live per SSE in der Web-GUI.
## Aufgaben ## Aufgaben
- [ ] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung` - [x] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung`
- [ ] Phasen-Runner mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis - [x] Phasen-Runner (frameworkfrei) mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis, Abbruch zwischen Phasen
- [ ] SSE-Endpunkt mit Last-Event-ID, Polling-Fallback - [x] SSE-Endpunkt mit Last-Event-ID, Herzschlag, `X-Accel-Buffering: no`; Polling-Fallback im Client
- [ ] VMID-Vergabe mit Advisory-Lock, Test mit parallelen Aufträgen - [x] VMID-Vergabe mit Advisory-Lock, Test mit zehn parallelen Reservierungen
- [ ] Live-Ansicht als Terminal-Karte, Detail-Log, Ergebnis-Karte - [x] Live-Ansicht als Terminal-Karte (Aufbau der CLI-Ausgabe), Detail-Log, Ergebnis-Box; Verlaufsseite
- [x] Verwaiste Aufträge nach worker-Neustart als fehlgeschlagen markieren; doppelt gelieferte Jobs laufen nur einmal
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test) - [x] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test)
- [ ] Neuladen der Seite mitten im Auftrag zeigt den vollständigen Stand - [x] Neuladen der Seite mitten im Auftrag zeigt den vollständigen Stand (Stand kommt immer ganz, Log ab Last-Event-ID)
- [x] Live geprüft mit einem echten Template-Bau auf pve-demo (#007)
## Abhängigkeiten ## Abhängigkeiten
#003 #003
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **ssh2 und pg sind CommonJS:** benannte Importe gehen im Next-Bundle, aber nicht im worker unter reinem Node-ESM – jetzt Default-Importe.
- **Der worker lädt `.env` selbst** (`process.loadEnvFile`, falls vorhanden); im Container kommt die Umgebung von außen.
- **Kein automatisches Wiederholen** (`retryLimit: 0`): halb angelegte Gäste soll ein Mensch ansehen.
- **Abfrageschleifen** (Task-Status, VM-Status) erscheinen im Detail-Log nur bei Fehlern.
- **Kopfzeile** (`Auftrag.kopf`) setzt die Engine – Web und CLI zeigen denselben Text.
- Client-Komponenten importieren nichts aus `src/server` (struktureller Test in `tests/zugang.test.ts`).
+15 -11
View File
@@ -1,26 +1,30 @@
# #007 Template-Bau für VMs # #007 Template-Bau für VMs
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature, proxmox **Labels:** feature, proxmox
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 12 min
## Ziel ## Ziel
Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren. Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren.
## Aufgaben ## Aufgaben
- [ ] Weg A (≥ 8.4): `download-url` (content=import, checksum) + `import-from` per Volume-ID - [x] Weg A (≥ 8.4): `download-url` (content=import, checksum) + `import-from` per Volume-ID
- [ ] Weg B (SSH): Download + Prüfung auf dem Node, Import per `qm importdisk`/`import-from` - [x] Weg B (SSH): Download + Prüfung auf dem Node, Import per `qm importdisk`/`import-from`
- [ ] VMID: nächste freie im Template-Bereich (Entscheidung 2026-10-12) - [x] VMID: nächste freie im Template-Bereich (Entscheidung 2026-10-12)
- [ ] Hardware: q35, OVMF+EFI-Disk (Standard) oder SeaBIOS, virtio-scsi-single, discard/ssd/iothread, CPU host, serielle Konsole, agent=1, cloud-init-Laufwerk - [x] Hardware: q35, OVMF+EFI-Disk (Standard) oder SeaBIOS, virtio-scsi-single, discard/ssd/iothread, CPU host, serielle Konsole, agent=1, cloud-init-Laufwerk
- [ ] Guest-Agent einbauen: Snippet (SSH) oder Bau-Adresse + SSH in die VM - [x] Guest-Agent einbauen per Snippet (SSH); Weg über Bau-Adresse ohne SSH → #021
- [ ] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar - [x] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar
- [ ] Singleton je Server+Distribution - [x] Singleton je Server+Distribution
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Debian-13-Template auf echtem Proxmox gebaut (8.x und 9.x, sobald verfügbar) - [x] Debian-13-Template auf pve-demo (PVE 9.2.2) gebaut, Auftrag t-8zca3, 5 min 26 s; 8.x folgt im Feldtest (#020)
## Abhängigkeiten ## Abhängigkeiten
#005, #006 #005, #006
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **Image-Dateiname mit Prüfsummen-Präfix** (`proxdeploy-debian-13-f46f0671a6e5.qcow2`): ein neues „latest“-Image wird neu geladen, ein gleiches wiederverwendet.
- **Proxmox prüft die Prüfsumme selbst** (`download-url` mit `checksum`); ProxDeploy holt sie vorher aus SHA512SUMS/SHA256SUMS.
- **Vorbereitungsstart mit vendor-data:** qemu-guest-agent, Maschinen-ID leeren (sonst teilen sich Klone DHCP-Client-ID), `cloud-init clean`, poweroff. Snippet und `cicustom` werden auch nach einem Fehler entfernt.
- **UEFI mit `pre-enrolled-keys=1`** (Secure Boot an) – Debian 13 bootet damit; Proxmox setzt selbst `ms-cert=2023k`.
- Auf dem Testhost dauerte der Vorbereitungsstart ~70 s.
+16 -9
View File
@@ -1,24 +1,31 @@
# #008 Deploy-Assistent und VM ausrollen # #008 Deploy-Assistent und VM ausrollen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** feature **Labels:** feature
**Geschätzt:** 10 h **Tatsächlich:** – **Geschätzt:** 10 h **Tatsächlich:** 0 h 36 min
## Ziel ## Ziel
Debian-13-VM aus Template vollständig unattended, Assistent mit Dry-Run und CLI-Befehl. Debian-13-VM aus Template vollständig unattended, Assistent mit Dry-Run und CLI-Befehl.
## Aufgaben ## Aufgaben
- [ ] Planer VM (Phasen + geplante API-Aufrufe), `POST /auftraege/plan` - [x] Anfrageschema für Web, CLI, Batch und Vorlagen (`src/lib/schemas/ausrollen.ts`), CLI-Befehl daraus
- [ ] Ausführung: Klonen, Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Agent, Provisionierung per Agent - [x] Ablauf VM: Prüfungen (Node, Template, Storage, Bridge/VLAN, VMID), Klonen (mit einem Wiederholversuch bei belegter VMID), Hardware + Disk vergrößern, cloud-init (sshkeys korrekt kodiert, ciupgrade=0), Start, Guest-Agent (ping, IP), Provisionierung per Agent mit Live-Fortschritt
- [ ] Fehlendes Template → automatisch bauen anbieten - [x] Dry-Run über denselben Ablauf mit Trocken-Client: `POST /api/v1/auftraege/plan`
- [ ] Assistent mit 9 Schritten, URL-Zustand, Handy-tauglich - [x] Fehlendes Template → „Template vorher bauen“ im Assistenten, läuft als Phase im selben Auftrag
- [ ] Aufräumen nach Bestätigung - [x] Assistent mit 9 Schritten, Zustand in der URL, jederzeit zurückspringbar, Handy-tauglich
- [ ] Aufräumen nach Bestätigung → folgt mit #014 (Verlauf); bis dahin werden angelegte VMIDs je Auftrag festgehalten
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] VM mit base-Profil per SSH erreichbar; Fehlerfall zeigt Ursache + Hinweis - [x] VM mit base-Profil per SSH erreichbar: pd-test01 (base, docker) per API, pd-test02 (base, zabbix-agent) über den Assistenten auf pve-demo, 2026-10-12
- [x] Fehlerfall zeigt Ursache + Hinweis (Tests: VLAN auf nicht VLAN-fähiger Bridge, Agent antwortet nicht, Profil scheitert mit Code)
## Abhängigkeiten ## Abhängigkeiten
#007 #007
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **Fund beim ersten echten Deploy:** `agent/file-write` scheitert an Nicht-ASCII („Wide character in subroutine entry“). ProxDeploy kodiert die Skriptstücke jetzt selbst als base64 (`encode=0`) und teilt nach Bytes.
- **Die Agent-Endpunkte antworten unterschiedlich** (exec → `{pid}`, exec-status/file-read direkt, ping/network-get-interfaces → `{result}`).
- **Fortschritt der Provisionierung** aus der Logdatei im Gast (`/var/log/proxdeploy-provisionierung.log`, per file-read), Marken je Profil.
- **Klon auf anderen Node** nur bei geteiltem Storage; sonst sagt die Prüfung, welchen Node man wählen soll.
- Zwei Felder hießen „Server“ (Umschalter und Assistent) – der Umschalter heißt jetzt „Aktiver Server“.
- Testgäste auf pve-demo: VM 100 `pd-test01`, VM 101 `pd-test02` (Tag `proxdeploy-test`).
+11 -6
View File
@@ -1,21 +1,26 @@
# #009 LXC-Container ausrollen # #009 LXC-Container ausrollen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature, proxmox **Labels:** feature, proxmox
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 30 min
## Ziel ## Ziel
LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container. LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container.
## Aufgaben ## Aufgaben
- [ ] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH) - [x] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH)
- [ ] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen) - [x] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] LXC mit base auf echtem Proxmox, einmal mit und einmal ohne Node-SSH - [x] Auf pve-demo: pd-ct01 (Debian 13, base+docker, pct exec, docker hello-world im unprivilegierten CT mit nesting) und pd-ct02 (Ubuntu 24.04, base, SSH in den Container, Bereitstellungsschlüssel danach entfernt)
## Abhängigkeiten ## Abhängigkeiten
#008 #008
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **Fund:** chrony läuft in unprivilegierten Containern nicht (keine CAP_SYS_TIME). Profile kennen jetzt die festen Bedingungen `if: vm` / `if: lxc`; base installiert chrony nur in VMs.
- **Fund:** Ubuntu 24.04 startet sshd per Socket, `/run/sshd` fehlt bis zur ersten Verbindung – `sshd -t` scheiterte. Der Adapter legt das Verzeichnis vorher an.
- **Docker im unprivilegierten Container braucht nur nesting**, kein keyctl (geprüft auf PVE 9.2.2 mit Debian 13).
- **keyctl** darf per Token nicht gesetzt werden; mit Node-SSH setzt ProxDeploy es per `pct set`, ohne lehnt die Prüfung ab.
- **Bereitstellungsschlüssel** (eigener ed25519, verschlüsselt in den Einstellungen) nur für den Weg ohne Node-SSH; die Schlüssel des Benutzers gehen nie an root.
- Bei mehreren passenden Container-Templates wird das neueste aus `aplinfo` geladen, wenn lokal nur ein älteres liegt.
+8 -6
View File
@@ -1,21 +1,23 @@
# #010 Ubuntu 22.04, 24.04 und 26.04 # #010 Ubuntu 22.04, 24.04 und 26.04
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature, katalog **Labels:** feature, katalog
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 05 min
## Ziel ## Ziel
Ubuntu als VM und LXC über den debian-Adapter. Ubuntu als VM und LXC über den debian-Adapter.
## Aufgaben ## Aufgaben
- [ ] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades) - [x] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades)
- [ ] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12) - [x] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Je eine Ubuntu-VM und ein Ubuntu-LXC ausgerollt - [x] Auf pve-demo: pd-ubu01 (Ubuntu 24.04 VM, Template 9001 im selben Auftrag gebaut) und pd-ct02 (Ubuntu 24.04 LXC)
## Abhängigkeiten ## Abhängigkeiten
#009 #009
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - Der Adapter brauchte für Ubuntu nur eine Änderung: `/run/sshd` anlegen (sshd per Socket, #009). Locale-Erzeugung unterscheidet Debian/Ubuntu.
- Ubuntu 22.04 und 26.04 laufen im Containertest (`pnpm test:container`); auf dem Testhost geprüft ist 24.04.
- Der Voll-Klon dauerte auf pve-demo ~9 min (3,5 GB, Storage ~3 MB/s) – die Phase „Klonen“ zeigt jetzt den Fortschritt aus dem Task-Log.
+10 -7
View File
@@ -1,22 +1,25 @@
# #011 Profile docker und zabbix-agent, Variablen, Editor # #011 Profile docker und zabbix-agent, Variablen, Editor
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature **Labels:** feature
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 0 h 10 min
## Ziel ## Ziel
Kombinierbare Profile mit Variablen; eigene Profile im YAML-Editor. Kombinierbare Profile mit Variablen; eigene Profile im YAML-Editor.
## Aufgaben ## Aufgaben
- [ ] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive) - [x] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive)
- [ ] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie - [x] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie
- [ ] YAML-Editor mit Validierung und Fehlerstelle - [x] YAML-Editor mit Validierung und Fehlerstelle
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] docker run hello-world im ausgerollten Gast erfolgreich - [x] docker hello-world in pd-test01 (VM) und pd-ct01 (LXC); zabbix-agent2 aktiv in pd-test02
## Abhängigkeiten ## Abhängigkeiten
#005 #005
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - Editor prüft beim Tippen (400 ms Pause) gegen dasselbe Schema wie der Katalog und zeigt das erzeugte Skript.
- Katalogprofile sind schreibgeschützt; „Als Kopie bearbeiten“ legt `<id>-eigen` an. Eigene Profile mit Katalog-id werden abgewiesen.
- Profile anlegen dürfen nur Admins – sie führen Befehle als root aus.
- Löschen verweigert, solange ein anderes Profil es per `requires` braucht.
+8 -5
View File
@@ -1,20 +1,23 @@
# #012 Vorlagen (Presets) # #012 Vorlagen (Presets)
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** feature **Labels:** feature
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 11 min
## Ziel ## Ziel
Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar. Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar.
## Aufgaben ## Aufgaben
- [ ] CRUD, „aus diesem Auftrag als Vorlage speichern“, Beispiel „Docker-Host klein“ - [x] CRUD, „Als Vorlage speichern“ im Assistenten, Beispiel „Docker-Host klein“ (einmalig angelegt)
- [x] Vorlagen-Auswahl im Assistenten, `?vorlage=` von der Vorlagenseite, `vorlage` in der API (für `--preset` der CLI)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] `--preset` in API und Assistent identisch aufgelöst - [x] `--preset` in API und Assistent identisch aufgelöst
## Abhängigkeiten ## Abhängigkeiten
#008 #008
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - Eine Vorlage liegt **unter** der Anfrage: was die Anfrage sagt, gewinnt; netz/features/variablen feldweise, Listen ersetzt.
- Name, VMID, IP und SSH-Schlüssel gehören nicht in eine Vorlage (Schema `strict`).
- Operatoren dürfen Vorlagen pflegen (Recht `vorlagenPflegen`).
+20 -8
View File
@@ -1,23 +1,35 @@
# #013 CLI proxdeploy (Go) # #013 CLI proxdeploy (Go)
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.4.0 **Meilenstein:** v0.4.0
**Labels:** feature, cli **Labels:** feature, cli
**Geschätzt:** 12 h **Tatsächlich:** – **Geschätzt:** 12 h **Tatsächlich:** 0 h 42 min
## Ziel ## Ziel
Alle Befehle aus der Vorgabe, Ausgabe exakt im Format der Vorgabe. Alle Befehle aus der Vorgabe, Ausgabe exakt im Format der Vorgabe.
## Aufgaben ## Aufgaben
- [ ] OpenAPI aus zod, Go-Client per oapi-codegen, Prüfung auf Aktualität - [x] API-Tokens (`pd_…`, nur SHA-256 gespeichert, Ablauf, Widerruf) mit Bearer-Anmeldung, `POST /auth/token` für `login`, Verwaltung in den Einstellungen
- [ ] login, servers, distros, profiles, vm, lxc, template build, batch, status, logs --follow - [x] Kurze Anfragen: Server füllt Storage, Bridge, Node, Benutzer, Schlüssel (`anfrageErgaenzen`), auch beim Template-Bau
- [ ] Renderer: Farbe 24-bit/256/aus, --ascii, --quiet, --json, --dry-run; Golden-Tests - [x] Abschlussbox serverseitig (`abschluss`), Web und CLI zeigen dieselben Zeilen
- [ ] Release-Builds für linux/darwin/windows × amd64/arm64 - [x] OpenAPI aus zod (`/api/v1/openapi.json`, `pnpm openapi`), Go-Typen per oapi-codegen, `pnpm cli:pruefen`
- [x] login, servers, distros, profiles, presets, vm, lxc, template list/build, batch, status, logs --follow, cancel
- [x] Renderer: Farbe 24-bit/256/aus, --ascii, --quiet, --json, --dry-run; Golden-Tests
- [x] Release-Builds für linux/darwin/windows × amd64/arm64 (`cli/bauen.sh`)
- [x] Abnahme auf pve-demo (VM 107 + CT 108 per batch, parallel)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Ausgabe entspricht zeichengenau dem Mockup (Golden-File) - [x] Ausgabe entspricht zeichengenau dem Mockup (`cli/internal/ausgabe/testdata/mockup.txt`, von Hand aus der Vorgabe übernommen)
## Abhängigkeiten ## Abhängigkeiten
#008, #012 #008, #012
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - **Anfragen als Map, Antworten typisiert.** oapi-codegen macht Pflichtfelder zu Nicht-Zeigern; Go
schickte dann `"storage": ""` und überschriebe Vorlagenwerte. Deshalb gehen nur gesetzte Flags hinaus.
- **`[instances.<name>]`** statt `[profiles.<name>]` in der Konfiguration – „profile“ ist schon `--profile`.
- **Strg+C hängt nur ab**, der Auftrag läuft weiter; Abbruch ist `proxdeploy cancel`.
- **Ohne Terminal** (Pipe, CI) kein Neuzeichnen: jede Phase eine Zeile, sobald fertig; die Kopfzeile
nach der ersten Phase, weil Node und Template erst dann feststehen.
- **SSE-Ausfall:** Wiederverbinden mit Last-Event-ID; nach drei Fehlschlägen ohne Stand Polling alle 2 s.
- **Go 1.27.2** ohne sudo unter `~/.local/go`; Bibliotheken mit abgelagerten Versionen (x/term 0.46.0,
go-runewidth 0.0.30 statt der wenige Tage alten Nachfolger).
+3 -2
View File
@@ -9,7 +9,8 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
## Aufgaben ## Aufgaben
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test) - [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test)
- [ ] Benutzerverwaltung, CLI-Tokens, SSH-Keys über `SchluesselQuelle` - [ ] Benutzerverwaltung, CLI-Tokens
- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar
- [ ] Audit-Log-Ansicht mit Filter - [ ] Audit-Log-Ansicht mit Filter
## Akzeptanzkriterien ## Akzeptanzkriterien
@@ -19,4 +20,4 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
#001 #001
## Notizen / Entscheidungen ## Notizen / Entscheidungen
– - SSH-Schlüssel auf Florians Frage nach Schlüsseln im Template vorgezogen (2026-10-12): Schlüssel gehören in die Verwaltung, nicht ins Template. `SchluesselQuelle` ist die Andockstelle für eine externe Verwaltung (z. B. sshkeyvault).
+27
View File
@@ -0,0 +1,27 @@
# #021 Template-Bau ohne SSH (Bau-Adresse)
**Status:** umgesetzt, Abnahme auf pve-demo ausstehend
**Meilenstein:** v0.3.0
**Labels:** feature, proxmox
**Geschätzt:** 3 h **Tatsächlich:** 0 h 19 min
## Ziel
Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet,
sondern über SSH **in die Vorbereitungs-VM** eingebaut.
## Aufgaben
- [x] Server-Einstellung `bauNetz`: statische IP/CIDR, Gateway, DNS (Bridge/VLAN wie beim Template-Bau gewählt)
- [x] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys`
- [x] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten
- [x] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
## Akzeptanzkriterien
- [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH
## Abhängigkeiten
#007
## Notizen / Entscheidungen
- **Fähigkeitserkennung war zu optimistisch:** „Template-Bau per API“ stand auch ohne Node-SSH da, obwohl der Guest-Agent dann nicht hineinkam. Jetzt: per API nur mit Snippet-Weg oder Bau-Adresse.
- Der Bereitstellungsschlüssel kommt per cloud-init an den Standardbenutzer des Images; danach werden alle authorized_keys, ciuser, sshkeys und die feste Adresse entfernt.
- Host-Key der frischen VM ist vorher unbekannt (TOFU, nur für diese eine Verbindung).
- Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH.
+30
View File
@@ -0,0 +1,30 @@
# #022 Templates mit eingebackenen Profilen
**Status:** erledigt
**Meilenstein:** v0.3.0
**Labels:** feature, proxmox
**Geschätzt:** 4 h **Tatsächlich:** 0 h 22 min
## Ziel
Beim Template-Bau Profile (z. B. base, docker) gleich mit installieren, damit Deployments schneller
fertig sind. Wunsch von Florian am 2026-10-12.
## Aufgaben
- [x] Template-Bau-Anfrage um `profile` erweitern (nur Profile ohne Pflichtvariablen bzw. mit Standardwerten)
- [x] Nach dem Vorbereitungsstart: VM erneut starten, Profile über den Guest-Agent ausführen (gleicher Code wie beim Deployment)
- [x] Aufräumen vor dem Template: Maschinen-ID leeren, `cloud-init clean`, SSH-Host-Keys löschen, `apt-get clean`, Logdatei entfernen
- [x] Name/Tags: `tpl-debian-13-docker`, Tags `proxdeploy-template;debian-13;profil-docker`
- [x] Ausrollen: passendes Template bevorzugen (eingebackene Profile ⊆ gewählte); eingebackene Profile beim Deploy überspringen, wenn ihre Variablen den Standardwerten entsprechen
- [ ] Optional: zusätzlicher Admin-/Notfall-Schlüssel im Template – zurückgestellt; Standard-Schlüssel je Benutzer/Team kommen stattdessen (#015)
## Akzeptanzkriterien
- [x] Template 9002 `tpl-debian-13-docker` auf pve-demo (8 min). Provisionierung beim Deploy: 141 s ohne, 29–32 s mit eingebackenem base+docker
## Abhängigkeiten
#007, #008
## Notizen / Entscheidungen
- SSH-Schlüssel gehören grundsätzlich nicht ins Template: beim Wechsel müsste jedes Template und jeder Klon angefasst werden. Dafür kommen Standard-Schlüssel je Benutzer/Team (aus #015 vorgezogen).
- **Fund:** Profile mit `{{ guest.* }}` hängen vom einzelnen Gast ab (docker nimmt den Benutzer in die Gruppe auf). Sie laufen beim Deploy trotzdem noch einmal – idempotent und schnell. Übersprungen wird nur, was gastunabhängig ist und Standardwerte hat.
- Vor dem Template: Maschinen-ID leeren, SSH-Host-Keys und Docker-engine-id löschen, apt-Cache leeren, `cloud-init clean`; geprüft: Klone haben eigene Maschinen-ID und eigene Host-Keys.
- Ausrollen wählt das Template mit den meisten eingebackenen Profilen, deren Profile alle gewählt sind – nie eins mit ungewählter Software.
- Gemeinsames Modul `gastagent.ts` für Provisionierung über den Guest-Agent (Deploy und Template).
+11 -9
View File
@@ -8,15 +8,15 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min | | [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min |
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min | | [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min |
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | 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 | offen | v0.2.0 | 6 h | – | | [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | erledigt | v0.2.0 | 6 h | 0 h 20 min |
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | offen | v0.2.0 | 8 h | – | | [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min |
| [#007](007-template-bau.md) | Template-Bau für VMs | offen | v0.2.0 | 8 h | – | | [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min |
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | offen | v0.2.0 | 10 h | – | | [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min |
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – | | [#009](009-lxc.md) | LXC-Container ausrollen | erledigt | v0.3.0 | 8 h | 0 h 30 min |
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – | | [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | erledigt | v0.3.0 | 3 h | 0 h 05 min |
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – | | [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | erledigt | v0.3.0 | 6 h | 0 h 10 min |
| [#012](012-vorlagen.md) | Vorlagen (Presets) | offen | v0.3.0 | 3 h | – | | [#012](012-vorlagen.md) | Vorlagen (Presets) | erledigt | v0.3.0 | 3 h | 0 h 11 min |
| [#013](013-cli.md) | CLI proxdeploy (Go) | offen | v0.4.0 | 12 h | – | | [#013](013-cli.md) | CLI proxdeploy (Go) | erledigt | v0.4.0 | 12 h | 0 h 42 min |
| [#014](014-verlauf.md) | Verlauf und Wiederholen | offen | v0.5.0 | 4 h | – | | [#014](014-verlauf.md) | Verlauf und Wiederholen | offen | v0.5.0 | 4 h | – |
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | offen | v0.5.0 | 6 h | – | | [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | offen | v0.5.0 | 6 h | – |
| [#016](016-oidc.md) | Anmeldung per OIDC | offen | v0.5.0 | 4 h | – | | [#016](016-oidc.md) | Anmeldung per OIDC | offen | v0.5.0 | 4 h | – |
@@ -24,3 +24,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – | | [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – | | [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – | | [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | umgesetzt, Abnahme offen | v0.3.0 | 3 h | 0 h 19 min |
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 22 min |
+124
View File
@@ -0,0 +1,124 @@
# Katalog: Distributionen und Profile
Distributionen und Profile sind Daten, kein Code. Jede liegt als YAML-Datei unter `catalog/` und wird
beim Start von ProxDeploy in die Datenbank abgeglichen. Die Datei ist maßgeblich für den Inhalt; in der
Oberfläche (*Katalog*) lassen sich Einträge nur aktivieren und deaktivieren. Eine fehlerhafte Datei
wird mit Dateiname und Fehlerstelle im Log gemeldet und übersprungen.
Regeln für beide Arten:
- Dateiname = `id` + `.yaml` (`catalog/distros/debian-13.yaml` → `id: debian-13`)
- unbekannte Schlüssel sind ein Fehler – ein Tippfehler fällt so sofort auf
- nur HTTPS-Quellen
## Neue Distribution hinzufügen
Beispiel Debian 13:
```yaml
id: debian-13
name: Debian 13 „Trixie“
family: debian # wählt den Provisionierungs-Adapter
os:
id: debian # für Platzhalter in Profilen: {{ os.id }}
version_id: "13" # {{ os.version_id }} – in Anführungszeichen, sonst wird 22.04 zur Zahl
codename: trixie # {{ os.codename }}
arch: amd64
vm:
image_url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
checksum_url: https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS
checksum_type: sha512 # sha256 | sha512
format: qcow2 # qcow2 | raw | vmdk
default_user: debian
guest_agent_preinstalled: false # sonst baut ProxDeploy ihn beim Template-Bau ein
bios: ovmf # ovmf (UEFI, Standard) | seabios
min_disk_gb: 4
lxc:
template_pattern: "debian-13-standard_*_amd64.tar.zst" # wie in `pveam available`
ostype: debian
```
`vm` oder `lxc` darf fehlen, wenn die Distribution nur eins davon kann. Bei mehreren passenden
LXC-Templates gewinnt die höchste Version. Die Prüfsummen-Datei darf im GNU-Format (`<hash> <datei>`
oder `<hash> *<datei>`) oder im BSD-Format (`SHA256 (<datei>) = <hash>`) vorliegen.
Gehört die Distribution zu einer Familie, für die es noch keinen Adapter gibt (etwa `rhel` für Rocky
oder Alma, `alpine`), braucht es zusätzlich einen Adapter:
1. `src/engine/adapter/<familie>.ts` anlegen, das Interface `FamilienAdapter` aus
`src/engine/adapter/schnittstelle.ts` umsetzen (Pakete, Dienste, Paketquellen, Zeitzone, Locale,
Tastatur, Benutzer, SSH-Härtung, Guest-Agent, automatische Updates)
2. in `src/engine/adapter/index.ts` eintragen
3. Tests nach dem Muster von `tests/engine/profile.test.ts`, dazu ein Image in
`tests/container/profile-pruefen.sh`
4. in den Profilen unter `families:` eine Umsetzung für die neue Familie ergänzen – Profile ohne
Umsetzung erscheinen im Assistenten ausgegraut
## Eigenes Profil anlegen
Ein Profil ist eine Liste von Schritten je Distributionsfamilie. Der Adapter übersetzt sie in ein
Shell-Skript, das bei VMs über den Guest-Agent und bei Containern per SSH läuft.
```yaml
id: nginx
name: nginx
description: Webserver mit Startseite
requires: [base] # wird automatisch davor ausgeführt
variables:
- { name: titel, label: Seitentitel, default: Willkommen }
- { name: tls, label: Zertifikat per certbot, type: boolean, default: false }
lxc:
features: { nesting: false } # Container-Features, die das Profil braucht
families:
debian:
- packages: [nginx]
- file:
path: /var/www/html/index.html
content: |
<h1>{{ var.titel }}</h1>
<p>{{ guest.name }} · {{ os.id }} {{ os.version_id }}</p>
- service: { name: nginx, enable: true, start: true }
- packages: [certbot, python3-certbot-nginx]
if: tls
```
### Schritte
| Schritt | Wirkung |
|---|---|
| `update: true` | Paketlisten aktualisieren und alle Pakete aktualisieren |
| `packages: [a, b]` | Pakete installieren |
| `service: { name, enable, start, restart }` | Dienst aktivieren, starten oder neu starten |
| `repo: { name, key_url, uris, suites, components }` | Paketquelle mit Schlüssel einrichten (deb822) |
| `repo_package: <url>` | ein Paket installieren, das selbst eine Quelle einrichtet (z. B. zabbix-release) |
| `file: { path, content, mode }` | Datei schreiben (Inhalt wird unverändert übertragen) |
| `timezone`, `locale`, `keyboard` | Zeitzone, Sprache, Tastaturbelegung |
| `ssh_harden: { password_auth, root_login }` | SSH absichern – läuft immer ganz am Ende |
| `guest_agent: true` | QEMU-Guest-Agent (in Containern übersprungen) |
| `unattended_upgrades: true` | automatische Sicherheitsupdates |
| `run: <befehl>` | freier Shell-Befehl – nicht portabel, nur wenn nichts anderes passt |
Jeder Schritt kann `if: <variable>` (oder `if: "!<variable>"`) tragen und läuft dann nur, wenn die
Variable wahr bzw. falsch ist. Fest eingebaut sind `if: vm` und `if: lxc` – etwa für Zeitsync, die
in einem unprivilegierten Container nicht laufen kann.
### Platzhalter
`{{ var.<name> }}` (Profil-Variable), `{{ os.id }}`, `{{ os.version_id }}`, `{{ os.codename }}`,
`{{ guest.name }}`, `{{ guest.user }}`. `{{ var.a ?? var.b }}` nimmt `b`, wenn `a` leer ist. Ein
unbekannter Platzhalter ist ein Fehler, kein leerer Text.
In `run`-Schritten wird jeder eingesetzte Wert für die Shell gequotet – eine Eingabe im Formular kann
so nie zu Code werden. Variablen dürfen keine Zeilenumbrüche enthalten.
### Variablentypen
`string` (Standard), `number`, `boolean`, `select` (mit `options: [...]`). `required: true` macht
das Feld im Assistenten zur Pflicht; `default` füllt es vor; `help` erscheint darunter.
### Prüfen
```bash
pnpm exec tsx scripts/profil-rendern.ts debian-13 nginx titel=Hallo # Skript ansehen
pnpm test:container debian-13 # wirklich ausführen
```
+7 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxdeploy", "name": "proxdeploy",
"version": "0.1.0", "version": "0.4.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI", "description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
@@ -24,7 +24,11 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"typecheck": "tsc --noEmit" "test:container": "tests/container/profile-pruefen.sh",
"typecheck": "tsc --noEmit",
"worker": "tsx src/worker/main.ts",
"openapi": "tsx scripts/openapi.ts",
"cli:pruefen": "scripts/cli-pruefen.sh"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0",
@@ -37,6 +41,7 @@
"lucide-react": "1.47.0", "lucide-react": "1.47.0",
"next": "16.4.0", "next": "16.4.0",
"pg": "^8.23.0", "pg": "^8.23.0",
"pg-boss": "12.37.1",
"pino": "10.4.0", "pino": "10.4.0",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
+87 -4
View File
@@ -192,10 +192,13 @@ importers:
version: 1.47.0(react@19.3.0) version: 1.47.0(react@19.3.0)
next: next:
specifier: 16.4.0 specifier: 16.4.0
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
pg: pg:
specifier: ^8.23.0 specifier: ^8.23.0
version: 8.23.1 version: 8.23.1
pg-boss:
specifier: 12.37.1
version: 12.37.1(@opentelemetry/api@1.9.1)
pino: pino:
specifier: 10.4.0 specifier: 10.4.0
version: 10.4.0 version: 10.4.0
@@ -286,7 +289,7 @@ importers:
version: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) version: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
vitest: vitest:
specifier: 5.0.1 specifier: 5.0.1
version: 5.0.1(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
packages: packages:
@@ -1199,6 +1202,10 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} engines: {node: '>=12.4.0'}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@oxc-project/types@0.153.0': '@oxc-project/types@0.153.0':
resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
@@ -2109,6 +2116,10 @@ packages:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
cron-parser@5.10.2:
resolution: {integrity: sha512-JfAYMY0LZaWQTuict8PhtF/ksSnq8xefSDmQUnnoqvkPYkU/y3UP0DTWzJKFBz0P1IXsDG8q0vY3zHkoFZOhvw==}
engines: {node: '>=18'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3080,6 +3091,10 @@ packages:
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -3164,6 +3179,10 @@ packages:
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
engines: {node: '>=18'} engines: {node: '>=18'}
non-error@0.1.0:
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
engines: {node: '>=20'}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3248,6 +3267,13 @@ packages:
perfect-debounce@2.1.0: perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
pg-boss@12.37.1:
resolution: {integrity: sha512-LghWeOIbMCiLcTY3+8MInXaJ0wk0AOkbeUZ8oE+cGO0uoheGSW3GK57eDqEbrDGpwTIpX9BVphvdvWEDYTcJ9A==}
engines: {node: '>=22.12.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.9.0
pg-cloudflare@1.4.1: pg-cloudflare@1.4.1:
resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==}
@@ -3469,6 +3495,9 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
rrule-temporal@2.2.8:
resolution: {integrity: sha512-+wWUe0J02oMh1UWKIt1RTu2H0Cz+gP11O/iweK0MN2UP6JLGeepTUkbSBa90Hxq0W+ywSxM9IlJNouIUdMOwhg==}
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -3510,6 +3539,10 @@ packages:
seq-queue@0.0.5: seq-queue@0.0.5:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
serialize-error@13.0.2:
resolution: {integrity: sha512-G+YKwp9I6IvWMe5RlwqxdmTPIcq/G8HK/sU51gdV1XAJznYkRSTB0ph+zB9AfSxG0fx6xrqYA6i9HeIcHFTXow==}
engines: {node: '>=20'}
set-function-length@1.2.2: set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3668,6 +3701,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwindcss@4.3.3: tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
@@ -3675,6 +3712,9 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'} engines: {node: '>=6'}
temporal-spec@1.0.1:
resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==}
thread-stream@4.2.0: thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -3723,6 +3763,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
type-fest@5.10.0:
resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==}
engines: {node: '>=20'}
typed-array-buffer@1.0.3: typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4663,6 +4707,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {} '@nolyfill/is-core-module@1.0.39': {}
'@opentelemetry/api@1.9.1': {}
'@oxc-project/types@0.153.0': {} '@oxc-project/types@0.153.0': {}
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
@@ -5557,6 +5603,10 @@ snapshots:
nan: 2.29.0 nan: 2.29.0
optional: true optional: true
cron-parser@5.10.2:
dependencies:
luxon: 3.7.2
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -6640,6 +6690,8 @@ snapshots:
dependencies: dependencies:
react: 19.3.0 react: 19.3.0
luxon@3.7.2: {}
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/sourcemap-codec': 1.6.0
@@ -6694,7 +6746,7 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies: dependencies:
'@next/env': 16.4.0 '@next/env': 16.4.0
'@swc/helpers': 0.5.23 '@swc/helpers': 0.5.23
@@ -6713,6 +6765,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.4.0 '@next/swc-linux-x64-musl': 16.4.0
'@next/swc-win32-arm64-msvc': 16.4.0 '@next/swc-win32-arm64-msvc': 16.4.0
'@next/swc-win32-x64-msvc': 16.4.0 '@next/swc-win32-x64-msvc': 16.4.0
'@opentelemetry/api': 1.9.1
'@playwright/test': 1.63.0 '@playwright/test': 1.63.0
sharp: 0.35.5(@types/node@24.19.2) sharp: 0.35.5(@types/node@24.19.2)
transitivePeerDependencies: transitivePeerDependencies:
@@ -6729,6 +6782,8 @@ snapshots:
node-releases@2.0.58: {} node-releases@2.0.58: {}
non-error@0.1.0: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
@@ -6822,6 +6877,16 @@ snapshots:
perfect-debounce@2.1.0: {} perfect-debounce@2.1.0: {}
pg-boss@12.37.1(@opentelemetry/api@1.9.1):
dependencies:
'@opentelemetry/api': 1.9.1
cron-parser: 5.10.2
pg: 8.23.1
rrule-temporal: 2.2.8
serialize-error: 13.0.2
transitivePeerDependencies:
- pg-native
pg-cloudflare@1.4.1: pg-cloudflare@1.4.1:
optional: true optional: true
@@ -7052,6 +7117,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.13 '@rolldown/binding-win32-arm64-msvc': 1.2.13
'@rolldown/binding-win32-x64-msvc': 1.2.13 '@rolldown/binding-win32-x64-msvc': 1.2.13
rrule-temporal@2.2.8:
dependencies:
temporal-spec: 1.0.1
run-parallel@1.2.0: run-parallel@1.2.0:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
@@ -7091,6 +7160,11 @@ snapshots:
seq-queue@0.0.5: {} seq-queue@0.0.5: {}
serialize-error@13.0.2:
dependencies:
non-error: 0.1.0
type-fest: 5.10.0
set-function-length@1.2.2: set-function-length@1.2.2:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@@ -7303,10 +7377,14 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
tagged-tag@1.0.0: {}
tailwindcss@4.3.3: {} tailwindcss@4.3.3: {}
tapable@2.3.3: {} tapable@2.3.3: {}
temporal-spec@1.0.1: {}
thread-stream@4.2.0: thread-stream@4.2.0:
dependencies: dependencies:
real-require: 1.0.0 real-require: 1.0.0
@@ -7351,6 +7429,10 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-fest@5.10.0:
dependencies:
tagged-tag: 1.0.0
typed-array-buffer@1.0.3: typed-array-buffer@1.0.3:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -7467,7 +7549,7 @@ snapshots:
tsx: 4.23.15 tsx: 4.23.15
yaml: 2.9.1 yaml: 2.9.1
vitest@5.0.1(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): vitest@5.0.1(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
dependencies: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
@@ -7484,6 +7566,7 @@ snapshots:
vite: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) vite: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.19.2 '@types/node': 24.19.2
transitivePeerDependencies: transitivePeerDependencies:
- msw - msw
@@ -0,0 +1,118 @@
-- CreateEnum
CREATE TYPE "GastTyp" AS ENUM ('VM', 'LXC');
-- CreateEnum
CREATE TYPE "AuftragsArt" AS ENUM ('AUSROLLEN', 'TEMPLATE_BAUEN', 'AUFRAEUMEN');
-- CreateEnum
CREATE TYPE "AuftragsStatus" AS ENUM ('WARTEND', 'LAEUFT', 'ERFOLGREICH', 'FEHLGESCHLAGEN', 'ABGEBROCHEN', 'AUFGERAEUMT');
-- CreateEnum
CREATE TYPE "PhasenStatus" AS ENUM ('OFFEN', 'LAEUFT', 'ERLEDIGT', 'FEHLER', 'UEBERSPRUNGEN');
-- CreateTable
CREATE TABLE "Auftrag" (
"id" TEXT NOT NULL,
"kurzId" TEXT NOT NULL,
"art" "AuftragsArt" NOT NULL,
"status" "AuftragsStatus" NOT NULL DEFAULT 'WARTEND',
"serverId" TEXT NOT NULL,
"knoten" TEXT,
"gastTyp" "GastTyp",
"vmid" INTEGER,
"name" TEXT,
"distributionId" TEXT,
"profile" TEXT[],
"anfrage" JSONB NOT NULL,
"plan" JSONB,
"kopf" TEXT,
"ergebnis" JSONB,
"angelegt" JSONB,
"fehler" JSONB,
"quelle" "Quelle" NOT NULL,
"erstelltVonId" TEXT NOT NULL,
"wiederholtVonId" TEXT,
"bezugId" TEXT,
"abbrechen" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"gestartetAm" TIMESTAMP(3),
"beendetAm" TIMESTAMP(3),
CONSTRAINT "Auftrag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Phase" (
"id" TEXT NOT NULL,
"auftragId" TEXT NOT NULL,
"reihenfolge" INTEGER NOT NULL,
"schluessel" TEXT NOT NULL,
"titel" TEXT NOT NULL,
"status" "PhasenStatus" NOT NULL DEFAULT 'OFFEN',
"detail" TEXT,
"ursache" TEXT,
"hinweis" TEXT,
"gestartetAm" TIMESTAMP(3),
"beendetAm" TIMESTAMP(3),
CONSTRAINT "Phase_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Logeintrag" (
"id" BIGSERIAL NOT NULL,
"auftragId" TEXT NOT NULL,
"zeit" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"stufe" TEXT NOT NULL,
"phase" TEXT,
"nachricht" TEXT NOT NULL,
"daten" JSONB,
CONSTRAINT "Logeintrag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VmidReservierung" (
"serverId" TEXT NOT NULL,
"vmid" INTEGER NOT NULL,
"auftragId" TEXT NOT NULL,
"ablaufAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VmidReservierung_pkey" PRIMARY KEY ("serverId","vmid")
);
-- CreateIndex
CREATE UNIQUE INDEX "Auftrag_kurzId_key" ON "Auftrag"("kurzId");
-- CreateIndex
CREATE INDEX "Auftrag_serverId_erstelltAm_idx" ON "Auftrag"("serverId", "erstelltAm");
-- CreateIndex
CREATE INDEX "Auftrag_status_idx" ON "Auftrag"("status");
-- CreateIndex
CREATE INDEX "Auftrag_erstelltAm_idx" ON "Auftrag"("erstelltAm");
-- CreateIndex
CREATE INDEX "Phase_auftragId_reihenfolge_idx" ON "Phase"("auftragId", "reihenfolge");
-- CreateIndex
CREATE UNIQUE INDEX "Phase_auftragId_schluessel_key" ON "Phase"("auftragId", "schluessel");
-- CreateIndex
CREATE INDEX "Logeintrag_auftragId_id_idx" ON "Logeintrag"("auftragId", "id");
-- CreateIndex
CREATE INDEX "VmidReservierung_auftragId_idx" ON "VmidReservierung"("auftragId");
-- AddForeignKey
ALTER TABLE "Auftrag" ADD CONSTRAINT "Auftrag_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Auftrag" ADD CONSTRAINT "Auftrag_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Phase" ADD CONSTRAINT "Phase_auftragId_fkey" FOREIGN KEY ("auftragId") REFERENCES "Auftrag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Logeintrag" ADD CONSTRAINT "Logeintrag_auftragId_fkey" FOREIGN KEY ("auftragId") REFERENCES "Auftrag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "Template" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"knoten" TEXT NOT NULL,
"vmid" INTEGER NOT NULL,
"distributionId" TEXT NOT NULL,
"storage" TEXT NOT NULL,
"imageDatei" TEXT NOT NULL,
"pruefsumme" TEXT NOT NULL,
"gebautVonAuftragId" TEXT,
"gebautAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Template_serverId_distributionId_idx" ON "Template"("serverId", "distributionId");
-- CreateIndex
CREATE UNIQUE INDEX "Template_serverId_vmid_key" ON "Template"("serverId", "vmid");
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "Vorlage" (
"name" TEXT NOT NULL,
"titel" TEXT NOT NULL,
"beschreibung" TEXT,
"parameter" JSONB NOT NULL,
"erstelltVonId" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Vorlage_pkey" PRIMARY KEY ("name")
);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "profile" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "SshSchluessel" (
"id" TEXT NOT NULL,
"benutzerId" TEXT,
"name" TEXT NOT NULL,
"oeffentlich" TEXT NOT NULL,
"fingerabdruck" TEXT NOT NULL,
"quelle" TEXT NOT NULL DEFAULT 'lokal',
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SshSchluessel_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SshSchluessel_benutzerId_fingerabdruck_key" ON "SshSchluessel"("benutzerId", "fingerabdruck");
-- AddForeignKey
ALTER TABLE "SshSchluessel" ADD CONSTRAINT "SshSchluessel_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "ApiToken" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"praefix" TEXT NOT NULL,
"hash" TEXT NOT NULL,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zuletztGenutzt" TIMESTAMP(3),
"ablaufAm" TIMESTAMP(3),
CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiToken_hash_key" ON "ApiToken"("hash");
-- CreateIndex
CREATE UNIQUE INDEX "ApiToken_benutzerId_name_key" ON "ApiToken"("benutzerId", "name");
-- AddForeignKey
ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+206 -18
View File
@@ -24,20 +24,23 @@ enum Quelle {
} }
model Benutzer { model Benutzer {
id String @id @default(cuid()) id String @id @default(cuid())
benutzername String @unique benutzername String @unique
anzeigename String anzeigename String
email String? email String?
/// argon2id; leer bei Benutzern, die sich nur per OIDC anmelden. /// argon2id; leer bei Benutzern, die sich nur per OIDC anmelden.
passwortHash String? passwortHash String?
oidcSubjekt String? @unique oidcSubjekt String? @unique
rolle Rolle @default(OPERATOR) rolle Rolle @default(OPERATOR)
aktiv Boolean @default(true) aktiv Boolean @default(true)
/// Persönliche Standardwerte im Assistenten. /// Persönliche Standardwerte im Assistenten.
standards Json? standards Json?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
protokoll Protokoll[] protokoll Protokoll[]
auftraege Auftrag[]
schluessel SshSchluessel[]
tokens ApiToken[]
} }
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE. /// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
@@ -64,9 +67,9 @@ model Einstellung {
/// Ein Proxmox-VE-Einzelhost oder -Cluster. Geheimnisse liegen AES-256-GCM-verschlüsselt (v1.…). /// Ein Proxmox-VE-Einzelhost oder -Cluster. Geheimnisse liegen AES-256-GCM-verschlüsselt (v1.…).
model Server { model Server {
id String @id @default(cuid()) id String @id @default(cuid())
/// Kurzname, auch für die CLI (`--server rz1`). /// Kurzname, auch für die CLI (`--server rz1`).
name String @unique name String @unique
/// Anzeigename, z. B. „Rechenzentrum 1“. /// Anzeigename, z. B. „Rechenzentrum 1“.
titel String titel String
/// https://host:8006 /// https://host:8006
@@ -75,19 +78,19 @@ model Server {
tokenId String tokenId String
tokenGeheimnis String tokenGeheimnis String
/// Aus: nur ein gepinnter Fingerprint zählt. An: Systemvertrauen (+ optional Pin). /// Aus: nur ein gepinnter Fingerprint zählt. An: Systemvertrauen (+ optional Pin).
tlsPruefen Boolean @default(false) tlsPruefen Boolean @default(false)
/// SHA-256 des Serverzertifikats, Großbuchstaben mit Doppelpunkten. /// SHA-256 des Serverzertifikats, Großbuchstaben mit Doppelpunkten.
fingerabdruck String? fingerabdruck String?
sshAktiv Boolean @default(false) sshAktiv Boolean @default(false)
sshBenutzer String @default("root") sshBenutzer String @default("root")
sshPort Int @default(22) sshPort Int @default(22)
sshPrivat String? sshPrivat String?
sshOeffentlich String? sshOeffentlich String?
/// Gepinnte Host-Keys je Node: { "pve01": "SHA256:…" }. /// Gepinnte Host-Keys je Node: { "pve01": "SHA256:…" }.
sshHostSchluessel Json? sshHostSchluessel Json?
templateVmidVon Int @default(9000) templateVmidVon Int @default(9000)
templateVmidBis Int @default(9999) templateVmidBis Int @default(9999)
gastVmidVon Int @default(100) gastVmidVon Int @default(100)
/// Statische Adresse für die Template-Vorbereitung ohne SSH: { bridge, vlan, ip, gateway }. /// Statische Adresse für die Template-Vorbereitung ohne SSH: { bridge, vlan, ip, gateway }.
bauNetz Json? bauNetz Json?
/// Standard-Node, -Storage, -Bridge für den Assistenten. /// Standard-Node, -Storage, -Bridge für den Assistenten.
@@ -97,8 +100,10 @@ model Server {
erreichbar Boolean? erreichbar Boolean?
pveVersion String? pveVersion String?
geprueftAm DateTime? geprueftAm DateTime?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
auftraege Auftrag[]
templates Template[]
} }
enum Herkunft { enum Herkunft {
@@ -139,3 +144,186 @@ model Profil {
geaendertVonId String? geaendertVonId String?
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
} }
enum GastTyp {
VM
LXC
}
enum AuftragsArt {
AUSROLLEN
TEMPLATE_BAUEN
AUFRAEUMEN
}
enum AuftragsStatus {
WARTEND
LAEUFT
ERFOLGREICH
FEHLGESCHLAGEN
ABGEBROCHEN
AUFGERAEUMT
}
enum PhasenStatus {
OFFEN
LAEUFT
ERLEDIGT
FEHLER
UEBERSPRUNGEN
}
/// Ein Hintergrundjob: Ausrollen, Template bauen oder Aufräumen. Läuft im worker.
model Auftrag {
id String @id @default(cuid())
/// Kurz und tippbar für CLI und URLs: „d-7f3k2“.
kurzId String @unique
art AuftragsArt
status AuftragsStatus @default(WARTEND)
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Restrict)
knoten String?
gastTyp GastTyp?
vmid Int?
name String?
distributionId String?
profile String[]
/// Validierte Anfrage – Grundlage für „Wiederholen“.
anfrage Json
/// Plan zum Zeitpunkt des Starts (Phasen und geplante API-Aufrufe).
plan Json?
/// Kopfzeile wie in der CLI: „Server rz1 · Node pve01 · Debian 13 (Template 9013)“ – von der Engine gesetzt.
kopf String?
/// IP, SSH-Befehl, Dauer …
ergebnis Json?
/// Was ProxDeploy angelegt hat – Grundlage fürs Aufräumen.
angelegt Json?
/// { phase, ursache, hinweis }
fehler Json?
quelle Quelle
erstelltVonId String
erstelltVon Benutzer @relation(fields: [erstelltVonId], references: [id], onDelete: Restrict)
wiederholtVonId String?
/// Bei AUFRAEUMEN: der Auftrag, dessen Reste entfernt werden.
bezugId String?
abbrechen Boolean @default(false)
erstelltAm DateTime @default(now())
gestartetAm DateTime?
beendetAm DateTime?
phasen Phase[]
log Logeintrag[]
@@index([serverId, erstelltAm])
@@index([status])
@@index([erstelltAm])
}
model Phase {
id String @id @default(cuid())
auftragId String
auftrag Auftrag @relation(fields: [auftragId], references: [id], onDelete: Cascade)
reihenfolge Int
schluessel String
titel String
status PhasenStatus @default(OFFEN)
/// Der graue Detailtext hinter dem Phasennamen – in Web und CLI derselbe.
detail String?
ursache String?
hinweis String?
gestartetAm DateTime?
beendetAm DateTime?
@@unique([auftragId, schluessel])
@@index([auftragId, reihenfolge])
}
model Logeintrag {
/// Zugleich die Event-ID im SSE-Strom (Last-Event-ID).
id BigInt @id @default(autoincrement())
auftragId String
auftrag Auftrag @relation(fields: [auftragId], references: [id], onDelete: Cascade)
zeit DateTime @default(now())
stufe String
phase String?
nachricht String
daten Json?
@@index([auftragId, id])
}
/// Vergebene VMIDs, solange ein Auftrag läuft – verhindert Doppelvergabe zwischen parallelen Aufträgen.
model VmidReservierung {
serverId String
vmid Int
auftragId String
ablaufAm DateTime
@@id([serverId, vmid])
@@index([auftragId])
}
/// Von ProxDeploy gebaute Templates. Maßgeblich ist Proxmox (Tag proxdeploy-template); hier steht, woraus gebaut wurde.
model Template {
id String @id @default(cuid())
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
knoten String
vmid Int
distributionId String
storage String
imageDatei String
pruefsumme String
/// Eingebackene Profile (#022), aufgelöst inkl. Voraussetzungen.
profile String[] @default([])
gebautVonAuftragId String?
gebautAm DateTime @default(now())
@@unique([serverId, vmid])
@@index([serverId, distributionId])
}
/// Vorlage (Preset): eine gespeicherte Kombination, die Assistent, CLI (--preset) und API vorbelegen.
model Vorlage {
/// Kurzname für die CLI: „docker-host-klein“
name String @id
titel String
beschreibung String?
/// Teilmenge der Ausroll-Anfrage (typ, distribution, cores, speicherMb, diskGb, profile, variablen, tags …).
parameter Json
erstelltVonId String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
}
/// Öffentliche SSH-Schlüssel, die in Gäste kommen. benutzerId leer = Team-Schlüssel (in jeden Gast).
model SshSchluessel {
id String @id @default(cuid())
benutzerId String?
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
name String
oeffentlich String
/// SHA256:… wie ssh-keygen -lf
fingerabdruck String
/// Kennung der SchluesselQuelle („lokal“; später z. B. eine externe Schlüsselverwaltung).
quelle String @default("lokal")
erstelltAm DateTime @default(now())
@@unique([benutzerId, fingerabdruck])
}
/// API-Token der CLI (`Authorization: Bearer pd_…`). Gespeichert wird nur der SHA-256 des Tokens –
/// es hat 256 Bit Zufall, ein langsamer Hash brächte nichts. Rolle kommt je Anfrage vom Benutzer.
model ApiToken {
id String @id @default(cuid())
benutzerId String
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
name String
/// Die ersten Zeichen zur Wiedererkennung („pd_AbC1…“).
praefix String
hash String @unique
erstelltAm DateTime @default(now())
zuletztGenutzt DateTime?
ablaufAm DateTime?
@@unique([benutzerId, name])
}
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Prüft, dass openapi.json und die erzeugten Go-Typen zum Code passen, und testet die CLI.
# Schlägt fehl, wenn `pnpm openapi` oder `go generate` etwas ändern würden.
set -euo pipefail
cd "$(dirname "$0")/.."
export PATH="$HOME/.local/go/bin:$PATH"
pnpm openapi >/dev/null
(cd cli && go generate ./... && go vet ./... && go test ./...)
if ! git diff --quiet -- cli/api/openapi.json cli/internal/api/typen.gen.go; then
echo "openapi.json oder typen.gen.go sind veraltet – die Änderungen oben einchecken." >&2
git diff --stat -- cli/api/openapi.json cli/internal/api/typen.gen.go >&2
exit 1
fi
echo "CLI aktuell und grün."
+9
View File
@@ -0,0 +1,9 @@
/**
* Schreibt cli/api/openapi.json aus den zod-Schemas. Danach in cli/: `go generate ./...`.
* tests/openapi.test.ts schlägt fehl, wenn die Datei nicht zum Code passt.
*/
import { writeFileSync } from "node:fs";
import { openApi } from "@/server/openapi";
writeFileSync("cli/api/openapi.json", `${JSON.stringify(openApi(), null, 2)}\n`);
console.log("cli/api/openapi.json geschrieben");
+42
View File
@@ -0,0 +1,42 @@
/**
* Rendert Profile für eine Distribution auf stdout – zum Ansehen und für die Integrationsprüfung
* in einem Container: `pnpm exec tsx scripts/profil-rendern.ts debian-13 base,docker server=zbx.local`
*/
import { adapterFuer } from "@/engine/adapter";
import { katalogLaden } from "@/engine/katalog/laden";
import { profileAufloesen } from "@/engine/profile/aufloesen";
import { profileRendern } from "@/engine/profile/rendern";
const [distroId = "debian-13", auswahl = "base", ...zuweisungen] = process.argv.slice(2);
const katalog = await katalogLaden("catalog");
const distro = katalog.distributionen.find((d) => d.eintrag.id === distroId)?.eintrag;
if (!distro) throw new Error(`Unbekannte Distribution ${distroId}`);
const profile = new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag]));
const eingaben: Record<string, Record<string, string>> = {};
for (const z of zuweisungen) {
const [name, wert] = z.split("=");
for (const p of profile.values())
if (p.variables.some((v) => v.name === name)) (eingaben[p.id] ??= {})[name!] = wert!;
}
const aufloesung = profileAufloesen(auswahl.split(","), profile, distro.family, eingaben);
if (!aufloesung.ok) {
console.error(aufloesung.fehler);
process.exit(1);
}
const adapter = adapterFuer(distro.family)!;
const { skript } = profileRendern(
aufloesung.profile,
adapter,
{
os: distro.os,
gast: { name: "testgast", benutzer: "florian" },
},
[
adapter.benutzerAnlegen({
name: "florian",
sshSchluessel: ["ssh-ed25519 AAAAtest florian@test"],
sudo: true,
}),
],
);
process.stdout.write(skript);
@@ -0,0 +1,169 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Code } from "@/components/formular";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { STATUS_ANZEIGE } from "@/lib/auftrag-text";
import { TerminalKarte } from "@/components/terminal-karte";
import { apiAufruf } from "@/lib/api-client";
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
/**
* Hängt sich per SSE an den Auftrag. Fällt SSE aus (z. B. ein Proxy, der Streams puffert), wird
* alle 3 s abgefragt – die Anzeige bleibt dieselbe.
*/
export function LiveAnsicht({
anfang,
anfangsLog,
version,
abbrechenErlaubt,
}: {
anfang: AuftragSicht;
anfangsLog: LogZeile[];
version: string;
abbrechenErlaubt: boolean;
}) {
const [auftrag, setAuftrag] = useState(anfang);
const [log, setLog] = useState(anfangsLog);
const [verbindung, setVerbindung] = useState<"live" | "abfrage" | "beendet">(
ENDE.has(anfang.status) ? "beendet" : "live",
);
const logBis = useRef(anfangsLog.at(-1)?.id ?? "0");
useEffect(() => {
if (ENDE.has(anfang.status)) return;
let fehlschlaege = 0;
let abfrage: ReturnType<typeof setInterval> | undefined;
const quelle = new EventSource(`/api/v1/auftraege/${anfang.kurzId}/ereignisse?ab=${logBis.current}`);
const zeilenAnhaengen = (neu: LogZeile[]) => {
const bekannt = BigInt(logBis.current);
const frisch = neu.filter((z) => BigInt(z.id) > bekannt);
if (frisch.length === 0) return;
logBis.current = frisch.at(-1)!.id;
setLog((alt) => [...alt, ...frisch]);
};
quelle.addEventListener("stand", (e) => {
fehlschlaege = 0;
setAuftrag(JSON.parse((e as MessageEvent).data));
});
quelle.addEventListener("log", (e) => zeilenAnhaengen(JSON.parse((e as MessageEvent).data)));
quelle.addEventListener("ende", () => {
quelle.close();
setVerbindung("beendet");
});
quelle.onerror = () => {
fehlschlaege += 1;
if (fehlschlaege < 3 || abfrage) return;
quelle.close();
setVerbindung("abfrage");
abfrage = setInterval(async () => {
try {
const stand = await apiAufruf<AuftragSicht>("GET", `/api/v1/auftraege/${anfang.kurzId}`);
setAuftrag(stand);
zeilenAnhaengen(
await apiAufruf<LogZeile[]>("GET", `/api/v1/auftraege/${anfang.kurzId}/log?ab=${logBis.current}`),
);
if (ENDE.has(stand.status)) {
clearInterval(abfrage);
setVerbindung("beendet");
}
} catch {
// nächster Versuch in 3 s
}
}, 3000);
};
return () => {
quelle.close();
if (abfrage) clearInterval(abfrage);
};
}, [anfang.kurzId, anfang.status]);
const [art, text] =
auftrag.status === "WARTEND"
? (["offen", "wartet auf den worker"] as const)
: STATUS_ANZEIGE[auftrag.status];
const ergebnis = auftrag.abschluss;
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div className="min-w-0">
<p className="text-muted font-mono text-sm">
{auftrag.kurzId} · gestartet von {auftrag.erstelltVon}
</p>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
{auftrag.art === "TEMPLATE_BAUEN"
? "Template bauen"
: auftrag.art === "AUFRAEUMEN"
? "Aufräumen"
: (auftrag.name ?? "Ausrollen")}
</h1>
</div>
<div className="flex items-center gap-4">
<Status art={art}>{text}</Status>
{abbrechenErlaubt && !ENDE.has(auftrag.status) ? (
<Knopf
variante="gefahr"
onClick={() => void apiAufruf("POST", `/api/v1/auftraege/${auftrag.kurzId}/abbrechen`)}
>
Abbrechen
</Knopf>
) : null}
</div>
</header>
<TerminalKarte version={version} kopf={auftrag.kopf} phasen={auftrag.phasen} ergebnis={ergebnis} />
{typeof ssh === "string" ? (
<div className="flex flex-col gap-1.5">
<span className="text-muted text-sm">SSH-Befehl</span>
<Code>{ssh}</Code>
</div>
) : null}
<details
className="bg-panel border-linie rounded-karte border"
open={auftrag.status === "FEHLGESCHLAGEN"}
>
<summary className="cursor-pointer px-5 py-3.5 font-semibold">
Detail-Log <span className="text-muted font-normal">({log.length} Zeilen)</span>
</summary>
<ol className="border-linie max-h-[60vh] overflow-auto border-t px-5 py-3 font-mono text-xs leading-5">
{log.map((zeile) => (
<li key={zeile.id} className="flex gap-3">
<time className="text-muted shrink-0">{new Date(zeile.zeit).toLocaleTimeString("de-DE")}</time>
<span
className={
zeile.stufe === "error"
? "text-fehler"
: zeile.stufe === "warn"
? "text-laeuft"
: zeile.stufe === "debug"
? "text-muted"
: "text-text"
}
>
{zeile.phase ? <span className="text-muted">[{zeile.phase}] </span> : null}
<span className="break-all whitespace-pre-wrap">{zeile.nachricht}</span>
</span>
</li>
))}
</ol>
</details>
<p className="text-muted text-xs" aria-live="polite">
{verbindung === "live"
? "Live verbunden."
: verbindung === "abfrage"
? "Live-Verbindung nicht möglich – Aktualisierung alle 3 s."
: null}
</p>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { versionText } from "@/lib/version";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { AnwendungsFehler } from "@/server/fehler";
import { auftragHolen, logAb } from "@/server/services/auftraege";
import { LiveAnsicht } from "./live-ansicht";
export async function generateMetadata({
params,
}: {
params: Promise<{ kurzId: string }>;
}): Promise<Metadata> {
return { title: (await params).kurzId };
}
export default async function AuftragSeite({ params }: { params: Promise<{ kurzId: string }> }) {
const { kurzId } = await params;
const benutzer = await seiteSchuetzen("lesen", `/auftraege/${kurzId}`);
const auftrag = await auftragHolen(kurzId).catch((fehler) => {
if (fehler instanceof AnwendungsFehler && fehler.status === 404) notFound();
throw fehler;
});
const log = await logAb(auftrag.id, BigInt(0), 2000);
return (
<LiveAnsicht
anfang={auftrag}
anfangsLog={log}
version={versionText()}
abbrechenErlaubt={darf(benutzer.rolle, "ausrollen")}
/>
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { ART_TEXT, STATUS_ANZEIGE } from "@/lib/auftrag-text";
import { zeitpunkt } from "@/lib/formate";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { auftraegeListe } from "@/server/services/auftraege";
export const metadata: Metadata = { title: "Verlauf" };
/** Verlauf aller Aufträge. Filter und Wiederholen kommen mit #014. */
export default async function Verlauf() {
await seiteSchuetzen("lesen", "/auftraege");
const auftraege = await auftraegeListe({ grenze: 100 });
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
<Karte>
{auftraege.length === 0 ? (
<Status art="offen">Noch keine Aufträge.</Status>
) : (
<ul className="divide-linie flex flex-col divide-y">
{auftraege.map((a) => {
const [art, text] = STATUS_ANZEIGE[a.status];
return (
<li key={a.id}>
<Link
href={`/auftraege/${a.kurzId}`}
className="hover:bg-panel-hoch/60 -mx-2 flex flex-col gap-1 rounded-lg px-2 py-3 sm:flex-row sm:items-center sm:gap-4"
>
<span className="font-mono text-sm sm:w-20">{a.kurzId}</span>
<span className="min-w-0 flex-1">
<span className="font-medium">{a.name ?? ART_TEXT[a.art]}</span>
<span className="text-muted text-sm">
{" "}
· {ART_TEXT[a.art]} · {a.server.name}
{a.distributionId ? ` · ${a.distributionId}` : ""}
{a.vmid ? ` · ${a.vmid}` : ""}
</span>
</span>
<span className="text-muted text-sm sm:w-28">{zeitpunkt(a.erstelltAm)}</span>
<Status art={art} className="text-sm sm:w-32">
{text}
</Status>
</Link>
</li>
);
})}
</ul>
)}
</Karte>
</div>
);
}
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
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 { vorlagenListe } from "@/server/services/vorlagen";
import { schluesselFuer } from "@/server/services/schluessel";
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(),
vorlagen: await vorlagenListe(),
schluessel: (await schluesselFuer(benutzer)).map((x) => ({
name: x.name,
oeffentlich: x.oeffentlich,
fingerabdruck: x.fingerabdruck,
bereich: x.bereich,
})),
};
const vorauswahl = aktiverServer(
daten.server.map((s) => s.name),
(await cookies()).get(SERVER_COOKIE)?.value,
);
return <Assistent daten={daten} vorauswahl={vorauswahl} />;
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { schluesselFuer } from "@/server/services/schluessel";
import { tokensVon } from "@/server/services/tokens";
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
import { TokenVerwaltung } from "./token-verwaltung";
export const metadata: Metadata = { title: "Einstellungen" };
/** Einstellungen. Heute: SSH-Schlüssel und CLI-Tokens. Benutzer und Standardwerte folgen mit #015. */
export default async function Einstellungen() {
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
<p className="text-muted text-sm">Angemeldet als {benutzer.benutzername}</p>
</header>
<SchluesselVerwaltung
anfang={await schluesselFuer(benutzer)}
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
/>
<TokenVerwaltung
anfang={await tokensVon(benutzer.id)}
adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin}
/>
</div>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { EINGABE_KLASSE, Feld, Schalter } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
type Schluessel = {
id: string;
name: string;
fingerabdruck: string;
bereich: "persoenlich" | "team";
quelle: string;
oeffentlich: string;
};
/**
* Öffentliche SSH-Schlüssel, die automatisch in jeden Gast kommen – eigene und (von Admins
* gepflegte) Team-Schlüssel. Im Assistenten lassen sie sich je Gast abwählen.
*/
export function SchluesselVerwaltung({
anfang,
teamErlaubt,
}: {
anfang: Schluessel[];
teamErlaubt: boolean;
}) {
const router = useRouter();
const [neu, setNeu] = useState({ name: "", oeffentlich: "", team: false });
const [meldung, setMeldung] = useState<{
art: "erledigt" | "fehler";
text: string;
felder?: Record<string, string>;
} | null>(null);
async function hinzufuegen(ereignis: React.FormEvent) {
ereignis.preventDefault();
try {
await apiAufruf("POST", "/api/v1/schluessel", neu);
setNeu({ name: "", oeffentlich: "", team: false });
setMeldung({ art: "erledigt", text: "Schlüssel hinzugefügt." });
router.refresh();
} catch (fehler) {
setMeldung({
art: "fehler",
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
});
}
}
async function entfernen(s: Schluessel) {
if (!window.confirm(`Schlüssel „${s.name}“ entfernen? Bereits ausgerollte Gäste behalten ihn.`)) return;
try {
await apiAufruf("DELETE", `/api/v1/schluessel/${s.id}`);
router.refresh();
} catch (fehler) {
setMeldung({ art: "fehler", text: fehler instanceof ApiFehler ? fehler.message : "Fehler." });
}
}
return (
<Karte titel="SSH-Schlüssel">
<div className="flex flex-col gap-5">
<p className="text-muted text-sm">
Diese öffentlichen Schlüssel kommen in jeden Gast, den du ausrollst – auch per CLI, wenn dort keine
angegeben sind.
</p>
{anfang.length === 0 ? (
<Status art="offen">Noch keine Schlüssel hinterlegt.</Status>
) : (
<ul className="divide-linie flex flex-col divide-y">
{anfang.map((s) => (
<li key={s.id} className="flex flex-col gap-2 py-3 first:pt-0 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<p className="font-medium">
{s.name}{" "}
<span className="text-muted text-sm">
· {s.bereich === "team" ? "Team" : "persönlich"}
</span>
</p>
<p className="text-muted font-mono text-xs break-all">{s.fingerabdruck}</p>
</div>
{s.bereich === "persoenlich" || teamErlaubt ? (
<Knopf
variante="gefahr"
onClick={() => void entfernen(s)}
aria-label={`Schlüssel ${s.name} entfernen`}
>
Entfernen
</Knopf>
) : null}
</li>
))}
</ul>
)}
<form onSubmit={hinzufuegen} className="border-linie flex flex-col gap-4 border-t pt-5">
<Feld id="schluessel-name" label="Name" fehler={meldung?.felder?.["name"]}>
<input
id="schluessel-name"
className={`${EINGABE_KLASSE} sm:max-w-sm`}
value={neu.name}
onChange={(e) => setNeu({ ...neu, name: e.target.value })}
placeholder="Laptop"
/>
</Feld>
<Feld
id="schluessel-text"
label="Öffentlicher Schlüssel"
hilfe="Inhalt von ~/.ssh/id_ed25519.pub"
fehler={meldung?.felder?.["oeffentlich"]}
>
<textarea
id="schluessel-text"
rows={3}
className={`${EINGABE_KLASSE} h-auto py-2 font-mono text-xs`}
value={neu.oeffentlich}
onChange={(e) => setNeu({ ...neu, oeffentlich: e.target.value })}
placeholder="ssh-ed25519 AAAA… name@rechner"
spellCheck={false}
/>
</Feld>
{teamErlaubt ? (
<Schalter
id="schluessel-team"
label="Team-Schlüssel"
hilfe="Kommt in jeden Gast, egal wer ausrollt."
checked={neu.team}
onChange={(an) => setNeu({ ...neu, team: an })}
/>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Knopf type="submit" disabled={!neu.name || !neu.oeffentlich}>
Hinzufügen
</Knopf>
<div aria-live="polite" className="text-sm">
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
</div>
</div>
</form>
</div>
</Karte>
);
}
@@ -0,0 +1,157 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Code, EINGABE_KLASSE, Feld } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
import { zeitpunkt } from "@/lib/formate";
type Token = {
id: string;
name: string;
praefix: string;
erstelltAm: string;
zuletztGenutzt: string | null;
ablaufAm: string | null;
};
/**
* API-Tokens für die CLI. Das Token steht genau einmal da – direkt nach dem Anlegen; gespeichert
* wird nur sein Hash.
*/
export function TokenVerwaltung({ anfang, adresse }: { anfang: Token[]; adresse: string }) {
const router = useRouter();
const [neu, setNeu] = useState({ name: "", tage: "" });
const [frisch, setFrisch] = useState<{ name: string; token: string } | null>(null);
const [meldung, setMeldung] = useState<{
art: "erledigt" | "fehler";
text: string;
felder?: Record<string, string>;
} | null>(null);
async function anlegen(ereignis: React.FormEvent) {
ereignis.preventDefault();
try {
const antwort = await apiAufruf<Token & { token: string }>("POST", "/api/v1/tokens", {
name: neu.name,
...(neu.tage ? { tage: Number(neu.tage) } : {}),
});
setFrisch({ name: antwort.name, token: antwort.token });
setNeu({ name: "", tage: "" });
setMeldung(null);
router.refresh();
} catch (fehler) {
setMeldung({
art: "fehler",
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
});
}
}
async function widerrufen(t: Token) {
if (!window.confirm(`Token „${t.name}“ widerrufen? Die CLI mit diesem Token ist danach abgemeldet.`))
return;
try {
await apiAufruf("DELETE", `/api/v1/tokens/${t.id}`);
if (frisch?.name === t.name) setFrisch(null);
router.refresh();
} catch (fehler) {
setMeldung({ art: "fehler", text: fehler instanceof ApiFehler ? fehler.message : "Fehler." });
}
}
return (
<Karte titel="API-Tokens für die CLI">
<div className="flex flex-col gap-5">
<p className="text-muted text-sm">
Am einfachsten meldet sich die CLI selbst an: <Code>{`proxdeploy login ${adresse}`}</Code>. Für
Skripte und CI ein Token hier anlegen und mit <Code>--token</Code> übergeben.
</p>
{frisch ? (
<div className="border-akzent flex flex-col gap-2 rounded-lg border p-4" role="status">
<Status art="erledigt">Token „{frisch.name}“ angelegt – es wird nur jetzt angezeigt.</Status>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Code className="flex-1 break-all">{frisch.token}</Code>
<Knopf
variante="sekundaer"
onClick={() => void navigator.clipboard.writeText(frisch.token)}
aria-label="Token kopieren"
>
Kopieren
</Knopf>
</div>
<p className="text-muted text-xs break-all">
proxdeploy login {adresse} --token {frisch.token.slice(0, 9)}…
</p>
</div>
) : null}
{anfang.length === 0 ? (
<Status art="offen">Noch keine Tokens.</Status>
) : (
<ul className="divide-linie flex flex-col divide-y">
{anfang.map((t) => (
<li key={t.id} className="flex flex-col gap-2 py-3 first:pt-0 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<p className="font-medium">
{t.name} <span className="text-muted font-mono text-sm">· {t.praefix}…</span>
</p>
<p className="text-muted text-xs">
angelegt {zeitpunkt(t.erstelltAm)} · zuletzt genutzt {zeitpunkt(t.zuletztGenutzt)}
{t.ablaufAm ? ` · läuft ab ${new Date(t.ablaufAm).toLocaleDateString("de-DE")}` : ""}
</p>
</div>
<Knopf
variante="gefahr"
onClick={() => void widerrufen(t)}
aria-label={`Token ${t.name} widerrufen`}
>
Widerrufen
</Knopf>
</li>
))}
</ul>
)}
<form onSubmit={anlegen} className="border-linie flex flex-col gap-4 border-t pt-5">
<div className="flex flex-col gap-4 sm:flex-row">
<Feld id="token-name" label="Name" fehler={meldung?.felder?.["name"]}>
<input
id="token-name"
className={`${EINGABE_KLASSE} sm:max-w-sm`}
value={neu.name}
onChange={(e) => setNeu({ ...neu, name: e.target.value })}
placeholder="ci-pipeline"
/>
</Feld>
<Feld
id="token-tage"
label="Gültig (Tage)"
hilfe="Leer = unbefristet"
fehler={meldung?.felder?.["tage"]}
>
<input
id="token-tage"
inputMode="numeric"
className={`${EINGABE_KLASSE} sm:max-w-[10rem]`}
value={neu.tage}
onChange={(e) => setNeu({ ...neu, tage: e.target.value.replace(/\D/g, "") })}
placeholder="90"
/>
</Feld>
</div>
<div className="flex flex-wrap items-center gap-3">
<Knopf type="submit" disabled={!neu.name}>
Token anlegen
</Knopf>
<div aria-live="polite" className="text-sm">
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
</div>
</div>
</form>
</div>
</Karte>
);
}
+14 -2
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { Karte } from "@/components/karte"; import { Karte } from "@/components/karte";
import { Status } from "@/components/status"; import { Status } from "@/components/status";
import { darf } from "@/server/auth/rechte"; import { darf } from "@/server/auth/rechte";
@@ -55,7 +56,16 @@ export default async function KatalogSeite() {
</ul> </ul>
</Karte> </Karte>
<Karte titel="Profile"> <Karte
titel="Profile"
aktion={
verwalten ? (
<Link href="/katalog/profile/neu" className="text-akzent text-sm font-semibold">
Profil anlegen
</Link>
) : null
}
>
<ul className="divide-linie flex flex-col divide-y"> <ul className="divide-linie flex flex-col divide-y">
{profile.map((p) => ( {profile.map((p) => (
<li <li
@@ -64,7 +74,9 @@ export default async function KatalogSeite() {
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium"> <p className="font-medium">
{p.name}{" "} <Link href={`/katalog/profile/${p.id}`} className="hover:underline">
{p.name}
</Link>{" "}
<span className="text-muted font-mono text-xs"> <span className="text-muted font-mono text-xs">
{p.id} · {p.herkunft === "KATALOG" ? "Katalog" : "eigenes"} {p.id} · {p.herkunft === "KATALOG" ? "Katalog" : "eigenes"}
</span> </span>
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { ProfilEditor } from "../profil-editor";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
return { title: `Profil ${(await params).id}` };
}
/** Katalogprofile nur ansehen (bearbeiten als Kopie), eigene bearbeiten. */
export default async function ProfilSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const benutzer = await seiteSchuetzen("lesen", `/katalog/profile/${id}`);
const profil = await prisma.profil.findUnique({ where: { id } });
if (!profil || profil.entferntAm) notFound();
const bearbeitbar = profil.herkunft === "EIGEN" && darf(benutzer.rolle, "katalogVerwalten");
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-muted font-mono text-sm">
{profil.id} · {profil.herkunft === "KATALOG" ? "Katalog (schreibgeschützt)" : "eigenes Profil"}
</p>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{profil.name}</h1>
</div>
{profil.herkunft === "KATALOG" && darf(benutzer.rolle, "katalogVerwalten") ? (
<Link
href={`/katalog/profile/neu?von=${profil.id}`}
className="border-linie bg-panel-hoch hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
>
Als Kopie bearbeiten
</Link>
) : null}
</header>
<ProfilEditor
id={bearbeitbar ? profil.id : undefined}
anfang={profil.quelltext}
schreibgeschuetzt={!bearbeitbar}
/>
</div>
);
}
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { ProfilEditor } from "../profil-editor";
export const metadata: Metadata = { title: "Profil anlegen" };
const VORLAGE = `# Eigenes Profil – Format und Schritte: docs/katalog.md
id: mein-profil
name: Mein Profil
description: Was das Profil tut
requires: [base]
variables:
- { name: beispiel, label: Beispielwert, default: hallo }
families:
debian:
- packages: [htop]
- file:
path: /etc/motd
content: |
{{ guest.name }} – {{ var.beispiel }}
`;
/** Neues Profil, leer oder als Kopie (`?von=<id>`). */
export default async function ProfilNeu({ searchParams }: { searchParams: Promise<{ von?: string }> }) {
await seiteSchuetzen("katalogVerwalten", "/katalog/profile/neu");
const von = (await searchParams).von;
const quelle = von ? await prisma.profil.findUnique({ where: { id: von } }) : null;
const anfang = quelle
? quelle.quelltext
.replace(/^id:\s*.+$/m, `id: ${quelle.id}-eigen`)
.replace(/^name:\s*(.+)$/m, "name: $1 (eigen)")
: VORLAGE;
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
{quelle ? `${quelle.name} als Kopie` : "Profil anlegen"}
</h1>
<ProfilEditor anfang={anfang} schreibgeschuetzt={false} />
</div>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { Knopf } from "@/components/knopf";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
type Pruefung = { ok: true; vorschau: string | null } | { ok: false; fehler: string[] };
/**
* YAML-Editor für Profile mit Prüfung beim Tippen (nach kurzer Pause) und Vorschau des Skripts.
* Gespeichert wird nur, was der Server für gültig hält.
*/
export function ProfilEditor({
id,
anfang,
schreibgeschuetzt,
}: {
id?: string;
anfang: string;
schreibgeschuetzt: boolean;
}) {
const router = useRouter();
const [text, setText] = useState(anfang);
const [pruefung, setPruefung] = useState<Pruefung | null>(null);
const [meldung, setMeldung] = useState<{ text: string; hinweis?: string } | null>(null);
const [speichert, setSpeichert] = useState(false);
const zaehler = useRef(0);
useEffect(() => {
const nummer = ++zaehler.current;
const uhr = setTimeout(async () => {
try {
const ergebnis = await apiAufruf<Pruefung>("POST", "/api/v1/profile/pruefen", { quelltext: text });
// Eine ältere Antwort darf eine neuere nicht überschreiben.
if (nummer === zaehler.current) setPruefung(ergebnis);
} catch {
// Prüfung ist Komfort – beim Speichern prüft der Server ohnehin.
}
}, 400);
return () => clearTimeout(uhr);
}, [text]);
async function speichern() {
setSpeichert(true);
setMeldung(null);
try {
const gespeichert = id
? await apiAufruf<{ id: string }>("PUT", `/api/v1/profile/${id}`, { quelltext: text })
: await apiAufruf<{ id: string }>("POST", "/api/v1/profile", { quelltext: text });
router.push(`/katalog/profile/${gespeichert.id}`);
router.refresh();
} catch (fehler) {
setMeldung(
fehler instanceof ApiFehler
? { text: fehler.message, hinweis: fehler.hinweis }
: { text: "Unerwarteter Fehler." },
);
} finally {
setSpeichert(false);
}
}
async function loeschen() {
if (!id || !window.confirm(`Profil „${id}“ wirklich löschen?`)) return;
try {
await apiAufruf("DELETE", `/api/v1/profile/${id}`);
router.push("/katalog");
router.refresh();
} catch (fehler) {
setMeldung(
fehler instanceof ApiFehler
? { text: fehler.message, hinweis: fehler.hinweis }
: { text: "Unerwarteter Fehler." },
);
}
}
return (
<div className="flex flex-col gap-6">
<Karte titel="YAML">
<label htmlFor="quelltext" className="sr-only">
Profil als YAML
</label>
<textarea
id="quelltext"
value={text}
onChange={(e) => setText(e.target.value)}
readOnly={schreibgeschuetzt}
spellCheck={false}
rows={Math.min(40, Math.max(14, text.split("\n").length + 2))}
className="bg-grund border-linie text-text w-full rounded-lg border p-3 font-mono text-xs leading-5 sm:text-sm"
aria-describedby="pruefung"
/>
<div id="pruefung" aria-live="polite" className="mt-3 text-sm">
{pruefung === null ? (
<Status art="laeuft">Prüft …</Status>
) : pruefung.ok ? (
<Status art="erledigt">Gültig</Status>
) : (
<ul className="flex flex-col gap-1">
{pruefung.fehler.map((f) => (
<li key={f}>
<Status art="fehler">{f}</Status>
</li>
))}
</ul>
)}
</div>
</Karte>
{pruefung?.ok && pruefung.vorschau ? (
<details className="bg-panel border-linie rounded-karte border">
<summary className="cursor-pointer px-5 py-3.5 font-semibold">Vorschau: erzeugtes Skript</summary>
<pre className="border-linie max-h-[50vh] overflow-auto border-t p-5 font-mono text-xs">
{pruefung.vorschau}
</pre>
</details>
) : null}
{!schreibgeschuetzt ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<div aria-live="polite" className="text-sm">
{meldung ? (
<div>
<Status art="fehler">{meldung.text}</Status>
{meldung.hinweis ? <p className="text-muted">{meldung.hinweis}</p> : null}
</div>
) : null}
</div>
<div className="flex gap-3">
{id ? (
<Knopf variante="gefahr" onClick={loeschen}>
Löschen
</Knopf>
) : null}
<Knopf onClick={speichern} disabled={speichert || !pruefung?.ok}>
{speichert ? "Speichert …" : "Speichern"}
</Knopf>
</div>
</div>
) : null}
</div>
);
}
+49 -1
View File
@@ -43,6 +43,9 @@ type Werte = {
templateVmidVon: string; templateVmidVon: string;
templateVmidBis: string; templateVmidBis: string;
gastVmidVon: string; gastVmidVon: string;
bauIp: string;
bauGateway: string;
bauDns: string;
}; };
function anfangswerte(server?: ServerSicht): Werte { function anfangswerte(server?: ServerSicht): Werte {
@@ -60,6 +63,9 @@ function anfangswerte(server?: ServerSicht): Werte {
templateVmidVon: String(server?.templateVmidVon ?? 9000), templateVmidVon: String(server?.templateVmidVon ?? 9000),
templateVmidBis: String(server?.templateVmidBis ?? 9999), templateVmidBis: String(server?.templateVmidBis ?? 9999),
gastVmidVon: String(server?.gastVmidVon ?? 100), gastVmidVon: String(server?.gastVmidVon ?? 100),
bauIp: server?.bauNetz?.ip ?? "",
bauGateway: server?.bauNetz?.gateway ?? "",
bauDns: server?.bauNetz?.dns.join(", ") ?? "",
}; };
} }
@@ -117,12 +123,16 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
setSpeichert(true); setSpeichert(true);
setFelder({}); setFelder({});
setMeldung(null); setMeldung(null);
const { bauIp, bauGateway, bauDns, ...ohneBau } = werte;
const rumpf = { const rumpf = {
...werte, ...ohneBau,
sshPort: Number(werte.sshPort), sshPort: Number(werte.sshPort),
templateVmidVon: Number(werte.templateVmidVon), templateVmidVon: Number(werte.templateVmidVon),
templateVmidBis: Number(werte.templateVmidBis), templateVmidBis: Number(werte.templateVmidBis),
gastVmidVon: Number(werte.gastVmidVon), gastVmidVon: Number(werte.gastVmidVon),
bauNetz: bauIp.trim()
? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) }
: null,
}; };
try { try {
const gespeichert = bearbeiten const gespeichert = bearbeiten
@@ -361,6 +371,44 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
</Feld> </Feld>
</div> </div>
<fieldset className="flex flex-col gap-3">
<legend className="text-sm font-medium">Bau-Adresse (optional)</legend>
<p className="text-muted text-sm">
Feste Adresse für den Vorbereitungsstart beim Template-Bau. Nur nötig ohne SSH zum Node:
ProxDeploy meldet sich darüber kurz in der VM an und installiert den Guest-Agent. Braucht
Internetzugang.
</p>
<div className="grid gap-4 sm:grid-cols-3">
<Feld id="bauIp" label="IP/Präfix" fehler={felder["bauNetz.ip"]}>
<input
id="bauIp"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.bauIp}
onChange={(e) => setze("bauIp", e.target.value)}
placeholder="10.0.10.250/24"
/>
</Feld>
<Feld id="bauGateway" label="Gateway" fehler={felder["bauNetz.gateway"]}>
<input
id="bauGateway"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.bauGateway}
onChange={(e) => setze("bauGateway", e.target.value)}
placeholder="10.0.10.1"
/>
</Feld>
<Feld id="bauDns" label="DNS" fehler={felder["bauNetz.dns"]}>
<input
id="bauDns"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.bauDns}
onChange={(e) => setze("bauDns", e.target.value)}
placeholder="10.0.10.1"
/>
</Feld>
</div>
</fieldset>
<Schalter <Schalter
id="sshAktiv" id="sshAktiv"
label="SSH zu den Nodes nutzen" label="SSH zu den Nodes nutzen"
+139
View File
@@ -0,0 +1,139 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { prisma } from "@/lib/db";
import { zeitpunkt } from "@/lib/formate";
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { AnwendungsFehler } from "@/server/fehler";
import { distributionenListe, profileListe } from "@/server/services/katalog";
import { templatesListe, type TemplateSicht } from "@/server/services/templates";
import { TemplateBauen } from "./template-bauen";
export const metadata: Metadata = { title: "Templates" };
export default async function TemplatesSeite() {
const benutzer = await seiteSchuetzen("lesen", "/templates");
const alle = await prisma.server.findMany({ orderBy: { name: "asc" } });
const name = aktiverServer(
alle.map((s) => s.name),
(await cookies()).get(SERVER_COOKIE)?.value,
);
const server = alle.find((s) => s.name === name);
if (!server) {
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Templates</h1>
<Karte>
<Status art="offen">Noch kein Server angelegt.</Status>
</Karte>
</div>
);
}
let templates: TemplateSicht[] = [];
let fehler: { ursache: string; hinweis?: string } | null = null;
try {
templates = await templatesListe(server.name);
} catch (ursache) {
if (!(ursache instanceof AnwendungsFehler)) throw ursache;
fehler = { ursache: ursache.message, hinweis: ursache.hinweis };
}
const bericht = server.letzterTest as VerbindungsBericht | null;
const distributionen = (await distributionenListe({ typ: "vm", nurAktive: true })).map((d) => ({
id: d.id,
name: d.name,
familie: d.familie,
}));
return (
<div className="flex flex-col gap-6">
<header>
<p className="text-muted font-mono text-sm">{server.name}</p>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Templates</h1>
<p className="text-muted text-sm">
VM-Templates mit eingebautem Guest-Agent, VMID-Bereich {server.templateVmidVon}–
{server.templateVmidBis}.
</p>
</header>
<Karte titel="Vorhanden">
{fehler ? (
<div>
<Status art="fehler">{fehler.ursache}</Status>
{fehler.hinweis ? <p className="text-muted mt-1 text-sm">{fehler.hinweis}</p> : null}
</div>
) : templates.length === 0 ? (
<Status art="offen">Noch kein ProxDeploy-Template auf diesem Server.</Status>
) : (
<ul className="divide-linie flex flex-col divide-y">
{templates.map((t) => (
<li
key={t.vmid}
className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:gap-4"
>
<span className="font-mono sm:w-16">{t.vmid}</span>
<span className="min-w-0 flex-1">
<span className="font-medium">{t.name}</span>
<span className="text-muted text-sm">
{" "}
· {t.knoten}
{t.distributionId ? ` · ${t.distributionId}` : ""}
{t.profile.length > 0 ? ` · mit ${t.profile.join(", ")}` : ""}
</span>
</span>
<span className="text-muted text-sm">
{t.gebautAm ? `gebaut ${zeitpunkt(t.gebautAm)}` : "nicht von hier gebaut"}
</span>
</li>
))}
</ul>
)}
</Karte>
{darf(benutzer.rolle, "ausrollen") ? (
bericht ? (
<Karte titel="Template bauen">
<TemplateBauen
server={server.name}
distributionen={distributionen}
knoten={bericht.knoten
.filter((k) => k.online)
.map((k) => ({
name: k.name,
storages: k.storages
.filter((s) => s.aktiv && s.inhalte.includes("images"))
.map((s) => s.storage),
bridges: k.bridges.map((b) => b.name),
}))}
faehigkeit={bericht.faehigkeiten.templateBau}
profile={(await profileListe({ nurAktive: true }))
.filter((p) => !p.definition.variables.some((v) => v.required && v.default === undefined))
.map((p) => ({
id: p.id,
name: p.name,
requires: p.definition.requires,
familien: p.familien,
}))}
/>
</Karte>
) : (
<Karte titel="Template bauen">
<Status art="offen">
Erst die Verbindung testen –{" "}
<Link href={`/server/${server.name}`} className="underline">
zur Serverseite
</Link>
.
</Status>
</Karte>
)
) : null}
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { EINGABE_KLASSE, Feld } from "@/components/formular";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
type Knoten = { name: string; storages: string[]; bridges: string[] };
export function TemplateBauen({
server,
distributionen,
knoten,
faehigkeit,
profile,
}: {
server: string;
distributionen: Array<{ id: string; name: string; familie: string }>;
profile: Array<{ id: string; name: string; requires: string[]; familien: string[] }>;
knoten: Knoten[];
faehigkeit: { weg: "api" | "ssh" | "nicht_moeglich"; grund: string };
}) {
const router = useRouter();
const [werte, setWerte] = useState({
distribution: distributionen.find((d) => d.id === "debian-13")?.id ?? distributionen[0]?.id ?? "",
knoten: knoten[0]?.name ?? "",
storage: knoten[0]?.storages[0] ?? "",
bridge: knoten[0]?.bridges.includes("vmbr0") ? "vmbr0" : (knoten[0]?.bridges[0] ?? ""),
profile: [] as string[],
});
const [fehler, setFehler] = useState<{ text: string; hinweis?: string } | null>(null);
const [sendet, setSendet] = useState(false);
const aktuell = knoten.find((k) => k.name === werte.knoten);
async function bauen(ereignis: React.FormEvent) {
ereignis.preventDefault();
setSendet(true);
setFehler(null);
try {
const { kurzId } = await apiAufruf<{ kurzId: string }>(
"POST",
`/api/v1/server/${server}/templates`,
werte,
);
router.push(`/auftraege/${kurzId}`);
} catch (ursache) {
setFehler(
ursache instanceof ApiFehler
? { text: ursache.message, hinweis: ursache.hinweis }
: { text: "Unerwarteter Fehler." },
);
setSendet(false);
}
}
return (
<form onSubmit={bauen} className="flex flex-col gap-4">
<Status art={faehigkeit.weg === "api" ? "erledigt" : faehigkeit.weg === "ssh" ? "laeuft" : "fehler"}>
{faehigkeit.weg === "api" ? "Per API" : faehigkeit.weg === "ssh" ? "Per SSH" : "Nicht möglich"} –{" "}
{faehigkeit.grund}
</Status>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Feld id="distribution" label="Distribution">
<select
id="distribution"
className={EINGABE_KLASSE}
value={werte.distribution}
onChange={(e) => setWerte({ ...werte, distribution: e.target.value })}
>
{distributionen.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</Feld>
<Feld id="knoten" label="Node">
<select
id="knoten"
className={EINGABE_KLASSE}
value={werte.knoten}
onChange={(e) => {
const k = knoten.find((x) => x.name === e.target.value);
setWerte({
...werte,
knoten: e.target.value,
storage: k?.storages[0] ?? "",
bridge: k?.bridges[0] ?? "",
});
}}
>
{knoten.map((k) => (
<option key={k.name}>{k.name}</option>
))}
</select>
</Feld>
<Feld id="storage" label="Storage">
<select
id="storage"
className={EINGABE_KLASSE}
value={werte.storage}
onChange={(e) => setWerte({ ...werte, storage: e.target.value })}
>
{aktuell?.storages.map((s) => (
<option key={s}>{s}</option>
))}
</select>
</Feld>
<Feld id="bridge" label="Bridge (Vorbereitungsstart)">
<select
id="bridge"
className={EINGABE_KLASSE}
value={werte.bridge}
onChange={(e) => setWerte({ ...werte, bridge: e.target.value })}
>
{aktuell?.bridges.map((b) => (
<option key={b}>{b}</option>
))}
</select>
</Feld>
</div>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Profile einbacken (optional)</legend>
<p className="text-muted text-sm">
Was hier gewählt ist, ist in jedem Klon schon installiert – Deployments mit diesen Profilen sind
schneller fertig. Profile mit Pflichtangaben (z. B. Zabbix-Server) laufen beim Ausrollen.
</p>
<div className="flex flex-wrap gap-x-6 gap-y-2">
{profile
.filter((p) =>
p.familien.includes(distributionen.find((d) => d.id === werte.distribution)?.familie ?? ""),
)
.map((p) => {
const benoetigt = profile.some(
(x) => werte.profile.includes(x.id) && x.requires.includes(p.id),
);
return (
<label key={p.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="accent-akzent h-4 w-4"
checked={werte.profile.includes(p.id) || benoetigt}
disabled={benoetigt && !werte.profile.includes(p.id)}
onChange={(e) =>
setWerte({
...werte,
profile: e.target.checked
? [...werte.profile, p.id]
: werte.profile.filter((x) => x !== p.id),
})
}
/>
{p.name}
</label>
);
})}
</div>
</fieldset>
<p className="text-muted text-sm">
Der Vorbereitungsstart braucht DHCP und Internetzugang auf der Bridge, um qemu-guest-agent zu
installieren.
</p>
<div className="flex flex-wrap items-center gap-3">
<Knopf type="submit" disabled={sendet || faehigkeit.weg === "nicht_moeglich" || !werte.distribution}>
{sendet ? "Startet …" : "Template bauen"}
</Knopf>
<div aria-live="polite" className="text-sm">
{fehler ? (
<div>
<Status art="fehler">{fehler.text}</Status>
{fehler.hinweis ? <p className="text-muted">{fehler.hinweis}</p> : null}
</div>
) : null}
</div>
</div>
</form>
);
}
+68
View File
@@ -0,0 +1,68 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { vorlagenListe } from "@/server/services/vorlagen";
import { VorlageLoeschen } from "./vorlage-loeschen";
export const metadata: Metadata = { title: "Vorlagen" };
export default async function Vorlagen() {
const benutzer = await seiteSchuetzen("lesen", "/vorlagen");
const liste = await vorlagenListe();
const pflegen = darf(benutzer.rolle, "vorlagenPflegen");
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Vorlagen</h1>
<p className="text-muted text-sm">
Gespeicherte Kombinationen für den Assistenten und die CLI (
<span className="font-mono">--preset</span>). Anlegen: im Assistenten auf der Zusammenfassung „Als
Vorlage speichern“.
</p>
</header>
{liste.length === 0 ? (
<Karte>
<Status art="offen">Noch keine Vorlage.</Status>
</Karte>
) : (
<ul className="grid gap-4 md:grid-cols-2">
{liste.map((v) => {
const p = v.parameter;
const eckdaten = [
p.typ?.toUpperCase(),
p.distribution,
p.cores ? `${p.cores} CPU` : null,
p.speicherMb ? `${p.speicherMb / 1024} GB RAM` : null,
p.diskGb ? `${p.diskGb} GB Disk` : null,
].filter(Boolean);
return (
<li key={v.name} className="bg-panel border-linie rounded-karte flex flex-col gap-3 border p-5">
<div>
<p className="font-semibold">{v.titel}</p>
<p className="text-muted font-mono text-xs">--preset {v.name}</p>
</div>
{v.beschreibung ? <p className="text-muted text-sm">{v.beschreibung}</p> : null}
<p className="font-mono text-xs">{eckdaten.join(" · ")}</p>
{p.profile && p.profile.length > 0 ? (
<p className="text-muted text-xs">Profile: {p.profile.join(", ")}</p>
) : null}
<div className="mt-auto flex flex-wrap items-center gap-3 pt-2">
<Link
href={`/ausrollen?vorlage=${v.name}`}
className="bg-akzent text-akzent-text inline-flex h-11 items-center rounded-lg px-4 text-sm font-semibold sm:h-10"
>
Ausrollen mit dieser Vorlage
</Link>
{pflegen ? <VorlageLoeschen name={v.name} /> : null}
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -0,0 +1,21 @@
"use client";
import { useRouter } from "next/navigation";
import { Knopf } from "@/components/knopf";
import { apiAufruf } from "@/lib/api-client";
export function VorlageLoeschen({ name }: { name: string }) {
const router = useRouter();
return (
<Knopf
variante="gefahr"
onClick={async () => {
if (!window.confirm(`Vorlage „${name}“ löschen?`)) return;
await apiAufruf("DELETE", `/api/v1/vorlagen/${name}`);
router.refresh();
}}
>
Löschen
</Knopf>
);
}
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftragAbbrechen } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
await auftragAbbrechen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return NextResponse.json({ ok: true });
});
@@ -0,0 +1,101 @@
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { abonnieren } from "@/server/ereignisse/hoeren";
import { auftragHolen, ENDZUSTAENDE, logAb } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
export const dynamic = "force-dynamic";
/**
* Live-Fortschritt als Server-Sent Events – für Web-GUI und CLI derselbe Strom.
*
* - `stand`: der vollständige Auftrag mit allen Phasen (klein, deshalb immer ganz)
* - `log`: neue Detail-Log-Zeilen; ihre ID ist die Event-ID, damit ein Wiederverbinden mit
* `Last-Event-ID` (oder `?ab=`) genau dort weitermacht
* - `ende`: der Auftrag ist fertig, der Strom schließt
*/
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
const anfangsStand = await auftragHolen((await params).id);
const auftragId = anfangsStand.id;
const letzte = anfrage.headers.get("last-event-id") ?? new URL(anfrage.url).searchParams.get("ab") ?? "0";
let logBis = BigInt(/^\d+$/.test(letzte) ? letzte : "0");
const kodierer = new TextEncoder();
let aufraeumen = () => {};
const strom = new ReadableStream<Uint8Array>({
async start(steuerung) {
let offen = true;
const senden = (text: string) => {
if (offen) steuerung.enqueue(kodierer.encode(text));
};
const ereignis = (art: string, daten: unknown, id?: string) =>
senden(`${id ? `id: ${id}\n` : ""}event: ${art}\ndata: ${JSON.stringify(daten)}\n\n`);
let laeuft = false;
let nochmal = false;
const aktualisieren = async () => {
// Signale können dicht kommen; zusammenfassen statt parallel abzufragen.
if (laeuft) {
nochmal = true;
return;
}
laeuft = true;
try {
do {
nochmal = false;
const stand = await auftragHolen(auftragId);
const zeilen = await logAb(auftragId, logBis);
if (zeilen.length > 0) {
logBis = BigInt(zeilen.at(-1)!.id);
ereignis("log", zeilen, zeilen.at(-1)!.id);
}
ereignis("stand", stand);
if (ENDZUSTAENDE.has(stand.status)) {
ereignis("ende", { status: stand.status });
schliessen();
return;
}
} while (nochmal);
} finally {
laeuft = false;
}
};
const abmelden = await abonnieren(auftragId, () => void aktualisieren());
// Herzschlag: hält Proxys davon ab, die Verbindung als tot zu schließen.
const herz = setInterval(() => senden(": ping\n\n"), 15_000);
const schliessen = () => {
if (!offen) return;
offen = false;
clearInterval(herz);
abmelden();
try {
steuerung.close();
} catch {
// schon geschlossen
}
};
aufraeumen = schliessen;
anfrage.signal.addEventListener("abort", schliessen);
senden("retry: 3000\n\n");
await aktualisieren();
},
cancel() {
aufraeumen();
},
});
return new Response(strom, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
// nginx puffert sonst und der Fortschritt käme in Brocken an.
"x-accel-buffering": "no",
},
});
});
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftragHolen, logAb } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
/** Detail-Log ab einer Zeilen-ID (`?ab=`), für Polling und `proxdeploy logs`. */
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
const auftrag = await auftragHolen((await params).id);
const ab = new URL(anfrage.url).searchParams.get("ab");
return NextResponse.json(await logAb(auftrag.id, BigInt(/^\d+$/.test(ab ?? "") ? ab! : "0")));
});
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftragHolen } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await auftragHolen((await params).id));
});
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { ausrollenPlanen } from "@/server/services/ausrollen";
/** Dry-Run: Phasen, geplante Proxmox-Aufrufe, Skript und CLI-Befehl – angelegt wird nichts. */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage), aufrufer));
});
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { ausrollenStarten } from "@/server/services/ausrollen";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftraegeListe } from "@/server/services/auftraege";
/** Verlauf. Filter und Seitenblättern folgen mit #014. */
export const GET = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await auftraegeListe());
});
/** Ausrollen → 202 mit Kurz-ID; Fortschritt unter /api/v1/auftraege/<kurzId>/ereignisse. */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
const auftrag = await ausrollenStarten(
await jsonLesen(anfrage),
aufrufer,
aufruferAdresse(anfrage.headers),
);
return NextResponse.json({ id: auftrag.id, kurzId: auftrag.kurzId }, { status: 202 });
});
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api";
import { mitPasswortAnmelden } from "@/server/services/anmeldung";
import { tokenAnlegen } from "@/server/services/tokens";
const Eingabe = z.object({
benutzername: z.string().trim().min(1, "Benutzername fehlt.").max(64),
passwort: z.string().min(1, "Passwort fehlt.").max(1024),
name: z.string().trim().min(1, "Name fehlt.").max(80),
tage: z.coerce.number().int().min(1).max(3650).optional(),
});
/**
* `proxdeploy login`: Benutzername + Passwort gegen ein API-Token tauschen. Öffentlich wie die
* Anmeldung, mit derselben Sperre gegen Durchprobieren.
*/
export const POST = api(async (anfrage: Request) => {
const eingabe = Eingabe.parse(await jsonLesen(anfrage));
const ip = aufruferAdresse(anfrage.headers);
const benutzer = await mitPasswortAnmelden(eingabe.benutzername, eingabe.passwort, ip, "CLI");
const token = await tokenAnlegen(
{ name: eingabe.name, ...(eingabe.tage ? { tage: eingabe.tage } : {}) },
{ benutzer, quelle: "CLI" },
ip,
);
return NextResponse.json(
{ ...token, benutzername: benutzer.benutzername, rolle: benutzer.rolle },
{ status: 201 },
);
});
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { openApi } from "@/server/openapi";
/** Die OpenAPI-Beschreibung (für Angemeldete – sie verrät nichts, aber auch niemandem etwas Fremdem). */
export const GET = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(openApi());
});
+17 -1
View File
@@ -3,7 +3,7 @@ import { aufruferAdresse } from "@/lib/adressen";
import { z } from "@/lib/validierung"; import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api"; import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang"; import { rechtErforderlich } from "@/server/auth/zugang";
import { profilAktivSetzen } from "@/server/services/katalog"; import { profilAktivSetzen, profilLoeschen, profilSpeichern } from "@/server/services/katalog";
type Kontext = { params: Promise<{ id: string }> }; type Kontext = { params: Promise<{ id: string }> };
@@ -13,3 +13,19 @@ export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
await profilAktivSetzen((await params).id, aktiv, aufrufer, aufruferAdresse(anfrage.headers)); await profilAktivSetzen((await params).id, aktiv, aufrufer, aufruferAdresse(anfrage.headers));
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
}); });
export const PUT = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
const { quelltext } = z
.object({ quelltext: z.string().min(1).max(200_000) })
.parse(await jsonLesen(anfrage));
return NextResponse.json(
await profilSpeichern(quelltext, aufrufer, aufruferAdresse(anfrage.headers), (await params).id),
);
});
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
await profilLoeschen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return new NextResponse(null, { status: 204 });
});
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { profilPruefen } from "@/server/services/katalog";
/** Live-Prüfung im Editor: Fehlerliste oder Vorschau des Skripts. Speichert nichts. */
export const POST = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "katalogVerwalten");
const { quelltext } = z.object({ quelltext: z.string().max(200_000) }).parse(await jsonLesen(anfrage));
return NextResponse.json(await profilPruefen(quelltext));
});
+15 -2
View File
@@ -1,8 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { api } from "@/server/api"; import { aufruferAdresse } from "@/lib/adressen";
import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api";
import { darf } from "@/server/auth/rechte"; import { darf } from "@/server/auth/rechte";
import { rechtErforderlich } from "@/server/auth/zugang"; import { rechtErforderlich } from "@/server/auth/zugang";
import { profileListe } from "@/server/services/katalog"; import { profileListe, profilSpeichern } from "@/server/services/katalog";
export const GET = api(async (anfrage: Request) => { export const GET = api(async (anfrage: Request) => {
const { benutzer } = await rechtErforderlich(anfrage, "lesen"); const { benutzer } = await rechtErforderlich(anfrage, "lesen");
@@ -10,3 +12,14 @@ export const GET = api(async (anfrage: Request) => {
new URL(anfrage.url).searchParams.get("alle") === "1" && darf(benutzer.rolle, "katalogVerwalten"); new URL(anfrage.url).searchParams.get("alle") === "1" && darf(benutzer.rolle, "katalogVerwalten");
return NextResponse.json(await profileListe({ nurAktive: !alle })); return NextResponse.json(await profileListe({ nurAktive: !alle }));
}); });
/** Eigenes Profil anlegen (YAML im Feld `quelltext`). Nur Admins – Profile führen Befehle als root aus. */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
const { quelltext } = z
.object({ quelltext: z.string().min(1).max(200_000) })
.parse(await jsonLesen(anfrage));
return NextResponse.json(await profilSpeichern(quelltext, aufrufer, aufruferAdresse(anfrage.headers)), {
status: 201,
});
});
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { schluesselLoeschen } from "@/server/services/schluessel";
type Kontext = { params: Promise<{ id: string }> };
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
await schluesselLoeschen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return new NextResponse(null, { status: 204 });
});
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { schluesselAnlegen, schluesselFuer } from "@/server/services/schluessel";
/** Die eigenen und die Team-Schlüssel – das, was ohne Angabe in jeden Gast kommt. */
export const GET = api(async (anfrage: Request) => {
const { benutzer } = await rechtErforderlich(anfrage, "eigeneZugaenge");
return NextResponse.json(await schluesselFuer(benutzer));
});
/** `{ name, oeffentlich, team? }` – Team-Schlüssel nur für Admins. */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
return NextResponse.json(
await schluesselAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
{ status: 201 },
);
});
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { templateBauStarten, templatesListe } from "@/server/services/templates";
type Kontext = { params: Promise<{ name: string }> };
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await templatesListe((await params).name));
});
/** Template bauen → Auftrag; Fortschritt unter /api/v1/auftraege/<kurzId>/ereignisse. */
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
const auftrag = await templateBauStarten(
(await params).name,
await jsonLesen(anfrage),
aufrufer,
aufruferAdresse(anfrage.headers),
);
return NextResponse.json({ id: auftrag.id, kurzId: auftrag.kurzId }, { status: 202 });
});
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { tokenLoeschen } from "@/server/services/tokens";
type Kontext = { params: Promise<{ id: string }> };
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
await tokenLoeschen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return new NextResponse(null, { status: 204 });
});

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