Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
543fc1c8b4 | ||
|
|
4ae08abd7c | ||
|
|
bd4bca5399 | ||
|
|
9fdced62db | ||
|
|
1e195f9bca | ||
|
|
8bfa89fb3b | ||
|
|
21edff2141 | ||
|
|
426d3ec95a | ||
|
|
00712dc6ce | ||
|
|
ebcf63100a | ||
|
|
1b1ee4b876 | ||
|
|
e4306a6a53 | ||
|
|
d25ec54ef9 |
@@ -17,6 +17,37 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### 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
|
||||
|
||||
@@ -38,6 +38,7 @@ src/app/api/v1/* Route Handler: Recht prüfen, zod, Service. Sonst nichts
|
||||
src/components/* Darstellung, keine Geschäftslogik.
|
||||
src/lib/* Framework-freie Helfer; was Client und Server teilen, liegt hier.
|
||||
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.
|
||||
@@ -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),
|
||||
**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.
|
||||
Prisma 7: Client aus `@/generated/prisma/client`, Enum-Werte aus `@/generated/prisma/enums`, Zugriff
|
||||
ü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
|
||||
pnpm db:up
|
||||
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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 2 h 50 min (Sitzung 1)
|
||||
**Gesamt:** 5 h 20 min (Sitzung 1–3)
|
||||
|
||||
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
||||
|
||||
@@ -16,11 +16,67 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
||||
| #006 Aufträge und Live-Fortschritt | 0 h 15 min |
|
||||
| #007 Template-Bau | 0 h 12 min |
|
||||
| #008 VM ausrollen und Assistent | 0 h 36 min |
|
||||
| #009 LXC | 0 h 31 min |
|
||||
| #010 Ubuntu | 0 h 05 min |
|
||||
| #011 Profil-Editor | 0 h 10 min |
|
||||
| #012 Vorlagen | 0 h 11 min |
|
||||
| #022 Profile im Template | 0 h 22 min |
|
||||
| #015 (Teil) SSH-Schlüssel | 0 h 10 min |
|
||||
| #021 Template-Bau ohne SSH | 0 h 19 min |
|
||||
| #013 CLI und API-Tokens | 0 h 42 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
| v0.1.0 | 1 h 15 min | **abgeschlossen**
|
||||
| v0.2.0 | 1 h 32 min | **abgeschlossen**
|
||||
| v0.3.0 | 1 h 48 min | **abgeschlossen** (Abnahme #021 offen)
|
||||
| v0.4.0 | 0 h 42 min | **abgeschlossen**
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ In Entwicklung. Version und Fortschritt stehen im [CHANGELOG](CHANGELOG.md), der
|
||||
|---|---|---|
|
||||
| 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft |
|
||||
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt, Assistent | ✓ fertig, auf PVE 9.2 geprüft |
|
||||
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen | ◐ als Nächstes |
|
||||
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ○ geplant |
|
||||
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ 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), API-Tokens | ✓ fertig, auf PVE 9.2 geprüft |
|
||||
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ◐ als Nächstes |
|
||||
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
|
||||
|
||||
## 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
|
||||
[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
|
||||
|
||||
Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL.
|
||||
@@ -54,8 +120,12 @@ Prüfen vor jedem Commit:
|
||||
```bash
|
||||
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test
|
||||
pnpm test:e2e # Browser-Abläufe gegen den gebauten Stand (Desktop und 390 px)
|
||||
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).
|
||||
|
||||
## Betrieb
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Grundausstattung für jeden Gast. Platzhalter: {{ var.<name> }} aus den Variablen unten,
|
||||
# {{ os.id }}, {{ os.version_id }}, {{ os.codename }} aus der Distribution,
|
||||
# {{ guest.name }}, {{ guest.user }} aus dem Deployment. `a ?? b` nimmt b, wenn a leer ist.
|
||||
# Bedingungen: `if: <variable>`, dazu fest `if: vm` / `if: lxc`.
|
||||
id: base
|
||||
name: Basis
|
||||
description: Updates, Guest-Agent, Zeitzone, Locale, Tastatur, automatische Sicherheitsupdates, Zeitsync, SSH-Härtung
|
||||
@@ -16,8 +17,12 @@ families:
|
||||
- timezone: "{{ var.zeitzone }}"
|
||||
- locale: "{{ var.locale }}"
|
||||
- keyboard: "{{ var.tastatur }}"
|
||||
- packages: [chrony, sudo, curl, ca-certificates]
|
||||
- packages: [sudo, curl, ca-certificates]
|
||||
# Zeitsync nur in VMs: ein unprivilegierter Container darf die Uhr nicht stellen, er nimmt die des Hosts.
|
||||
- packages: [chrony]
|
||||
if: vm
|
||||
- service: { name: chrony, enable: true, start: true }
|
||||
if: vm
|
||||
- unattended_upgrades: true
|
||||
- ssh_harden: { password_auth: false, root_login: false }
|
||||
- packages: [fail2ban]
|
||||
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+19
@@ -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
@@ -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
@@ -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=
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{"◐", "◓", "◑", "◒"}
|
||||
@@ -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. │
|
||||
└───────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
@@ -0,0 +1,16 @@
|
||||
[38;2;139;152;165m⬡ [0m[38;2;230;237;243mProx[0m[1;38;2;45;212;191mDeploy[0m[38;2;139;152;165m v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013)[0m
|
||||
|
||||
[38;2;45;212;191m✓[0m [38;2;230;237;243mPrüfungen [0m[38;2;139;152;165mVMID 120 frei · local-zfs · vmbr0 VLAN 20[0m
|
||||
[38;2;45;212;191m✓[0m [38;2;230;237;243mKlonen [0m[38;2;139;152;165mfull clone 9013 → 120 · Disk auf 40G[0m
|
||||
[38;2;45;212;191m✓[0m [38;2;230;237;243mcloud-init [0m[38;2;139;152;165muser florian · SSH-Key · Profile base, docker[0m
|
||||
[38;2;45;212;191m✓[0m [38;2;230;237;243mStart [0m[38;2;139;152;165mVM 120 läuft[0m
|
||||
[38;2;45;212;191m✓[0m [38;2;230;237;243mGuest-Agent [0m[38;2;139;152;165mantwortet nach 18s[0m
|
||||
[38;2;245;184;65m◐[0m [38;2;230;237;243mProvisionierung [0m[38;2;139;152;165mcloud-init läuft … 1m 12s[0m
|
||||
|
||||
[38;2;45;212;191m┌──────────────────────────────────────┐[0m
|
||||
[38;2;45;212;191m│[0m [38;2;45;212;191m●[0m [1;38;2;230;237;243mweb01 bereit[0m[38;2;139;152;165m · 1m 54s[0m [38;2;45;212;191m│[0m
|
||||
[38;2;45;212;191m│[0m [38;2;139;152;165mVMID [0m[38;2;230;237;243m120[0m [38;2;45;212;191m│[0m
|
||||
[38;2;45;212;191m│[0m [38;2;139;152;165mIP [0m[38;2;230;237;243m10.0.20.15[0m [38;2;45;212;191m│[0m
|
||||
[38;2;45;212;191m│[0m [38;2;139;152;165mProfile [0m[38;2;230;237;243mbase, docker[0m [38;2;45;212;191m│[0m
|
||||
[38;2;45;212;191m│[0m [38;2;139;152;165mSSH [0m[38;2;230;237;243mssh florian@10.0.20.15[0m [38;2;45;212;191m│[0m
|
||||
[38;2;45;212;191m└──────────────────────────────────────┘[0m
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
[38;5;246m⬡ [0m[38;5;254mProx[0m[1;38;5;43mDeploy[0m[38;5;246m v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013)[0m
|
||||
|
||||
[38;5;43m✓[0m [38;5;254mPrüfungen [0m[38;5;246mVMID 120 frei · local-zfs · vmbr0 VLAN 20[0m
|
||||
[38;5;43m✓[0m [38;5;254mKlonen [0m[38;5;246mfull clone 9013 → 120 · Disk auf 40G[0m
|
||||
[38;5;43m✓[0m [38;5;254mcloud-init [0m[38;5;246muser florian · SSH-Key · Profile base, docker[0m
|
||||
[38;5;43m✓[0m [38;5;254mStart [0m[38;5;246mVM 120 läuft[0m
|
||||
[38;5;43m✓[0m [38;5;254mGuest-Agent [0m[38;5;246mantwortet nach 18s[0m
|
||||
[38;5;215m◐[0m [38;5;254mProvisionierung [0m[38;5;246mcloud-init läuft … 1m 12s[0m
|
||||
|
||||
[38;5;43m┌──────────────────────────────────────┐[0m
|
||||
[38;5;43m│[0m [38;5;43m●[0m [1;38;5;254mweb01 bereit[0m[38;5;246m · 1m 54s[0m [38;5;43m│[0m
|
||||
[38;5;43m│[0m [38;5;246mVMID [0m[38;5;254m120[0m [38;5;43m│[0m
|
||||
[38;5;43m│[0m [38;5;246mIP [0m[38;5;254m10.0.20.15[0m [38;5;43m│[0m
|
||||
[38;5;43m│[0m [38;5;246mProfile [0m[38;5;254mbase, docker[0m [38;5;43m│[0m
|
||||
[38;5;43m│[0m [38;5;246mSSH [0m[38;5;254mssh florian@10.0.20.15[0m [38;5;43m│[0m
|
||||
[38;5;43m└──────────────────────────────────────┘[0m
|
||||
@@ -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
@@ -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 │
|
||||
└──────────────────────────────────────┘
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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 –
|
||||
@@ -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
@@ -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
@@ -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). |
|
||||
+------------------------------------------------+
|
||||
@@ -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 │
|
||||
└──────────────────────────────────────┘
|
||||
@@ -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}}
|
||||
`
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
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
|
||||
Instanzen), Rechte 0600; `PROXDEPLOY_URL`/`PROXDEPLOY_TOKEN` überschreiben.
|
||||
- `login <url>` fragt Benutzername/Passwort ab, legt ein Token an und speichert es (oder `--token`).
|
||||
- Konfig `~/.config/proxdeploy/config.toml` (`url`, `token`, optional `server` als Standard und
|
||||
`[instances.<name>]` für mehrere Instanzen – nicht `profiles`, das hieße wie `--profile`), Rechte
|
||||
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,
|
||||
`--no-color`, `--ascii`, `--quiet`, `--json`, `--dry-run`. Breite der Phasenspalte aus dem längsten
|
||||
Phasennamen, Box-Breite aus dem Inhalt (Unicode-Breite beachtet).
|
||||
|
||||
+11
-6
@@ -1,21 +1,26 @@
|
||||
# #009 LXC-Container ausrollen
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature, proxmox
|
||||
**Geschätzt:** 8 h **Tatsächlich:** –
|
||||
**Geschätzt:** 8 h **Tatsächlich:** 0 h 30 min
|
||||
|
||||
## Ziel
|
||||
LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH)
|
||||
- [ ] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen)
|
||||
- [x] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH)
|
||||
- [x] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] LXC mit base auf echtem Proxmox, einmal mit und einmal ohne Node-SSH
|
||||
- [x] Auf pve-demo: pd-ct01 (Debian 13, base+docker, pct exec, docker hello-world im unprivilegierten CT mit nesting) und pd-ct02 (Ubuntu 24.04, base, SSH in den Container, Bereitstellungsschlüssel danach entfernt)
|
||||
|
||||
## Abhängigkeiten
|
||||
#008
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- **Fund:** chrony läuft in unprivilegierten Containern nicht (keine CAP_SYS_TIME). Profile kennen jetzt die festen Bedingungen `if: vm` / `if: lxc`; base installiert chrony nur in VMs.
|
||||
- **Fund:** Ubuntu 24.04 startet sshd per Socket, `/run/sshd` fehlt bis zur ersten Verbindung – `sshd -t` scheiterte. Der Adapter legt das Verzeichnis vorher an.
|
||||
- **Docker im unprivilegierten Container braucht nur nesting**, kein keyctl (geprüft auf PVE 9.2.2 mit Debian 13).
|
||||
- **keyctl** darf per Token nicht gesetzt werden; mit Node-SSH setzt ProxDeploy es per `pct set`, ohne lehnt die Prüfung ab.
|
||||
- **Bereitstellungsschlüssel** (eigener ed25519, verschlüsselt in den Einstellungen) nur für den Weg ohne Node-SSH; die Schlüssel des Benutzers gehen nie an root.
|
||||
- Bei mehreren passenden Container-Templates wird das neueste aus `aplinfo` geladen, wenn lokal nur ein älteres liegt.
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
# #010 Ubuntu 22.04, 24.04 und 26.04
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature, katalog
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 05 min
|
||||
|
||||
## Ziel
|
||||
Ubuntu als VM und LXC über den debian-Adapter.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades)
|
||||
- [ ] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12)
|
||||
- [x] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades)
|
||||
- [x] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Je eine Ubuntu-VM und ein Ubuntu-LXC ausgerollt
|
||||
- [x] Auf pve-demo: pd-ubu01 (Ubuntu 24.04 VM, Template 9001 im selben Auftrag gebaut) und pd-ct02 (Ubuntu 24.04 LXC)
|
||||
|
||||
## Abhängigkeiten
|
||||
#009
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- Der Adapter brauchte für Ubuntu nur eine Änderung: `/run/sshd` anlegen (sshd per Socket, #009). Locale-Erzeugung unterscheidet Debian/Ubuntu.
|
||||
- Ubuntu 22.04 und 26.04 laufen im Containertest (`pnpm test:container`); auf dem Testhost geprüft ist 24.04.
|
||||
- Der Voll-Klon dauerte auf pve-demo ~9 min (3,5 GB, Storage ~3 MB/s) – die Phase „Klonen“ zeigt jetzt den Fortschritt aus dem Task-Log.
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
# #011 Profile docker und zabbix-agent, Variablen, Editor
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature
|
||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
||||
**Geschätzt:** 6 h **Tatsächlich:** 0 h 10 min
|
||||
|
||||
## Ziel
|
||||
Kombinierbare Profile mit Variablen; eigene Profile im YAML-Editor.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive)
|
||||
- [ ] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie
|
||||
- [ ] YAML-Editor mit Validierung und Fehlerstelle
|
||||
- [x] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive)
|
||||
- [x] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie
|
||||
- [x] YAML-Editor mit Validierung und Fehlerstelle
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] docker run hello-world im ausgerollten Gast erfolgreich
|
||||
- [x] docker hello-world in pd-test01 (VM) und pd-ct01 (LXC); zabbix-agent2 aktiv in pd-test02
|
||||
|
||||
## Abhängigkeiten
|
||||
#005
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- Editor prüft beim Tippen (400 ms Pause) gegen dasselbe Schema wie der Katalog und zeigt das erzeugte Skript.
|
||||
- Katalogprofile sind schreibgeschützt; „Als Kopie bearbeiten“ legt `<id>-eigen` an. Eigene Profile mit Katalog-id werden abgewiesen.
|
||||
- Profile anlegen dürfen nur Admins – sie führen Befehle als root aus.
|
||||
- Löschen verweigert, solange ein anderes Profil es per `requires` braucht.
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
# #012 Vorlagen (Presets)
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 11 min
|
||||
|
||||
## Ziel
|
||||
Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] CRUD, „aus diesem Auftrag als Vorlage speichern“, Beispiel „Docker-Host klein“
|
||||
- [x] CRUD, „Als Vorlage speichern“ im Assistenten, Beispiel „Docker-Host klein“ (einmalig angelegt)
|
||||
- [x] Vorlagen-Auswahl im Assistenten, `?vorlage=` von der Vorlagenseite, `vorlage` in der API (für `--preset` der CLI)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] `--preset` in API und Assistent identisch aufgelöst
|
||||
- [x] `--preset` in API und Assistent identisch aufgelöst
|
||||
|
||||
## Abhängigkeiten
|
||||
#008
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- Eine Vorlage liegt **unter** der Anfrage: was die Anfrage sagt, gewinnt; netz/features/variablen feldweise, Listen ersetzt.
|
||||
- Name, VMID, IP und SSH-Schlüssel gehören nicht in eine Vorlage (Schema `strict`).
|
||||
- Operatoren dürfen Vorlagen pflegen (Recht `vorlagenPflegen`).
|
||||
|
||||
+20
-8
@@ -1,23 +1,35 @@
|
||||
# #013 CLI proxdeploy (Go)
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.4.0
|
||||
**Labels:** feature, cli
|
||||
**Geschätzt:** 12 h **Tatsächlich:** –
|
||||
**Geschätzt:** 12 h **Tatsächlich:** 0 h 42 min
|
||||
|
||||
## Ziel
|
||||
Alle Befehle aus der Vorgabe, Ausgabe exakt im Format der Vorgabe.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] OpenAPI aus zod, Go-Client per oapi-codegen, Prüfung auf Aktualität
|
||||
- [ ] login, servers, distros, profiles, vm, lxc, template build, batch, status, logs --follow
|
||||
- [ ] Renderer: Farbe 24-bit/256/aus, --ascii, --quiet, --json, --dry-run; Golden-Tests
|
||||
- [ ] Release-Builds für linux/darwin/windows × amd64/arm64
|
||||
- [x] API-Tokens (`pd_…`, nur SHA-256 gespeichert, Ablauf, Widerruf) mit Bearer-Anmeldung, `POST /auth/token` für `login`, Verwaltung in den Einstellungen
|
||||
- [x] Kurze Anfragen: Server füllt Storage, Bridge, Node, Benutzer, Schlüssel (`anfrageErgaenzen`), auch beim Template-Bau
|
||||
- [x] Abschlussbox serverseitig (`abschluss`), Web und CLI zeigen dieselben Zeilen
|
||||
- [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
|
||||
- [ ] 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
|
||||
#008, #012
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -9,7 +9,8 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test)
|
||||
- [ ] Benutzerverwaltung, CLI-Tokens, SSH-Keys über `SchluesselQuelle`
|
||||
- [ ] Benutzerverwaltung, CLI-Tokens
|
||||
- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar
|
||||
- [ ] Audit-Log-Ansicht mit Filter
|
||||
|
||||
## Akzeptanzkriterien
|
||||
@@ -19,4 +20,4 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
||||
#001
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- SSH-Schlüssel auf Florians Frage nach Schlüsseln im Template vorgezogen (2026-10-12): Schlüssel gehören in die Verwaltung, nicht ins Template. `SchluesselQuelle` ist die Andockstelle für eine externe Verwaltung (z. B. sshkeyvault).
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# #021 Template-Bau ohne SSH (Bau-Adresse)
|
||||
**Status:** offen
|
||||
**Status:** umgesetzt, Abnahme auf pve-demo ausstehend
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature, proxmox
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 19 min
|
||||
|
||||
## Ziel
|
||||
Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet,
|
||||
sondern über SSH **in die Vorbereitungs-VM** eingebaut.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Server-Einstellung `bauNetz`: Bridge, VLAN, statische IP/CIDR, Gateway, DNS
|
||||
- [ ] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys`
|
||||
- [ ] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten
|
||||
- [ ] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
|
||||
- [x] Server-Einstellung `bauNetz`: statische IP/CIDR, Gateway, DNS (Bridge/VLAN wie beim Template-Bau gewählt)
|
||||
- [x] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys`
|
||||
- [x] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten
|
||||
- [x] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH
|
||||
@@ -21,4 +21,7 @@ sondern über SSH **in die Vorbereitungs-VM** eingebaut.
|
||||
#007
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Fähigkeitserkennung war zu optimistisch:** „Template-Bau per API“ stand auch ohne Node-SSH da, obwohl der Guest-Agent dann nicht hineinkam. Jetzt: per API nur mit Snippet-Weg oder Bau-Adresse.
|
||||
- Der Bereitstellungsschlüssel kommt per cloud-init an den Standardbenutzer des Images; danach werden alle authorized_keys, ciuser, sshkeys und die feste Adresse entfernt.
|
||||
- Host-Key der frischen VM ist vorher unbekannt (TOFU, nur für diese eine Verbindung).
|
||||
- Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# #022 Templates mit eingebackenen Profilen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature, proxmox
|
||||
**Geschätzt:** 4 h **Tatsächlich:** 0 h 22 min
|
||||
|
||||
## Ziel
|
||||
Beim Template-Bau Profile (z. B. base, docker) gleich mit installieren, damit Deployments schneller
|
||||
fertig sind. Wunsch von Florian am 2026-10-12.
|
||||
|
||||
## Aufgaben
|
||||
- [x] Template-Bau-Anfrage um `profile` erweitern (nur Profile ohne Pflichtvariablen bzw. mit Standardwerten)
|
||||
- [x] Nach dem Vorbereitungsstart: VM erneut starten, Profile über den Guest-Agent ausführen (gleicher Code wie beim Deployment)
|
||||
- [x] Aufräumen vor dem Template: Maschinen-ID leeren, `cloud-init clean`, SSH-Host-Keys löschen, `apt-get clean`, Logdatei entfernen
|
||||
- [x] Name/Tags: `tpl-debian-13-docker`, Tags `proxdeploy-template;debian-13;profil-docker`
|
||||
- [x] Ausrollen: passendes Template bevorzugen (eingebackene Profile ⊆ gewählte); eingebackene Profile beim Deploy überspringen, wenn ihre Variablen den Standardwerten entsprechen
|
||||
- [ ] Optional: zusätzlicher Admin-/Notfall-Schlüssel im Template – zurückgestellt; Standard-Schlüssel je Benutzer/Team kommen stattdessen (#015)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [x] Template 9002 `tpl-debian-13-docker` auf pve-demo (8 min). Provisionierung beim Deploy: 141 s ohne, 29–32 s mit eingebackenem base+docker
|
||||
|
||||
## Abhängigkeiten
|
||||
#007, #008
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- SSH-Schlüssel gehören grundsätzlich nicht ins Template: beim Wechsel müsste jedes Template und jeder Klon angefasst werden. Dafür kommen Standard-Schlüssel je Benutzer/Team (aus #015 vorgezogen).
|
||||
- **Fund:** Profile mit `{{ guest.* }}` hängen vom einzelnen Gast ab (docker nimmt den Benutzer in die Gruppe auf). Sie laufen beim Deploy trotzdem noch einmal – idempotent und schnell. Übersprungen wird nur, was gastunabhängig ist und Standardwerte hat.
|
||||
- Vor dem Template: Maschinen-ID leeren, SSH-Host-Keys und Docker-engine-id löschen, apt-Cache leeren, `cloud-init clean`; geprüft: Klone haben eigene Maschinen-ID und eigene Host-Keys.
|
||||
- Ausrollen wählt das Template mit den meisten eingebackenen Profilen, deren Profile alle gewählt sind – nie eins mit ungewählter Software.
|
||||
- Gemeinsames Modul `gastagent.ts` für Provisionierung über den Guest-Agent (Deploy und Template).
|
||||
@@ -12,11 +12,11 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
||||
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min |
|
||||
| [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min |
|
||||
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min |
|
||||
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – |
|
||||
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – |
|
||||
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – |
|
||||
| [#012](012-vorlagen.md) | Vorlagen (Presets) | offen | v0.3.0 | 3 h | – |
|
||||
| [#013](013-cli.md) | CLI proxdeploy (Go) | offen | v0.4.0 | 12 h | – |
|
||||
| [#009](009-lxc.md) | LXC-Container ausrollen | erledigt | v0.3.0 | 8 h | 0 h 30 min |
|
||||
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | erledigt | v0.3.0 | 3 h | 0 h 05 min |
|
||||
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | erledigt | v0.3.0 | 6 h | 0 h 10 min |
|
||||
| [#012](012-vorlagen.md) | Vorlagen (Presets) | erledigt | v0.3.0 | 3 h | 0 h 11 min |
|
||||
| [#013](013-cli.md) | CLI proxdeploy (Go) | erledigt | v0.4.0 | 12 h | 0 h 42 min |
|
||||
| [#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 | – |
|
||||
| [#016](016-oidc.md) | Anmeldung per OIDC | offen | v0.5.0 | 4 h | – |
|
||||
@@ -24,4 +24,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
||||
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
|
||||
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
||||
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
|
||||
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | offen | v0.3.0 | 3 h | – |
|
||||
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | umgesetzt, Abnahme offen | v0.3.0 | 3 h | 0 h 19 min |
|
||||
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 22 min |
|
||||
|
||||
+2
-1
@@ -99,7 +99,8 @@ families:
|
||||
| `run: <befehl>` | freier Shell-Befehl – nicht portabel, nur wenn nichts anderes passt |
|
||||
|
||||
Jeder Schritt kann `if: <variable>` (oder `if: "!<variable>"`) tragen und läuft dann nur, wenn die
|
||||
Variable wahr bzw. falsch ist.
|
||||
Variable wahr bzw. falsch ist. Fest eingebaut sind `if: vm` und `if: lxc` – etwa für Zeitsync, die
|
||||
in einem unprivilegierten Container nicht laufen kann.
|
||||
|
||||
### Platzhalter
|
||||
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxdeploy",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
||||
@@ -26,7 +26,9 @@
|
||||
"test:e2e": "playwright test",
|
||||
"test:container": "tests/container/profile-pruefen.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"worker": "tsx src/worker/main.ts"
|
||||
"worker": "tsx src/worker/main.ts",
|
||||
"openapi": "tsx scripts/openapi.ts",
|
||||
"cli:pruefen": "scripts/cli-pruefen.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
|
||||
@@ -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;
|
||||
@@ -39,6 +39,8 @@ model Benutzer {
|
||||
geaendertAm DateTime @updatedAt
|
||||
protokoll Protokoll[]
|
||||
auftraege Auftrag[]
|
||||
schluessel SshSchluessel[]
|
||||
tokens ApiToken[]
|
||||
}
|
||||
|
||||
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
|
||||
@@ -271,9 +273,57 @@ model Template {
|
||||
storage String
|
||||
imageDatei String
|
||||
pruefsumme String
|
||||
/// Eingebackene Profile (#022), aufgelöst inkl. Voraussetzungen.
|
||||
profile String[] @default([])
|
||||
gebautVonAuftragId String?
|
||||
gebautAm DateTime @default(now())
|
||||
|
||||
@@unique([serverId, vmid])
|
||||
@@index([serverId, distributionId])
|
||||
}
|
||||
|
||||
/// Vorlage (Preset): eine gespeicherte Kombination, die Assistent, CLI (--preset) und API vorbelegen.
|
||||
model Vorlage {
|
||||
/// Kurzname für die CLI: „docker-host-klein“
|
||||
name String @id
|
||||
titel String
|
||||
beschreibung String?
|
||||
/// Teilmenge der Ausroll-Anfrage (typ, distribution, cores, speicherMb, diskGb, profile, variablen, tags …).
|
||||
parameter Json
|
||||
erstelltVonId String?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Öffentliche SSH-Schlüssel, die in Gäste kommen. benutzerId leer = Team-Schlüssel (in jeden Gast).
|
||||
model SshSchluessel {
|
||||
id String @id @default(cuid())
|
||||
benutzerId String?
|
||||
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
oeffentlich String
|
||||
/// SHA256:… wie ssh-keygen -lf
|
||||
fingerabdruck String
|
||||
/// Kennung der SchluesselQuelle („lokal“; später z. B. eine externe Schlüsselverwaltung).
|
||||
quelle String @default("lokal")
|
||||
erstelltAm DateTime @default(now())
|
||||
|
||||
@@unique([benutzerId, fingerabdruck])
|
||||
}
|
||||
|
||||
/// 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])
|
||||
}
|
||||
|
||||
Executable
+14
@@ -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."
|
||||
@@ -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");
|
||||
@@ -5,37 +5,12 @@ import { Code } from "@/components/formular";
|
||||
import { Knopf } from "@/components/knopf";
|
||||
import { Status } from "@/components/status";
|
||||
import { STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
||||
import { TerminalKarte, type TerminalErgebnis } from "@/components/terminal-karte";
|
||||
import { TerminalKarte } from "@/components/terminal-karte";
|
||||
import { apiAufruf } from "@/lib/api-client";
|
||||
import { dauer } from "@/lib/formate";
|
||||
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
|
||||
|
||||
const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
||||
|
||||
function ergebnisAus(a: AuftragSicht): TerminalErgebnis | null {
|
||||
if (!ENDE.has(a.status)) return null;
|
||||
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
|
||||
const gesamt =
|
||||
a.gestartetAm && a.beendetAm
|
||||
? dauer(new Date(a.beendetAm).getTime() - new Date(a.gestartetAm).getTime())
|
||||
: null;
|
||||
const name = a.name ?? a.kurzId;
|
||||
if (a.status !== "ERFOLGREICH") {
|
||||
return {
|
||||
ok: false,
|
||||
titel: `${name} ${a.status === "ABGEBROCHEN" ? "abgebrochen" : "fehlgeschlagen"}`,
|
||||
dauer: gesamt,
|
||||
zeilen: [],
|
||||
};
|
||||
}
|
||||
const zeilen: Array<[string, string]> = [];
|
||||
if (a.vmid) zeilen.push([a.gastTyp === "LXC" ? "CTID" : "VMID", String(a.vmid)]);
|
||||
if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]);
|
||||
if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]);
|
||||
if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]);
|
||||
return { ok: true, titel: `${name} bereit`, dauer: gesamt, zeilen };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hängt sich per SSE an den Auftrag. Fällt SSE aus (z. B. ein Proxy, der Streams puffert), wird
|
||||
* alle 3 s abgefragt – die Anzeige bleibt dieselbe.
|
||||
@@ -112,7 +87,7 @@ export function LiveAnsicht({
|
||||
auftrag.status === "WARTEND"
|
||||
? (["offen", "wartet auf den worker"] as const)
|
||||
: STATUS_ANZEIGE[auftrag.status];
|
||||
const ergebnis = ergebnisAus(auftrag);
|
||||
const ergebnis = auftrag.abschluss;
|
||||
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Status } from "@/components/status";
|
||||
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
|
||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen";
|
||||
import type { VorlagenParameter } from "@/lib/schemas/vorlage";
|
||||
|
||||
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,6 +48,13 @@ export type AssistentDaten = {
|
||||
}>;
|
||||
benutzer: string;
|
||||
version: string;
|
||||
vorlagen: Array<{ name: string; titel: string; beschreibung: string | null; parameter: VorlagenParameter }>;
|
||||
schluessel: Array<{
|
||||
name: string;
|
||||
oeffentlich: string;
|
||||
fingerabdruck: string;
|
||||
bereich: "persoenlich" | "team";
|
||||
}>;
|
||||
};
|
||||
|
||||
type Plan = {
|
||||
@@ -92,9 +100,12 @@ type Werte = {
|
||||
suchdomain: string;
|
||||
benutzer: string;
|
||||
schluessel: string;
|
||||
/** Fingerprints hinterlegter Schlüssel, die für diesen Gast abgewählt sind. */
|
||||
ohneSchluessel: string[];
|
||||
profile: string[];
|
||||
variablen: Record<string, Record<string, string | boolean>>;
|
||||
templateBauen: boolean;
|
||||
vorlage: string;
|
||||
};
|
||||
|
||||
const SCHRITTE = [
|
||||
@@ -125,9 +136,7 @@ function dekodieren(text: string | null): Partial<Werte> {
|
||||
}
|
||||
}
|
||||
|
||||
const SCHLUESSEL_SPEICHER = "proxdeploy.ssh-schluessel";
|
||||
|
||||
function anfrageAus(w: Werte): AusrollEingabe {
|
||||
function anfrageAus(w: Werte, hinterlegt: AssistentDaten["schluessel"] = []): AusrollEingabe {
|
||||
const zahl = (text: string) => (text.trim() === "" ? undefined : Number(text));
|
||||
return {
|
||||
typ: w.typ,
|
||||
@@ -155,14 +164,63 @@ function anfrageAus(w: Werte): AusrollEingabe {
|
||||
},
|
||||
zugang: {
|
||||
benutzer: w.benutzer,
|
||||
sshSchluessel: w.schluessel
|
||||
sshSchluessel: [
|
||||
...hinterlegt.filter((s) => !w.ohneSchluessel.includes(s.fingerabdruck)).map((s) => s.oeffentlich),
|
||||
...w.schluessel
|
||||
.split("\n")
|
||||
.map((z) => z.trim())
|
||||
.filter(Boolean),
|
||||
],
|
||||
},
|
||||
profile: w.profile,
|
||||
variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))),
|
||||
templateBauen: w.templateBauen,
|
||||
vorlage: w.vorlage || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Eine Vorlage in die Formularwerte übernehmen – nur, was sie festlegt. */
|
||||
function vorlageUebernehmen(w: Werte, name: string, p: VorlagenParameter, daten: AssistentDaten): Werte {
|
||||
const s = (zahl: number | undefined, alt: string) => (zahl === undefined ? alt : String(zahl));
|
||||
const server = p.server && daten.server.some((x) => x.name === p.server) ? p.server : w.server;
|
||||
return {
|
||||
...w,
|
||||
vorlage: name,
|
||||
server,
|
||||
typ: p.typ ?? w.typ,
|
||||
distribution: p.distribution ?? w.distribution,
|
||||
cores: s(p.cores, w.cores),
|
||||
speicherMb: s(p.speicherMb, w.speicherMb),
|
||||
diskGb: s(p.diskGb, w.diskGb),
|
||||
storage: p.storage ?? w.storage,
|
||||
swapMb: s(p.swapMb, w.swapMb),
|
||||
unprivilegiert: p.unprivilegiert ?? w.unprivilegiert,
|
||||
nesting: p.features?.nesting ?? w.nesting,
|
||||
bridge: p.netz?.bridge ?? w.bridge,
|
||||
vlan: s(p.netz?.vlan, w.vlan),
|
||||
profile: p.profile ?? w.profile,
|
||||
variablen: (p.variablen as Werte["variablen"] | undefined) ?? w.variablen,
|
||||
tags: p.tags ? p.tags.join(",") : w.tags,
|
||||
};
|
||||
}
|
||||
|
||||
function werteZuVorlage(w: Werte): VorlagenParameter {
|
||||
const a = anfrageAus(w, []);
|
||||
return {
|
||||
typ: a.typ,
|
||||
server: a.server,
|
||||
distribution: a.distribution,
|
||||
cores: Number(a.cores),
|
||||
speicherMb: Number(a.speicherMb),
|
||||
diskGb: Number(a.diskGb),
|
||||
storage: a.storage,
|
||||
...(a.typ === "lxc"
|
||||
? { swapMb: Number(a.swapMb), unprivilegiert: a.unprivilegiert, features: a.features }
|
||||
: {}),
|
||||
netz: { bridge: a.netz.bridge, ...(a.netz.vlan ? { vlan: Number(a.netz.vlan) } : {}) },
|
||||
profile: a.profile,
|
||||
variablen: a.variablen as VorlagenParameter["variablen"],
|
||||
tags: a.tags,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,13 +265,33 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
suchdomain: "",
|
||||
benutzer: daten.benutzer.replace(/[^a-z0-9_-]/g, "") || "admin",
|
||||
schluessel: "",
|
||||
ohneSchluessel: [],
|
||||
profile: ["base"],
|
||||
variablen: {},
|
||||
templateBauen: false,
|
||||
vorlage: "",
|
||||
};
|
||||
}, [daten, vorauswahl]);
|
||||
|
||||
const [werte, setWerte] = useState<Werte>(() => ({ ...anfang, ...dekodieren(parameter.get("d")) }));
|
||||
const [werte, setWerte] = useState<Werte>(() => {
|
||||
const ausUrl = { ...anfang, ...dekodieren(parameter.get("d")) };
|
||||
// ?vorlage=<name> von der Vorlagenseite: einmal anwenden, danach steht alles in d.
|
||||
const vorlage = !parameter.get("d")
|
||||
? daten.vorlagen.find((v) => v.name === parameter.get("vorlage"))
|
||||
: undefined;
|
||||
return vorlage ? vorlageUebernehmen(ausUrl, vorlage.name, vorlage.parameter, daten) : ausUrl;
|
||||
});
|
||||
const [vorlageSpeichern, setVorlageSpeichern] = useState<{
|
||||
name: string;
|
||||
titel: string;
|
||||
offen: boolean;
|
||||
meldung: string | null;
|
||||
}>({
|
||||
name: "",
|
||||
titel: "",
|
||||
offen: false,
|
||||
meldung: null,
|
||||
});
|
||||
const [plan, setPlan] = useState<Plan | null>(null);
|
||||
const [fehler, setFehler] = useState<{
|
||||
text: string;
|
||||
@@ -237,19 +315,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
};
|
||||
|
||||
const gehe = (ziel: number) => {
|
||||
let naechste = werte;
|
||||
// Gespeicherte Schlüssel als Bequemlichkeit vorbelegen (bis die Schlüsselverwaltung kommt, #015).
|
||||
if (SCHRITTE[ziel] === "Zugang" && !werte.schluessel) {
|
||||
try {
|
||||
const gemerkt = localStorage.getItem(SCHLUESSEL_SPEICHER);
|
||||
if (gemerkt) {
|
||||
naechste = { ...werte, schluessel: gemerkt };
|
||||
setWerte(naechste);
|
||||
}
|
||||
} catch {
|
||||
// kein Speicher verfügbar – dann eben nicht
|
||||
}
|
||||
}
|
||||
const naechste = werte;
|
||||
if (SCHRITTE[ziel] === "Zusammenfassung") void planen(naechste);
|
||||
const neu = new URLSearchParams({ s: String(ziel), d: kodieren(naechste) });
|
||||
router.replace(`${pfad}?${neu.toString()}`, { scroll: false });
|
||||
@@ -289,7 +355,9 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
.filter(Boolean);
|
||||
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(werte.benutzer) || werte.benutzer === "root")
|
||||
return "Gültigen Benutzernamen angeben (nicht root).";
|
||||
if (schluessel.length === 0) return "Mindestens einen öffentlichen SSH-Schlüssel angeben.";
|
||||
const hinterlegt = daten.schluessel.filter((s) => !werte.ohneSchluessel.includes(s.fingerabdruck));
|
||||
if (schluessel.length + hinterlegt.length === 0)
|
||||
return "Mindestens einen öffentlichen SSH-Schlüssel wählen oder angeben.";
|
||||
if (schluessel.some((z) => !SSH_SCHLUESSEL.test(z)))
|
||||
return "Eine Zeile ist kein öffentlicher SSH-Schlüssel.";
|
||||
return null;
|
||||
@@ -304,7 +372,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
setPlan(await apiAufruf<Plan>("POST", "/api/v1/auftraege/plan", anfrageAus(stand)));
|
||||
setPlan(await apiAufruf<Plan>("POST", "/api/v1/auftraege/plan", anfrageAus(stand, daten.schluessel)));
|
||||
} catch (ursache) {
|
||||
setFehler(
|
||||
ursache instanceof ApiFehler
|
||||
@@ -320,12 +388,11 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
try {
|
||||
localStorage.setItem(SCHLUESSEL_SPEICHER, werte.schluessel);
|
||||
} catch {
|
||||
// egal
|
||||
}
|
||||
const { kurzId } = await apiAufruf<{ kurzId: string }>("POST", "/api/v1/auftraege", anfrageAus(werte));
|
||||
const { kurzId } = await apiAufruf<{ kurzId: string }>(
|
||||
"POST",
|
||||
"/api/v1/auftraege",
|
||||
anfrageAus(werte, daten.schluessel),
|
||||
);
|
||||
router.push(`/auftraege/${kurzId}`);
|
||||
} catch (ursache) {
|
||||
setFehler(
|
||||
@@ -388,6 +455,34 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{SCHRITTE[schritt] === "Server" ? (
|
||||
<Karte titel="Server und Node">
|
||||
{daten.vorlagen.length > 0 ? (
|
||||
<div className="mb-5">
|
||||
<Feld
|
||||
id="vorlage"
|
||||
label="Vorlage"
|
||||
hilfe="Belegt Distribution, Ressourcen und Profile vor – alles bleibt änderbar."
|
||||
>
|
||||
<select
|
||||
id="vorlage"
|
||||
className={EINGABE_KLASSE}
|
||||
value={werte.vorlage}
|
||||
onChange={(e) => {
|
||||
const v = daten.vorlagen.find((x) => x.name === e.target.value);
|
||||
if (v) setWerte((alt) => vorlageUebernehmen(alt, v.name, v.parameter, daten));
|
||||
else setze({ vorlage: "" });
|
||||
setPlan(null);
|
||||
}}
|
||||
>
|
||||
<option value="">keine</option>
|
||||
{daten.vorlagen.map((v) => (
|
||||
<option key={v.name} value={v.name}>
|
||||
{v.titel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Feld>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id="server" label="Server">
|
||||
<select
|
||||
@@ -465,7 +560,6 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
className={clsx(
|
||||
"border-linie flex cursor-pointer flex-col gap-1 rounded-lg border p-4",
|
||||
werte.typ === wert && "border-akzent",
|
||||
wert === "lxc" && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-semibold">
|
||||
@@ -555,7 +649,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
autoFocus
|
||||
/>
|
||||
</Feld>
|
||||
<Feld id="vmid" label="VMID" hilfe="Leer = nächste freie">
|
||||
<Feld id="vmid" label={werte.typ === "lxc" ? "CTID" : "VMID"} hilfe="Leer = nächste freie">
|
||||
<input
|
||||
id="vmid"
|
||||
inputMode="numeric"
|
||||
@@ -631,6 +725,40 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
</select>
|
||||
</Feld>
|
||||
</div>
|
||||
{werte.typ === "lxc" ? (
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-3">
|
||||
<Feld id="swap" label="Swap (MB)">
|
||||
<input
|
||||
id="swap"
|
||||
type="number"
|
||||
min={0}
|
||||
step={256}
|
||||
className={EINGABE_KLASSE}
|
||||
value={werte.swapMb}
|
||||
onChange={(e) => setze({ swapMb: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Schalter
|
||||
id="unprivilegiert"
|
||||
label="Unprivilegiert"
|
||||
hilfe="Standard und empfohlen. Privilegierte Container braucht es selten."
|
||||
checked={werte.unprivilegiert}
|
||||
onChange={(an) => setze({ unprivilegiert: an })}
|
||||
/>
|
||||
<Schalter
|
||||
id="nesting"
|
||||
label="nesting"
|
||||
hilfe={
|
||||
werte.profile.includes("docker")
|
||||
? "Das Profil docker schaltet es ohnehin ein."
|
||||
: "Für Docker oder systemd-Dienste mit eigenem Namensraum."
|
||||
}
|
||||
checked={werte.nesting || werte.profile.includes("docker")}
|
||||
disabled={werte.profile.includes("docker")}
|
||||
onChange={(an) => setze({ nesting: an })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Karte>
|
||||
) : null}
|
||||
|
||||
@@ -742,10 +870,35 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Feld>
|
||||
{daten.schluessel.length > 0 ? (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-1 text-sm font-medium">Hinterlegte Schlüssel</legend>
|
||||
{daten.schluessel.map((s) => (
|
||||
<Schalter
|
||||
key={s.fingerabdruck}
|
||||
id={`schluessel-${s.fingerabdruck}`}
|
||||
label={`${s.name}${s.bereich === "team" ? " (Team)" : ""}`}
|
||||
hilfe={s.fingerabdruck}
|
||||
checked={!werte.ohneSchluessel.includes(s.fingerabdruck)}
|
||||
onChange={(an) =>
|
||||
setze({
|
||||
ohneSchluessel: an
|
||||
? werte.ohneSchluessel.filter((f) => f !== s.fingerabdruck)
|
||||
: [...werte.ohneSchluessel, s.fingerabdruck],
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
<Feld
|
||||
id="schluessel"
|
||||
label="Öffentliche SSH-Schlüssel"
|
||||
hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Gespeicherte Schlüssel folgen mit #015."
|
||||
label={
|
||||
daten.schluessel.length > 0
|
||||
? "Weitere öffentliche SSH-Schlüssel"
|
||||
: "Öffentliche SSH-Schlüssel"
|
||||
}
|
||||
hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Dauerhaft hinterlegen: Einstellungen."
|
||||
>
|
||||
<textarea
|
||||
id="schluessel"
|
||||
@@ -944,6 +1097,73 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
</Knopf>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{!vorlageSpeichern.offen ? (
|
||||
<Knopf
|
||||
variante="sekundaer"
|
||||
className="self-start"
|
||||
onClick={() => setVorlageSpeichern((v) => ({ ...v, offen: true, meldung: null }))}
|
||||
>
|
||||
Als Vorlage speichern
|
||||
</Knopf>
|
||||
) : (
|
||||
<Karte titel="Als Vorlage speichern">
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
|
||||
<Feld id="vorlage-name" label="Kurzname (für --preset)">
|
||||
<input
|
||||
id="vorlage-name"
|
||||
className={`${EINGABE_KLASSE} font-mono`}
|
||||
value={vorlageSpeichern.name}
|
||||
onChange={(e) => setVorlageSpeichern((v) => ({ ...v, name: e.target.value }))}
|
||||
placeholder="docker-host-klein"
|
||||
/>
|
||||
</Feld>
|
||||
<Feld id="vorlage-titel" label="Titel">
|
||||
<input
|
||||
id="vorlage-titel"
|
||||
className={EINGABE_KLASSE}
|
||||
value={vorlageSpeichern.titel}
|
||||
onChange={(e) => setVorlageSpeichern((v) => ({ ...v, titel: e.target.value }))}
|
||||
placeholder="Docker-Host klein"
|
||||
/>
|
||||
</Feld>
|
||||
<Knopf
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiAufruf("POST", "/api/v1/vorlagen", {
|
||||
name: vorlageSpeichern.name,
|
||||
titel: vorlageSpeichern.titel,
|
||||
parameter: werteZuVorlage(werte),
|
||||
});
|
||||
setVorlageSpeichern({
|
||||
name: "",
|
||||
titel: "",
|
||||
offen: false,
|
||||
meldung: `Vorlage „${vorlageSpeichern.titel}“ gespeichert.`,
|
||||
});
|
||||
} catch (ursache) {
|
||||
setVorlageSpeichern((v) => ({
|
||||
...v,
|
||||
meldung:
|
||||
ursache instanceof ApiFehler ? ursache.message : "Fehler beim Speichern.",
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Speichern
|
||||
</Knopf>
|
||||
</div>
|
||||
<p className="text-muted mt-3 text-sm">
|
||||
Name, VMID, IP und SSH-Schlüssel gehören nicht in eine Vorlage.
|
||||
</p>
|
||||
</Karte>
|
||||
)}
|
||||
{vorlageSpeichern.meldung ? (
|
||||
<Status art={vorlageSpeichern.meldung.startsWith("Vorlage") ? "erledigt" : "fehler"}>
|
||||
{vorlageSpeichern.meldung}
|
||||
</Status>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||
import { versionText } from "@/lib/version";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
||||
import { vorlagenListe } from "@/server/services/vorlagen";
|
||||
import { schluesselFuer } from "@/server/services/schluessel";
|
||||
import { Assistent, type AssistentDaten } from "./assistent";
|
||||
|
||||
export const metadata: Metadata = { title: "Ausrollen" };
|
||||
@@ -73,6 +75,13 @@ export default async function Ausrollen() {
|
||||
})),
|
||||
benutzer: benutzer.benutzername,
|
||||
version: versionText(),
|
||||
vorlagen: await vorlagenListe(),
|
||||
schluessel: (await schluesselFuer(benutzer)).map((x) => ({
|
||||
name: x.name,
|
||||
oeffentlich: x.oeffentlich,
|
||||
fingerabdruck: x.fingerabdruck,
|
||||
bereich: x.bereich,
|
||||
})),
|
||||
};
|
||||
const vorauswahl = aktiverServer(
|
||||
daten.server.map((s) => s.name),
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
@@ -55,7 +56,16 @@ export default async function KatalogSeite() {
|
||||
</ul>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Profile">
|
||||
<Karte
|
||||
titel="Profile"
|
||||
aktion={
|
||||
verwalten ? (
|
||||
<Link href="/katalog/profile/neu" className="text-akzent text-sm font-semibold">
|
||||
Profil anlegen
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{profile.map((p) => (
|
||||
<li
|
||||
@@ -64,7 +74,9 @@ export default async function KatalogSeite() {
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{p.name}{" "}
|
||||
<Link href={`/katalog/profile/${p.id}`} className="hover:underline">
|
||||
{p.name}
|
||||
</Link>{" "}
|
||||
<span className="text-muted font-mono text-xs">
|
||||
{p.id} · {p.herkunft === "KATALOG" ? "Katalog" : "eigenes"}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { ProfilEditor } from "../profil-editor";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
return { title: `Profil ${(await params).id}` };
|
||||
}
|
||||
|
||||
/** Katalogprofile nur ansehen (bearbeiten als Kopie), eigene bearbeiten. */
|
||||
export default async function ProfilSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const benutzer = await seiteSchuetzen("lesen", `/katalog/profile/${id}`);
|
||||
const profil = await prisma.profil.findUnique({ where: { id } });
|
||||
if (!profil || profil.entferntAm) notFound();
|
||||
const bearbeitbar = profil.herkunft === "EIGEN" && darf(benutzer.rolle, "katalogVerwalten");
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-muted font-mono text-sm">
|
||||
{profil.id} · {profil.herkunft === "KATALOG" ? "Katalog (schreibgeschützt)" : "eigenes Profil"}
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{profil.name}</h1>
|
||||
</div>
|
||||
{profil.herkunft === "KATALOG" && darf(benutzer.rolle, "katalogVerwalten") ? (
|
||||
<Link
|
||||
href={`/katalog/profile/neu?von=${profil.id}`}
|
||||
className="border-linie bg-panel-hoch hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
|
||||
>
|
||||
Als Kopie bearbeiten
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
<ProfilEditor
|
||||
id={bearbeitbar ? profil.id : undefined}
|
||||
anfang={profil.quelltext}
|
||||
schreibgeschuetzt={!bearbeitbar}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { ProfilEditor } from "../profil-editor";
|
||||
|
||||
export const metadata: Metadata = { title: "Profil anlegen" };
|
||||
|
||||
const VORLAGE = `# Eigenes Profil – Format und Schritte: docs/katalog.md
|
||||
id: mein-profil
|
||||
name: Mein Profil
|
||||
description: Was das Profil tut
|
||||
requires: [base]
|
||||
variables:
|
||||
- { name: beispiel, label: Beispielwert, default: hallo }
|
||||
families:
|
||||
debian:
|
||||
- packages: [htop]
|
||||
- file:
|
||||
path: /etc/motd
|
||||
content: |
|
||||
{{ guest.name }} – {{ var.beispiel }}
|
||||
`;
|
||||
|
||||
/** Neues Profil, leer oder als Kopie (`?von=<id>`). */
|
||||
export default async function ProfilNeu({ searchParams }: { searchParams: Promise<{ von?: string }> }) {
|
||||
await seiteSchuetzen("katalogVerwalten", "/katalog/profile/neu");
|
||||
const von = (await searchParams).von;
|
||||
const quelle = von ? await prisma.profil.findUnique({ where: { id: von } }) : null;
|
||||
const anfang = quelle
|
||||
? quelle.quelltext
|
||||
.replace(/^id:\s*.+$/m, `id: ${quelle.id}-eigen`)
|
||||
.replace(/^name:\s*(.+)$/m, "name: $1 (eigen)")
|
||||
: VORLAGE;
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{quelle ? `${quelle.name} als Kopie` : "Profil anlegen"}
|
||||
</h1>
|
||||
<ProfilEditor anfang={anfang} schreibgeschuetzt={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Knopf } from "@/components/knopf";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||
|
||||
type Pruefung = { ok: true; vorschau: string | null } | { ok: false; fehler: string[] };
|
||||
|
||||
/**
|
||||
* YAML-Editor für Profile mit Prüfung beim Tippen (nach kurzer Pause) und Vorschau des Skripts.
|
||||
* Gespeichert wird nur, was der Server für gültig hält.
|
||||
*/
|
||||
export function ProfilEditor({
|
||||
id,
|
||||
anfang,
|
||||
schreibgeschuetzt,
|
||||
}: {
|
||||
id?: string;
|
||||
anfang: string;
|
||||
schreibgeschuetzt: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [text, setText] = useState(anfang);
|
||||
const [pruefung, setPruefung] = useState<Pruefung | null>(null);
|
||||
const [meldung, setMeldung] = useState<{ text: string; hinweis?: string } | null>(null);
|
||||
const [speichert, setSpeichert] = useState(false);
|
||||
const zaehler = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const nummer = ++zaehler.current;
|
||||
const uhr = setTimeout(async () => {
|
||||
try {
|
||||
const ergebnis = await apiAufruf<Pruefung>("POST", "/api/v1/profile/pruefen", { quelltext: text });
|
||||
// Eine ältere Antwort darf eine neuere nicht überschreiben.
|
||||
if (nummer === zaehler.current) setPruefung(ergebnis);
|
||||
} catch {
|
||||
// Prüfung ist Komfort – beim Speichern prüft der Server ohnehin.
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(uhr);
|
||||
}, [text]);
|
||||
|
||||
async function speichern() {
|
||||
setSpeichert(true);
|
||||
setMeldung(null);
|
||||
try {
|
||||
const gespeichert = id
|
||||
? await apiAufruf<{ id: string }>("PUT", `/api/v1/profile/${id}`, { quelltext: text })
|
||||
: await apiAufruf<{ id: string }>("POST", "/api/v1/profile", { quelltext: text });
|
||||
router.push(`/katalog/profile/${gespeichert.id}`);
|
||||
router.refresh();
|
||||
} catch (fehler) {
|
||||
setMeldung(
|
||||
fehler instanceof ApiFehler
|
||||
? { text: fehler.message, hinweis: fehler.hinweis }
|
||||
: { text: "Unerwarteter Fehler." },
|
||||
);
|
||||
} finally {
|
||||
setSpeichert(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loeschen() {
|
||||
if (!id || !window.confirm(`Profil „${id}“ wirklich löschen?`)) return;
|
||||
try {
|
||||
await apiAufruf("DELETE", `/api/v1/profile/${id}`);
|
||||
router.push("/katalog");
|
||||
router.refresh();
|
||||
} catch (fehler) {
|
||||
setMeldung(
|
||||
fehler instanceof ApiFehler
|
||||
? { text: fehler.message, hinweis: fehler.hinweis }
|
||||
: { text: "Unerwarteter Fehler." },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Karte titel="YAML">
|
||||
<label htmlFor="quelltext" className="sr-only">
|
||||
Profil als YAML
|
||||
</label>
|
||||
<textarea
|
||||
id="quelltext"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
readOnly={schreibgeschuetzt}
|
||||
spellCheck={false}
|
||||
rows={Math.min(40, Math.max(14, text.split("\n").length + 2))}
|
||||
className="bg-grund border-linie text-text w-full rounded-lg border p-3 font-mono text-xs leading-5 sm:text-sm"
|
||||
aria-describedby="pruefung"
|
||||
/>
|
||||
<div id="pruefung" aria-live="polite" className="mt-3 text-sm">
|
||||
{pruefung === null ? (
|
||||
<Status art="laeuft">Prüft …</Status>
|
||||
) : pruefung.ok ? (
|
||||
<Status art="erledigt">Gültig</Status>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{pruefung.fehler.map((f) => (
|
||||
<li key={f}>
|
||||
<Status art="fehler">{f}</Status>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
{pruefung?.ok && pruefung.vorschau ? (
|
||||
<details className="bg-panel border-linie rounded-karte border">
|
||||
<summary className="cursor-pointer px-5 py-3.5 font-semibold">Vorschau: erzeugtes Skript</summary>
|
||||
<pre className="border-linie max-h-[50vh] overflow-auto border-t p-5 font-mono text-xs">
|
||||
{pruefung.vorschau}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{!schreibgeschuetzt ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div aria-live="polite" className="text-sm">
|
||||
{meldung ? (
|
||||
<div>
|
||||
<Status art="fehler">{meldung.text}</Status>
|
||||
{meldung.hinweis ? <p className="text-muted">{meldung.hinweis}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{id ? (
|
||||
<Knopf variante="gefahr" onClick={loeschen}>
|
||||
Löschen
|
||||
</Knopf>
|
||||
) : null}
|
||||
<Knopf onClick={speichern} disabled={speichert || !pruefung?.ok}>
|
||||
{speichert ? "Speichert …" : "Speichern"}
|
||||
</Knopf>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,9 @@ type Werte = {
|
||||
templateVmidVon: string;
|
||||
templateVmidBis: string;
|
||||
gastVmidVon: string;
|
||||
bauIp: string;
|
||||
bauGateway: string;
|
||||
bauDns: string;
|
||||
};
|
||||
|
||||
function anfangswerte(server?: ServerSicht): Werte {
|
||||
@@ -60,6 +63,9 @@ function anfangswerte(server?: ServerSicht): Werte {
|
||||
templateVmidVon: String(server?.templateVmidVon ?? 9000),
|
||||
templateVmidBis: String(server?.templateVmidBis ?? 9999),
|
||||
gastVmidVon: String(server?.gastVmidVon ?? 100),
|
||||
bauIp: server?.bauNetz?.ip ?? "",
|
||||
bauGateway: server?.bauNetz?.gateway ?? "",
|
||||
bauDns: server?.bauNetz?.dns.join(", ") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,12 +123,16 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
|
||||
setSpeichert(true);
|
||||
setFelder({});
|
||||
setMeldung(null);
|
||||
const { bauIp, bauGateway, bauDns, ...ohneBau } = werte;
|
||||
const rumpf = {
|
||||
...werte,
|
||||
...ohneBau,
|
||||
sshPort: Number(werte.sshPort),
|
||||
templateVmidVon: Number(werte.templateVmidVon),
|
||||
templateVmidBis: Number(werte.templateVmidBis),
|
||||
gastVmidVon: Number(werte.gastVmidVon),
|
||||
bauNetz: bauIp.trim()
|
||||
? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) }
|
||||
: null,
|
||||
};
|
||||
try {
|
||||
const gespeichert = bearbeiten
|
||||
@@ -361,6 +371,44 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="text-sm font-medium">Bau-Adresse (optional)</legend>
|
||||
<p className="text-muted text-sm">
|
||||
Feste Adresse für den Vorbereitungsstart beim Template-Bau. Nur nötig ohne SSH zum Node:
|
||||
ProxDeploy meldet sich darüber kurz in der VM an und installiert den Guest-Agent. Braucht
|
||||
Internetzugang.
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Feld id="bauIp" label="IP/Präfix" fehler={felder["bauNetz.ip"]}>
|
||||
<input
|
||||
id="bauIp"
|
||||
className={`${EINGABE_KLASSE} font-mono`}
|
||||
value={werte.bauIp}
|
||||
onChange={(e) => setze("bauIp", e.target.value)}
|
||||
placeholder="10.0.10.250/24"
|
||||
/>
|
||||
</Feld>
|
||||
<Feld id="bauGateway" label="Gateway" fehler={felder["bauNetz.gateway"]}>
|
||||
<input
|
||||
id="bauGateway"
|
||||
className={`${EINGABE_KLASSE} font-mono`}
|
||||
value={werte.bauGateway}
|
||||
onChange={(e) => setze("bauGateway", e.target.value)}
|
||||
placeholder="10.0.10.1"
|
||||
/>
|
||||
</Feld>
|
||||
<Feld id="bauDns" label="DNS" fehler={felder["bauNetz.dns"]}>
|
||||
<input
|
||||
id="bauDns"
|
||||
className={`${EINGABE_KLASSE} font-mono`}
|
||||
value={werte.bauDns}
|
||||
onChange={(e) => setze("bauDns", e.target.value)}
|
||||
placeholder="10.0.10.1"
|
||||
/>
|
||||
</Feld>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<Schalter
|
||||
id="sshAktiv"
|
||||
label="SSH zu den Nodes nutzen"
|
||||
|
||||
@@ -10,7 +10,7 @@ import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { AnwendungsFehler } from "@/server/fehler";
|
||||
import { distributionenListe } from "@/server/services/katalog";
|
||||
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
||||
import { templatesListe, type TemplateSicht } from "@/server/services/templates";
|
||||
import { TemplateBauen } from "./template-bauen";
|
||||
|
||||
@@ -48,6 +48,7 @@ export default async function TemplatesSeite() {
|
||||
const distributionen = (await distributionenListe({ typ: "vm", nurAktive: true })).map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
familie: d.familie,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -83,6 +84,7 @@ export default async function TemplatesSeite() {
|
||||
{" "}
|
||||
· {t.knoten}
|
||||
{t.distributionId ? ` · ${t.distributionId}` : ""}
|
||||
{t.profile.length > 0 ? ` · mit ${t.profile.join(", ")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted text-sm">
|
||||
@@ -110,6 +112,14 @@ export default async function TemplatesSeite() {
|
||||
bridges: k.bridges.map((b) => b.name),
|
||||
}))}
|
||||
faehigkeit={bericht.faehigkeiten.templateBau}
|
||||
profile={(await profileListe({ nurAktive: true }))
|
||||
.filter((p) => !p.definition.variables.some((v) => v.required && v.default === undefined))
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
requires: p.definition.requires,
|
||||
familien: p.familien,
|
||||
}))}
|
||||
/>
|
||||
</Karte>
|
||||
) : (
|
||||
|
||||
@@ -14,9 +14,11 @@ export function TemplateBauen({
|
||||
distributionen,
|
||||
knoten,
|
||||
faehigkeit,
|
||||
profile,
|
||||
}: {
|
||||
server: string;
|
||||
distributionen: Array<{ id: string; name: string }>;
|
||||
distributionen: Array<{ id: string; name: string; familie: string }>;
|
||||
profile: Array<{ id: string; name: string; requires: string[]; familien: string[] }>;
|
||||
knoten: Knoten[];
|
||||
faehigkeit: { weg: "api" | "ssh" | "nicht_moeglich"; grund: string };
|
||||
}) {
|
||||
@@ -26,6 +28,7 @@ export function TemplateBauen({
|
||||
knoten: knoten[0]?.name ?? "",
|
||||
storage: knoten[0]?.storages[0] ?? "",
|
||||
bridge: knoten[0]?.bridges.includes("vmbr0") ? "vmbr0" : (knoten[0]?.bridges[0] ?? ""),
|
||||
profile: [] as string[],
|
||||
});
|
||||
const [fehler, setFehler] = useState<{ text: string; hinweis?: string } | null>(null);
|
||||
const [sendet, setSendet] = useState(false);
|
||||
@@ -118,6 +121,43 @@ export function TemplateBauen({
|
||||
</select>
|
||||
</Feld>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-1 text-sm font-medium">Profile einbacken (optional)</legend>
|
||||
<p className="text-muted text-sm">
|
||||
Was hier gewählt ist, ist in jedem Klon schon installiert – Deployments mit diesen Profilen sind
|
||||
schneller fertig. Profile mit Pflichtangaben (z. B. Zabbix-Server) laufen beim Ausrollen.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
||||
{profile
|
||||
.filter((p) =>
|
||||
p.familien.includes(distributionen.find((d) => d.id === werte.distribution)?.familie ?? ""),
|
||||
)
|
||||
.map((p) => {
|
||||
const benoetigt = profile.some(
|
||||
(x) => werte.profile.includes(x.id) && x.requires.includes(p.id),
|
||||
);
|
||||
return (
|
||||
<label key={p.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-akzent h-4 w-4"
|
||||
checked={werte.profile.includes(p.id) || benoetigt}
|
||||
disabled={benoetigt && !werte.profile.includes(p.id)}
|
||||
onChange={(e) =>
|
||||
setWerte({
|
||||
...werte,
|
||||
profile: e.target.checked
|
||||
? [...werte.profile, p.id]
|
||||
: werte.profile.filter((x) => x !== p.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{p.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
<p className="text-muted text-sm">
|
||||
Der Vorbereitungsstart braucht DHCP und Internetzugang auf der Bridge, um qemu-guest-agent zu
|
||||
installieren.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { vorlagenListe } from "@/server/services/vorlagen";
|
||||
import { VorlageLoeschen } from "./vorlage-loeschen";
|
||||
|
||||
export const metadata: Metadata = { title: "Vorlagen" };
|
||||
|
||||
export default async function Vorlagen() {
|
||||
const benutzer = await seiteSchuetzen("lesen", "/vorlagen");
|
||||
const liste = await vorlagenListe();
|
||||
const pflegen = darf(benutzer.rolle, "vorlagenPflegen");
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Vorlagen</h1>
|
||||
<p className="text-muted text-sm">
|
||||
Gespeicherte Kombinationen für den Assistenten und die CLI (
|
||||
<span className="font-mono">--preset</span>). Anlegen: im Assistenten auf der Zusammenfassung „Als
|
||||
Vorlage speichern“.
|
||||
</p>
|
||||
</header>
|
||||
{liste.length === 0 ? (
|
||||
<Karte>
|
||||
<Status art="offen">Noch keine Vorlage.</Status>
|
||||
</Karte>
|
||||
) : (
|
||||
<ul className="grid gap-4 md:grid-cols-2">
|
||||
{liste.map((v) => {
|
||||
const p = v.parameter;
|
||||
const eckdaten = [
|
||||
p.typ?.toUpperCase(),
|
||||
p.distribution,
|
||||
p.cores ? `${p.cores} CPU` : null,
|
||||
p.speicherMb ? `${p.speicherMb / 1024} GB RAM` : null,
|
||||
p.diskGb ? `${p.diskGb} GB Disk` : null,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<li key={v.name} className="bg-panel border-linie rounded-karte flex flex-col gap-3 border p-5">
|
||||
<div>
|
||||
<p className="font-semibold">{v.titel}</p>
|
||||
<p className="text-muted font-mono text-xs">--preset {v.name}</p>
|
||||
</div>
|
||||
{v.beschreibung ? <p className="text-muted text-sm">{v.beschreibung}</p> : null}
|
||||
<p className="font-mono text-xs">{eckdaten.join(" · ")}</p>
|
||||
{p.profile && p.profile.length > 0 ? (
|
||||
<p className="text-muted text-xs">Profile: {p.profile.join(", ")}</p>
|
||||
) : null}
|
||||
<div className="mt-auto flex flex-wrap items-center gap-3 pt-2">
|
||||
<Link
|
||||
href={`/ausrollen?vorlage=${v.name}`}
|
||||
className="bg-akzent text-akzent-text inline-flex h-11 items-center rounded-lg px-4 text-sm font-semibold sm:h-10"
|
||||
>
|
||||
Ausrollen mit dieser Vorlage
|
||||
</Link>
|
||||
{pflegen ? <VorlageLoeschen name={v.name} /> : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Knopf } from "@/components/knopf";
|
||||
import { apiAufruf } from "@/lib/api-client";
|
||||
|
||||
export function VorlageLoeschen({ name }: { name: string }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Knopf
|
||||
variante="gefahr"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`Vorlage „${name}“ löschen?`)) return;
|
||||
await apiAufruf("DELETE", `/api/v1/vorlagen/${name}`);
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</Knopf>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,6 @@ import { ausrollenPlanen } from "@/server/services/ausrollen";
|
||||
|
||||
/** Dry-Run: Phasen, geplante Proxmox-Aufrufe, Skript und CLI-Befehl – angelegt wird nichts. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
await rechtErforderlich(anfrage, "lesen");
|
||||
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage)));
|
||||
const aufrufer = await rechtErforderlich(anfrage, "lesen");
|
||||
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage), aufrufer));
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { profilAktivSetzen } from "@/server/services/katalog";
|
||||
import { profilAktivSetzen, profilLoeschen, profilSpeichern } from "@/server/services/katalog";
|
||||
|
||||
type Kontext = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -13,3 +13,19 @@ export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
await profilAktivSetzen((await params).id, aktiv, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
|
||||
export const PUT = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
const { quelltext } = z
|
||||
.object({ quelltext: z.string().min(1).max(200_000) })
|
||||
.parse(await jsonLesen(anfrage));
|
||||
return NextResponse.json(
|
||||
await profilSpeichern(quelltext, aufrufer, aufruferAdresse(anfrage.headers), (await params).id),
|
||||
);
|
||||
});
|
||||
|
||||
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
await profilLoeschen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { profilPruefen } from "@/server/services/katalog";
|
||||
|
||||
/** Live-Prüfung im Editor: Fehlerliste oder Vorschau des Skripts. Speichert nichts. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
const { quelltext } = z.object({ quelltext: z.string().max(200_000) }).parse(await jsonLesen(anfrage));
|
||||
return NextResponse.json(await profilPruefen(quelltext));
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { api } from "@/server/api";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { profileListe } from "@/server/services/katalog";
|
||||
import { profileListe, profilSpeichern } from "@/server/services/katalog";
|
||||
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const { benutzer } = await rechtErforderlich(anfrage, "lesen");
|
||||
@@ -10,3 +12,14 @@ export const GET = api(async (anfrage: Request) => {
|
||||
new URL(anfrage.url).searchParams.get("alle") === "1" && darf(benutzer.rolle, "katalogVerwalten");
|
||||
return NextResponse.json(await profileListe({ nurAktive: !alle }));
|
||||
});
|
||||
|
||||
/** Eigenes Profil anlegen (YAML im Feld `quelltext`). Nur Admins – Profile führen Befehle als root aus. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
const { quelltext } = z
|
||||
.object({ quelltext: z.string().min(1).max(200_000) })
|
||||
.parse(await jsonLesen(anfrage));
|
||||
return NextResponse.json(await profilSpeichern(quelltext, aufrufer, aufruferAdresse(anfrage.headers)), {
|
||||
status: 201,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { api } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { schluesselLoeschen } from "@/server/services/schluessel";
|
||||
|
||||
type Kontext = { params: Promise<{ id: string }> };
|
||||
|
||||
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||
await schluesselLoeschen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { schluesselAnlegen, schluesselFuer } from "@/server/services/schluessel";
|
||||
|
||||
/** Die eigenen und die Team-Schlüssel – das, was ohne Angabe in jeden Gast kommt. */
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const { benutzer } = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||
return NextResponse.json(await schluesselFuer(benutzer));
|
||||
});
|
||||
|
||||
/** `{ name, oeffentlich, team? }` – Team-Schlüssel nur für Admins. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||
return NextResponse.json(
|
||||
await schluesselAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
|
||||
{ status: 201 },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,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 });
|
||||
});
|
||||
@@ -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 { tokenAnlegen, tokensVon } from "@/server/services/tokens";
|
||||
|
||||
/** Die eigenen API-Tokens – ohne das Token selbst. */
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const { benutzer } = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||
return NextResponse.json(await tokensVon(benutzer.id));
|
||||
});
|
||||
|
||||
/** `{ name, tage? }` – die Antwort enthält das Token, und nur sie. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||
return NextResponse.json(
|
||||
await tokenAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
|
||||
{ status: 201 },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { vorlageHolen, vorlageLoeschen, vorlageSpeichern } from "@/server/services/vorlagen";
|
||||
|
||||
type Kontext = { params: Promise<{ name: string }> };
|
||||
|
||||
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
await rechtErforderlich(anfrage, "lesen");
|
||||
return NextResponse.json(await vorlageHolen((await params).name));
|
||||
});
|
||||
|
||||
export const PUT = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen");
|
||||
return NextResponse.json(
|
||||
await vorlageSpeichern(
|
||||
await jsonLesen(anfrage),
|
||||
aufrufer,
|
||||
aufruferAdresse(anfrage.headers),
|
||||
(await params).name,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen");
|
||||
await vorlageLoeschen((await params).name, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { vorlageSpeichern, vorlagenListe } from "@/server/services/vorlagen";
|
||||
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
await rechtErforderlich(anfrage, "lesen");
|
||||
return NextResponse.json(await vorlagenListe());
|
||||
});
|
||||
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen");
|
||||
return NextResponse.json(
|
||||
await vorlageSpeichern(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
|
||||
{ status: 201 },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||
import type { PhasenKontext } from "@/engine/ablauf/ablauf";
|
||||
import { MARKE } from "@/engine/profile/rendern";
|
||||
import type { Parameter, ProxmoxClient } from "@/engine/proxmox/client";
|
||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||
|
||||
/**
|
||||
* Provisionierung über den QEMU-Guest-Agent – gemeinsam für Deployments (#008) und Templates mit
|
||||
* eingebackenen Profilen (#022). Kein SSH nötig.
|
||||
*
|
||||
* Die Agent-Endpunkte antworten unterschiedlich: exec → {pid}, exec-status und file-read direkt,
|
||||
* ping und network-get-interfaces → {result}.
|
||||
*/
|
||||
export const SKRIPT = "/root/proxdeploy-provisionierung.sh";
|
||||
export const LOG = "/var/log/proxdeploy-provisionierung.log";
|
||||
|
||||
/**
|
||||
* file-write nimmt höchstens 60 KiB je Aufruf (API-Schema, Feld content). ProxDeploy kodiert selbst
|
||||
* als base64 (encode=0): Proxmox' eigene Kodierung scheitert an Nicht-ASCII („Wide character in
|
||||
* subroutine entry“, gefunden beim ersten echten Deploy). 40 KiB Rohdaten werden ~54 KiB base64.
|
||||
*/
|
||||
const STUECK = 40 * 1024;
|
||||
|
||||
type ExecStatus = { exited: number | boolean; exitcode?: number };
|
||||
|
||||
export function agent(client: ProxmoxClient, vmPfad: string) {
|
||||
return async <T>(befehl: string, methode: "GET" | "POST" = "POST", parameter?: Parameter) =>
|
||||
await client.aufrufen<T>({ methode, pfad: `${vmPfad}/agent/${befehl}`, parameter });
|
||||
}
|
||||
|
||||
/** Wartet, bis der Agent antwortet; liefert die Wartezeit in ms. */
|
||||
export async function agentAbwarten(
|
||||
client: ProxmoxClient,
|
||||
vmPfad: string,
|
||||
k: PhasenKontext,
|
||||
optionen: { grenzeMs: number; warten: (ms: number) => Promise<void> },
|
||||
): Promise<number> {
|
||||
const rufen = agent(client, vmPfad);
|
||||
const beginn = Date.now();
|
||||
for (;;) {
|
||||
await k.pruefeAbbruch();
|
||||
try {
|
||||
await rufen("ping");
|
||||
return Date.now() - beginn;
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof ProxmoxFehler && fehler.art === "recht") throw fehler;
|
||||
if (Date.now() - beginn > optionen.grenzeMs) {
|
||||
throw new AblaufFehler(
|
||||
`Der Guest-Agent antwortet nach ${Math.round(optionen.grenzeMs / 1000)} s nicht.`,
|
||||
"Bootet die VM? Konsole ansehen. Wurde das Template von ProxDeploy gebaut (Agent eingebaut)?",
|
||||
);
|
||||
}
|
||||
}
|
||||
await k.detail(`wartet … ${Math.round((Date.now() - beginn) / 1000)}s`);
|
||||
await optionen.warten(3000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Überträgt ein Skript in Stücken (file-write ersetzt, hängt nicht an), führt es aus und zeigt den
|
||||
* Fortschritt aus der Logdatei im Gast (Marken `::proxdeploy:: profil <id>`).
|
||||
*/
|
||||
export async function skriptAusfuehren(
|
||||
client: ProxmoxClient,
|
||||
vmPfad: string,
|
||||
skript: string,
|
||||
k: PhasenKontext,
|
||||
optionen: { grenzeMs: number; warten: (ms: number) => Promise<void> },
|
||||
): Promise<void> {
|
||||
const rufen = agent(client, vmPfad);
|
||||
const bytes = Buffer.from(skript, "utf8");
|
||||
const stuecke: Buffer[] = [];
|
||||
for (let i = 0; i < bytes.length; i += STUECK) stuecke.push(bytes.subarray(i, i + STUECK));
|
||||
for (const [nr, inhalt] of stuecke.entries()) {
|
||||
await rufen("file-write", "POST", {
|
||||
file: `${SKRIPT}.${nr}`,
|
||||
content: inhalt.toString("base64"),
|
||||
encode: "0",
|
||||
});
|
||||
}
|
||||
const teile = stuecke.map((_, nr) => `${SKRIPT}.${nr}`).join(" ");
|
||||
const { pid } = await rufen<{ pid: number }>("exec", "POST", {
|
||||
command: [
|
||||
"/bin/bash",
|
||||
"-c",
|
||||
`cat ${teile} > ${SKRIPT} && rm -f ${teile} && chmod 700 ${SKRIPT} && ${SKRIPT} > ${LOG} 2>&1`,
|
||||
],
|
||||
});
|
||||
|
||||
const beginn = Date.now();
|
||||
let gelesen = 0;
|
||||
let profil = "";
|
||||
for (;;) {
|
||||
await optionen.warten(3000);
|
||||
const status = await rufen<ExecStatus>("exec-status", "GET", { pid: String(pid) });
|
||||
try {
|
||||
const datei = await rufen<{ content: string }>("file-read", "GET", { file: LOG });
|
||||
const zeilen = datei.content.split("\n");
|
||||
for (const zeile of zeilen.slice(gelesen, zeilen.length - 1)) {
|
||||
const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile);
|
||||
if (marke) profil = marke[1]!;
|
||||
await k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug");
|
||||
}
|
||||
gelesen = Math.max(gelesen, zeilen.length - 1);
|
||||
} catch {
|
||||
// Logdatei noch nicht da – beim nächsten Mal.
|
||||
}
|
||||
if (status.exited) {
|
||||
if (status.exitcode !== 0) {
|
||||
throw new AblaufFehler(
|
||||
`Provisionierung ${profil ? `im Profil ${profil} ` : ""}mit Code ${status.exitcode} abgebrochen.`,
|
||||
`Detail-Log oder ${LOG} in der VM ansehen.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (Date.now() - beginn > optionen.grenzeMs) {
|
||||
throw new AblaufFehler(
|
||||
`Provisionierung läuft länger als ${Math.round(optionen.grenzeMs / 60000)} min.`,
|
||||
`${LOG} in der VM ansehen.`,
|
||||
);
|
||||
}
|
||||
const s = Math.round((Date.now() - beginn) / 1000);
|
||||
await k.detail(
|
||||
`${profil ? `Profil ${profil}` : "Vorbereitung"} läuft … ${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`,
|
||||
);
|
||||
}
|
||||
await rufen("exec", "POST", { command: ["rm", "-f", SKRIPT] }).catch(() => undefined);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||
import type { PhasenKontext, PhasenSchritt } from "@/engine/ablauf/ablauf";
|
||||
import { adapterFuer } from "@/engine/adapter";
|
||||
import { templateWaehlen } from "@/engine/katalog/lxc-template";
|
||||
import type { Distribution } from "@/engine/katalog/schema";
|
||||
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||
import { MARKE, profileRendern } from "@/engine/profile/rendern";
|
||||
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
|
||||
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||||
import { q } from "@/engine/shell";
|
||||
import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
|
||||
import type { AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||
import type { NodeBefehl } from "./template-bau";
|
||||
|
||||
/**
|
||||
* Einen LXC-Container ausrollen. Anlegen, Netz und Start gehen per API; Befehle im Container
|
||||
* nicht – Proxmox hat dafür keine API (geprüft gegen das Schema von PVE 9.2). Provisioniert wird
|
||||
* deshalb per SSH, auf einem von zwei Wegen:
|
||||
*
|
||||
* - **Node**: `pct exec <ctid> -- bash -s` über SSH zum Node. Braucht keinen Netzweg zum Container.
|
||||
* - **Gast**: SSH als root direkt in den Container mit dem ProxDeploy-Bereitstellungsschlüssel, den
|
||||
* das Anlegen per `ssh-public-keys` hinterlegt; am Ende wird er wieder entfernt.
|
||||
*/
|
||||
export type Bereitstellungsschluessel = { privat: string; oeffentlich: string };
|
||||
|
||||
export type LxcAusrollEingabe = {
|
||||
client: ProxmoxClient;
|
||||
anfrage: AusrollAnfrage;
|
||||
distribution: Distribution;
|
||||
profile: AufgeloestesProfil[];
|
||||
/** Container-Features, die Profile verlangen (z. B. docker → nesting). */
|
||||
profilFeatures: { nesting: boolean; keyctl: boolean };
|
||||
vmidReservieren: (belegt: Set<number>, wunsch?: number) => Promise<number>;
|
||||
sshFuer: (knoten: string) => NodeBefehl | null;
|
||||
bereitstellung: () => Promise<Bereitstellungsschluessel>;
|
||||
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
|
||||
auftragKurzId: string;
|
||||
warten?: (ms: number) => Promise<void>;
|
||||
netzMs?: number;
|
||||
/** Nur für Tests: SSH in den Gast ersetzen. */
|
||||
gastSsh?: (
|
||||
ziel: { host: string; schluessel: Bereitstellungsschluessel },
|
||||
skript: string,
|
||||
beiAusgabe: (t: string) => void,
|
||||
warten: (ms: number) => Promise<void>,
|
||||
) => Promise<number | null>;
|
||||
};
|
||||
|
||||
export type LxcAusrollStand = {
|
||||
knoten?: string;
|
||||
vmid?: number;
|
||||
ostemplate?: string;
|
||||
vztmplStorage?: string;
|
||||
ip?: string;
|
||||
weg?: "node" | "gast";
|
||||
};
|
||||
|
||||
type Ressource = {
|
||||
vmid?: number;
|
||||
node?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
maxmem?: number;
|
||||
mem?: number;
|
||||
};
|
||||
type StorageEintrag = { storage: string; content: string; active?: number; enabled?: number };
|
||||
type Aplinfo = { template: string; section?: string; package?: string };
|
||||
|
||||
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
|
||||
|
||||
/** Markenzeilen der Provisionierung in Detail und Log übersetzen – für beide Wege gleich. */
|
||||
export function ausgabeVerfolgen(k: PhasenKontext) {
|
||||
let rest = "";
|
||||
let profil = "";
|
||||
const beginn = Date.now();
|
||||
return {
|
||||
get profil() {
|
||||
return profil;
|
||||
},
|
||||
weiter(text: string) {
|
||||
rest += text;
|
||||
const zeilen = rest.split("\n");
|
||||
rest = zeilen.pop() ?? "";
|
||||
for (const zeile of zeilen) {
|
||||
const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile);
|
||||
if (marke) {
|
||||
profil = marke[1]!;
|
||||
const s = Math.round((Date.now() - beginn) / 1000);
|
||||
void k.detail(
|
||||
`Profil ${profil} läuft … ${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`,
|
||||
);
|
||||
}
|
||||
void k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function lxcNetz(netz: AusrollAnfrage["netz"]): string {
|
||||
return [
|
||||
"name=eth0",
|
||||
`bridge=${netz.bridge}`,
|
||||
...(netz.vlan ? [`tag=${netz.vlan}`] : []),
|
||||
netz.modus === "dhcp" ? "ip=dhcp" : `ip=${netz.ip}`,
|
||||
...(netz.modus === "statisch" && netz.gateway ? [`gw=${netz.gateway}`] : []),
|
||||
"type=veth",
|
||||
].join(",");
|
||||
}
|
||||
|
||||
export function lxcAusrollSchritte(e: LxcAusrollEingabe, stand: LxcAusrollStand = {}): PhasenSchritt[] {
|
||||
const { client, anfrage: a, distribution: d } = e;
|
||||
const warten = e.warten ?? schlafen;
|
||||
const lxc = d.lxc;
|
||||
const ctPfad = () => pfad`/nodes/${stand.knoten!}/lxc/${stand.vmid!}`;
|
||||
const features = {
|
||||
nesting: a.features.nesting || e.profilFeatures.nesting,
|
||||
keyctl: a.features.keyctl || e.profilFeatures.keyctl,
|
||||
};
|
||||
let bereitstellung: Bereitstellungsschluessel | undefined;
|
||||
|
||||
return [
|
||||
{
|
||||
schluessel: "pruefungen",
|
||||
titel: "Prüfungen",
|
||||
ausfuehren: async () => {
|
||||
if (!lxc)
|
||||
throw new AblaufFehler(
|
||||
`${d.name} gibt es nicht als LXC-Container.`,
|
||||
"Eine andere Distribution wählen.",
|
||||
);
|
||||
const ressourcen = await client.get<Ressource[]>("/cluster/resources");
|
||||
const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online");
|
||||
stand.knoten =
|
||||
a.knoten ??
|
||||
[...nodes].sort((x, y) => (y.maxmem ?? 0) - (y.mem ?? 0) - ((x.maxmem ?? 0) - (x.mem ?? 0)))[0]
|
||||
?.node;
|
||||
if (!stand.knoten || !nodes.some((n) => n.node === stand.knoten)) {
|
||||
throw new AblaufFehler(`Node ${stand.knoten ?? "?"} ist nicht online.`, "Anderen Node wählen.");
|
||||
}
|
||||
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${stand.knoten}/storage`);
|
||||
const aktiv = (s: StorageEintrag) => s.active === 1 && s.enabled !== 0;
|
||||
const ziel = storages.find((s) => s.storage === a.storage);
|
||||
if (!ziel || !aktiv(ziel) || !ziel.content.split(",").includes("rootdir")) {
|
||||
throw new AblaufFehler(
|
||||
`Storage ${a.storage} nimmt auf ${stand.knoten} keine Container auf.`,
|
||||
`Geeignet sind: ${
|
||||
storages
|
||||
.filter((s) => aktiv(s) && s.content.includes("rootdir"))
|
||||
.map((s) => s.storage)
|
||||
.join(", ") || "keiner"
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
stand.vztmplStorage = storages.find(
|
||||
(s) => aktiv(s) && s.content.split(",").includes("vztmpl"),
|
||||
)?.storage;
|
||||
if (!stand.vztmplStorage)
|
||||
throw new AblaufFehler(
|
||||
"Kein Storage mit Inhaltstyp „Container-Templates“.",
|
||||
"Einem Dir-Storage den Inhaltstyp vztmpl geben.",
|
||||
);
|
||||
|
||||
const netze = await client.get<Array<{ iface: string; type: string; bridge_vlan_aware?: number }>>(
|
||||
pfad`/nodes/${stand.knoten}/network`,
|
||||
);
|
||||
const bridge = netze.find(
|
||||
(n) => n.iface === a.netz.bridge && (n.type === "bridge" || n.type === "OVSBridge"),
|
||||
);
|
||||
if (!bridge)
|
||||
throw new AblaufFehler(
|
||||
`Bridge ${a.netz.bridge} gibt es auf ${stand.knoten} nicht.`,
|
||||
"Andere Bridge wählen.",
|
||||
);
|
||||
if (a.netz.vlan && bridge.type === "bridge" && bridge.bridge_vlan_aware !== 1) {
|
||||
throw new AblaufFehler(
|
||||
`Bridge ${a.netz.bridge} ist nicht VLAN-fähig.`,
|
||||
"„VLAN aware“ an der Bridge einschalten oder ohne VLAN ausrollen.",
|
||||
);
|
||||
}
|
||||
|
||||
// Provisionierung braucht SSH: lieber zum Node (kein Netzweg zum Container nötig).
|
||||
stand.weg = e.sshFuer(stand.knoten) ? "node" : "gast";
|
||||
if (features.keyctl && stand.weg !== "node") {
|
||||
throw new AblaufFehler(
|
||||
"Das Feature keyctl darf per API-Token nicht gesetzt werden.",
|
||||
"SSH zum Node einrichten (dann setzt ProxDeploy es per pct set) oder ohne keyctl ausrollen.",
|
||||
);
|
||||
}
|
||||
const belegt = new Set(
|
||||
ressourcen.map((r) => r.vmid).filter((v): v is number => typeof v === "number"),
|
||||
);
|
||||
stand.vmid = await e.vmidReservieren(belegt, a.vmid);
|
||||
await e.angelegt({ knoten: stand.knoten, typ: "LXC" });
|
||||
return {
|
||||
detail: `CTID ${stand.vmid} frei · ${a.storage} · ${a.netz.bridge}${a.netz.vlan ? ` VLAN ${a.netz.vlan}` : ""} · Provisionierung über ${stand.weg === "node" ? "pct exec" : "SSH in den Container"}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
schluessel: "lxc-template",
|
||||
titel: "LXC-Template",
|
||||
ausfuehren: async () => {
|
||||
const vorhanden = await client.get<Array<{ volid: string }>>(
|
||||
pfad`/nodes/${stand.knoten!}/storage/${stand.vztmplStorage!}/content`,
|
||||
{
|
||||
content: "vztmpl",
|
||||
},
|
||||
);
|
||||
const dateien = vorhanden.map((v) => v.volid.split("/").pop()!);
|
||||
const lokal = templateWaehlen(lxc!.template_pattern, dateien);
|
||||
// Was es zum Herunterladen gibt – ist dort eine neuere Version, wird sie geladen.
|
||||
const verfuegbar = (await client.get<Aplinfo[]>(pfad`/nodes/${stand.knoten!}/aplinfo`)).map(
|
||||
(x) => x.template,
|
||||
);
|
||||
const neueste = templateWaehlen(lxc!.template_pattern, verfuegbar);
|
||||
if (lokal && (!neueste || lokal === neueste)) {
|
||||
stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${lokal}`;
|
||||
return { uebersprungen: true, detail: `${lokal} vorhanden` };
|
||||
}
|
||||
if (!neueste) {
|
||||
if (lokal) {
|
||||
stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${lokal}`;
|
||||
return { uebersprungen: true, detail: `${lokal} vorhanden` };
|
||||
}
|
||||
throw new AblaufFehler(
|
||||
`Kein Container-Template passt zu „${lxc!.template_pattern}“.`,
|
||||
"Mit „pveam update“ die Liste auf dem Node auffrischen oder das Muster im Katalog prüfen.",
|
||||
);
|
||||
}
|
||||
await aufgabeAbwarten(
|
||||
client,
|
||||
await client.post<string>(pfad`/nodes/${stand.knoten!}/aplinfo`, {
|
||||
storage: stand.vztmplStorage!,
|
||||
template: neueste,
|
||||
}),
|
||||
{ zeitlimitMs: 30 * 60 * 1000 },
|
||||
);
|
||||
stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${neueste}`;
|
||||
return { detail: `${neueste} geladen` };
|
||||
},
|
||||
},
|
||||
{
|
||||
schluessel: "anlegen",
|
||||
titel: "Anlegen",
|
||||
ausfuehren: async () => {
|
||||
if (stand.weg === "gast") bereitstellung = await e.bereitstellung();
|
||||
const featureListe = [features.nesting ? "nesting=1" : null].filter(Boolean).join(",");
|
||||
await aufgabeAbwarten(
|
||||
client,
|
||||
await client.post<string>(pfad`/nodes/${stand.knoten!}/lxc`, {
|
||||
vmid: stand.vmid!,
|
||||
hostname: a.name,
|
||||
ostemplate: stand.ostemplate!,
|
||||
ostype: lxc!.ostype,
|
||||
rootfs: `${a.storage}:${a.diskGb}`,
|
||||
cores: a.cores,
|
||||
memory: a.speicherMb,
|
||||
swap: a.swapMb,
|
||||
unprivileged: a.unprivilegiert,
|
||||
...(featureListe ? { features: featureListe } : {}),
|
||||
net0: lxcNetz(a.netz),
|
||||
...(a.netz.dns.length > 0 ? { nameserver: a.netz.dns.join(" ") } : {}),
|
||||
...(a.netz.suchdomain ? { searchdomain: a.netz.suchdomain } : {}),
|
||||
// Nur der Bereitstellungsschlüssel kommt an root – der Benutzer bekommt seine eigenen Schlüssel.
|
||||
...(bereitstellung ? { "ssh-public-keys": bereitstellung.oeffentlich } : {}),
|
||||
onboot: a.startBeiBoot,
|
||||
tags: ["proxdeploy", d.id, ...a.tags].join(";"),
|
||||
description: `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`,
|
||||
}),
|
||||
{ zeitlimitMs: 15 * 60 * 1000 },
|
||||
);
|
||||
await e.angelegt({ vmid: stand.vmid });
|
||||
if (features.keyctl) {
|
||||
const ssh = e.sshFuer(stand.knoten!)!;
|
||||
if (!client.trocken) {
|
||||
const ergebnis = await ssh(
|
||||
`pct set ${stand.vmid} --features ${q(`nesting=${features.nesting ? 1 : 0},keyctl=1`)}`,
|
||||
);
|
||||
if (ergebnis.code !== 0)
|
||||
throw new AblaufFehler(
|
||||
`keyctl ließ sich nicht setzen: ${ergebnis.stderr.trim()}`,
|
||||
"Rechte des SSH-Benutzers prüfen.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
detail: `${a.unprivilegiert ? "unprivilegiert" : "privilegiert"} · ${a.cores} CPU · ${a.speicherMb} MB · ${a.diskGb}G${
|
||||
features.nesting || features.keyctl
|
||||
? ` · ${[features.nesting && "nesting", features.keyctl && "keyctl"].filter(Boolean).join(", ")}`
|
||||
: ""
|
||||
}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
schluessel: "start",
|
||||
titel: "Start",
|
||||
ausfuehren: async () => {
|
||||
await aufgabeAbwarten(client, await client.post<string>(`${ctPfad()}/status/start`), {
|
||||
zeitlimitMs: 5 * 60 * 1000,
|
||||
});
|
||||
return { detail: `CT ${stand.vmid} läuft` };
|
||||
},
|
||||
},
|
||||
{
|
||||
schluessel: "netzwerk",
|
||||
titel: "Netzwerk",
|
||||
ausfuehren: async (k) => {
|
||||
if (a.netz.modus === "statisch") {
|
||||
stand.ip = a.netz.ip!.split("/")[0];
|
||||
return { detail: `IP ${stand.ip}` };
|
||||
}
|
||||
if (client.trocken) return { detail: "IP per DHCP" };
|
||||
const beginn = Date.now();
|
||||
const grenze = e.netzMs ?? 2 * 60 * 1000;
|
||||
while (!stand.ip) {
|
||||
await k.pruefeAbbruch();
|
||||
const schnittstellen = await client.get<Array<{ name: string; inet?: string }>>(
|
||||
`${ctPfad()}/interfaces`,
|
||||
);
|
||||
stand.ip = schnittstellen.find((s) => s.name === "eth0" && s.inet)?.inet?.split("/")[0];
|
||||
if (stand.ip) break;
|
||||
if (Date.now() - beginn > grenze) {
|
||||
throw new AblaufFehler(
|
||||
"Der Container hat per DHCP keine Adresse bekommen.",
|
||||
"DHCP auf der Bridge bzw. im VLAN prüfen oder eine statische Adresse wählen.",
|
||||
);
|
||||
}
|
||||
await k.detail(`wartet auf DHCP … ${Math.round((Date.now() - beginn) / 1000)}s`);
|
||||
await warten(2000);
|
||||
}
|
||||
return { detail: `IP ${stand.ip} per DHCP` };
|
||||
},
|
||||
},
|
||||
{
|
||||
schluessel: "provisionierung",
|
||||
titel: "Provisionierung",
|
||||
ausfuehren: async (k) => {
|
||||
const adapter = adapterFuer(d.family);
|
||||
if (!adapter)
|
||||
throw new AblaufFehler(
|
||||
`Für die Familie „${d.family}“ gibt es keinen Adapter.`,
|
||||
"Adapter ergänzen (docs/katalog.md).",
|
||||
);
|
||||
const nachspann =
|
||||
stand.weg === "gast"
|
||||
? [
|
||||
`echo '${MARKE} bereitstellung-entfernen'\nsed -i '/proxdeploy-bereitstellung/d' /root/.ssh/authorized_keys\n`,
|
||||
]
|
||||
: [];
|
||||
const { skript } = profileRendern(
|
||||
e.profile,
|
||||
adapter,
|
||||
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "lxc" } },
|
||||
[
|
||||
adapter.benutzerAnlegen({
|
||||
name: a.zugang.benutzer,
|
||||
sshSchluessel: a.zugang.sshSchluessel,
|
||||
sudo: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
const gesamt = skript + nachspann.join("");
|
||||
if (client.trocken) {
|
||||
await k.log(gesamt, "info");
|
||||
return {
|
||||
detail: `über ${stand.weg === "node" ? "pct exec" : "SSH"}${e.profile.length > 0 ? ` · Profile ${e.profile.map((p) => p.profil.id).join(", ")}` : ""}`,
|
||||
};
|
||||
}
|
||||
const verfolgen = ausgabeVerfolgen(k);
|
||||
let code: number | null;
|
||||
if (stand.weg === "node") {
|
||||
const ssh = e.sshFuer(stand.knoten!)!;
|
||||
code = (
|
||||
await ssh(`pct exec ${stand.vmid} -- bash -s`, {
|
||||
eingabe: gesamt,
|
||||
zeitlimitMs: 60 * 60 * 1000,
|
||||
beiAusgabe: (t) => verfolgen.weiter(t),
|
||||
})
|
||||
).code;
|
||||
} else {
|
||||
const ziel = { host: stand.ip!, schluessel: bereitstellung! };
|
||||
code = await (e.gastSsh ?? gastSshStandard)(ziel, gesamt, (t) => verfolgen.weiter(t), warten);
|
||||
}
|
||||
verfolgen.weiter("\n");
|
||||
if (code !== 0) {
|
||||
throw new AblaufFehler(
|
||||
`Provisionierung ${verfolgen.profil ? `im Profil ${verfolgen.profil} ` : ""}mit Code ${code} abgebrochen.`,
|
||||
"Detail-Log ansehen.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
detail:
|
||||
e.profile.length > 0
|
||||
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet`
|
||||
: "Benutzer eingerichtet",
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* SSH als root in den frischen Container. Seinen Host-Key kann ProxDeploy vorher nicht kennen –
|
||||
* er entsteht beim ersten Start. Vertraut wird deshalb dem ersten Schlüssel (TOFU), und das nur für
|
||||
* diese eine Verbindung; der Bereitstellungsschlüssel wird am Ende des Skripts wieder entfernt.
|
||||
*/
|
||||
async function gastSshStandard(
|
||||
ziel: { host: string; schluessel: Bereitstellungsschluessel },
|
||||
skript: string,
|
||||
beiAusgabe: (t: string) => void,
|
||||
warten: (ms: number) => Promise<void> = schlafen,
|
||||
): Promise<number | null> {
|
||||
const beginn = Date.now();
|
||||
for (;;) {
|
||||
try {
|
||||
const ergebnis = await sshAusfuehren(
|
||||
{
|
||||
host: ziel.host,
|
||||
port: 22,
|
||||
benutzer: "root",
|
||||
privatSchluessel: ziel.schluessel.privat,
|
||||
hostSchluessel: null,
|
||||
},
|
||||
"bash -s",
|
||||
{ erkunden: true, eingabe: skript, zeitlimitMs: 60 * 60 * 1000, beiAusgabe },
|
||||
);
|
||||
return ergebnis.code;
|
||||
} catch (fehler) {
|
||||
// sshd braucht nach dem Start ein paar Sekunden.
|
||||
if (fehler instanceof SshFehler && fehler.art === "verbindung" && Date.now() - beginn < 90_000) {
|
||||
await warten(3000);
|
||||
continue;
|
||||
}
|
||||
if (fehler instanceof SshFehler) {
|
||||
throw new AblaufFehler(
|
||||
`SSH in den Container ${ziel.host} fehlgeschlagen: ${fehler.ursache}`,
|
||||
"Ist der Container aus dem Netz von ProxDeploy erreichbar? Sonst SSH zum Node einrichten (pct exec).",
|
||||
);
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,13 @@ import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
|
||||
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||||
import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten";
|
||||
import { q } from "@/engine/shell";
|
||||
import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
|
||||
import type { BauNetz } from "@/lib/schemas/server";
|
||||
import type { Bereitstellungsschluessel } from "./lxc-ausrollen";
|
||||
import { adapterFuer } from "@/engine/adapter";
|
||||
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||
import { profileRendern } from "@/engine/profile/rendern";
|
||||
import { agentAbwarten, skriptAusfuehren } from "./gastagent";
|
||||
|
||||
/**
|
||||
* Template-Bau für VMs: Cloud-Image laden und prüfen, VM anlegen, Disk importieren, Guest-Agent
|
||||
@@ -24,12 +31,22 @@ import { q } from "@/engine/shell";
|
||||
/** Führt einen Befehl als root auf einem Node aus (ssh2, gepinnter Host-Key). */
|
||||
export type NodeBefehl = (
|
||||
befehl: string,
|
||||
optionen?: { eingabe?: string; zeitlimitMs?: number },
|
||||
optionen?: { eingabe?: string; zeitlimitMs?: number; beiAusgabe?: (text: string) => void },
|
||||
) => Promise<{ code: number | null; stdout: string; stderr: string }>;
|
||||
|
||||
export type TemplateBauEingabe = {
|
||||
client: ProxmoxClient;
|
||||
distribution: Distribution;
|
||||
/** Eingebackene Profile (#022), aufgelöst mit Standardwerten; leer = nur Guest-Agent. */
|
||||
profile?: AufgeloestesProfil[];
|
||||
/** Bau-Adresse (#021): Guest-Agent per SSH in die VM einbauen, wenn es kein Node-SSH gibt. */
|
||||
bauNetz?: BauNetz | null;
|
||||
bereitstellung?: () => Promise<Bereitstellungsschluessel>;
|
||||
/** Nur für Tests: SSH in die Vorbereitungs-VM ersetzen. */
|
||||
gastSsh?: (
|
||||
ziel: { host: string; benutzer: string; schluessel: Bereitstellungsschluessel },
|
||||
skript: string,
|
||||
) => Promise<number | null>;
|
||||
knoten: string;
|
||||
/** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */
|
||||
storage: string;
|
||||
@@ -50,6 +67,8 @@ export type TemplateBauEingabe = {
|
||||
|
||||
export type TemplateBauStand = {
|
||||
vmid?: number;
|
||||
/** Wie der Guest-Agent hineinkommt: Snippet (Node-SSH) oder SSH in die VM über die Bau-Adresse. */
|
||||
agentWeg?: "snippet" | "baunetz";
|
||||
weg?: "api" | "ssh";
|
||||
importStorage?: string;
|
||||
snippetStorage?: string;
|
||||
@@ -80,10 +99,28 @@ async function standardHolen(url: string): Promise<string> {
|
||||
return await antwort.text();
|
||||
}
|
||||
|
||||
export function templateName(distribution: Distribution): string {
|
||||
return `tpl-${distribution.id.replace(/[^a-z0-9-]/g, "-")}`;
|
||||
export function templateName(distribution: Distribution, profile: string[] = []): string {
|
||||
return ["tpl", distribution.id, ...profile.filter((p) => p !== "base")]
|
||||
.join("-")
|
||||
.replace(/[^a-z0-9-]/g, "-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufräumen vor dem Template, wenn Profile eingebacken wurden – alles, was je Klon eindeutig sein
|
||||
* muss oder nur Platz kostet. SSH-Host-Keys erzeugt cloud-init bei jedem Klon neu.
|
||||
*/
|
||||
export const TEMPLATE_AUFRAEUMEN = [
|
||||
"set -e",
|
||||
"truncate -s 0 /etc/machine-id",
|
||||
"rm -f /var/lib/dbus/machine-id",
|
||||
"rm -f /etc/ssh/ssh_host_*",
|
||||
"rm -f /var/lib/docker/engine-id",
|
||||
"apt-get clean || true",
|
||||
"rm -f /var/log/proxdeploy-provisionierung.log",
|
||||
"cloud-init clean --logs",
|
||||
"sync",
|
||||
].join("; ");
|
||||
|
||||
/**
|
||||
* vendor-data für den Vorbereitungsstart: Guest-Agent installieren, Maschinen-ID leeren (sonst
|
||||
* teilen sich alle Klone DHCP-Client-ID und journald-ID), cloud-init zurücksetzen, ausschalten.
|
||||
@@ -107,6 +144,126 @@ power_state:
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest-Agent ohne Node-SSH (#021): Vorbereitungsstart mit fester Bau-Adresse und dem
|
||||
* Bereitstellungsschlüssel für den Standardbenutzer des Images, dann SSH in die VM. Danach wird
|
||||
* der Schlüssel entfernt und die cloud-init-Angaben zurückgesetzt – im Template bleibt nichts davon.
|
||||
*/
|
||||
export const BAUNETZ_SKRIPT = `set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
cloud-init status --wait >/dev/null 2>&1 || true
|
||||
apt-get -q update
|
||||
apt-get -y -q -o DPkg::Lock::Timeout=600 install qemu-guest-agent
|
||||
systemctl enable qemu-guest-agent
|
||||
rm -f /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys
|
||||
truncate -s 0 /etc/machine-id
|
||||
rm -f /var/lib/dbus/machine-id
|
||||
cloud-init clean --logs
|
||||
sync
|
||||
`;
|
||||
|
||||
async function agentUeberBauNetz(
|
||||
e: TemplateBauEingabe,
|
||||
vmPfad: string,
|
||||
benutzer: string,
|
||||
k: PhasenKontext,
|
||||
warten: (ms: number) => Promise<void>,
|
||||
): Promise<{ detail: string }> {
|
||||
const { client } = e;
|
||||
const netz = e.bauNetz!;
|
||||
const schluessel = await e.bereitstellung!();
|
||||
const host = netz.ip.split("/")[0]!;
|
||||
await client.put(`${vmPfad}/config`, {
|
||||
ciuser: benutzer,
|
||||
sshkeys: encodeURIComponent(schluessel.oeffentlich).replace(
|
||||
/[!'()*]/g,
|
||||
(z) => `%${z.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
),
|
||||
ipconfig0: `ip=${netz.ip},gw=${netz.gateway}`,
|
||||
...(netz.dns.length > 0 ? { nameserver: netz.dns.join(" ") } : {}),
|
||||
});
|
||||
try {
|
||||
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/start`), {
|
||||
zeitlimitMs: 5 * 60 * 1000,
|
||||
});
|
||||
if (client.trocken) return { detail: `über Bau-Adresse ${host}` };
|
||||
await k.detail(`Vorbereitungsstart über Bau-Adresse ${host} …`);
|
||||
const code = await (e.gastSsh ?? gastSshBau)(
|
||||
{ host, benutzer, schluessel },
|
||||
BAUNETZ_SKRIPT,
|
||||
warten,
|
||||
(z) => void k.log(z, "debug"),
|
||||
);
|
||||
if (code !== 0) {
|
||||
throw new AblaufFehler(
|
||||
`Einbau des Guest-Agents über ${host} mit Code ${code} abgebrochen.`,
|
||||
"Hat die Bau-Adresse Internetzugang (Gateway, DNS)? Detail-Log ansehen.",
|
||||
);
|
||||
}
|
||||
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/shutdown`, { timeout: 180 }), {
|
||||
zeitlimitMs: 5 * 60 * 1000,
|
||||
});
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
if ((await client.get<{ status: string }>(`${vmPfad}/status/current`)).status === "stopped") break;
|
||||
await warten(3000);
|
||||
}
|
||||
} finally {
|
||||
// Bau-Adresse und Schlüssel gehören nicht ins Template.
|
||||
await client
|
||||
.put(`${vmPfad}/config`, { ipconfig0: "ip=dhcp", delete: "sshkeys,ciuser,nameserver" })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
return { detail: `qemu-guest-agent über Bau-Adresse ${host} installiert · Maschinen-ID geleert` };
|
||||
}
|
||||
|
||||
async function gastSshBau(
|
||||
ziel: { host: string; benutzer: string; schluessel: Bereitstellungsschluessel },
|
||||
skript: string,
|
||||
warten: (ms: number) => Promise<void>,
|
||||
beiZeile: (z: string) => void,
|
||||
): Promise<number | null> {
|
||||
// Den Host-Key der frischen VM kann niemand vorher kennen (TOFU, nur für diese Verbindung).
|
||||
const beginn = Date.now();
|
||||
for (;;) {
|
||||
try {
|
||||
const ergebnis = await sshAusfuehren(
|
||||
{
|
||||
host: ziel.host,
|
||||
port: 22,
|
||||
benutzer: ziel.benutzer,
|
||||
privatSchluessel: ziel.schluessel.privat,
|
||||
hostSchluessel: null,
|
||||
},
|
||||
"sudo -n bash -s",
|
||||
{
|
||||
erkunden: true,
|
||||
eingabe: skript,
|
||||
zeitlimitMs: 20 * 60 * 1000,
|
||||
beiAusgabe: (t) => t.split("\n").filter(Boolean).forEach(beiZeile),
|
||||
},
|
||||
);
|
||||
return ergebnis.code;
|
||||
} catch (fehler) {
|
||||
if (
|
||||
fehler instanceof SshFehler &&
|
||||
(fehler.art === "verbindung" || fehler.art === "anmeldung" || fehler.art === "zeitueberschreitung") &&
|
||||
Date.now() - beginn < 4 * 60 * 1000
|
||||
) {
|
||||
// Bootet noch oder cloud-init hat den Schlüssel noch nicht eingetragen.
|
||||
await warten(5000);
|
||||
continue;
|
||||
}
|
||||
if (fehler instanceof SshFehler) {
|
||||
throw new AblaufFehler(
|
||||
`SSH in die Vorbereitungs-VM (${ziel.host}) fehlgeschlagen: ${fehler.ursache}`,
|
||||
"Ist die Bau-Adresse aus dem Netz von ProxDeploy erreichbar? Stimmen Bridge/VLAN?",
|
||||
);
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauStand = {}): PhasenSchritt[] {
|
||||
const { client, distribution: d, knoten } = e;
|
||||
const vm = d.vm;
|
||||
@@ -118,6 +275,8 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
const holen = e.holen ?? standardHolen;
|
||||
const warten = e.warten ?? schlafen;
|
||||
const snippetDatei = `proxdeploy-vorbereitung-${e.auftragKurzId}.yaml`;
|
||||
const profilIds = (e.profile ?? []).map((p) => p.profil.id);
|
||||
const name = templateName(d, profilIds);
|
||||
|
||||
const ssh = async (
|
||||
befehl: string,
|
||||
@@ -189,14 +348,18 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
: "SSH zum Node einrichten oder auf PVE 8.4+ aktualisieren.",
|
||||
);
|
||||
}
|
||||
if (!vm.guest_agent_preinstalled && !(e.ssh && stand.snippetStorage)) {
|
||||
if (!vm.guest_agent_preinstalled) {
|
||||
if (e.ssh && stand.snippetStorage) stand.agentWeg = "snippet";
|
||||
else if (e.bauNetz && e.bereitstellung) stand.agentWeg = "baunetz";
|
||||
else {
|
||||
throw new AblaufFehler(
|
||||
"Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node und einen Storage mit Inhaltstyp „Snippets“.",
|
||||
"Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node (mit einem Snippets-Storage) oder eine Bau-Adresse.",
|
||||
e.ssh
|
||||
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben."
|
||||
: "SSH in den Server-Einstellungen einrichten.",
|
||||
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben oder in den Server-Einstellungen eine Bau-Adresse eintragen."
|
||||
: "SSH in den Server-Einstellungen einrichten oder dort eine Bau-Adresse (feste IP für die Vorbereitungs-VM) eintragen.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const netze = await client.get<Array<{ iface: string; type: string }>>(
|
||||
pfad`/nodes/${knoten}/network`,
|
||||
);
|
||||
@@ -294,7 +457,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
const disk = `${e.storage}:0,import-from=${importVon},discard=on,ssd=1,iothread=1`;
|
||||
const parameter = {
|
||||
vmid,
|
||||
name: templateName(d),
|
||||
name,
|
||||
ostype: "l26",
|
||||
machine: "q35",
|
||||
bios: vm.bios,
|
||||
@@ -310,8 +473,10 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
ide2: `${e.storage}:cloudinit`,
|
||||
ipconfig0: "ip=dhcp",
|
||||
ciupgrade: false,
|
||||
tags: `proxdeploy-template;${d.id}`,
|
||||
description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}\nGebaut: ${jetzt} (Auftrag ${e.auftragKurzId})`,
|
||||
tags: ["proxdeploy-template", d.id, ...profilIds.map((p) => `profil-${p}`)].join(";"),
|
||||
description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}${
|
||||
profilIds.length > 0 ? `\nEingebackene Profile: ${profilIds.join(", ")}` : ""
|
||||
}\nGebaut: ${jetzt} (Auftrag ${e.auftragKurzId})`,
|
||||
};
|
||||
if (stand.weg === "api") {
|
||||
await aufgabeAbwarten(
|
||||
@@ -336,7 +501,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
});
|
||||
}
|
||||
return {
|
||||
detail: `VM ${vmid} (${templateName(d)}) · q35 · ${vm.bios === "ovmf" ? "UEFI" : "SeaBIOS"} · Disk importiert`,
|
||||
detail: `VM ${vmid} (${name}) · q35 · ${vm.bios === "ovmf" ? "UEFI" : "SeaBIOS"} · Disk importiert`,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -347,6 +512,8 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
if (vm.guest_agent_preinstalled) return { uebersprungen: true, detail: "im Image enthalten" };
|
||||
const vmid = stand.vmid!;
|
||||
const vmPfad = pfad`/nodes/${knoten}/qemu/${vmid}`;
|
||||
if (stand.agentWeg === "baunetz")
|
||||
return await agentUeberBauNetz(e, vmPfad, vm.default_user, k, warten);
|
||||
const snippet = `${stand.snippetStorage}:snippets/${snippetDatei}`;
|
||||
await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, {
|
||||
eingabe: vorbereitungsSnippet(),
|
||||
@@ -383,6 +550,49 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
return { detail: "qemu-guest-agent installiert · Maschinen-ID geleert" };
|
||||
},
|
||||
},
|
||||
{
|
||||
schluessel: "profile",
|
||||
titel: "Profile",
|
||||
ausfuehren: async (k) => {
|
||||
if (profilIds.length === 0) return { uebersprungen: true, detail: "keine eingebacken" };
|
||||
const adapter = adapterFuer(d.family);
|
||||
if (!adapter)
|
||||
throw new AblaufFehler(
|
||||
`Für die Familie „${d.family}“ gibt es keinen Adapter.`,
|
||||
"Adapter ergänzen (docs/katalog.md).",
|
||||
);
|
||||
const vmPfad = pfad`/nodes/${knoten}/qemu/${stand.vmid!}`;
|
||||
const { skript } = profileRendern(e.profile!, adapter, {
|
||||
os: d.os,
|
||||
gast: { name, benutzer: vm.default_user, typ: "vm" },
|
||||
});
|
||||
if (client.trocken) {
|
||||
await k.log(skript, "info");
|
||||
return { detail: `${profilIds.join(", ")} würden eingebacken` };
|
||||
}
|
||||
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/start`), {
|
||||
zeitlimitMs: 5 * 60 * 1000,
|
||||
});
|
||||
await agentAbwarten(client, vmPfad, k, { grenzeMs: 5 * 60 * 1000, warten });
|
||||
await skriptAusfuehren(client, vmPfad, skript, k, { grenzeMs: 60 * 60 * 1000, warten });
|
||||
await k.detail("aufräumen und herunterfahren …");
|
||||
// Über denselben Weg wie die Profile: wartet aufs Ende, statt eine Pause zu raten.
|
||||
await skriptAusfuehren(client, vmPfad, `#!/bin/bash\n${TEMPLATE_AUFRAEUMEN}\n`, k, {
|
||||
grenzeMs: 5 * 60 * 1000,
|
||||
warten,
|
||||
});
|
||||
await aufgabeAbwarten(
|
||||
client,
|
||||
await client.post<string>(`${vmPfad}/status/shutdown`, { timeout: 180 }),
|
||||
{ zeitlimitMs: 5 * 60 * 1000 },
|
||||
);
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
if ((await client.get<{ status: string }>(`${vmPfad}/status/current`)).status === "stopped") break;
|
||||
await warten(3000);
|
||||
}
|
||||
return { detail: `${profilIds.join(", ")} eingebacken · Maschinen-ID und Host-Keys entfernt` };
|
||||
},
|
||||
},
|
||||
{
|
||||
schluessel: "template",
|
||||
titel: "Template",
|
||||
@@ -392,7 +602,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
await client.post<string>(pfad`/nodes/${knoten}/qemu/${stand.vmid!}/template`),
|
||||
{ zeitlimitMs: 10 * 60 * 1000 },
|
||||
);
|
||||
return { detail: `VM ${stand.vmid} ist jetzt Template ${templateName(d)}` };
|
||||
return { detail: `VM ${stand.vmid} ist jetzt Template ${name}` };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,8 +2,9 @@ import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
|
||||
import { adapterFuer } from "@/engine/adapter";
|
||||
import type { Distribution } from "@/engine/katalog/schema";
|
||||
import { profileRendern, MARKE } from "@/engine/profile/rendern";
|
||||
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||
import { profileRendern } from "@/engine/profile/rendern";
|
||||
import { agent, agentAbwarten, skriptAusfuehren } from "./gastagent";
|
||||
import { gastunabhaengig, mitStandardwerten, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
|
||||
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||
@@ -41,7 +42,7 @@ export type VmAusrollEingabe = {
|
||||
|
||||
export type VmAusrollStand = {
|
||||
knoten?: string;
|
||||
template?: { vmid: number; knoten: string };
|
||||
template?: { vmid: number; knoten: string; eingebacken: string[] };
|
||||
vmid?: number;
|
||||
ip?: string;
|
||||
agentNachMs?: number;
|
||||
@@ -81,17 +82,7 @@ export function ipconfig(netz: AusrollAnfrage["netz"]): string {
|
||||
return `ip=${netz.ip}${netz.gateway ? `,gw=${netz.gateway}` : ""}`;
|
||||
}
|
||||
|
||||
const SKRIPT = "/root/proxdeploy-provisionierung.sh";
|
||||
const LOG = "/var/log/proxdeploy-provisionierung.log";
|
||||
/**
|
||||
* file-write nimmt höchstens 60 KiB je Aufruf (API-Schema, Feld content). ProxDeploy kodiert selbst
|
||||
* als base64 (encode=0): Proxmox' eigene Kodierung scheitert an Nicht-ASCII („Wide character in
|
||||
* subroutine entry“, gefunden beim ersten echten Deploy). 40 KiB Rohdaten werden ~54 KiB base64.
|
||||
*/
|
||||
const STUECK = 40 * 1024;
|
||||
|
||||
type AgentAntwort<T> = { result: T };
|
||||
type ExecStatus = { exited: number | boolean; exitcode?: number; "out-data"?: string; "err-data"?: string };
|
||||
type Netzschnittstelle = {
|
||||
name: string;
|
||||
"ip-addresses"?: Array<{ "ip-address-type": string; "ip-address": string }>;
|
||||
@@ -101,13 +92,6 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
const { client, anfrage: a, distribution: d } = e;
|
||||
const warten = e.warten ?? schlafen;
|
||||
const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`;
|
||||
// Die Agent-Endpunkte antworten unterschiedlich: exec → {pid}, exec-status und file-read direkt,
|
||||
// ping und network-get-interfaces → {result}. Deshalb liefert der Helfer die Rohdaten.
|
||||
const agent = async <T>(
|
||||
befehl: string,
|
||||
methode: "GET" | "POST" = "POST",
|
||||
parameter?: Record<string, string | readonly string[]>,
|
||||
) => await client.aufrufen<T>({ methode, pfad: `${vmPfad()}/agent/${befehl}`, parameter });
|
||||
const templateStand: TemplateBauStand = {};
|
||||
|
||||
const schritte: PhasenSchritt[] = [
|
||||
@@ -119,17 +103,31 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
throw new AblaufFehler(`${d.name} bietet kein VM-Image.`, "Eine andere Distribution wählen.");
|
||||
const ressourcen = await client.get<Ressource[]>("/cluster/resources");
|
||||
const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online");
|
||||
const templates = ressourcen.filter(
|
||||
const gewaehlt = new Set(e.profile.map((p) => p.profil.id));
|
||||
const tags = (r: Ressource) => (r.tags ?? "").split(/[;, ]/);
|
||||
// Nur Templates, deren eingebackene Profile alle gewählt sind (#022) – sonst hätte der Gast
|
||||
// Software, die niemand bestellt hat.
|
||||
const templates = ressourcen
|
||||
.filter(
|
||||
(r) =>
|
||||
r.type === "qemu" &&
|
||||
r.template === 1 &&
|
||||
(r.tags ?? "").split(/[;, ]/).includes("proxdeploy-template") &&
|
||||
(r.tags ?? "").split(/[;, ]/).includes(d.id),
|
||||
);
|
||||
// Node: gewünscht, sonst der mit dem Template, sonst der mit dem meisten freien RAM.
|
||||
tags(r).includes("proxdeploy-template") &&
|
||||
tags(r).includes(d.id),
|
||||
)
|
||||
.map((r) => ({
|
||||
r,
|
||||
eingebacken: tags(r)
|
||||
.filter((t) => t.startsWith("profil-"))
|
||||
.map((t) => t.slice(7)),
|
||||
}))
|
||||
.filter((t) => t.eingebacken.every((p) => gewaehlt.has(p)))
|
||||
// Am meisten eingebacken zuerst, bei Gleichstand die kleinere VMID.
|
||||
.sort((x, y) => y.eingebacken.length - x.eingebacken.length || (x.r.vmid ?? 0) - (y.r.vmid ?? 0));
|
||||
// Node: gewünscht, sonst der mit dem besten Template, sonst der mit dem meisten freien RAM.
|
||||
stand.knoten =
|
||||
a.knoten ??
|
||||
templates.find((t) => nodes.some((n) => n.node === t.node))?.node ??
|
||||
templates.find((t) => nodes.some((n) => n.node === t.r.node))?.r.node ??
|
||||
[...nodes].sort((x, y) => (y.maxmem ?? 0) - (y.mem ?? 0) - ((x.maxmem ?? 0) - (x.mem ?? 0)))[0]
|
||||
?.node;
|
||||
if (!stand.knoten || !nodes.some((n) => n.node === stand.knoten)) {
|
||||
@@ -138,8 +136,10 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
"Anderen Node wählen oder Cluster-Status prüfen.",
|
||||
);
|
||||
}
|
||||
const tpl = templates.find((t) => t.node === stand.knoten) ?? templates[0];
|
||||
if (tpl) stand.template = { vmid: tpl.vmid!, knoten: tpl.node! };
|
||||
const besten = templates.find((t) => t.r.node === stand.knoten) ?? templates[0];
|
||||
const tpl = besten?.r;
|
||||
if (besten)
|
||||
stand.template = { vmid: besten.r.vmid!, knoten: besten.r.node!, eingebacken: besten.eingebacken };
|
||||
|
||||
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${stand.knoten}/storage`);
|
||||
const ziel = storages.find((s) => s.storage === a.storage);
|
||||
@@ -193,8 +193,13 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
schluessel: "template",
|
||||
titel: "Template",
|
||||
ausfuehren: async (k) => {
|
||||
if (stand.template)
|
||||
return { uebersprungen: true, detail: `${stand.template.vmid} auf ${stand.template.knoten}` };
|
||||
if (stand.template) {
|
||||
const t = stand.template;
|
||||
return {
|
||||
uebersprungen: true,
|
||||
detail: `${t.vmid} auf ${t.knoten}${t.eingebacken.length > 0 ? ` · ${t.eingebacken.join(", ")} eingebacken` : ""}`,
|
||||
};
|
||||
}
|
||||
if (!e.templateBau) throw new AblaufFehler("Template fehlt.", "Template bauen.");
|
||||
// Die Phasen des Template-Baus laufen hier als Unterschritte; ihr Fortschritt erscheint als Detail.
|
||||
const unter = templateBauSchritte(
|
||||
@@ -218,7 +223,7 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
});
|
||||
await k.log(`Template · ${schritt.titel}: ${ergebnis?.detail ?? "erledigt"}`);
|
||||
}
|
||||
stand.template = { vmid: templateStand.vmid!, knoten: stand.knoten! };
|
||||
stand.template = { vmid: templateStand.vmid!, knoten: stand.knoten!, eingebacken: [] };
|
||||
return { detail: `${templateStand.vmid} gebaut` };
|
||||
},
|
||||
},
|
||||
@@ -237,7 +242,19 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
target: stand.knoten!,
|
||||
storage: a.storage,
|
||||
}),
|
||||
{ zeitlimitMs: 30 * 60 * 1000 },
|
||||
{
|
||||
zeitlimitMs: 60 * 60 * 1000,
|
||||
intervallMs: 2000,
|
||||
// „transferred 1.2 GiB of 3.5 GiB (35.16%)“ – auf langsamen Storages dauert ein Voll-Klon.
|
||||
beiLog: (zeilen) => {
|
||||
const letzte = zeilen
|
||||
.map((z) => /transferred (.+) of (.+) \((\d+(?:\.\d+)?)%\)/.exec(z))
|
||||
.filter(Boolean)
|
||||
.at(-1);
|
||||
if (letzte)
|
||||
void k.detail(`${letzte[1]} von ${letzte[2]} (${Math.round(Number(letzte[3]))} %)`);
|
||||
},
|
||||
},
|
||||
);
|
||||
try {
|
||||
await klonen(stand.vmid!);
|
||||
@@ -314,33 +331,20 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
titel: "Guest-Agent",
|
||||
ausfuehren: async (k) => {
|
||||
if (client.trocken) return { detail: "wartet auf Antwort (ping)" };
|
||||
const beginn = Date.now();
|
||||
const grenze = e.agentMs ?? 5 * 60 * 1000;
|
||||
for (;;) {
|
||||
await k.pruefeAbbruch();
|
||||
try {
|
||||
await agent("ping");
|
||||
break;
|
||||
} catch (fehler) {
|
||||
if (Date.now() - beginn > grenze) {
|
||||
throw new AblaufFehler(
|
||||
`Der Guest-Agent antwortet nach ${Math.round(grenze / 1000)} s nicht.`,
|
||||
"Bootet die VM? Konsole ansehen. Wurde das Template von ProxDeploy gebaut (Agent eingebaut)?",
|
||||
);
|
||||
}
|
||||
if (fehler instanceof ProxmoxFehler && fehler.art === "recht") throw fehler;
|
||||
}
|
||||
await k.detail(`wartet … ${Math.round((Date.now() - beginn) / 1000)}s`);
|
||||
await warten(3000);
|
||||
}
|
||||
stand.agentNachMs = Date.now() - beginn;
|
||||
stand.agentNachMs = await agentAbwarten(client, vmPfad(), k, {
|
||||
grenzeMs: e.agentMs ?? 5 * 60 * 1000,
|
||||
warten,
|
||||
});
|
||||
// IP: bei statischer Adresse bekannt, sonst aus dem Agent (erste IPv4 außer Loopback).
|
||||
if (a.netz.modus === "statisch") {
|
||||
stand.ip = a.netz.ip!.split("/")[0];
|
||||
} else {
|
||||
for (let versuch = 0; versuch < 20 && !stand.ip; versuch += 1) {
|
||||
const schnittstellen = (
|
||||
await agent<AgentAntwort<Netzschnittstelle[]>>("network-get-interfaces", "GET")
|
||||
await agent(client, vmPfad())<AgentAntwort<Netzschnittstelle[]>>(
|
||||
"network-get-interfaces",
|
||||
"GET",
|
||||
)
|
||||
).result;
|
||||
stand.ip = schnittstellen
|
||||
.filter((s) => s.name !== "lo")
|
||||
@@ -366,10 +370,18 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
`Für die Familie „${d.family}“ gibt es keinen Adapter.`,
|
||||
"Adapter ergänzen (docs/katalog.md).",
|
||||
);
|
||||
// Eingebacken und mit Standardwerten → schon im Template, nicht noch einmal (#022).
|
||||
const eingebacken = new Set(stand.template?.eingebacken ?? []);
|
||||
const uebersprungen = e.profile
|
||||
.filter((p) => eingebacken.has(p.profil.id) && mitStandardwerten(p) && gastunabhaengig(p.profil))
|
||||
.map((p) => p.profil.id);
|
||||
const auszufuehren = e.profile.filter((p) => !uebersprungen.includes(p.profil.id));
|
||||
if (uebersprungen.length > 0)
|
||||
await k.log(`Im Template eingebacken, wird übersprungen: ${uebersprungen.join(", ")}`);
|
||||
const { skript } = profileRendern(
|
||||
e.profile,
|
||||
auszufuehren,
|
||||
adapter,
|
||||
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer } },
|
||||
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "vm" } },
|
||||
[
|
||||
adapter.benutzerAnlegen({
|
||||
name: a.zugang.benutzer,
|
||||
@@ -382,85 +394,17 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
`Skript: ${skript.split("\n").length} Zeilen, ${Math.round(skript.length / 1024)} KiB`,
|
||||
"debug",
|
||||
);
|
||||
const zusatz = uebersprungen.length > 0 ? ` (${uebersprungen.join(", ")} aus dem Template)` : "";
|
||||
const liste = auszufuehren.map((p) => p.profil.id).join(", ");
|
||||
if (client.trocken) {
|
||||
await k.log(skript, "info");
|
||||
return {
|
||||
detail:
|
||||
e.profile.length > 0
|
||||
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")}`
|
||||
: "nur Benutzer",
|
||||
};
|
||||
return { detail: `${liste ? `Profile ${liste}` : "nur Benutzer"}${zusatz}` };
|
||||
}
|
||||
|
||||
// Hochladen in Stücken (file-write ersetzt die Datei, hängt nicht an) und zusammensetzen.
|
||||
const bytes = Buffer.from(skript, "utf8");
|
||||
const stuecke: Buffer[] = [];
|
||||
for (let i = 0; i < bytes.length; i += STUECK) stuecke.push(bytes.subarray(i, i + STUECK));
|
||||
for (const [nr, inhalt] of stuecke.entries()) {
|
||||
await agent("file-write", "POST", {
|
||||
file: `${SKRIPT}.${nr}`,
|
||||
content: inhalt.toString("base64"),
|
||||
encode: "0",
|
||||
await skriptAusfuehren(client, vmPfad(), skript, k, {
|
||||
grenzeMs: e.provisionierungMs ?? 60 * 60 * 1000,
|
||||
warten,
|
||||
});
|
||||
}
|
||||
const zusammensetzen = stuecke.map((_, nr) => `${SKRIPT}.${nr}`).join(" ");
|
||||
const { pid } = await agent<{ pid: number }>("exec", "POST", {
|
||||
command: [
|
||||
"/bin/bash",
|
||||
"-c",
|
||||
`cat ${zusammensetzen} > ${SKRIPT} && rm -f ${zusammensetzen} && chmod 700 ${SKRIPT} && ${SKRIPT} > ${LOG} 2>&1`,
|
||||
],
|
||||
});
|
||||
|
||||
const beginn = Date.now();
|
||||
const grenze = e.provisionierungMs ?? 60 * 60 * 1000;
|
||||
let gelesen = 0;
|
||||
let profil = "";
|
||||
for (;;) {
|
||||
await warten(3000);
|
||||
const status = await agent<ExecStatus>("exec-status", "GET", { pid: String(pid) });
|
||||
// Fortschritt: neue Zeilen der Logdatei im Gast.
|
||||
try {
|
||||
const datei = await agent<{ content: string; truncated?: boolean }>("file-read", "GET", {
|
||||
file: LOG,
|
||||
});
|
||||
const zeilen = datei.content.split("\n");
|
||||
for (const zeile of zeilen.slice(gelesen, zeilen.length - 1)) {
|
||||
const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile);
|
||||
if (marke) profil = marke[1]!;
|
||||
await k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug");
|
||||
}
|
||||
gelesen = Math.max(gelesen, zeilen.length - 1);
|
||||
} catch {
|
||||
// Logdatei noch nicht da – beim nächsten Mal.
|
||||
}
|
||||
const sekunden = Math.round((Date.now() - beginn) / 1000);
|
||||
if (status.exited) {
|
||||
if (status.exitcode !== 0) {
|
||||
throw new AblaufFehler(
|
||||
`Provisionierung ${profil ? `im Profil ${profil} ` : ""}mit Code ${status.exitcode} abgebrochen.`,
|
||||
`Detail-Log oder ${LOG} in der VM ansehen.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (Date.now() - beginn > grenze) {
|
||||
throw new AblaufFehler(
|
||||
`Provisionierung läuft länger als ${Math.round(grenze / 60000)} min.`,
|
||||
`${LOG} in der VM ansehen.`,
|
||||
);
|
||||
}
|
||||
await k.detail(
|
||||
`${profil ? `Profil ${profil}` : "Vorbereitung"} läuft … ${Math.floor(sekunden / 60)}m ${String(sekunden % 60).padStart(2, "0")}s`,
|
||||
);
|
||||
}
|
||||
await agent("exec", "POST", { command: ["rm", "-f", SKRIPT] }).catch(() => undefined);
|
||||
return {
|
||||
detail:
|
||||
e.profile.length > 0
|
||||
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet`
|
||||
: "Benutzer eingerichtet",
|
||||
};
|
||||
return { detail: `${liste ? `Profile ${liste} angewendet` : "Benutzer eingerichtet"}${zusatz}` };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -147,6 +147,8 @@ KbdInteractiveAuthentication ${h.passwortAnmeldung ? "yes" : "no"}
|
||||
PermitRootLogin ${h.rootAnmeldung ? "prohibit-password" : "no"}
|
||||
PROXDEPLOY_SSH
|
||||
if command -v sshd >/dev/null 2>&1; then
|
||||
# Ubuntu 24.04 startet sshd per Socket – bis zur ersten Verbindung fehlt /run/sshd, und sshd -t bricht ab.
|
||||
install -d -m 0755 /run/sshd
|
||||
sshd -t
|
||||
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -56,7 +56,10 @@ export type Distribution = z.infer<typeof DistributionSchema>;
|
||||
|
||||
export const VariableSchema = z
|
||||
.object({
|
||||
name: z.string().regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich."),
|
||||
name: z
|
||||
.string()
|
||||
.regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich.")
|
||||
.refine((n) => n !== "vm" && n !== "lxc", "vm und lxc sind feste Bedingungen, keine Variablennamen."),
|
||||
label: z.string().min(1),
|
||||
type: z.enum(["string", "number", "boolean", "select"]).default("string"),
|
||||
required: z.boolean().default(false),
|
||||
|
||||
@@ -68,6 +68,40 @@ export function profileAufloesen(
|
||||
return { ok: true, profile, automatisch: reihenfolge.filter((id) => !auswahl.includes(id)) };
|
||||
}
|
||||
|
||||
/** Werte eines Profils aus der Eingabe, mit Standardwerten und Typprüfung. */
|
||||
export function variablenWerte(
|
||||
profil: Profil,
|
||||
eingabe: Record<string, unknown>,
|
||||
): { werte: Record<string, string | number | boolean>; fehler: ProfilFehler[] } {
|
||||
const werte: Record<string, string | number | boolean> = {};
|
||||
const fehler: ProfilFehler[] = [];
|
||||
for (const variable of profil.variables) {
|
||||
const ergebnis = wertPruefen(variable, eingabe[variable.name]);
|
||||
if ("fehler" in ergebnis)
|
||||
fehler.push({ profil: profil.id, variable: variable.name, meldung: ergebnis.fehler });
|
||||
else werte[variable.name] = ergebnis.wert;
|
||||
}
|
||||
return { werte, fehler };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein ins Template eingebackenes Profil muss beim Deploy nur dann nicht noch einmal laufen, wenn
|
||||
* seine Variablen genau die Standardwerte haben – mit denen wurde es eingebacken.
|
||||
*/
|
||||
export function mitStandardwerten(eintrag: AufgeloestesProfil): boolean {
|
||||
const standard = variablenWerte(eintrag.profil, {}).werte;
|
||||
return JSON.stringify(standard) === JSON.stringify(eintrag.werte);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verweist ein Profil auf `{{ guest.* }}` (Name, Benutzer), hängt es vom einzelnen Gast ab – im
|
||||
* Template stand dort etwas anderes. Solche Profile laufen beim Deploy trotzdem noch einmal; dank
|
||||
* Idempotenz ist das schnell (Fund: docker nahm sonst den Benutzer nicht in die Gruppe auf).
|
||||
*/
|
||||
export function gastunabhaengig(profil: Profil): boolean {
|
||||
return !/\{\{[^}]*\bguest\./.test(JSON.stringify(profil.families));
|
||||
}
|
||||
|
||||
function wertPruefen(
|
||||
variable: ProfilVariable,
|
||||
eingabe: unknown,
|
||||
|
||||
@@ -14,17 +14,26 @@ export const MARKE = "::proxdeploy::";
|
||||
|
||||
export type RenderKontext = {
|
||||
os: { id: string; version_id: string; codename?: string };
|
||||
gast: { name: string; benutzer: string };
|
||||
gast: { name: string; benutzer: string; typ?: "vm" | "lxc" };
|
||||
};
|
||||
|
||||
/** Feste Bedingungen neben den Profil-Variablen: `if: vm` / `if: lxc` (bzw. `!vm`, `!lxc`). */
|
||||
export const FESTE_BEDINGUNGEN = ["vm", "lxc"] as const;
|
||||
|
||||
export type Abschnitt = { profil: string; skript: string };
|
||||
|
||||
export type Gerendert = { skript: string; abschnitte: Abschnitt[] };
|
||||
|
||||
function bedingungErfuellt(schritt: ProfilSchritt, werte: Record<string, unknown>): boolean {
|
||||
function bedingungErfuellt(
|
||||
schritt: ProfilSchritt,
|
||||
werte: Record<string, unknown>,
|
||||
typ: "vm" | "lxc" | undefined,
|
||||
): boolean {
|
||||
if (!schritt.if) return true;
|
||||
const verneint = schritt.if.startsWith("!");
|
||||
const wert = Boolean(werte[schritt.if.replace(/^!/, "")]);
|
||||
const name = schritt.if.replace(/^!/, "");
|
||||
// Ohne bekannten Typ (Rendern zum Ansehen) laufen typabhängige Schritte mit.
|
||||
const wert = name === "vm" || name === "lxc" ? (typ ? typ === name : !verneint) : Boolean(werte[name]);
|
||||
return verneint ? !wert : wert;
|
||||
}
|
||||
|
||||
@@ -87,7 +96,7 @@ export function profileRendern(
|
||||
};
|
||||
const teile: string[] = [];
|
||||
for (const schritt of profil.families[adapter.familie] ?? []) {
|
||||
if (!bedingungErfuellt(schritt, werte)) continue;
|
||||
if (!bedingungErfuellt(schritt, werte, kontext.gast.typ)) continue;
|
||||
const text = schrittRendern(schritt, adapter, vorlagenKontext);
|
||||
if ("ssh_harden" in schritt) zumSchluss.push(text);
|
||||
else teile.push(text);
|
||||
|
||||
@@ -119,7 +119,7 @@ async function versuchen<T>(warnungen: string[], text: string, aufruf: () => Pro
|
||||
|
||||
export async function erkunden(
|
||||
client: ProxmoxClient,
|
||||
optionen: { serverHost: string; ssh: SshZugang | null; jetzt?: () => Date },
|
||||
optionen: { serverHost: string; ssh: SshZugang | null; bauNetz?: boolean; jetzt?: () => Date },
|
||||
): Promise<VerbindungsBericht> {
|
||||
const warnungen: string[] = [];
|
||||
const version = await client.get<{ version: string; release: string }>("/version");
|
||||
@@ -313,6 +313,7 @@ export async function erkunden(
|
||||
storages: alleStorages,
|
||||
ssh: sshGesamt,
|
||||
rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
|
||||
bauNetz: optionen.bauNetz,
|
||||
}),
|
||||
warnungen,
|
||||
};
|
||||
|
||||
@@ -230,6 +230,8 @@ export function faehigkeitenAbleiten(eingabe: {
|
||||
storages: StorageInfo[];
|
||||
ssh: SshZustand;
|
||||
rechteFehlen: boolean;
|
||||
/** Bau-Adresse gesetzt (#021) – Guest-Agent einbauen geht dann auch ohne SSH zum Node. */
|
||||
bauNetz?: boolean;
|
||||
}): Faehigkeiten {
|
||||
const { version, storages, ssh } = eingabe;
|
||||
const sshOk = ssh === "ok";
|
||||
@@ -237,10 +239,19 @@ export function faehigkeitenAbleiten(eingabe: {
|
||||
const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets"));
|
||||
|
||||
let templateBau: Strategie;
|
||||
if (mindestens(version, 8, 4) && importSpeicher.length > 0) {
|
||||
const agentEinbauen = (sshOk && snippetSpeicher.length > 0) || Boolean(eingabe.bauNetz);
|
||||
if (mindestens(version, 8, 4) && importSpeicher.length > 0 && !agentEinbauen) {
|
||||
templateBau = {
|
||||
weg: "nicht_moeglich",
|
||||
grund:
|
||||
"Image-Import ginge per API, aber der Guest-Agent muss ins Template: SSH zum Node (mit Snippets-Storage) oder eine Bau-Adresse in den Server-Einstellungen.",
|
||||
};
|
||||
} else if (mindestens(version, 8, 4) && importSpeicher.length > 0) {
|
||||
templateBau = {
|
||||
weg: "api",
|
||||
grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}`,
|
||||
grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}${
|
||||
sshOk && snippetSpeicher.length > 0 ? "" : "; Guest-Agent über die Bau-Adresse"
|
||||
}`,
|
||||
};
|
||||
} else if (sshOk) {
|
||||
templateBau = {
|
||||
|
||||
@@ -40,7 +40,13 @@ export function hostSchluesselFingerabdruck(schluessel: Buffer): string {
|
||||
export function sshAusfuehren(
|
||||
ziel: SshZiel,
|
||||
befehl: string,
|
||||
optionen: { zeitlimitMs?: number; eingabe?: string; erkunden?: boolean } = {},
|
||||
optionen: {
|
||||
zeitlimitMs?: number;
|
||||
eingabe?: string;
|
||||
erkunden?: boolean;
|
||||
/** Laufende Ausgabe (stdout und stderr), z. B. für Live-Fortschritt einer Provisionierung. */
|
||||
beiAusgabe?: (text: string) => void;
|
||||
} = {},
|
||||
): Promise<SshErgebnis> {
|
||||
if (!ziel.hostSchluessel && !optionen.erkunden) {
|
||||
return Promise.reject(
|
||||
@@ -95,8 +101,16 @@ export function sshAusfuehren(
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let code: number | null = null;
|
||||
kanal.on("data", (teil: Buffer) => (stdout += teil.toString("utf8")));
|
||||
kanal.stderr.on("data", (teil: Buffer) => (stderr += teil.toString("utf8")));
|
||||
kanal.on("data", (teil: Buffer) => {
|
||||
const text = teil.toString("utf8");
|
||||
stdout += text;
|
||||
optionen.beiAusgabe?.(text);
|
||||
});
|
||||
kanal.stderr.on("data", (teil: Buffer) => {
|
||||
const text = teil.toString("utf8");
|
||||
stderr += text;
|
||||
optionen.beiAusgabe?.(text);
|
||||
});
|
||||
kanal.on("exit", (exitCode: number | null) => (code = exitCode));
|
||||
kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen }));
|
||||
if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe);
|
||||
|
||||
@@ -8,10 +8,12 @@ export async function register(): Promise<void> {
|
||||
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
|
||||
const { logger } = await import("@/lib/logger");
|
||||
const { katalogSynchronisieren } = await import("@/server/services/katalog");
|
||||
const { beispielVorlageAnlegen } = await import("@/server/services/vorlagen");
|
||||
try {
|
||||
schluesselPruefen();
|
||||
await ersterAdminFallsLeer();
|
||||
const sync = await katalogSynchronisieren();
|
||||
await beispielVorlageAnlegen();
|
||||
logger.info(
|
||||
{ distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length },
|
||||
"Katalog abgeglichen",
|
||||
|
||||
+40
-1
@@ -1,5 +1,6 @@
|
||||
import type { StatusArt } from "@/components/status";
|
||||
import type { AuftragSicht } from "@/lib/schemas/auftrag";
|
||||
import { dauer } from "@/lib/formate";
|
||||
import type { Abschluss, AuftragSicht } from "@/lib/schemas/auftrag";
|
||||
|
||||
/** Beschriftungen für Aufträge – geteilt von Verlauf, Übersicht und Live-Ansicht. */
|
||||
export const STATUS_ANZEIGE: Record<AuftragSicht["status"], [StatusArt, string]> = {
|
||||
@@ -16,3 +17,41 @@ export const ART_TEXT: Record<AuftragSicht["art"], string> = {
|
||||
TEMPLATE_BAUEN: "Template",
|
||||
AUFRAEUMEN: "Aufräumen",
|
||||
};
|
||||
|
||||
const ENDE = new Set<AuftragSicht["status"]>(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
||||
|
||||
/**
|
||||
* Inhalt der Abschlussbox – auf dem Server berechnet und mit dem Auftrag ausgeliefert, damit Web
|
||||
* und CLI dieselben Zeilen zeigen.
|
||||
*/
|
||||
export function abschlussAus(a: Omit<AuftragSicht, "abschluss">): Abschluss | null {
|
||||
if (!ENDE.has(a.status)) return null;
|
||||
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
|
||||
const gesamt =
|
||||
a.gestartetAm && a.beendetAm
|
||||
? dauer(new Date(a.beendetAm).getTime() - new Date(a.gestartetAm).getTime())
|
||||
: null;
|
||||
const name = a.name ?? (typeof e["template"] === "string" ? e["template"] : a.kurzId);
|
||||
if (a.status !== "ERFOLGREICH") {
|
||||
const f = (a.fehler ?? {}) as { phase?: unknown; ursache?: unknown; hinweis?: unknown };
|
||||
const phase = a.phasen.find((p) => p.schluessel === f.phase)?.titel;
|
||||
const zeilen: Array<[string, string]> = [];
|
||||
if (phase) zeilen.push(["Phase", phase]);
|
||||
if (typeof f.ursache === "string") zeilen.push(["Ursache", f.ursache]);
|
||||
if (typeof f.hinweis === "string") zeilen.push(["Hinweis", f.hinweis]);
|
||||
return {
|
||||
ok: false,
|
||||
titel: `${name} ${a.status === "ABGEBROCHEN" ? "abgebrochen" : a.status === "AUFGERAEUMT" ? "aufgeräumt" : "fehlgeschlagen"}`,
|
||||
dauer: gesamt,
|
||||
zeilen,
|
||||
};
|
||||
}
|
||||
const zeilen: Array<[string, string]> = [];
|
||||
const vmid = a.vmid ?? (typeof e["vmid"] === "number" ? e["vmid"] : null);
|
||||
if (vmid) zeilen.push([a.gastTyp === "LXC" ? "CTID" : "VMID", String(vmid)]);
|
||||
if (a.art === "TEMPLATE_BAUEN" && typeof e["knoten"] === "string") zeilen.push(["Node", e["knoten"]]);
|
||||
if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]);
|
||||
if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]);
|
||||
if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]);
|
||||
return { ok: true, titel: `${name} bereit`, dauer: gesamt, zeilen };
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user