Compare commits
9
Commits
1e195f9bca
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e21ac97dbb | ||
|
|
6d6b0ff121 | ||
|
|
99357d59bd | ||
|
|
7f6cf00a0e | ||
|
|
8dca30e254 | ||
|
|
543fc1c8b4 | ||
|
|
4ae08abd7c | ||
|
|
bd4bca5399 | ||
|
|
9fdced62db |
+4
-1
@@ -22,12 +22,15 @@ ADMIN_BENUTZER=admin
|
|||||||
ADMIN_PASSWORT=
|
ADMIN_PASSWORT=
|
||||||
|
|
||||||
# ── OIDC (optional, z. B. authentik) ─────────────────────────────────────────
|
# ── OIDC (optional, z. B. authentik) ─────────────────────────────────────────
|
||||||
|
# Beim IdP als Redirect-URI eintragen: <APP_URL>/api/v1/auth/oidc/rueckruf
|
||||||
|
# Client-Typ „confidential“, Scopes openid profile email; der Gruppen-Claim muss im ID-Token stehen.
|
||||||
OIDC_AKTIV=false
|
OIDC_AKTIV=false
|
||||||
OIDC_ISSUER=
|
OIDC_ISSUER=
|
||||||
OIDC_CLIENT_ID=
|
OIDC_CLIENT_ID=
|
||||||
OIDC_CLIENT_SECRET=
|
OIDC_CLIENT_SECRET=
|
||||||
OIDC_ANZEIGENAME=SSO
|
OIDC_ANZEIGENAME=SSO
|
||||||
# Gruppen-Claim und Gruppennamen für die Rollen.
|
# Gruppen-Claim und Gruppennamen für die Rollen. Wer in keiner der beiden Gruppen ist, kommt nicht
|
||||||
|
# hinein. Beide leer = jeder beim IdP angemeldete Benutzer wird Operator.
|
||||||
OIDC_GRUPPEN_CLAIM=groups
|
OIDC_GRUPPEN_CLAIM=groups
|
||||||
OIDC_GRUPPE_ADMIN=proxdeploy-admins
|
OIDC_GRUPPE_ADMIN=proxdeploy-admins
|
||||||
OIDC_GRUPPE_OPERATOR=proxdeploy-operators
|
OIDC_GRUPPE_OPERATOR=proxdeploy-operators
|
||||||
|
|||||||
@@ -17,6 +17,39 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.5.0] – 2026-10-12
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Anmeldung per OIDC (z. B. authentik): PKCE, State und Nonce, Rolle aus dem Gruppen-Claim bei jeder Anmeldung, neue Konten beim ersten Anmelden, Verknüpfen eines lokalen Kontos aus den Einstellungen (#016)
|
||||||
|
- Benutzerverwaltung für Admins: anlegen, Rolle ändern, sperren und entsperren, Passwort neu setzen; der letzte Admin und das eigene Konto sind geschützt (#015)
|
||||||
|
- Eigenes Passwort in den Einstellungen ändern (#015)
|
||||||
|
- Audit-Log-Ansicht für Admins, filterbar nach Benutzer, Quelle, Text und Zeitraum; auch `GET /api/v1/protokoll` (#015)
|
||||||
|
- Verlauf filterbar nach Server, Typ, Distribution und Status, mit Seitenblättern; die Filter stehen in der URL. Auch per API und `proxdeploy history` (#014)
|
||||||
|
- „Wiederholen“: der Assistent übernimmt alle Werte eines früheren Auftrags, Name, VMID und feste IP gibt man neu an (#014)
|
||||||
|
- „Aufräumen“ nach Fehlschlag oder Abbruch: löscht nach Bestätigung den halb angelegten Gast – nur, wenn seine Beschreibung auf den Auftrag verweist; Templates nie. Web, API und `proxdeploy cleanup` (#014)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- „Einstellungen“ fehlte in der Navigation (#015)
|
||||||
|
- Erzeugte SSH-Schlüssel (je Server und Bereitstellungsschlüssel) waren in etwa 4 von 1000 Fällen für ssh2 selbst nicht lesbar; jetzt wird geprüft und neu erzeugt (#014)
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- SSO übernimmt nie ein lokales Konto über den Benutzernamen; Verknüpfen geht nur angemeldet (#016)
|
||||||
|
- Sperren, Passwort-Reset und Passwortwechsel beenden alle bestehenden Sitzungen des Benutzers (#015)
|
||||||
|
|
||||||
|
## [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
|
## [0.3.0] – 2026-10-12
|
||||||
|
|
||||||
### Hinzugefügt
|
### 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/components/* Darstellung, keine Geschäftslogik.
|
||||||
src/lib/* Framework-freie Helfer; was Client und Server teilen, liegt hier.
|
src/lib/* Framework-freie Helfer; was Client und Server teilen, liegt hier.
|
||||||
src/worker/* Hintergrundprozess (pg-boss).
|
src/worker/* Hintergrundprozess (pg-boss).
|
||||||
|
cli/ CLI in Go; nur REST-API. Typen aus cli/api/openapi.json (oapi-codegen).
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Web-GUI und CLI nutzen dieselbe REST-API.** Keine Logik nur für eine Oberfläche.
|
- **Web-GUI und CLI nutzen dieselbe REST-API.** Keine Logik nur für eine Oberfläche.
|
||||||
@@ -61,6 +62,11 @@ Node 24 über nvm (`export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"`), pnpm 12.
|
|||||||
`pino@10.4.0`. Obergrenzen: **eslint 9.x** (eslint-plugin-react aus eslint-config-next kann 10 nicht),
|
`pino@10.4.0`. Obergrenzen: **eslint 9.x** (eslint-plugin-react aus eslint-config-next kann 10 nicht),
|
||||||
**typescript ~6.0.3** (typescript-eslint 8 erlaubt < 6.1).
|
**typescript ~6.0.3** (typescript-eslint 8 erlaubt < 6.1).
|
||||||
|
|
||||||
|
CLI: **Go 1.27.2** ohne sudo unter `~/.local/go` (`export PATH="$HOME/.local/go/bin:$PATH"`),
|
||||||
|
`cobra 1.10.2`, `BurntSushi/toml 1.6.0`, `x/term 0.46.0`, `go-runewidth 0.0.30`,
|
||||||
|
`go.yaml.in/yaml/v3 3.0.5`, `oapi-codegen 2.8.0` (als `go tool`). Antwortschemas der API stehen in
|
||||||
|
`src/lib/schemas/api.ts`; nach einer Änderung `pnpm openapi` und `go generate ./...` in `cli/`.
|
||||||
|
|
||||||
Next 16: `proxy.ts` statt middleware; `cookies()`, `headers()`, `params`, `searchParams` sind async.
|
Next 16: `proxy.ts` statt middleware; `cookies()`, `headers()`, `params`, `searchParams` sind async.
|
||||||
Prisma 7: Client aus `@/generated/prisma/client`, Enum-Werte aus `@/generated/prisma/enums`, Zugriff
|
Prisma 7: Client aus `@/generated/prisma/client`, Enum-Werte aus `@/generated/prisma/enums`, Zugriff
|
||||||
über den Lazy-Proxy `src/lib/db.ts`.
|
über den Lazy-Proxy `src/lib/db.ts`.
|
||||||
@@ -70,6 +76,7 @@ Prisma 7: Client aus `@/generated/prisma/client`, Enum-Werte aus `@/generated/pr
|
|||||||
```bash
|
```bash
|
||||||
pnpm db:up
|
pnpm db:up
|
||||||
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test
|
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test
|
||||||
|
pnpm cli:pruefen # wenn API-Schemas oder cli/ betroffen sind
|
||||||
```
|
```
|
||||||
|
|
||||||
Ohne laufende Datenbank überspringt `pnpm test` die Datenbanktests und sagt es auf stderr.
|
Ohne laufende Datenbank überspringt `pnpm test` die Datenbanktests und sagt es auf stderr.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 4 h 38 min (Sitzung 1 und 2)
|
**Gesamt:** 6 h 59 min (Sitzung 1–3)
|
||||||
|
|
||||||
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
||||||
|
|
||||||
@@ -23,16 +23,52 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
| #022 Profile im Template | 0 h 22 min |
|
| #022 Profile im Template | 0 h 22 min |
|
||||||
| #015 (Teil) SSH-Schlüssel | 0 h 10 min |
|
| #015 (Teil) SSH-Schlüssel | 0 h 10 min |
|
||||||
| #021 Template-Bau ohne SSH | 0 h 19 min |
|
| #021 Template-Bau ohne SSH | 0 h 19 min |
|
||||||
|
| #013 CLI und API-Tokens | 0 h 42 min |
|
||||||
|
| #014 Verlauf, Wiederholen, Aufräumen | 1 h 21 min |
|
||||||
|
| #015 Benutzer, Passwort, Audit-Log | 0 h 10 min |
|
||||||
|
| #016 OIDC | 0 h 08 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
| v0.1.0 | 1 h 15 min | **abgeschlossen**
|
| v0.1.0 | 1 h 15 min | **abgeschlossen**
|
||||||
| v0.2.0 | 1 h 32 min | **abgeschlossen**
|
| v0.2.0 | 1 h 32 min | **abgeschlossen**
|
||||||
| v0.3.0 | 1 h 48 min | **abgeschlossen** (Abnahme #021 offen)
|
| v0.3.0 | 1 h 48 min | **abgeschlossen** (Abnahme #021 offen)
|
||||||
|
| v0.4.0 | 0 h 42 min | **abgeschlossen**
|
||||||
|
| v0.5.0 | 1 h 39 min | **abgeschlossen**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-10-12 · Sitzung 2 · 12:48–laufend
|
## 2026-10-12 · Sitzung 3 · 14:39–17:00
|
||||||
|
**Issue:** #013–#016
|
||||||
|
**Version:** 0.4.0, 0.5.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
|
||||||
|
|
||||||
|
- Etappe 5 (v0.5.0): Verlauf mit Filtern in der URL, Wiederholen, Aufräumen (auf pve-demo: CT 109 nach Abbruch gelöscht, Original „aufgeräumt“); Benutzerverwaltung, Passwort ändern, Audit-Log-Ansicht; OIDC gegen Mock-IdP
|
||||||
|
- CLI: `history` und `cleanup`
|
||||||
|
- Gefunden: ssh2 erzeugt in ~0,4 % unlesbare ed25519-Schlüssel (war die Ursache des wackligen SSH-Tests) – behoben; „Einstellungen“ fehlte seit v0.3.0 in der Navigation
|
||||||
|
- 308 Vitest, 26 Playwright-Abläufe 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.
|
||||||
|
- **Aufräumen löscht nur, was die Beschreibung „(Auftrag <kurzId>)“ trägt** – deshalb setzt das Klonen die Beschreibung jetzt sofort; Templates nie.
|
||||||
|
- **Sitzungen beenden über `Benutzer.sitzungenAb`** statt einer Sitzungstabelle.
|
||||||
|
- **SSO übernimmt nie ein lokales Konto über den Namen**; die Rolle kommt bei jeder Anmeldung aus den Gruppen.
|
||||||
|
- 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
|
**Issue:** #009–#012, #015 (Teil), #021, #022
|
||||||
**Version:** 0.3.0-dev
|
**Version:** 0.3.0-dev
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,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 |
|
| 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 |
|
| 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, Profile im Template, SSH-Schlüssel | ✓ fertig, auf PVE 9.2 geprüft (Bau-Adresse #021: Abnahme offen) |
|
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen, Profile im Template, SSH-Schlüssel | ✓ fertig, auf PVE 9.2 geprüft (Bau-Adresse #021: Abnahme offen) |
|
||||||
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ◐ als Nächstes |
|
| 4 · v0.4.0 | CLI `proxdeploy` (Go), API-Tokens | ✓ fertig, auf PVE 9.2 geprüft |
|
||||||
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ geplant |
|
| 5 · v0.5.0 | Verlauf, Aufräumen, Audit-Log, Benutzer, OIDC | ✓ fertig (Aufräumen auf PVE 9.2 geprüft, OIDC gegen Mock-IdP) |
|
||||||
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
|
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ◐ als Nächstes |
|
||||||
|
|
||||||
## Was ProxDeploy auf einem Server kann
|
## Was ProxDeploy auf einem Server kann
|
||||||
|
|
||||||
@@ -37,6 +37,98 @@ Maßgeblich ist die Proxmox-API (geprüft gegen PVE 8.x und 9.2):
|
|||||||
Das API-Token braucht eine eigene Rolle; die Befehle stehen in
|
Das API-Token braucht eine eigene Rolle; die Befehle stehen in
|
||||||
[docs/proxmox-rechte.md](docs/proxmox-rechte.md).
|
[docs/proxmox-rechte.md](docs/proxmox-rechte.md).
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
`proxdeploy` ist ein einzelnes Binary (Linux, macOS, Windows; amd64 und arm64) und spricht nur die
|
||||||
|
REST-API – dieselbe wie die Web-GUI, mit denselben Phasentexten.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
proxdeploy login https://proxdeploy.example # fragt Benutzer und Passwort, speichert ein Token
|
||||||
|
proxdeploy servers
|
||||||
|
proxdeploy distros --type lxc
|
||||||
|
proxdeploy profiles
|
||||||
|
proxdeploy vm web01 --server rz1 --distro debian-13 --profile base,docker --ip 10.0.20.15/24 --gw 10.0.20.1 --vlan 20
|
||||||
|
proxdeploy lxc dns01 --server rz1 --distro ubuntu-24.04 --dry-run
|
||||||
|
proxdeploy template build --server rz1 --distro debian-13 --profile base,docker
|
||||||
|
proxdeploy batch gaeste.yaml
|
||||||
|
proxdeploy status a-7f3k
|
||||||
|
proxdeploy logs a-7f3k --follow
|
||||||
|
proxdeploy history --type lxc --status fehlgeschlagen
|
||||||
|
proxdeploy cleanup a-7f3k # halb angelegten Gast nach Fehlschlag löschen (mit Rückfrage)
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
⬡ 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).
|
||||||
|
|
||||||
|
## Anmeldung per OIDC (z. B. authentik)
|
||||||
|
|
||||||
|
Optional zusätzlich zu lokalen Konten. Beim Identity Provider einen vertraulichen Client anlegen,
|
||||||
|
Redirect-URI `<APP_URL>/api/v1/auth/oidc/rueckruf`, Scopes `openid profile email`, den
|
||||||
|
Gruppen-Claim ins ID-Token geben. Dann in der `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OIDC_AKTIV=true
|
||||||
|
OIDC_ISSUER=https://auth.example/application/o/proxdeploy/
|
||||||
|
OIDC_CLIENT_ID=…
|
||||||
|
OIDC_CLIENT_SECRET=…
|
||||||
|
OIDC_ANZEIGENAME=authentik
|
||||||
|
OIDC_GRUPPEN_CLAIM=groups
|
||||||
|
OIDC_GRUPPE_ADMIN=proxdeploy-admins
|
||||||
|
OIDC_GRUPPE_OPERATOR=proxdeploy-operators
|
||||||
|
```
|
||||||
|
|
||||||
|
- Ablauf: Authorization Code mit PKCE, State und Nonce; das ID-Token wird gegen die Schlüssel des IdP geprüft.
|
||||||
|
- Die Rolle kommt bei **jeder** Anmeldung aus den Gruppen; wer in keiner der beiden Gruppen ist, kommt nicht hinein.
|
||||||
|
- Neue SSO-Benutzer werden beim ersten Anmelden angelegt. Ein vorhandenes lokales Konto wird **nie**
|
||||||
|
über den Namen übernommen; verknüpfen geht angemeldet unter Einstellungen → „Mit … verknüpfen“.
|
||||||
|
- Gesperrte Konten bleiben gesperrt, auch per SSO. Getestet gegen einen Mock-IdP (`tests/oidc.test.ts`);
|
||||||
|
ein echter IdP folgt beim Feldtest.
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL.
|
Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL.
|
||||||
@@ -54,8 +146,12 @@ Prüfen vor jedem Commit:
|
|||||||
```bash
|
```bash
|
||||||
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test
|
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test
|
||||||
pnpm test:e2e # Browser-Abläufe gegen den gebauten Stand (Desktop und 390 px)
|
pnpm test:e2e # Browser-Abläufe gegen den gebauten Stand (Desktop und 390 px)
|
||||||
|
pnpm cli:pruefen # openapi.json und Go-Typen aktuell, go vet, go test (Golden-Files)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ändert sich ein Schema der API, erst `pnpm openapi`, dann in `cli/` `go generate ./...` – der
|
||||||
|
Go-Client nutzt die aus `cli/api/openapi.json` erzeugten Typen.
|
||||||
|
|
||||||
Konventionen (deutsche Bezeichner, Schichtung, Versionen) stehen in [CLAUDE.md](CLAUDE.md).
|
Konventionen (deutsche Bezeichner, Schichtung, Versionen) stehen in [CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
|
|||||||
@@ -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,308 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verlauf: eine Seite, gefiltert. Leere Filter fallen weg.
|
||||||
|
func (c *Client) Verlauf(ctx context.Context, filter map[string]string) (Verlauf, error) {
|
||||||
|
werte := url.Values{}
|
||||||
|
for k, v := range filter {
|
||||||
|
if v != "" {
|
||||||
|
werte.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pfad := "/auftraege"
|
||||||
|
if len(werte) > 0 {
|
||||||
|
pfad += "?" + werte.Encode()
|
||||||
|
}
|
||||||
|
var v Verlauf
|
||||||
|
return v, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Aufraeumen(ctx context.Context, id string) (AuftragAngenommen, error) {
|
||||||
|
var a AuftragAngenommen
|
||||||
|
return a, c.Aufrufen(ctx, http.MethodPost, "/auftraege/"+url.PathEscape(id)+"/aufraeumen", nil, &a)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,821 @@
|
|||||||
|
// 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 AuftragAufraeumbarTyp.
|
||||||
|
const (
|
||||||
|
AuftragAufraeumbarTypLXC AuftragAufraeumbarTyp = "LXC"
|
||||||
|
AuftragAufraeumbarTypVM AuftragAufraeumbarTyp = "VM"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid indicates whether the value is a known member of the AuftragAufraeumbarTyp enum.
|
||||||
|
func (e AuftragAufraeumbarTyp) Valid() bool {
|
||||||
|
switch e {
|
||||||
|
case AuftragAufraeumbarTypLXC:
|
||||||
|
return true
|
||||||
|
case AuftragAufraeumbarTypVM:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defines values for AuftragGastTyp.
|
||||||
|
const (
|
||||||
|
AuftragGastTypLXC AuftragGastTyp = "LXC"
|
||||||
|
AuftragGastTypVM AuftragGastTyp = "VM"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid indicates whether the value is a known member of the AuftragGastTyp enum.
|
||||||
|
func (e AuftragGastTyp) Valid() bool {
|
||||||
|
switch e {
|
||||||
|
case AuftragGastTypLXC:
|
||||||
|
return true
|
||||||
|
case AuftragGastTypVM:
|
||||||
|
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 VerlaufParamsTyp.
|
||||||
|
const (
|
||||||
|
VerlaufParamsTypAufraeumen VerlaufParamsTyp = "aufraeumen"
|
||||||
|
VerlaufParamsTypLxc VerlaufParamsTyp = "lxc"
|
||||||
|
VerlaufParamsTypTemplate VerlaufParamsTyp = "template"
|
||||||
|
VerlaufParamsTypVm VerlaufParamsTyp = "vm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid indicates whether the value is a known member of the VerlaufParamsTyp enum.
|
||||||
|
func (e VerlaufParamsTyp) Valid() bool {
|
||||||
|
switch e {
|
||||||
|
case VerlaufParamsTypAufraeumen:
|
||||||
|
return true
|
||||||
|
case VerlaufParamsTypLxc:
|
||||||
|
return true
|
||||||
|
case VerlaufParamsTypTemplate:
|
||||||
|
return true
|
||||||
|
case VerlaufParamsTypVm:
|
||||||
|
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"`
|
||||||
|
Aufraeumbar *struct {
|
||||||
|
Knoten *string `json:"knoten"`
|
||||||
|
Typ AuftragAufraeumbarTyp `json:"typ"`
|
||||||
|
Vmid int `json:"vmid"`
|
||||||
|
} `json:"aufraeumbar"`
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// AuftragAufraeumbarTyp defines model for Auftrag.Aufraeumbar.Typ.
|
||||||
|
type AuftragAufraeumbarTyp 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verlauf defines model for Verlauf.
|
||||||
|
type Verlauf struct {
|
||||||
|
Eintraege []Auftrag `json:"eintraege"`
|
||||||
|
Gesamt int `json:"gesamt"`
|
||||||
|
Seite int `json:"seite"`
|
||||||
|
Seiten int `json:"seiten"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorlage defines model for Vorlage.
|
||||||
|
type Vorlage struct {
|
||||||
|
Beschreibung *string `json:"beschreibung"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Titel string `json:"titel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerlaufParams defines parameters for Verlauf.
|
||||||
|
type VerlaufParams struct {
|
||||||
|
Server *string `form:"server,omitempty" json:"server,omitempty"`
|
||||||
|
Typ *VerlaufParamsTyp `form:"typ,omitempty" json:"typ,omitempty"`
|
||||||
|
Distribution *string `form:"distribution,omitempty" json:"distribution,omitempty"`
|
||||||
|
Status *string `form:"status,omitempty" json:"status,omitempty"`
|
||||||
|
Seite *int `form:"seite,omitempty" json:"seite,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerlaufParamsTyp defines parameters for Verlauf.
|
||||||
|
type VerlaufParamsTyp string
|
||||||
|
|
||||||
|
// 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,320 @@
|
|||||||
|
package befehle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statustexte wie in der Web-GUI.
|
||||||
|
var statusText = map[api.AuftragStatus]string{
|
||||||
|
api.AuftragStatusWARTEND: "wartet", api.AuftragStatusLAEUFT: "läuft", api.AuftragStatusERFOLGREICH: "erfolgreich",
|
||||||
|
api.AuftragStatusFEHLGESCHLAGEN: "fehlgeschlagen", api.AuftragStatusABGEBROCHEN: "abgebrochen",
|
||||||
|
api.AuftragStatusAUFGERAEUMT: "aufgeräumt",
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusAusText nimmt „fehlgeschlagen“, „läuft“, „aufgeräumt“ oder den API-Wert.
|
||||||
|
func statusAusText(text string) string {
|
||||||
|
for wert, t := range statusText {
|
||||||
|
if strings.EqualFold(t, text) || strings.EqualFold(string(wert), text) {
|
||||||
|
return string(wert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.ToUpper(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *umgebung) historyBefehl() *cobra.Command {
|
||||||
|
var server, typ, distro, status string
|
||||||
|
var seite int
|
||||||
|
befehl := &cobra.Command{
|
||||||
|
Use: "history",
|
||||||
|
Aliases: []string{"verlauf"},
|
||||||
|
Short: "Verlauf der Aufträge, neueste zuerst",
|
||||||
|
Example: " proxdeploy history --type lxc --status FEHLGESCHLAGEN\n proxdeploy history --server rz1 --page 2",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
c, _, err := u.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filter := map[string]string{"server": server, "typ": typ, "distribution": distro, "status": ""}
|
||||||
|
if status != "" {
|
||||||
|
filter["status"] = statusAusText(status)
|
||||||
|
}
|
||||||
|
if seite > 1 {
|
||||||
|
filter["seite"] = fmt.Sprint(seite)
|
||||||
|
}
|
||||||
|
v, err := c.Verlauf(cmd.Context(), filter)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.opt.json {
|
||||||
|
return u.jsonAusgeben(v)
|
||||||
|
}
|
||||||
|
s := u.stil()
|
||||||
|
if len(v.Eintraege) == 0 {
|
||||||
|
fmt.Fprintln(u.aus, s.Muted("Keine Aufträge."))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
symbole := map[api.AuftragStatus]string{
|
||||||
|
api.AuftragStatusERFOLGREICH: "✓", api.AuftragStatusFEHLGESCHLAGEN: "✗", api.AuftragStatusABGEBROCHEN: "✗",
|
||||||
|
api.AuftragStatusLAEUFT: "◐", api.AuftragStatusWARTEND: "○", api.AuftragStatusAUFGERAEUMT: "○",
|
||||||
|
}
|
||||||
|
zeilen := [][]string{}
|
||||||
|
for _, a := range v.Eintraege {
|
||||||
|
art := map[api.AuftragArt]string{api.AUSROLLEN: "VM", api.TEMPLATEBAUEN: "Template", api.AUFRAEUMEN: "Aufräumen"}[a.Art]
|
||||||
|
if a.Art == api.AUSROLLEN && a.GastTyp != nil && *a.GastTyp == api.AuftragGastTypLXC {
|
||||||
|
art = "LXC"
|
||||||
|
}
|
||||||
|
id := "–"
|
||||||
|
if a.Vmid != nil {
|
||||||
|
id = fmt.Sprint(*a.Vmid)
|
||||||
|
}
|
||||||
|
zustand := symbole[a.Status] + " " + statusText[a.Status]
|
||||||
|
if s.ASCII {
|
||||||
|
zustand = strings.NewReplacer("✓", "[OK]", "✗", "[!!]", "◐", "[..]", "○", "[--]").Replace(zustand)
|
||||||
|
}
|
||||||
|
zeit := a.ErstelltAm
|
||||||
|
if t, ok := zeitAus(&a.ErstelltAm); ok {
|
||||||
|
zeit = t.Local().Format("02.01. 15:04")
|
||||||
|
}
|
||||||
|
name := wert(a.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = "–"
|
||||||
|
}
|
||||||
|
zeilen = append(zeilen, []string{a.KurzId, name, art, a.Server.Name, wert(a.DistributionId), id, zustand, zeit})
|
||||||
|
}
|
||||||
|
u.tabelle(s, []string{"AUFTRAG", "NAME", "TYP", "SERVER", "DISTRIBUTION", "ID", "STATUS", "ZEIT"}, zeilen)
|
||||||
|
if v.Seiten > 1 {
|
||||||
|
fmt.Fprintln(u.aus, s.Muted(fmt.Sprintf("Seite %d von %d · %d Aufträge · weiter mit --page %d", v.Seite, v.Seiten, v.Gesamt, min(v.Seite+1, v.Seiten))))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f := befehl.Flags()
|
||||||
|
f.StringVar(&server, "server", "", "nur dieser Server")
|
||||||
|
f.StringVar(&typ, "type", "", "vm, lxc, template oder aufraeumen")
|
||||||
|
f.StringVar(&distro, "distro", "", "nur diese Distribution")
|
||||||
|
f.StringVar(&status, "status", "", "z. B. fehlgeschlagen, erfolgreich, laeuft")
|
||||||
|
f.IntVar(&seite, "page", 1, "Seite (25 je Seite)")
|
||||||
|
return befehl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *umgebung) cleanupBefehl() *cobra.Command {
|
||||||
|
var ja, nichtWarten bool
|
||||||
|
befehl := &cobra.Command{
|
||||||
|
Use: "cleanup <job-id>",
|
||||||
|
Short: "Halb angelegten Gast eines fehlgeschlagenen Auftrags löschen",
|
||||||
|
Long: "Stoppt und löscht die VM bzw. den Container, den ein fehlgeschlagener oder abgebrochener\n" +
|
||||||
|
"Auftrag angelegt hat. Gelöscht wird nur, was laut Beschreibung zu diesem Auftrag gehört.",
|
||||||
|
Args: eineID("cleanup"),
|
||||||
|
RunE: func(cmd *cobra.Command, argumente []string) error {
|
||||||
|
c, _, err := u.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a, err := c.Auftrag(cmd.Context(), argumente[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.Aufraeumbar == nil {
|
||||||
|
return bedienfehler("bei %s gibt es nichts aufzuräumen (Status %s)", a.KurzId, statusText[a.Status])
|
||||||
|
}
|
||||||
|
z := a.Aufraeumbar
|
||||||
|
wort := map[api.AuftragAufraeumbarTyp]string{api.AuftragAufraeumbarTypVM: "VM", api.AuftragAufraeumbarTypLXC: "Container"}[z.Typ]
|
||||||
|
ziel := fmt.Sprintf("%s %d", wort, z.Vmid)
|
||||||
|
if z.Knoten != nil {
|
||||||
|
ziel += " auf " + *z.Knoten
|
||||||
|
}
|
||||||
|
if !ja {
|
||||||
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
|
return bedienfehler("%s löschen? Ohne Terminal mit --yes bestätigen", ziel)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(u.fehler, "%s stoppen und mit allen Disks löschen? [j/N] ", ziel)
|
||||||
|
antwort, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
if a := strings.ToLower(strings.TrimSpace(antwort)); a != "j" && a != "ja" && a != "y" && a != "yes" {
|
||||||
|
fmt.Fprintln(u.fehler, u.stil().Muted("Nichts gelöscht."))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
angenommen, err := c.Aufraeumen(cmd.Context(), a.KurzId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return u.angenommen(cmd.Context(), c, angenommen, nichtWarten)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
befehl.Flags().BoolVarP(&ja, "yes", "y", false, "ohne Rückfrage")
|
||||||
|
befehl.Flags().BoolVar(&nichtWarten, "no-wait", false, "nicht verfolgen, nur die Auftrags-ID ausgeben")
|
||||||
|
return befehl
|
||||||
|
}
|
||||||
@@ -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,387 @@
|
|||||||
|
package befehle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
verlauf map[string]any
|
||||||
|
einzeln map[string]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
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.anfragen == nil {
|
||||||
|
f.anfragen = map[string][]map[string]any{}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
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" && r.Method == http.MethodGet:
|
||||||
|
f.mu.Lock()
|
||||||
|
f.anfragen["GET "+r.URL.RawQuery] = nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
schreiben(200, f.verlauf)
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/aufraeumen"):
|
||||||
|
schreiben(202, map[string]any{"id": "c2", "kurzId": "c-9x9x9"})
|
||||||
|
case r.URL.Path == "/api/v1/auftraege/d-kaputt":
|
||||||
|
schreiben(200, f.einzeln)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistory(t *testing.T) {
|
||||||
|
ok := auftrag("ERFOLGREICH", alle, nil)
|
||||||
|
ok["erstelltAm"] = "2026-10-12T08:05:00.000Z"
|
||||||
|
ct := auftrag("FEHLGESCHLAGEN", alle, nil)
|
||||||
|
ct["kurzId"], ct["name"], ct["gastTyp"], ct["vmid"], ct["distributionId"] = "d-ct001", "dns01", "LXC", 121, "ubuntu-24.04"
|
||||||
|
ct["erstelltAm"] = "2026-10-12T08:00:00.000Z"
|
||||||
|
f := &fakeAPI{verlauf: map[string]any{"eintraege": []any{ok, ct}, "gesamt": 30, "seite": 1, "seiten": 2}}
|
||||||
|
alt := time.Local
|
||||||
|
time.Local = time.UTC
|
||||||
|
t.Cleanup(func() { time.Local = alt })
|
||||||
|
aus, fehler, code := lauf(t, f, "history", "--type", "lxc", "--status", "fehlgeschlagen")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("Exit %d: %s", code, fehler)
|
||||||
|
}
|
||||||
|
golden(t, "history.golden", aus)
|
||||||
|
if _, gesehen := f.anfragen["GET status=FEHLGESCHLAGEN&typ=lxc"]; !gesehen {
|
||||||
|
t.Errorf("Filter nicht gesendet: %v", f.anfragen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanup(t *testing.T) {
|
||||||
|
kaputt := auftrag("FEHLGESCHLAGEN", alle, nil)
|
||||||
|
kaputt["kurzId"] = "d-kaputt"
|
||||||
|
kaputt["aufraeumbar"] = map[string]any{"typ": "VM", "vmid": 120, "knoten": "pve01"}
|
||||||
|
f := &fakeAPI{einzeln: kaputt}
|
||||||
|
// Ohne Terminal keine Rückfrage möglich: ohne --yes wird nichts gelöscht.
|
||||||
|
_, fehler, code := lauf(t, f, "cleanup", "d-kaputt")
|
||||||
|
if code != ExitBedienung || !strings.Contains(fehler, "VM 120 auf pve01 löschen?") {
|
||||||
|
t.Errorf("ohne --yes: Exit %d, %s", code, fehler)
|
||||||
|
}
|
||||||
|
if len(f.anfragen["/api/v1/auftraege/d-kaputt/aufraeumen"]) != 0 {
|
||||||
|
t.Error("ohne Bestätigung aufgeräumt")
|
||||||
|
}
|
||||||
|
aus, _, code := lauf(t, f, "cleanup", "d-kaputt", "--yes", "--no-wait")
|
||||||
|
if code != 0 || strings.TrimSpace(aus) != "c-9x9x9" || len(f.anfragen["/api/v1/auftraege/d-kaputt/aufraeumen"]) != 1 {
|
||||||
|
t.Errorf("mit --yes: Exit %d, %q", code, aus)
|
||||||
|
}
|
||||||
|
|
||||||
|
kaputt["aufraeumbar"] = nil
|
||||||
|
_, fehler, code = lauf(t, f, "cleanup", "d-kaputt", "--yes")
|
||||||
|
if code != ExitBedienung || !strings.Contains(fehler, "nichts aufzuräumen") {
|
||||||
|
t.Errorf("nicht aufräumbar: Exit %d, %s", code, fehler)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
AUFTRAG NAME TYP SERVER DISTRIBUTION ID STATUS ZEIT
|
||||||
|
a-7f3k web01 VM rz1 debian-13 120 ✓ erfolgreich 12.10. 08:05
|
||||||
|
d-ct001 dns01 LXC rz1 ubuntu-24.04 121 ✗ fehlgeschlagen 12.10. 08:00
|
||||||
|
Seite 1 von 2 · 30 Aufträge · weiter mit --page 2
|
||||||
+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,316 @@
|
|||||||
|
// 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(),
|
||||||
|
u.historyBefehl(),
|
||||||
|
u.cleanupBefehl(),
|
||||||
|
)
|
||||||
|
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
|
`openapi.json` generierten Client (`oapi-codegen`) aufgefangen; ein CI-Schritt prüft, dass der
|
||||||
generierte Code aktuell ist. Bibliotheken: `cobra` (Befehle), `BurntSushi/toml`, `golang.org/x/term`.
|
generierte Code aktuell ist. Bibliotheken: `cobra` (Befehle), `BurntSushi/toml`, `golang.org/x/term`.
|
||||||
|
|
||||||
- Konfig `~/.config/proxdeploy/config.toml` (`url`, `token`, optional `[profiles.<name>]` für mehrere
|
- Konfig `~/.config/proxdeploy/config.toml` (`url`, `token`, optional `server` als Standard und
|
||||||
Instanzen), Rechte 0600; `PROXDEPLOY_URL`/`PROXDEPLOY_TOKEN` überschreiben.
|
`[instances.<name>]` für mehrere Instanzen – nicht `profiles`, das hieße wie `--profile`), Rechte
|
||||||
- `login <url>` fragt Benutzername/Passwort ab, legt ein Token an und speichert es (oder `--token`).
|
0600; `PROXDEPLOY_URL`/`PROXDEPLOY_TOKEN`/`PROXDEPLOY_INSTANCE` überschreiben.
|
||||||
|
- **Kurze Anfragen:** Die CLI schickt nur gesetzte Flags. Der Server füllt nach der Vorlage Storage,
|
||||||
|
Bridge, Node (Server-Standards bzw. Verbindungstest), Benutzer (eigener Name) und SSH-Schlüssel
|
||||||
|
(hinterlegte) – `anfrageErgaenzen` in `src/server/services/ausrollen.ts`.
|
||||||
|
- **Abschlussbox** (`abschluss` im Auftrag) berechnet der Server; Web und CLI zeigen dieselben Zeilen.
|
||||||
|
- **Generierter Code:** Antwortschemas als zod in `src/lib/schemas/api.ts`, daraus `openapi.json`
|
||||||
|
(`pnpm openapi`) und daraus die Go-Typen (oapi-codegen, nur Modelle). Anfragen gehen als Map hinaus
|
||||||
|
(sonst schickte Go leere Pflichtfelder statt sie wegzulassen). `pnpm cli:pruefen` schlägt fehl,
|
||||||
|
wenn etwas veraltet ist; `tests/api-schemas.test.ts` prüft echte Antworten gegen die Schemas.
|
||||||
|
- `login <url>` fragt Benutzername/Passwort ab, tauscht sie über `POST /api/v1/auth/token` gegen ein
|
||||||
|
Token und speichert es (oder `--token`). Tokens: `pd_` + 256 Bit, gespeichert nur als SHA-256.
|
||||||
- Ausgabe exakt nach Vorgabe; Renderer in `cli/internal/ausgabe/` mit Golden-File-Tests für Farbe,
|
- Ausgabe exakt nach Vorgabe; Renderer in `cli/internal/ausgabe/` mit Golden-File-Tests für Farbe,
|
||||||
`--no-color`, `--ascii`, `--quiet`, `--json`, `--dry-run`. Breite der Phasenspalte aus dem längsten
|
`--no-color`, `--ascii`, `--quiet`, `--json`, `--dry-run`. Breite der Phasenspalte aus dem längsten
|
||||||
Phasennamen, Box-Breite aus dem Inhalt (Unicode-Breite beachtet).
|
Phasennamen, Box-Breite aus dem Inhalt (Unicode-Breite beachtet).
|
||||||
|
|||||||
+20
-8
@@ -1,23 +1,35 @@
|
|||||||
# #013 CLI proxdeploy (Go)
|
# #013 CLI proxdeploy (Go)
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** feature, cli
|
**Labels:** feature, cli
|
||||||
**Geschätzt:** 12 h **Tatsächlich:** –
|
**Geschätzt:** 12 h **Tatsächlich:** 0 h 42 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Alle Befehle aus der Vorgabe, Ausgabe exakt im Format der Vorgabe.
|
Alle Befehle aus der Vorgabe, Ausgabe exakt im Format der Vorgabe.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] OpenAPI aus zod, Go-Client per oapi-codegen, Prüfung auf Aktualität
|
- [x] API-Tokens (`pd_…`, nur SHA-256 gespeichert, Ablauf, Widerruf) mit Bearer-Anmeldung, `POST /auth/token` für `login`, Verwaltung in den Einstellungen
|
||||||
- [ ] login, servers, distros, profiles, vm, lxc, template build, batch, status, logs --follow
|
- [x] Kurze Anfragen: Server füllt Storage, Bridge, Node, Benutzer, Schlüssel (`anfrageErgaenzen`), auch beim Template-Bau
|
||||||
- [ ] Renderer: Farbe 24-bit/256/aus, --ascii, --quiet, --json, --dry-run; Golden-Tests
|
- [x] Abschlussbox serverseitig (`abschluss`), Web und CLI zeigen dieselben Zeilen
|
||||||
- [ ] Release-Builds für linux/darwin/windows × amd64/arm64
|
- [x] OpenAPI aus zod (`/api/v1/openapi.json`, `pnpm openapi`), Go-Typen per oapi-codegen, `pnpm cli:pruefen`
|
||||||
|
- [x] login, servers, distros, profiles, presets, vm, lxc, template list/build, batch, status, logs --follow, cancel
|
||||||
|
- [x] Renderer: Farbe 24-bit/256/aus, --ascii, --quiet, --json, --dry-run; Golden-Tests
|
||||||
|
- [x] Release-Builds für linux/darwin/windows × amd64/arm64 (`cli/bauen.sh`)
|
||||||
|
- [x] Abnahme auf pve-demo (VM 107 + CT 108 per batch, parallel)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Ausgabe entspricht zeichengenau dem Mockup (Golden-File)
|
- [x] Ausgabe entspricht zeichengenau dem Mockup (`cli/internal/ausgabe/testdata/mockup.txt`, von Hand aus der Vorgabe übernommen)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#008, #012
|
#008, #012
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Anfragen als Map, Antworten typisiert.** oapi-codegen macht Pflichtfelder zu Nicht-Zeigern; Go
|
||||||
|
schickte dann `"storage": ""` und überschriebe Vorlagenwerte. Deshalb gehen nur gesetzte Flags hinaus.
|
||||||
|
- **`[instances.<name>]`** statt `[profiles.<name>]` in der Konfiguration – „profile“ ist schon `--profile`.
|
||||||
|
- **Strg+C hängt nur ab**, der Auftrag läuft weiter; Abbruch ist `proxdeploy cancel`.
|
||||||
|
- **Ohne Terminal** (Pipe, CI) kein Neuzeichnen: jede Phase eine Zeile, sobald fertig; die Kopfzeile
|
||||||
|
nach der ersten Phase, weil Node und Template erst dann feststehen.
|
||||||
|
- **SSE-Ausfall:** Wiederverbinden mit Last-Event-ID; nach drei Fehlschlägen ohne Stand Polling alle 2 s.
|
||||||
|
- **Go 1.27.2** ohne sudo unter `~/.local/go`; Bibliotheken mit abgelagerten Versionen (x/term 0.46.0,
|
||||||
|
go-runewidth 0.0.30 statt der wenige Tage alten Nachfolger).
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
# #014 Verlauf und Wiederholen
|
# #014 Verlauf und Wiederholen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** feature
|
**Labels:** feature
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 1 h 21 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Alle Aufträge filterbar, mit gleichen Parametern wiederholen.
|
Alle Aufträge filterbar, mit gleichen Parametern wiederholen.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Filter Server/Typ/Distribution/Status, Seitenblättern
|
- [x] Filter Server/Typ/Distribution/Status, Seitenblättern (Web, `GET /api/v1/auftraege?…`, `proxdeploy history`)
|
||||||
- [ ] Wiederholen (neue VMID/Name abfragen)
|
- [x] Wiederholen (neue VMID/Name abfragen): `/ausrollen?wiederholen=<kurzId>`, Name, VMID und feste IP leer
|
||||||
|
- [x] Aufräumen nach Fehlschlag/Abbruch mit Bestätigung (Vorgabe „Allgemeine Anforderungen“): Web-Knopf, `POST …/aufraeumen`, `proxdeploy cleanup`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Filter bleiben in der URL
|
- [x] Filter bleiben in der URL (Playwright)
|
||||||
|
- [x] Aufräumen auf pve-demo: CT 109 nach Abbruch erkannt, gestoppt, gelöscht; Original danach „aufgeräumt“
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#008
|
#008
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Gelöscht wird nur, was nachweislich zum Auftrag gehört:** die Beschreibung des Gastes enthält
|
||||||
|
„(Auftrag <kurzId>)“. Dafür setzt das Klonen die Beschreibung jetzt sofort (vorher erst in der
|
||||||
|
Hardware-Phase). Templates, gesperrte Gäste und fremde Beschreibungen: Abbruch, nichts angefasst.
|
||||||
|
- Wiederholen leert auch eine feste IP – dieselbe Adresse zweimal ist fast nie gewollt.
|
||||||
|
- Nebenbei gefunden: ssh2 erzeugt in ~0,4 % der Fälle ed25519-Schlüssel, die es selbst nicht
|
||||||
|
lesen kann (Ursache des „wackligen“ SSH-Tests). Behoben mit `ed25519Erzeugen`.
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
# #015 Rollen, Benutzer, Tokens, Audit-Log
|
# #015 Rollen, Benutzer, Tokens, Audit-Log
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** feature, sicherheit
|
**Labels:** feature, sicherheit
|
||||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
**Geschätzt:** 6 h **Tatsächlich:** 0 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test)
|
- [x] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test) – seit #001, jetzt mit den neuen Routen
|
||||||
- [ ] Benutzerverwaltung, CLI-Tokens
|
- [x] Benutzerverwaltung (anlegen, Rolle, sperren, Passwort setzen), eigenes Passwort ändern; CLI-Tokens seit v0.4.0
|
||||||
- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar
|
- [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
|
- [x] Audit-Log-Ansicht mit Filter (Benutzer, Quelle, Text, Zeitraum), auch `GET /api/v1/protokoll`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Operator kann keine Server/Katalog/Benutzer ändern (Tests)
|
- [x] Operator kann keine Server/Katalog/Benutzer ändern (Tests in `tests/benutzer.test.ts`)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Sitzungen beenden ohne Sitzungstabelle:** `Benutzer.sitzungenAb`; ältere JWTs gelten nicht mehr.
|
||||||
|
Gesetzt bei Sperre, Passwort-Reset und eigenem Passwortwechsel (die eigene Sitzung bekommt ein
|
||||||
|
neues Cookie). Auf volle Sekunden, weil `iat` Sekunden hat.
|
||||||
|
- **Schutzregeln serverseitig:** der letzte aktive Admin bleibt Admin und aktiv (Zeilensperre gegen
|
||||||
|
zwei gleichzeitige Herabstufungen), das eigene Konto lässt sich nicht sperren.
|
||||||
|
- Benutzer werden nicht gelöscht, nur gesperrt – Aufträge und Audit-Log verweisen auf sie.
|
||||||
|
- „Einstellungen“ fehlte seit v0.3.0 in der Navigation (`ab: "#015"`) – jetzt sichtbar, dazu „Audit-Log“ für Admins.
|
||||||
- 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).
|
- 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).
|
||||||
|
|||||||
+13
-5
@@ -1,20 +1,28 @@
|
|||||||
# #016 Anmeldung per OIDC
|
# #016 Anmeldung per OIDC
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** feature, sicherheit
|
**Labels:** feature, sicherheit
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 08 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Optionale OIDC-Anmeldung (z. B. authentik), Rollen über Gruppen-Claim.
|
Optionale OIDC-Anmeldung (z. B. authentik), Rollen über Gruppen-Claim.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] openid-client mit PKCE, Discovery, Gruppen → Rolle, Konto-Verknüpfung
|
- [x] openid-client 6.8.8 mit PKCE, State, Nonce, Discovery; Gruppen → Rolle bei jeder Anmeldung; Konto-Verknüpfung aus den Einstellungen
|
||||||
|
- [x] Anmeldeseite mit „Mit … anmelden“ und verständlichen Fehlermeldungen (`?sso=…`)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Tests gegen einen Mock-IdP (echter IdP später)
|
- [x] Tests gegen einen Mock-IdP (`tests/oidc-mock.ts`): Anlegen, Rollenwechsel, keine Gruppe, keine Übernahme per Name, Verknüpfen, gesperrt, gefälschter State, fehlendes Cookie, falsche Nonce, fremde Audience – echter IdP beim Feldtest (#020)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#015
|
#015
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Keine automatische Übernahme lokaler Konten über den Namen** – sonst übernähme, wer beim IdP
|
||||||
|
„admin“ heißt, den lokalen Admin. Bei Namensgleichheit bekommt das SSO-Konto „name-2“.
|
||||||
|
- **IdP maßgeblich für die Rolle:** bei jeder Anmeldung aus den Gruppen; Wechsel kommt ins Audit-Log.
|
||||||
|
- Ablaufzustand (Verifier, State, Nonce, Ziel) in einem kurzlebigen signierten Cookie
|
||||||
|
(`pd_oidc`, 10 min, nur `/api/v1/auth/oidc`, SameSite=Lax) statt in der Datenbank.
|
||||||
|
- `redirect_uri` und die Rückruf-URL werden aus `APP_URL` gebildet – hinter dem Reverse-Proxy
|
||||||
|
trägt die Anfrage den internen Host.
|
||||||
|
- Client-Anmeldung `client_secret_post` (Standard von openid-client); authentik und Keycloak können beides.
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | erledigt | v0.3.0 | 3 h | 0 h 05 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 |
|
| [#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 |
|
| [#012](012-vorlagen.md) | Vorlagen (Presets) | erledigt | v0.3.0 | 3 h | 0 h 11 min |
|
||||||
| [#013](013-cli.md) | CLI proxdeploy (Go) | offen | v0.4.0 | 12 h | – |
|
| [#013](013-cli.md) | CLI proxdeploy (Go) | erledigt | v0.4.0 | 12 h | 0 h 42 min |
|
||||||
| [#014](014-verlauf.md) | Verlauf und Wiederholen | offen | v0.5.0 | 4 h | – |
|
| [#014](014-verlauf.md) | Verlauf und Wiederholen | erledigt | v0.5.0 | 4 h | 1 h 21 min |
|
||||||
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | offen | v0.5.0 | 6 h | – |
|
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | erledigt | v0.5.0 | 6 h | 0 h 10 min |
|
||||||
| [#016](016-oidc.md) | Anmeldung per OIDC | offen | v0.5.0 | 4 h | – |
|
| [#016](016-oidc.md) | Anmeldung per OIDC | erledigt | v0.5.0 | 4 h | 0 h 08 min |
|
||||||
| [#017](017-branding.md) | Branding-Assets | offen | v0.6.0 | 4 h | – |
|
| [#017](017-branding.md) | Branding-Assets | offen | v0.6.0 | 4 h | – |
|
||||||
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
|
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
|
||||||
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxdeploy",
|
"name": "proxdeploy",
|
||||||
"version": "0.3.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
||||||
@@ -26,7 +26,9 @@
|
|||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:container": "tests/container/profile-pruefen.sh",
|
"test:container": "tests/container/profile-pruefen.sh",
|
||||||
"typecheck": "tsc --noEmit",
|
"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": {
|
"dependencies": {
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"jose": "^6.2.12",
|
"jose": "^6.2.12",
|
||||||
"lucide-react": "1.47.0",
|
"lucide-react": "1.47.0",
|
||||||
"next": "16.4.0",
|
"next": "16.4.0",
|
||||||
|
"openid-client": "6.8.8",
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"pg-boss": "12.37.1",
|
"pg-boss": "12.37.1",
|
||||||
"pino": "10.4.0",
|
"pino": "10.4.0",
|
||||||
|
|||||||
Generated
+16
@@ -193,6 +193,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 16.4.0
|
specifier: 16.4.0
|
||||||
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||||
|
openid-client:
|
||||||
|
specifier: 6.8.8
|
||||||
|
version: 6.8.8
|
||||||
pg:
|
pg:
|
||||||
specifier: ^8.23.0
|
specifier: ^8.23.0
|
||||||
version: 8.23.1
|
version: 8.23.1
|
||||||
@@ -3183,6 +3186,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
oauth4webapi@3.8.8:
|
||||||
|
resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3226,6 +3232,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
openid-client@6.8.8:
|
||||||
|
resolution: {integrity: sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -6784,6 +6793,8 @@ snapshots:
|
|||||||
|
|
||||||
non-error@0.1.0: {}
|
non-error@0.1.0: {}
|
||||||
|
|
||||||
|
oauth4webapi@3.8.8: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -6832,6 +6843,11 @@ snapshots:
|
|||||||
|
|
||||||
on-exit-leak-free@2.1.2: {}
|
on-exit-leak-free@2.1.2: {}
|
||||||
|
|
||||||
|
openid-client@6.8.8:
|
||||||
|
dependencies:
|
||||||
|
jose: 6.2.12
|
||||||
|
oauth4webapi: 3.8.8
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Benutzer" ADD COLUMN "sitzungenAb" TIMESTAMP(3);
|
||||||
@@ -33,6 +33,8 @@ model Benutzer {
|
|||||||
oidcSubjekt String? @unique
|
oidcSubjekt String? @unique
|
||||||
rolle Rolle @default(OPERATOR)
|
rolle Rolle @default(OPERATOR)
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
|
/// Sitzungen, die vorher ausgestellt wurden, gelten nicht mehr (Passwortwechsel, Sperre).
|
||||||
|
sitzungenAb DateTime?
|
||||||
/// Persönliche Standardwerte im Assistenten.
|
/// Persönliche Standardwerte im Assistenten.
|
||||||
standards Json?
|
standards Json?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
@@ -40,6 +42,7 @@ model Benutzer {
|
|||||||
protokoll Protokoll[]
|
protokoll Protokoll[]
|
||||||
auftraege Auftrag[]
|
auftraege Auftrag[]
|
||||||
schluessel SshSchluessel[]
|
schluessel SshSchluessel[]
|
||||||
|
tokens ApiToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
|
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
|
||||||
@@ -309,3 +312,20 @@ model SshSchluessel {
|
|||||||
|
|
||||||
@@unique([benutzerId, fingerabdruck])
|
@@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");
|
||||||
@@ -1,41 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Code } from "@/components/formular";
|
import { Code } from "@/components/formular";
|
||||||
import { Knopf } from "@/components/knopf";
|
import { Knopf } from "@/components/knopf";
|
||||||
import { Status } from "@/components/status";
|
import { Status } from "@/components/status";
|
||||||
import { STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
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 { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||||
import { dauer } from "@/lib/formate";
|
|
||||||
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
|
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
|
||||||
|
|
||||||
const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
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
|
* 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.
|
* alle 3 s abgefragt – die Anzeige bleibt dieselbe.
|
||||||
@@ -44,13 +21,15 @@ export function LiveAnsicht({
|
|||||||
anfang,
|
anfang,
|
||||||
anfangsLog,
|
anfangsLog,
|
||||||
version,
|
version,
|
||||||
abbrechenErlaubt,
|
darfAusrollen,
|
||||||
}: {
|
}: {
|
||||||
anfang: AuftragSicht;
|
anfang: AuftragSicht;
|
||||||
anfangsLog: LogZeile[];
|
anfangsLog: LogZeile[];
|
||||||
version: string;
|
version: string;
|
||||||
abbrechenErlaubt: boolean;
|
darfAusrollen: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [aktionFehler, setAktionFehler] = useState<string | null>(null);
|
||||||
const [auftrag, setAuftrag] = useState(anfang);
|
const [auftrag, setAuftrag] = useState(anfang);
|
||||||
const [log, setLog] = useState(anfangsLog);
|
const [log, setLog] = useState(anfangsLog);
|
||||||
const [verbindung, setVerbindung] = useState<"live" | "abfrage" | "beendet">(
|
const [verbindung, setVerbindung] = useState<"live" | "abfrage" | "beendet">(
|
||||||
@@ -112,9 +91,28 @@ export function LiveAnsicht({
|
|||||||
auftrag.status === "WARTEND"
|
auftrag.status === "WARTEND"
|
||||||
? (["offen", "wartet auf den worker"] as const)
|
? (["offen", "wartet auf den worker"] as const)
|
||||||
: STATUS_ANZEIGE[auftrag.status];
|
: STATUS_ANZEIGE[auftrag.status];
|
||||||
const ergebnis = ergebnisAus(auftrag);
|
const ergebnis = auftrag.abschluss;
|
||||||
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
|
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
|
||||||
|
|
||||||
|
async function aufraeumen() {
|
||||||
|
const ziel = auftrag.aufraeumbar;
|
||||||
|
if (!ziel) return;
|
||||||
|
const wort = ziel.typ === "LXC" ? "Container" : "VM";
|
||||||
|
const frage =
|
||||||
|
`${wort} ${ziel.vmid}${ziel.knoten ? ` auf ${ziel.knoten}` : ""} stoppen und mit allen Disks löschen?\n\n` +
|
||||||
|
`ProxDeploy löscht nur, wenn die Beschreibung des Gastes auf ${auftrag.kurzId} verweist.`;
|
||||||
|
if (!window.confirm(frage)) return;
|
||||||
|
try {
|
||||||
|
const neu = await apiAufruf<{ kurzId: string }>(
|
||||||
|
"POST",
|
||||||
|
`/api/v1/auftraege/${auftrag.kurzId}/aufraeumen`,
|
||||||
|
);
|
||||||
|
router.push(`/auftraege/${neu.kurzId}`);
|
||||||
|
} catch (fehler) {
|
||||||
|
setAktionFehler(fehler instanceof ApiFehler ? fehler.message : "Aufräumen ließ sich nicht starten.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||||
@@ -130,9 +128,22 @@ export function LiveAnsicht({
|
|||||||
: (auftrag.name ?? "Ausrollen")}
|
: (auftrag.name ?? "Ausrollen")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
|
||||||
<Status art={art}>{text}</Status>
|
<Status art={art}>{text}</Status>
|
||||||
{abbrechenErlaubt && !ENDE.has(auftrag.status) ? (
|
{darfAusrollen && auftrag.aufraeumbar ? (
|
||||||
|
<Knopf variante="gefahr" onClick={() => void aufraeumen()}>
|
||||||
|
Aufräumen
|
||||||
|
</Knopf>
|
||||||
|
) : null}
|
||||||
|
{darfAusrollen && auftrag.art === "AUSROLLEN" && ENDE.has(auftrag.status) ? (
|
||||||
|
<Link
|
||||||
|
href={`/ausrollen?wiederholen=${auftrag.kurzId}`}
|
||||||
|
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
|
||||||
|
>
|
||||||
|
Wiederholen
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{darfAusrollen && !ENDE.has(auftrag.status) ? (
|
||||||
<Knopf
|
<Knopf
|
||||||
variante="gefahr"
|
variante="gefahr"
|
||||||
onClick={() => void apiAufruf("POST", `/api/v1/auftraege/${auftrag.kurzId}/abbrechen`)}
|
onClick={() => void apiAufruf("POST", `/api/v1/auftraege/${auftrag.kurzId}/abbrechen`)}
|
||||||
@@ -143,6 +154,8 @@ export function LiveAnsicht({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{aktionFehler ? <Status art="fehler">{aktionFehler}</Status> : null}
|
||||||
|
|
||||||
<TerminalKarte version={version} kopf={auftrag.kopf} phasen={auftrag.phasen} ergebnis={ergebnis} />
|
<TerminalKarte version={version} kopf={auftrag.kopf} phasen={auftrag.phasen} ergebnis={ergebnis} />
|
||||||
|
|
||||||
{typeof ssh === "string" ? (
|
{typeof ssh === "string" ? (
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default async function AuftragSeite({ params }: { params: Promise<{ kurzI
|
|||||||
anfang={auftrag}
|
anfang={auftrag}
|
||||||
anfangsLog={log}
|
anfangsLog={log}
|
||||||
version={versionText()}
|
version={versionText()}
|
||||||
abbrechenErlaubt={darf(benutzer.rolle, "ausrollen")}
|
darfAusrollen={darf(benutzer.rolle, "ausrollen")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { EINGABE_KLASSE, Feld } from "@/components/formular";
|
||||||
|
import { Knopf } from "@/components/knopf";
|
||||||
|
|
||||||
|
type Auswahl = Array<{ wert: string; text: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter des Verlaufs. Der Stand steht in der URL – Neuladen und Weitergeben behalten ihn; jede
|
||||||
|
* Änderung springt auf Seite 1.
|
||||||
|
*/
|
||||||
|
export function FilterLeiste({ server, distributionen }: { server: Auswahl; distributionen: Auswahl }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pfad = usePathname();
|
||||||
|
const parameter = useSearchParams();
|
||||||
|
|
||||||
|
const setze = (name: string, wert: string) => {
|
||||||
|
const neu = new URLSearchParams(parameter);
|
||||||
|
if (wert) neu.set(name, wert);
|
||||||
|
else neu.delete(name);
|
||||||
|
neu.delete("seite");
|
||||||
|
const text = neu.toString();
|
||||||
|
router.push(text ? `${pfad}?${text}` : pfad);
|
||||||
|
};
|
||||||
|
|
||||||
|
const felder: Array<{ name: string; label: string; optionen: Auswahl }> = [
|
||||||
|
{ name: "server", label: "Server", optionen: server },
|
||||||
|
{
|
||||||
|
name: "typ",
|
||||||
|
label: "Typ",
|
||||||
|
optionen: [
|
||||||
|
{ wert: "vm", text: "VM" },
|
||||||
|
{ wert: "lxc", text: "LXC" },
|
||||||
|
{ wert: "template", text: "Template" },
|
||||||
|
{ wert: "aufraeumen", text: "Aufräumen" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: "distribution", label: "Distribution", optionen: distributionen },
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: "Status",
|
||||||
|
optionen: [
|
||||||
|
{ wert: "LAEUFT", text: "läuft" },
|
||||||
|
{ wert: "WARTEND", text: "wartet" },
|
||||||
|
{ wert: "ERFOLGREICH", text: "erfolgreich" },
|
||||||
|
{ wert: "FEHLGESCHLAGEN", text: "fehlgeschlagen" },
|
||||||
|
{ wert: "ABGEBROCHEN", text: "abgebrochen" },
|
||||||
|
{ wert: "AUFGERAEUMT", text: "aufgeräumt" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const aktiv = felder.some((f) => parameter.get(f.name));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:flex lg:items-end">
|
||||||
|
{felder.map((f) => (
|
||||||
|
<Feld key={f.name} id={`filter-${f.name}`} label={f.label}>
|
||||||
|
<select
|
||||||
|
id={`filter-${f.name}`}
|
||||||
|
className={`${EINGABE_KLASSE} lg:w-44`}
|
||||||
|
value={parameter.get(f.name) ?? ""}
|
||||||
|
onChange={(e) => setze(f.name, e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">alle</option>
|
||||||
|
{f.optionen.map((o) => (
|
||||||
|
<option key={o.wert} value={o.wert}>
|
||||||
|
{o.text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
))}
|
||||||
|
{aktiv ? (
|
||||||
|
<Knopf variante="sekundaer" className="col-span-2 sm:col-span-1" onClick={() => router.push(pfad)}>
|
||||||
|
Filter zurücksetzen
|
||||||
|
</Knopf>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,25 +3,56 @@ import Link from "next/link";
|
|||||||
import { Karte } from "@/components/karte";
|
import { Karte } from "@/components/karte";
|
||||||
import { Status } from "@/components/status";
|
import { Status } from "@/components/status";
|
||||||
import { ART_TEXT, STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
import { ART_TEXT, STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
import { zeitpunkt } from "@/lib/formate";
|
import { zeitpunkt } from "@/lib/formate";
|
||||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
import { auftraegeListe } from "@/server/services/auftraege";
|
import { auftraegeSuchen, verlaufFilterAus } from "@/server/services/auftraege";
|
||||||
|
import { distributionenListe } from "@/server/services/katalog";
|
||||||
|
import { FilterLeiste } from "./filter-leiste";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Verlauf" };
|
export const metadata: Metadata = { title: "Verlauf" };
|
||||||
|
|
||||||
/** Verlauf aller Aufträge. Filter und Wiederholen kommen mit #014. */
|
type Suche = Record<string, string | string[] | undefined>;
|
||||||
export default async function Verlauf() {
|
|
||||||
|
/** Verlauf aller Aufträge, filterbar nach Server, Typ, Distribution und Status (Stand in der URL). */
|
||||||
|
export default async function Verlauf({ searchParams }: { searchParams: Promise<Suche> }) {
|
||||||
await seiteSchuetzen("lesen", "/auftraege");
|
await seiteSchuetzen("lesen", "/auftraege");
|
||||||
const auftraege = await auftraegeListe({ grenze: 100 });
|
const suche = await searchParams;
|
||||||
|
const filter = verlaufFilterAus(suche);
|
||||||
|
const [{ eintraege, gesamt, seite, seiten }, server, distributionen] = await Promise.all([
|
||||||
|
auftraegeSuchen(filter),
|
||||||
|
prisma.server.findMany({ select: { name: true, titel: true }, orderBy: { name: "asc" } }),
|
||||||
|
distributionenListe(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const seitenLink = (ziel: number) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(filter)) if (v !== undefined && k !== "seite") p.set(k, String(v));
|
||||||
|
if (ziel > 1) p.set("seite", String(ziel));
|
||||||
|
const text = p.toString();
|
||||||
|
return text ? `/auftraege?${text}` : "/auftraege";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
<header className="flex flex-wrap items-end justify-between gap-2">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
{gesamt} {gesamt === 1 ? "Auftrag" : "Aufträge"}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<FilterLeiste
|
||||||
|
server={server.map((s) => ({ wert: s.name, text: s.titel }))}
|
||||||
|
distributionen={distributionen.map((d) => ({ wert: d.id, text: d.name }))}
|
||||||
|
/>
|
||||||
<Karte>
|
<Karte>
|
||||||
{auftraege.length === 0 ? (
|
{eintraege.length === 0 ? (
|
||||||
<Status art="offen">Noch keine Aufträge.</Status>
|
<Status art="offen">
|
||||||
|
{gesamt === 0 && seite === 1 ? "Keine Aufträge." : "Keine Aufträge auf dieser Seite."}
|
||||||
|
</Status>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-linie flex flex-col divide-y">
|
<ul className="divide-linie flex flex-col divide-y">
|
||||||
{auftraege.map((a) => {
|
{eintraege.map((a) => {
|
||||||
const [art, text] = STATUS_ANZEIGE[a.status];
|
const [art, text] = STATUS_ANZEIGE[a.status];
|
||||||
return (
|
return (
|
||||||
<li key={a.id}>
|
<li key={a.id}>
|
||||||
@@ -34,9 +65,15 @@ export default async function Verlauf() {
|
|||||||
<span className="font-medium">{a.name ?? ART_TEXT[a.art]}</span>
|
<span className="font-medium">{a.name ?? ART_TEXT[a.art]}</span>
|
||||||
<span className="text-muted text-sm">
|
<span className="text-muted text-sm">
|
||||||
{" "}
|
{" "}
|
||||||
· {ART_TEXT[a.art]} · {a.server.name}
|
·{" "}
|
||||||
|
{a.art === "AUSROLLEN" && a.gastTyp
|
||||||
|
? a.gastTyp === "LXC"
|
||||||
|
? "LXC"
|
||||||
|
: "VM"
|
||||||
|
: ART_TEXT[a.art]}{" "}
|
||||||
|
· {a.server.name}
|
||||||
{a.distributionId ? ` · ${a.distributionId}` : ""}
|
{a.distributionId ? ` · ${a.distributionId}` : ""}
|
||||||
{a.vmid ? ` · ${a.vmid}` : ""}
|
{a.vmid ? ` · ${a.vmid}` : ""} · {a.erstelltVon}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted text-sm sm:w-28">{zeitpunkt(a.erstelltAm)}</span>
|
<span className="text-muted text-sm sm:w-28">{zeitpunkt(a.erstelltAm)}</span>
|
||||||
@@ -50,6 +87,27 @@ export default async function Verlauf() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Karte>
|
</Karte>
|
||||||
|
{seiten > 1 ? (
|
||||||
|
<nav aria-label="Seiten" className="flex items-center justify-between gap-4 text-sm">
|
||||||
|
{seite > 1 ? (
|
||||||
|
<Link className="text-akzent hover:underline" href={seitenLink(seite - 1)}>
|
||||||
|
← Neuere
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<span className="text-muted">
|
||||||
|
Seite {seite} von {seiten}
|
||||||
|
</span>
|
||||||
|
{seite < seiten ? (
|
||||||
|
<Link className="text-akzent hover:underline" href={seitenLink(seite + 1)}>
|
||||||
|
Ältere →
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Knopf } from "@/components/knopf";
|
|||||||
import { Status } from "@/components/status";
|
import { Status } from "@/components/status";
|
||||||
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
|
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
|
||||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||||
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen";
|
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollAnfrage, type AusrollEingabe } from "@/lib/schemas/ausrollen";
|
||||||
import type { VorlagenParameter } from "@/lib/schemas/vorlage";
|
import type { VorlagenParameter } from "@/lib/schemas/vorlage";
|
||||||
|
|
||||||
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
|
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -204,6 +204,53 @@ function vorlageUebernehmen(w: Werte, name: string, p: VorlagenParameter, daten:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einen früheren Auftrag in die Formularwerte übernehmen („Wiederholen“). Name, VMID und eine
|
||||||
|
* statische IP gehören zum alten Gast und werden geleert – sie muss man neu angeben.
|
||||||
|
*/
|
||||||
|
function wiederholungUebernehmen(w: Werte, a: AusrollAnfrage, daten: AssistentDaten): Werte {
|
||||||
|
const hinterlegt = new Set(daten.schluessel.map((x) => x.oeffentlich));
|
||||||
|
const gewaehlt = new Set(a.zugang.sshSchluessel);
|
||||||
|
const variablen: Werte["variablen"] = {};
|
||||||
|
for (const [profil, werte] of Object.entries(a.variablen)) {
|
||||||
|
variablen[profil] = Object.fromEntries(
|
||||||
|
Object.entries(werte).map(([k, v]) => [k, typeof v === "boolean" ? v : String(v)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const server = daten.server.some((x) => x.name === a.server) ? a.server : w.server;
|
||||||
|
return {
|
||||||
|
...w,
|
||||||
|
server,
|
||||||
|
knoten: a.knoten ?? "",
|
||||||
|
typ: a.typ,
|
||||||
|
distribution: a.distribution,
|
||||||
|
name: "",
|
||||||
|
vmid: "",
|
||||||
|
tags: a.tags.join(","),
|
||||||
|
cores: String(a.cores),
|
||||||
|
speicherMb: String(a.speicherMb),
|
||||||
|
diskGb: String(a.diskGb),
|
||||||
|
storage: a.storage,
|
||||||
|
swapMb: String(a.swapMb),
|
||||||
|
unprivilegiert: a.unprivilegiert,
|
||||||
|
nesting: a.features.nesting,
|
||||||
|
bridge: a.netz.bridge,
|
||||||
|
vlan: a.netz.vlan ? String(a.netz.vlan) : "",
|
||||||
|
modus: a.netz.modus,
|
||||||
|
ip: "",
|
||||||
|
gateway: a.netz.gateway ?? "",
|
||||||
|
dns: a.netz.dns.join(","),
|
||||||
|
suchdomain: a.netz.suchdomain ?? "",
|
||||||
|
benutzer: a.zugang.benutzer,
|
||||||
|
schluessel: a.zugang.sshSchluessel.filter((k) => !hinterlegt.has(k)).join("\n"),
|
||||||
|
ohneSchluessel: daten.schluessel.filter((x) => !gewaehlt.has(x.oeffentlich)).map((x) => x.fingerabdruck),
|
||||||
|
profile: a.profile,
|
||||||
|
variablen,
|
||||||
|
templateBauen: a.templateBauen,
|
||||||
|
vorlage: a.vorlage ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function werteZuVorlage(w: Werte): VorlagenParameter {
|
function werteZuVorlage(w: Werte): VorlagenParameter {
|
||||||
const a = anfrageAus(w, []);
|
const a = anfrageAus(w, []);
|
||||||
return {
|
return {
|
||||||
@@ -231,7 +278,16 @@ function werteZuVorlage(w: Werte): VorlagenParameter {
|
|||||||
* Neuladen und Weitergeben funktionieren damit ohne Server-Zwischenspeicher. Geprüft wird
|
* Neuladen und Weitergeben funktionieren damit ohne Server-Zwischenspeicher. Geprüft wird
|
||||||
* endgültig auf dem Server – hier nur so viel, dass niemand ins Leere klickt.
|
* endgültig auf dem Server – hier nur so viel, dass niemand ins Leere klickt.
|
||||||
*/
|
*/
|
||||||
export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; vorauswahl: string | null }) {
|
export function Assistent({
|
||||||
|
daten,
|
||||||
|
vorauswahl,
|
||||||
|
wiederholung = null,
|
||||||
|
}: {
|
||||||
|
daten: AssistentDaten;
|
||||||
|
vorauswahl: string | null;
|
||||||
|
/** `?wiederholen=<kurzId>`: Anfrage eines früheren Auftrags als Vorbelegung. */
|
||||||
|
wiederholung?: { kurzId: string; anfrage: AusrollAnfrage } | null;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pfad = usePathname();
|
const pfad = usePathname();
|
||||||
const parameter = useSearchParams();
|
const parameter = useSearchParams();
|
||||||
@@ -279,6 +335,8 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
const vorlage = !parameter.get("d")
|
const vorlage = !parameter.get("d")
|
||||||
? daten.vorlagen.find((v) => v.name === parameter.get("vorlage"))
|
? daten.vorlagen.find((v) => v.name === parameter.get("vorlage"))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (!parameter.get("d") && wiederholung)
|
||||||
|
return wiederholungUebernehmen(ausUrl, wiederholung.anfrage, daten);
|
||||||
return vorlage ? vorlageUebernehmen(ausUrl, vorlage.name, vorlage.parameter, daten) : ausUrl;
|
return vorlage ? vorlageUebernehmen(ausUrl, vorlage.name, vorlage.parameter, daten) : ausUrl;
|
||||||
});
|
});
|
||||||
const [vorlageSpeichern, setVorlageSpeichern] = useState<{
|
const [vorlageSpeichern, setVorlageSpeichern] = useState<{
|
||||||
@@ -421,6 +479,11 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
<p className="text-muted text-sm">
|
<p className="text-muted text-sm">
|
||||||
Schritt {schritt + 1} von {SCHRITTE.length} · {SCHRITTE[schritt]}
|
Schritt {schritt + 1} von {SCHRITTE.length} · {SCHRITTE[schritt]}
|
||||||
</p>
|
</p>
|
||||||
|
{wiederholung ? (
|
||||||
|
<Status art="offen" className="mt-2 text-sm">
|
||||||
|
Wiederholung von {wiederholung.kurzId} – Name, VMID und eine feste IP neu angeben.
|
||||||
|
</Status>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[14rem_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[14rem_1fr]">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Karte } from "@/components/karte";
|
|||||||
import { Status } from "@/components/status";
|
import { Status } from "@/components/status";
|
||||||
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||||
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||||
import { versionText } from "@/lib/version";
|
import { versionText } from "@/lib/version";
|
||||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
@@ -15,7 +16,11 @@ import { Assistent, type AssistentDaten } from "./assistent";
|
|||||||
export const metadata: Metadata = { title: "Ausrollen" };
|
export const metadata: Metadata = { title: "Ausrollen" };
|
||||||
|
|
||||||
/** Der Deploy-Assistent. Alle Daten für die Auswahlfelder kommen hier einmal vom Server. */
|
/** Der Deploy-Assistent. Alle Daten für die Auswahlfelder kommen hier einmal vom Server. */
|
||||||
export default async function Ausrollen() {
|
export default async function Ausrollen({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ wiederholen?: string }>;
|
||||||
|
}) {
|
||||||
const benutzer = await seiteSchuetzen("ausrollen", "/ausrollen");
|
const benutzer = await seiteSchuetzen("ausrollen", "/ausrollen");
|
||||||
const server = await prisma.server.findMany({ orderBy: { name: "asc" } });
|
const server = await prisma.server.findMany({ orderBy: { name: "asc" } });
|
||||||
const mitTest = server.filter((s) => s.letzterTest);
|
const mitTest = server.filter((s) => s.letzterTest);
|
||||||
@@ -87,5 +92,22 @@ export default async function Ausrollen() {
|
|||||||
daten.server.map((s) => s.name),
|
daten.server.map((s) => s.name),
|
||||||
(await cookies()).get(SERVER_COOKIE)?.value,
|
(await cookies()).get(SERVER_COOKIE)?.value,
|
||||||
);
|
);
|
||||||
return <Assistent daten={daten} vorauswahl={vorauswahl} />;
|
return (
|
||||||
|
<Assistent
|
||||||
|
daten={daten}
|
||||||
|
vorauswahl={vorauswahl}
|
||||||
|
wiederholung={await wiederholungLaden((await searchParams).wiederholen)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Anfrage eines früheren Ausroll-Auftrags – nur, wenn sie noch zum heutigen Schema passt. */
|
||||||
|
async function wiederholungLaden(kennung: string | undefined) {
|
||||||
|
if (!kennung) return null;
|
||||||
|
const auftrag = await prisma.auftrag.findFirst({
|
||||||
|
where: { OR: [{ kurzId: kennung }, { id: kennung }], art: "AUSROLLEN" },
|
||||||
|
select: { kurzId: true, anfrage: true },
|
||||||
|
});
|
||||||
|
const anfrage = AusrollAnfrage.safeParse(auftrag?.anfrage);
|
||||||
|
return auftrag && anfrage.success ? { kurzId: auftrag.kurzId, anfrage: anfrage.data } : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { 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";
|
||||||
|
|
||||||
|
type Benutzer = {
|
||||||
|
id: string;
|
||||||
|
benutzername: string;
|
||||||
|
anzeigename: string;
|
||||||
|
email: string | null;
|
||||||
|
rolle: "ADMIN" | "OPERATOR";
|
||||||
|
aktiv: boolean;
|
||||||
|
passwort: boolean;
|
||||||
|
oidc: boolean;
|
||||||
|
tokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Meldung = { art: "erledigt" | "fehler"; text: string; felder?: Record<string, string> } | null;
|
||||||
|
|
||||||
|
const leer = { benutzername: "", anzeigename: "", email: "", rolle: "OPERATOR" as const, passwort: "" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzer anlegen, Rolle ändern, sperren, Passwort neu setzen. Admin: Server, Katalog, Benutzer;
|
||||||
|
* Operator: ansehen und ausrollen. Das eigene Konto lässt sich nicht sperren, der letzte Admin
|
||||||
|
* nicht herabstufen – das prüft der Server.
|
||||||
|
*/
|
||||||
|
export function BenutzerVerwaltung({ anfang, ich }: { anfang: Benutzer[]; ich: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [neu, setNeu] = useState<{
|
||||||
|
benutzername: string;
|
||||||
|
anzeigename: string;
|
||||||
|
email: string;
|
||||||
|
rolle: "ADMIN" | "OPERATOR";
|
||||||
|
passwort: string;
|
||||||
|
}>(leer);
|
||||||
|
const [meldung, setMeldung] = useState<Meldung>(null);
|
||||||
|
const [passwortFuer, setPasswortFuer] = useState<{ id: string; passwort: string } | null>(null);
|
||||||
|
|
||||||
|
const fehlerZeigen = (fehler: unknown) =>
|
||||||
|
setMeldung({
|
||||||
|
art: "fehler",
|
||||||
|
text:
|
||||||
|
fehler instanceof ApiFehler ? [fehler.message, fehler.hinweis].filter(Boolean).join(" ") : "Fehler.",
|
||||||
|
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function aendern(b: Benutzer, aenderung: Partial<Pick<Benutzer, "rolle" | "aktiv">>, text: string) {
|
||||||
|
try {
|
||||||
|
await apiAufruf("PATCH", `/api/v1/benutzer/${b.id}`, aenderung);
|
||||||
|
setMeldung({ art: "erledigt", text });
|
||||||
|
router.refresh();
|
||||||
|
} catch (fehler) {
|
||||||
|
fehlerZeigen(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function anlegen(ereignis: React.FormEvent) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
try {
|
||||||
|
await apiAufruf("POST", "/api/v1/benutzer", neu);
|
||||||
|
setMeldung({ art: "erledigt", text: `${neu.benutzername} angelegt.` });
|
||||||
|
setNeu(leer);
|
||||||
|
router.refresh();
|
||||||
|
} catch (fehler) {
|
||||||
|
fehlerZeigen(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function passwortSetzen(ereignis: React.FormEvent, b: Benutzer) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
if (!passwortFuer) return;
|
||||||
|
try {
|
||||||
|
await apiAufruf("POST", `/api/v1/benutzer/${b.id}/passwort`, { passwort: passwortFuer.passwort });
|
||||||
|
setPasswortFuer(null);
|
||||||
|
setMeldung({
|
||||||
|
art: "erledigt",
|
||||||
|
text: `Passwort von ${b.benutzername} gesetzt – alle Sitzungen beendet.`,
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (fehler) {
|
||||||
|
fehlerZeigen(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte titel="Benutzer">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<ul className="divide-linie flex flex-col divide-y">
|
||||||
|
{anfang.map((b) => (
|
||||||
|
<li key={b.id} className="flex flex-col gap-3 py-3 first:pt-0">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{b.anzeigename} <span className="text-muted font-mono text-sm">· {b.benutzername}</span>
|
||||||
|
{b.benutzername === ich ? <span className="text-muted text-sm"> · du</span> : null}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted text-xs">
|
||||||
|
{[
|
||||||
|
b.email,
|
||||||
|
b.passwort ? "Passwort" : null,
|
||||||
|
b.oidc ? "OIDC" : null,
|
||||||
|
b.tokens > 0 ? `${b.tokens} API-Token${b.tokens === 1 ? "" : "s"}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "–"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{!b.aktiv ? <Status art="fehler">gesperrt</Status> : null}
|
||||||
|
<label className="sr-only" htmlFor={`rolle-${b.id}`}>
|
||||||
|
Rolle von {b.benutzername}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`rolle-${b.id}`}
|
||||||
|
className={`${EINGABE_KLASSE} w-36`}
|
||||||
|
value={b.rolle}
|
||||||
|
onChange={(e) =>
|
||||||
|
void aendern(
|
||||||
|
b,
|
||||||
|
{ rolle: e.target.value as Benutzer["rolle"] },
|
||||||
|
`${b.benutzername} ist jetzt ${e.target.value === "ADMIN" ? "Admin" : "Operator"}.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="OPERATOR">Operator</option>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
</select>
|
||||||
|
<Knopf
|
||||||
|
variante="sekundaer"
|
||||||
|
onClick={() =>
|
||||||
|
setPasswortFuer(passwortFuer?.id === b.id ? null : { id: b.id, passwort: "" })
|
||||||
|
}
|
||||||
|
aria-label={`Passwort von ${b.benutzername} setzen`}
|
||||||
|
>
|
||||||
|
Passwort
|
||||||
|
</Knopf>
|
||||||
|
{b.benutzername !== ich ? (
|
||||||
|
<Knopf
|
||||||
|
variante={b.aktiv ? "gefahr" : "sekundaer"}
|
||||||
|
onClick={() =>
|
||||||
|
void aendern(
|
||||||
|
b,
|
||||||
|
{ aktiv: !b.aktiv },
|
||||||
|
b.aktiv
|
||||||
|
? `${b.benutzername} gesperrt – Sitzungen beendet.`
|
||||||
|
: `${b.benutzername} entsperrt.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label={`${b.benutzername} ${b.aktiv ? "sperren" : "entsperren"}`}
|
||||||
|
>
|
||||||
|
{b.aktiv ? "Sperren" : "Entsperren"}
|
||||||
|
</Knopf>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{passwortFuer?.id === b.id ? (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => void passwortSetzen(e, b)}
|
||||||
|
className="flex flex-col gap-2 sm:flex-row sm:items-end"
|
||||||
|
>
|
||||||
|
<Feld
|
||||||
|
id={`pw-${b.id}`}
|
||||||
|
label={`Neues Passwort für ${b.benutzername}`}
|
||||||
|
hilfe="Mindestens 12 Zeichen"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={`pw-${b.id}`}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={`${EINGABE_KLASSE} sm:w-72`}
|
||||||
|
value={passwortFuer.passwort}
|
||||||
|
onChange={(e) => setPasswortFuer({ id: b.id, passwort: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Knopf type="submit" disabled={passwortFuer.passwort.length < 12}>
|
||||||
|
Setzen
|
||||||
|
</Knopf>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div aria-live="polite" className="text-sm">
|
||||||
|
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={(e) => void anlegen(e)} className="border-linie flex flex-col gap-4 border-t pt-5">
|
||||||
|
<p className="font-semibold">Neuer Benutzer</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld id="b-name" label="Benutzername" fehler={meldung?.felder?.["benutzername"]}>
|
||||||
|
<input
|
||||||
|
id="b-name"
|
||||||
|
autoComplete="off"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={neu.benutzername}
|
||||||
|
onChange={(e) => setNeu({ ...neu, benutzername: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="b-anzeige" label="Anzeigename" fehler={meldung?.felder?.["anzeigename"]}>
|
||||||
|
<input
|
||||||
|
id="b-anzeige"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={neu.anzeigename}
|
||||||
|
onChange={(e) => setNeu({ ...neu, anzeigename: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="b-email" label="E-Mail (optional)" fehler={meldung?.felder?.["email"]}>
|
||||||
|
<input
|
||||||
|
id="b-email"
|
||||||
|
type="email"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={neu.email}
|
||||||
|
onChange={(e) => setNeu({ ...neu, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="b-rolle" label="Rolle">
|
||||||
|
<select
|
||||||
|
id="b-rolle"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={neu.rolle}
|
||||||
|
onChange={(e) => setNeu({ ...neu, rolle: e.target.value as Benutzer["rolle"] })}
|
||||||
|
>
|
||||||
|
<option value="OPERATOR">Operator – ansehen und ausrollen</option>
|
||||||
|
<option value="ADMIN">Admin – auch Server, Katalog, Benutzer</option>
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="b-passwort"
|
||||||
|
label="Erstes Passwort"
|
||||||
|
hilfe="Mindestens 12 Zeichen; der Benutzer kann es in den Einstellungen ändern."
|
||||||
|
fehler={meldung?.felder?.["passwort"]}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="b-passwort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={neu.passwort}
|
||||||
|
onChange={(e) => setNeu({ ...neu, passwort: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Knopf type="submit" disabled={!neu.benutzername || !neu.anzeigename || neu.passwort.length < 12}>
|
||||||
|
Benutzer anlegen
|
||||||
|
</Knopf>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,70 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { oidcEinstellungen } from "@/server/auth/oidc";
|
||||||
import { darf } from "@/server/auth/rechte";
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { benutzerListe } from "@/server/services/benutzer";
|
||||||
import { schluesselFuer } from "@/server/services/schluessel";
|
import { schluesselFuer } from "@/server/services/schluessel";
|
||||||
|
import { tokensVon } from "@/server/services/tokens";
|
||||||
|
import { BenutzerVerwaltung } from "./benutzer-verwaltung";
|
||||||
|
import { PasswortAendern } from "./passwort-aendern";
|
||||||
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
|
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
|
||||||
|
import { TokenVerwaltung } from "./token-verwaltung";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Einstellungen" };
|
export const metadata: Metadata = { title: "Einstellungen" };
|
||||||
|
|
||||||
/** Einstellungen. Heute: SSH-Schlüssel. Benutzer, CLI-Tokens und Standardwerte folgen mit #015. */
|
/** Einstellungen: eigene SSH-Schlüssel, CLI-Tokens, Passwort; für Admins die Benutzer. */
|
||||||
export default async function Einstellungen() {
|
export default async function Einstellungen({ searchParams }: { searchParams: Promise<{ sso?: string }> }) {
|
||||||
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
|
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
|
||||||
|
const sso = oidcEinstellungen();
|
||||||
|
const ssoFehler = (await searchParams).sso;
|
||||||
|
const admin = darf(benutzer.rolle, "benutzerVerwalten");
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<header>
|
<header>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
|
||||||
<p className="text-muted text-sm">Angemeldet als {benutzer.benutzername}</p>
|
<p className="text-muted text-sm">
|
||||||
|
Angemeldet als {benutzer.benutzername} · {benutzer.rolle === "ADMIN" ? "Admin" : "Operator"}
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<SchluesselVerwaltung
|
<SchluesselVerwaltung anfang={await schluesselFuer(benutzer)} teamErlaubt={admin} />
|
||||||
anfang={await schluesselFuer(benutzer)}
|
<TokenVerwaltung
|
||||||
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
|
anfang={await tokensVon(benutzer.id)}
|
||||||
|
adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin}
|
||||||
/>
|
/>
|
||||||
|
{benutzer.passwortHash ? <PasswortAendern /> : null}
|
||||||
|
{sso ? (
|
||||||
|
<Karte titel="Anmeldung per SSO">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{ssoFehler === "schon_verknuepft" ? (
|
||||||
|
<Status art="fehler">Dieses SSO-Konto gehört schon zu einem anderen Benutzer.</Status>
|
||||||
|
) : null}
|
||||||
|
{benutzer.oidcSubjekt ? (
|
||||||
|
<Status art="erledigt">
|
||||||
|
Mit {sso.anzeigename} verknüpft – die Rolle kommt bei jeder SSO-Anmeldung aus den Gruppen.
|
||||||
|
</Status>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
Dein Konto ist noch nicht mit {sso.anzeigename} verknüpft. Danach kannst du dich auch dort
|
||||||
|
anmelden; die Rolle kommt dann aus den Gruppen beim Identity Provider.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="/api/v1/auth/oidc/start?verknuepfen=1"
|
||||||
|
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
|
||||||
|
>
|
||||||
|
Mit {sso.anzeigename} verknüpfen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
{admin ? <BenutzerVerwaltung anfang={await benutzerListe()} ich={benutzer.benutzername} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { 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";
|
||||||
|
|
||||||
|
/** Eigenes Passwort ändern. Andere Sitzungen (andere Geräte) enden, diese bleibt angemeldet. */
|
||||||
|
export function PasswortAendern() {
|
||||||
|
const [werte, setWerte] = useState({ alt: "", neu: "", wiederholt: "" });
|
||||||
|
const [meldung, setMeldung] = useState<{
|
||||||
|
art: "erledigt" | "fehler";
|
||||||
|
text: string;
|
||||||
|
felder?: Record<string, string>;
|
||||||
|
} | null>(null);
|
||||||
|
const abweichend = werte.wiederholt !== "" && werte.neu !== werte.wiederholt;
|
||||||
|
|
||||||
|
async function speichern(ereignis: React.FormEvent) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
try {
|
||||||
|
await apiAufruf("POST", "/api/v1/ich/passwort", { alt: werte.alt, neu: werte.neu });
|
||||||
|
setWerte({ alt: "", neu: "", wiederholt: "" });
|
||||||
|
setMeldung({ art: "erledigt", text: "Passwort geändert. Andere Sitzungen sind beendet." });
|
||||||
|
} catch (fehler) {
|
||||||
|
setMeldung({
|
||||||
|
art: "fehler",
|
||||||
|
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
|
||||||
|
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Karte titel="Passwort">
|
||||||
|
<form onSubmit={(e) => void speichern(e)} className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Feld id="pw-alt" label="Bisheriges Passwort" fehler={meldung?.felder?.["alt"]}>
|
||||||
|
<input
|
||||||
|
id="pw-alt"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.alt}
|
||||||
|
onChange={(e) => setWerte({ ...werte, alt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="pw-neu"
|
||||||
|
label="Neues Passwort"
|
||||||
|
hilfe="Mindestens 12 Zeichen"
|
||||||
|
fehler={meldung?.felder?.["neu"]}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="pw-neu"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.neu}
|
||||||
|
onChange={(e) => setWerte({ ...werte, neu: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld
|
||||||
|
id="pw-wieder"
|
||||||
|
label="Neues Passwort wiederholen"
|
||||||
|
fehler={abweichend ? "Stimmt nicht überein." : undefined}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="pw-wieder"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.wiederholt}
|
||||||
|
onChange={(e) => setWerte({ ...werte, wiederholt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Knopf
|
||||||
|
type="submit"
|
||||||
|
disabled={!werte.alt || werte.neu.length < 12 || werte.neu !== werte.wiederholt}
|
||||||
|
>
|
||||||
|
Passwort ändern
|
||||||
|
</Knopf>
|
||||||
|
<div aria-live="polite" className="text-sm">
|
||||||
|
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { EINGABE_KLASSE, Feld } from "@/components/formular";
|
||||||
|
import { Karte } from "@/components/karte";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { protokollFilterAus, protokollSuchen } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Audit-Log" };
|
||||||
|
|
||||||
|
type Suche = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
const QUELLEN = { WEB: "Web", CLI: "CLI", API: "API", SYSTEM: "System" } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit-Log: jede Aktion mit Benutzer, Quelle und Adresse. Nur lesbar – die Datenbank verweigert
|
||||||
|
* Ändern und Löschen per Trigger. Der Filter ist ein schlichtes GET-Formular, der Stand steht in der URL.
|
||||||
|
*/
|
||||||
|
export default async function Protokoll({ searchParams }: { searchParams: Promise<Suche> }) {
|
||||||
|
await seiteSchuetzen("protokollLesen", "/protokoll");
|
||||||
|
const filter = protokollFilterAus(await searchParams);
|
||||||
|
const [{ eintraege, gesamt, seite, seiten }, benutzer] = await Promise.all([
|
||||||
|
protokollSuchen(filter),
|
||||||
|
prisma.benutzer.findMany({ select: { benutzername: true }, orderBy: { benutzername: "asc" } }),
|
||||||
|
]);
|
||||||
|
const seitenLink = (ziel: number) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(filter)) if (v !== undefined && k !== "seite") p.set(k, String(v));
|
||||||
|
if (ziel > 1) p.set("seite", String(ziel));
|
||||||
|
return `/protokoll${p.size > 0 ? `?${p.toString()}` : ""}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header className="flex flex-wrap items-end justify-between gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Audit-Log</h1>
|
||||||
|
<p className="text-muted text-sm">{gesamt} Einträge</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="get"
|
||||||
|
action="/protokoll"
|
||||||
|
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:flex lg:items-end"
|
||||||
|
>
|
||||||
|
<Feld id="p-suche" label="Suche" hilfe="Aktion oder Ziel">
|
||||||
|
<input
|
||||||
|
id="p-suche"
|
||||||
|
name="suche"
|
||||||
|
defaultValue={filter.suche ?? ""}
|
||||||
|
className={`${EINGABE_KLASSE} lg:w-48`}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="p-benutzer" label="Benutzer">
|
||||||
|
<select
|
||||||
|
id="p-benutzer"
|
||||||
|
name="benutzer"
|
||||||
|
defaultValue={filter.benutzer ?? ""}
|
||||||
|
className={`${EINGABE_KLASSE} lg:w-40`}
|
||||||
|
>
|
||||||
|
<option value="">alle</option>
|
||||||
|
{benutzer.map((b) => (
|
||||||
|
<option key={b.benutzername}>{b.benutzername}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="p-quelle" label="Quelle">
|
||||||
|
<select
|
||||||
|
id="p-quelle"
|
||||||
|
name="quelle"
|
||||||
|
defaultValue={filter.quelle ?? ""}
|
||||||
|
className={`${EINGABE_KLASSE} lg:w-32`}
|
||||||
|
>
|
||||||
|
<option value="">alle</option>
|
||||||
|
{Object.entries(QUELLEN).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="p-von" label="Von">
|
||||||
|
<input
|
||||||
|
id="p-von"
|
||||||
|
name="von"
|
||||||
|
type="date"
|
||||||
|
defaultValue={filter.von ?? ""}
|
||||||
|
className={`${EINGABE_KLASSE} lg:w-40`}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="p-bis" label="Bis">
|
||||||
|
<input
|
||||||
|
id="p-bis"
|
||||||
|
name="bis"
|
||||||
|
type="date"
|
||||||
|
defaultValue={filter.bis ?? ""}
|
||||||
|
className={`${EINGABE_KLASSE} lg:w-40`}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-akzent text-akzent-text inline-flex h-11 items-center rounded-lg px-4 text-sm font-semibold hover:opacity-90 sm:h-10"
|
||||||
|
>
|
||||||
|
Filtern
|
||||||
|
</button>
|
||||||
|
<Link href="/protokoll" className="text-muted hover:text-text text-sm">
|
||||||
|
Zurücksetzen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Karte>
|
||||||
|
{eintraege.length === 0 ? (
|
||||||
|
<Status art="offen">Keine Einträge.</Status>
|
||||||
|
) : (
|
||||||
|
<ol className="divide-linie flex flex-col divide-y" aria-label="Einträge">
|
||||||
|
{eintraege.map((e) => (
|
||||||
|
<li key={e.id} className="flex flex-col gap-1 py-3 first:pt-0 sm:flex-row sm:gap-4">
|
||||||
|
<time className="text-muted shrink-0 font-mono text-xs sm:w-36 sm:pt-0.5" dateTime={e.zeit}>
|
||||||
|
{new Date(e.zeit).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" })}
|
||||||
|
</time>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">{e.aktion}</span>
|
||||||
|
{e.ziel ? (
|
||||||
|
<span className="text-muted font-mono text-sm break-all"> · {e.ziel}</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted text-xs">
|
||||||
|
{e.benutzer ?? "–"} · {QUELLEN[e.quelle]}
|
||||||
|
{e.ip ? ` · ${e.ip}` : ""}
|
||||||
|
</p>
|
||||||
|
{e.details ? (
|
||||||
|
<details className="mt-1">
|
||||||
|
<summary className="text-muted cursor-pointer text-xs">Details</summary>
|
||||||
|
<pre className="bg-grund border-linie mt-1 overflow-x-auto rounded-lg border p-2 font-mono text-xs">
|
||||||
|
{JSON.stringify(e.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{seiten > 1 ? (
|
||||||
|
<nav aria-label="Seiten" className="flex items-center justify-between gap-4 text-sm">
|
||||||
|
{seite > 1 ? (
|
||||||
|
<Link className="text-akzent hover:underline" href={seitenLink(seite - 1)}>
|
||||||
|
← Neuere
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<span className="text-muted">
|
||||||
|
Seite {seite} von {seiten}
|
||||||
|
</span>
|
||||||
|
{seite < seiten ? (
|
||||||
|
<Link className="text-akzent hover:underline" href={seitenLink(seite + 1)}>
|
||||||
|
Ältere →
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { Status } from "@/components/status";
|
||||||
import { Symbol, Wortmarke } from "@/components/wortmarke";
|
import { Symbol, Wortmarke } from "@/components/wortmarke";
|
||||||
|
import { oidcEinstellungen } from "@/server/auth/oidc";
|
||||||
import { aktuellerBenutzer } from "@/server/auth/zugang";
|
import { aktuellerBenutzer } from "@/server/auth/zugang";
|
||||||
import { Anmeldeformular } from "./anmeldeformular";
|
import { Anmeldeformular } from "./anmeldeformular";
|
||||||
|
|
||||||
@@ -11,12 +13,25 @@ function sicheresZiel(ziel: string | string[] | undefined): string {
|
|||||||
return typeof ziel === "string" && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
|
return typeof ziel === "string" && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SSO_MELDUNGEN: Record<string, string> = {
|
||||||
|
abgelaufen: "Die SSO-Anmeldung hat zu lange gedauert. Bitte noch einmal.",
|
||||||
|
idp: "Der Identity Provider hat die Anmeldung nicht bestätigt.",
|
||||||
|
keine_gruppe: "Dein SSO-Konto ist für ProxDeploy nicht freigegeben – es fehlt die Gruppe.",
|
||||||
|
gesperrt: "Dein Konto ist gesperrt.",
|
||||||
|
name_vergeben: "Für dein SSO-Konto ließ sich kein Benutzername vergeben.",
|
||||||
|
aus: "Die Anmeldung per SSO ist nicht eingerichtet.",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function Anmelden({
|
export default async function Anmelden({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<Record<string, string | string[]>>;
|
searchParams: Promise<Record<string, string | string[]>>;
|
||||||
}) {
|
}) {
|
||||||
const ziel = sicheresZiel((await searchParams)["ziel"]);
|
const parameter = await searchParams;
|
||||||
|
const ziel = sicheresZiel(parameter["ziel"]);
|
||||||
|
const sso = oidcEinstellungen();
|
||||||
|
const ssoFehler =
|
||||||
|
typeof parameter["sso"] === "string" ? (SSO_MELDUNGEN[parameter["sso"]] ?? SSO_MELDUNGEN["idp"]) : null;
|
||||||
if (await aktuellerBenutzer()) redirect(ziel);
|
if (await aktuellerBenutzer()) redirect(ziel);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +44,27 @@ export default async function Anmelden({
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted text-center text-sm">VMs und LXC-Container auf Proxmox VE ausrollen</p>
|
<p className="text-muted text-center text-sm">VMs und LXC-Container auf Proxmox VE ausrollen</p>
|
||||||
</div>
|
</div>
|
||||||
|
{ssoFehler ? (
|
||||||
|
<Status art="fehler" className="mb-4 text-sm">
|
||||||
|
{ssoFehler}
|
||||||
|
</Status>
|
||||||
|
) : null}
|
||||||
<Anmeldeformular ziel={ziel} />
|
<Anmeldeformular ziel={ziel} />
|
||||||
|
{sso ? (
|
||||||
|
<div className="mt-6 flex flex-col gap-3">
|
||||||
|
<div className="text-muted flex items-center gap-3 text-xs" aria-hidden="true">
|
||||||
|
<span className="bg-linie h-px flex-1" />
|
||||||
|
oder
|
||||||
|
<span className="bg-linie h-px flex-1" />
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`/api/v1/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}
|
||||||
|
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center justify-center rounded-lg border px-4 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
Mit {sso.anzeigename} anmelden
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { aufraeumenStarten } from "@/server/services/auftraege";
|
||||||
|
|
||||||
|
type Kontext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Halb angelegten Gast eines fehlgeschlagenen Auftrags löschen → neuer Auftrag (202). */
|
||||||
|
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
|
||||||
|
const auftrag = await aufraeumenStarten((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
|
||||||
|
return NextResponse.json({ id: auftrag.id, kurzId: auftrag.kurzId }, { status: 202 });
|
||||||
|
});
|
||||||
@@ -3,12 +3,13 @@ import { aufruferAdresse } from "@/lib/adressen";
|
|||||||
import { api, jsonLesen } from "@/server/api";
|
import { api, jsonLesen } from "@/server/api";
|
||||||
import { ausrollenStarten } from "@/server/services/ausrollen";
|
import { ausrollenStarten } from "@/server/services/ausrollen";
|
||||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
import { auftraegeListe } from "@/server/services/auftraege";
|
import { auftraegeSuchen, verlaufFilterAus } from "@/server/services/auftraege";
|
||||||
|
|
||||||
/** Verlauf. Filter und Seitenblättern folgen mit #014. */
|
/** Verlauf: `?server=&typ=vm|lxc|template|aufraeumen&distribution=&status=&seite=`. */
|
||||||
export const GET = api(async (anfrage: Request) => {
|
export const GET = api(async (anfrage: Request) => {
|
||||||
await rechtErforderlich(anfrage, "lesen");
|
await rechtErforderlich(anfrage, "lesen");
|
||||||
return NextResponse.json(await auftraegeListe());
|
const filter = verlaufFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
|
||||||
|
return NextResponse.json(await auftraegeSuchen(filter));
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Ausrollen → 202 mit Kurz-ID; Fortschritt unter /api/v1/auftraege/<kurzId>/ereignisse. */
|
/** Ausrollen → 202 mit Kurz-ID; Fortschritt unter /api/v1/auftraege/<kurzId>/ereignisse. */
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse, istHttps, oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { SITZUNG_COOKIE, SITZUNG_LAUFZEIT_STUNDEN, sitzungErzeugen } from "@/lib/sitzung";
|
||||||
|
import {
|
||||||
|
OIDC_COOKIE,
|
||||||
|
oidcAbschliessen,
|
||||||
|
oidcEinstellungen,
|
||||||
|
OidcFehler,
|
||||||
|
RUECKRUF_PFAD,
|
||||||
|
} from "@/server/auth/oidc";
|
||||||
|
|
||||||
|
function cookieLesen(kopf: string | null, name: string): string | undefined {
|
||||||
|
for (const teil of (kopf ?? "").split(";")) {
|
||||||
|
const [schluessel, ...wert] = teil.trim().split("=");
|
||||||
|
if (schluessel === name) return decodeURIComponent(wert.join("="));
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentlich: Rückkehr vom Identity Provider. Erfolg → Sitzungscookie und Weiterleitung zum Ziel;
|
||||||
|
* Fehler → Anmeldeseite mit Code (`?sso=keine_gruppe` …), nie eine Fehlerseite mit Interna.
|
||||||
|
*/
|
||||||
|
export async function GET(anfrage: Request): Promise<Response> {
|
||||||
|
const herkunft = new URL(anfrage.url).origin;
|
||||||
|
const e = oidcEinstellungen();
|
||||||
|
const zurAnmeldung = (code: string) =>
|
||||||
|
NextResponse.redirect(oeffentlicheAdresse(`/anmelden?sso=${code}`, herkunft), 302);
|
||||||
|
if (!e) return zurAnmeldung("aus");
|
||||||
|
|
||||||
|
// Die öffentliche Adresse: genau die, die als redirect_uri beim IdP registriert ist.
|
||||||
|
const aktuell = oeffentlicheAdresse(RUECKRUF_PFAD + new URL(anfrage.url).search, herkunft);
|
||||||
|
let antwort: NextResponse;
|
||||||
|
try {
|
||||||
|
const { benutzer, ziel } = await oidcAbschliessen(
|
||||||
|
e,
|
||||||
|
aktuell,
|
||||||
|
cookieLesen(anfrage.headers.get("cookie"), OIDC_COOKIE),
|
||||||
|
aufruferAdresse(anfrage.headers),
|
||||||
|
);
|
||||||
|
antwort = NextResponse.redirect(oeffentlicheAdresse(ziel, herkunft), 302);
|
||||||
|
antwort.cookies.set(
|
||||||
|
SITZUNG_COOKIE,
|
||||||
|
await sitzungErzeugen({ benutzerId: benutzer.id, herkunft: "oidc" }),
|
||||||
|
{
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: istHttps(anfrage.headers, anfrage.url),
|
||||||
|
path: "/",
|
||||||
|
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 3600,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (!(fehler instanceof OidcFehler)) logger.error({ err: fehler }, "OIDC-Rückruf fehlgeschlagen");
|
||||||
|
const code = fehler instanceof OidcFehler ? fehler.code : "idp";
|
||||||
|
// Beim Verknüpfen ist man schon angemeldet – die Meldung gehört dann in die Einstellungen.
|
||||||
|
antwort =
|
||||||
|
code === "schon_verknuepft"
|
||||||
|
? NextResponse.redirect(oeffentlicheAdresse(`/einstellungen?sso=${code}`, herkunft), 302)
|
||||||
|
: zurAnmeldung(code);
|
||||||
|
}
|
||||||
|
antwort.cookies.set(OIDC_COOKIE, "", { path: "/api/v1/auth/oidc", maxAge: 0 });
|
||||||
|
return antwort;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { istHttps, oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
|
import { api } from "@/server/api";
|
||||||
|
import { anfrageAufrufer } from "@/server/auth/zugang";
|
||||||
|
import { OIDC_COOKIE, oidcEinstellungen, oidcStarten, RUECKRUF_PFAD } from "@/server/auth/oidc";
|
||||||
|
import { AnwendungsFehler, nichtAngemeldet } from "@/server/fehler";
|
||||||
|
|
||||||
|
/** Nur relative Ziele – sonst wäre `?ziel=` eine offene Weiterleitung. */
|
||||||
|
function sicheresZiel(ziel: string | null): string {
|
||||||
|
return ziel && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentlich: leitet zum Identity Provider. `?verknuepfen=1` (angemeldet) hängt das SSO-Konto an
|
||||||
|
* das eigene lokale Konto statt anzumelden.
|
||||||
|
*/
|
||||||
|
export const GET = api(async (anfrage: Request) => {
|
||||||
|
const e = oidcEinstellungen();
|
||||||
|
if (!e) throw new AnwendungsFehler(404, "oidc_aus", "Die Anmeldung per SSO ist nicht eingerichtet.");
|
||||||
|
const abfrage = new URL(anfrage.url).searchParams;
|
||||||
|
let verknuepfen: string | null = null;
|
||||||
|
if (abfrage.get("verknuepfen") === "1") {
|
||||||
|
const aufrufer = await anfrageAufrufer(anfrage);
|
||||||
|
if (!aufrufer) throw nichtAngemeldet();
|
||||||
|
verknuepfen = aufrufer.benutzer.id;
|
||||||
|
}
|
||||||
|
const herkunft = new URL(anfrage.url).origin;
|
||||||
|
const { weiter, cookie } = await oidcStarten(
|
||||||
|
e,
|
||||||
|
oeffentlicheAdresse(RUECKRUF_PFAD, herkunft),
|
||||||
|
sicheresZiel(abfrage.get("ziel")),
|
||||||
|
verknuepfen,
|
||||||
|
);
|
||||||
|
const antwort = NextResponse.redirect(weiter, 302);
|
||||||
|
antwort.cookies.set(OIDC_COOKIE, cookie, {
|
||||||
|
httpOnly: true,
|
||||||
|
// Lax: der Rückruf ist eine Top-Level-Navigation vom IdP zurück, da kommt das Cookie mit.
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: istHttps(anfrage.headers, anfrage.url),
|
||||||
|
path: "/api/v1/auth/oidc",
|
||||||
|
maxAge: 600,
|
||||||
|
});
|
||||||
|
return antwort;
|
||||||
|
});
|
||||||
@@ -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,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { passwortZuruecksetzen } from "@/server/services/benutzer";
|
||||||
|
|
||||||
|
type Kontext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Admin setzt ein neues Passwort `{ passwort }`; alle Sitzungen des Benutzers enden. */
|
||||||
|
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
|
||||||
|
await passwortZuruecksetzen(
|
||||||
|
(await params).id,
|
||||||
|
await jsonLesen(anfrage),
|
||||||
|
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 { benutzerAendern } from "@/server/services/benutzer";
|
||||||
|
|
||||||
|
type Kontext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** `{ anzeigename?, email?, rolle?, aktiv? }` – Sperren beendet alle Sitzungen. */
|
||||||
|
export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
|
||||||
|
return NextResponse.json(
|
||||||
|
await benutzerAendern(
|
||||||
|
(await params).id,
|
||||||
|
await jsonLesen(anfrage),
|
||||||
|
aufrufer,
|
||||||
|
aufruferAdresse(anfrage.headers),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse } from "@/lib/adressen";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { benutzerAnlegen, benutzerListe } from "@/server/services/benutzer";
|
||||||
|
|
||||||
|
export const GET = api(async (anfrage: Request) => {
|
||||||
|
await rechtErforderlich(anfrage, "benutzerVerwalten");
|
||||||
|
return NextResponse.json(await benutzerListe());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** `{ benutzername, anzeigename, email?, rolle, passwort }` */
|
||||||
|
export const POST = api(async (anfrage: Request) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
|
||||||
|
return NextResponse.json(
|
||||||
|
await benutzerAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { aufruferAdresse, istHttps } from "@/lib/adressen";
|
||||||
|
import { SITZUNG_COOKIE, SITZUNG_LAUFZEIT_STUNDEN, sitzungErzeugen } from "@/lib/sitzung";
|
||||||
|
import { api, jsonLesen } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { eigenesPasswortAendern } from "@/server/services/benutzer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigenes Passwort ändern `{ alt, neu }`. Alle anderen Sitzungen enden; die aufrufende Web-Sitzung
|
||||||
|
* bekommt ein frisches Cookie, damit man angemeldet bleibt.
|
||||||
|
*/
|
||||||
|
export const POST = api(async (anfrage: Request) => {
|
||||||
|
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||||
|
await eigenesPasswortAendern(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers));
|
||||||
|
const antwort = new NextResponse(null, { status: 204 });
|
||||||
|
if (aufrufer.quelle === "WEB") {
|
||||||
|
antwort.cookies.set(
|
||||||
|
SITZUNG_COOKIE,
|
||||||
|
await sitzungErzeugen({ benutzerId: aufrufer.benutzer.id, herkunft: "passwort" }),
|
||||||
|
{
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: istHttps(anfrage.headers, anfrage.url),
|
||||||
|
path: "/",
|
||||||
|
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 3600,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return antwort;
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { api } from "@/server/api";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||||
|
import { protokollFilterAus, protokollSuchen } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/** Audit-Log, neueste zuerst: `?benutzer=&quelle=&suche=&von=YYYY-MM-DD&bis=YYYY-MM-DD&seite=`. Nur Admins. */
|
||||||
|
export const GET = api(async (anfrage: Request) => {
|
||||||
|
await rechtErforderlich(anfrage, "protokollLesen");
|
||||||
|
const filter = protokollFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
|
||||||
|
return NextResponse.json(await protokollSuchen(filter));
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
Rocket,
|
Rocket,
|
||||||
|
ScrollText,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
Layers,
|
Layers,
|
||||||
@@ -33,6 +34,7 @@ const SYMBOLE: Record<
|
|||||||
vorlagen: FileStack,
|
vorlagen: FileStack,
|
||||||
katalog: BookOpen,
|
katalog: BookOpen,
|
||||||
server: Server,
|
server: Server,
|
||||||
|
protokoll: ScrollText,
|
||||||
einstellungen: Settings,
|
einstellungen: Settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||||
|
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
|
||||||
|
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
|
||||||
|
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufräumen nach einem fehlgeschlagenen oder abgebrochenen Auftrag: den halb angelegten Gast
|
||||||
|
* stoppen und löschen. Gelöscht wird nur, was **nachweislich zu diesem Auftrag gehört** – die
|
||||||
|
* Beschreibung trägt „(Auftrag <kurzId>)“, gesetzt schon beim Anlegen bzw. Klonen. Templates
|
||||||
|
* werden nie gelöscht. Passt etwas nicht, bricht der Ablauf ab, ohne etwas anzufassen.
|
||||||
|
*/
|
||||||
|
export type AufraeumEingabe = {
|
||||||
|
client: ProxmoxClient;
|
||||||
|
typ: "VM" | "LXC";
|
||||||
|
vmid: number;
|
||||||
|
/** Node laut Auftrag; liegt der Gast inzwischen woanders (Migration), gilt der tatsächliche. */
|
||||||
|
knoten: string | null;
|
||||||
|
/** Kurz-ID des Auftrags, der den Gast angelegt hat. */
|
||||||
|
auftragKurzId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AufraeumStand = { knoten?: string; vorhanden?: boolean; laeuft?: boolean; name?: string };
|
||||||
|
|
||||||
|
type Ressource = {
|
||||||
|
vmid?: number;
|
||||||
|
node?: string;
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
name?: string;
|
||||||
|
template?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function aufraeumSchritte(e: AufraeumEingabe, stand: AufraeumStand = {}): PhasenSchritt[] {
|
||||||
|
const { client } = e;
|
||||||
|
const art = e.typ === "LXC" ? "lxc" : "qemu";
|
||||||
|
const wort = e.typ === "LXC" ? "Container" : "VM";
|
||||||
|
const gastPfad = () => pfad`/nodes/${stand.knoten!}/${art}/${e.vmid}`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
schluessel: "pruefungen",
|
||||||
|
titel: "Prüfungen",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
const ressourcen = await client.get<Ressource[]>("/cluster/resources", { type: "vm" });
|
||||||
|
const gast = ressourcen.find((r) => r.vmid === e.vmid);
|
||||||
|
if (!gast) {
|
||||||
|
stand.vorhanden = false;
|
||||||
|
return { detail: `${wort} ${e.vmid} gibt es nicht mehr – nichts zu löschen` };
|
||||||
|
}
|
||||||
|
if (gast.type !== art) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Unter ${e.vmid} liegt kein ${wort}, sondern ${gast.type === "lxc" ? "ein Container" : "eine VM"}.`,
|
||||||
|
"Nichts gelöscht. Die ID wurde inzwischen anders vergeben – von Hand prüfen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stand.knoten = gast.node!;
|
||||||
|
const konfig = await client.get<Record<string, unknown>>(`${gastPfad()}/config`);
|
||||||
|
if (gast.template === 1 || konfig["template"] === 1) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`${e.vmid} ist ein Template.`,
|
||||||
|
"Templates räumt ProxDeploy nie auf. Unter „Templates“ bzw. in Proxmox entfernen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const beschreibung = String(konfig["description"] ?? "");
|
||||||
|
if (!beschreibung.includes(`(Auftrag ${e.auftragKurzId})`)) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`${wort} ${e.vmid} gehört nicht erkennbar zu Auftrag ${e.auftragKurzId}.`,
|
||||||
|
"Nichts gelöscht. In Proxmox prüfen und von Hand entfernen, falls es der halb angelegte Gast ist.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (konfig["lock"]) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`${wort} ${e.vmid} ist gesperrt (${String(konfig["lock"])}).`,
|
||||||
|
`Läuft dort noch eine Aufgabe? Sonst auf dem Node „${e.typ === "LXC" ? "pct" : "qm"} unlock ${e.vmid}“ und erneut aufräumen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stand.vorhanden = true;
|
||||||
|
stand.laeuft = gast.status === "running";
|
||||||
|
stand.name = gast.name ?? String(konfig["name"] ?? konfig["hostname"] ?? "");
|
||||||
|
return {
|
||||||
|
detail: `${wort} ${e.vmid}${stand.name ? ` (${stand.name})` : ""} auf ${stand.knoten} gehört zu ${e.auftragKurzId}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "stoppen",
|
||||||
|
titel: "Stoppen",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
if (!stand.vorhanden) return { uebersprungen: true, detail: "nichts zu tun" };
|
||||||
|
if (!stand.laeuft) return { uebersprungen: true, detail: "läuft nicht" };
|
||||||
|
await aufgabeAbwarten(client, await client.post<string>(`${gastPfad()}/status/stop`), {
|
||||||
|
zeitlimitMs: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
return { detail: `${wort} ${e.vmid} gestoppt` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "loeschen",
|
||||||
|
titel: "Löschen",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
if (!stand.vorhanden) return { uebersprungen: true, detail: "nichts zu tun" };
|
||||||
|
await aufgabeAbwarten(
|
||||||
|
client,
|
||||||
|
await client.delete<string>(gastPfad(), { "destroy-unreferenced-disks": true }),
|
||||||
|
{ zeitlimitMs: 15 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
return { detail: `${wort} ${e.vmid} mit Disks gelöscht` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -92,6 +92,8 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
|||||||
const { client, anfrage: a, distribution: d } = e;
|
const { client, anfrage: a, distribution: d } = e;
|
||||||
const warten = e.warten ?? schlafen;
|
const warten = e.warten ?? schlafen;
|
||||||
const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`;
|
const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`;
|
||||||
|
const beschreibung = () =>
|
||||||
|
`Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`;
|
||||||
const templateStand: TemplateBauStand = {};
|
const templateStand: TemplateBauStand = {};
|
||||||
|
|
||||||
const schritte: PhasenSchritt[] = [
|
const schritte: PhasenSchritt[] = [
|
||||||
@@ -238,6 +240,8 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
|||||||
await client.post<string>(pfad`/nodes/${tpl.knoten}/qemu/${tpl.vmid}/clone`, {
|
await client.post<string>(pfad`/nodes/${tpl.knoten}/qemu/${tpl.vmid}/clone`, {
|
||||||
newid: vmid,
|
newid: vmid,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
|
// Schon beim Klonen: daran erkennt „Aufräumen“, dass die VM zu diesem Auftrag gehört.
|
||||||
|
description: beschreibung(),
|
||||||
full: true,
|
full: true,
|
||||||
target: stand.knoten!,
|
target: stand.knoten!,
|
||||||
storage: a.storage,
|
storage: a.storage,
|
||||||
@@ -284,7 +288,7 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
|||||||
net0: `virtio,bridge=${a.netz.bridge}${a.netz.vlan ? `,tag=${a.netz.vlan}` : ""}`,
|
net0: `virtio,bridge=${a.netz.bridge}${a.netz.vlan ? `,tag=${a.netz.vlan}` : ""}`,
|
||||||
onboot: a.startBeiBoot,
|
onboot: a.startBeiBoot,
|
||||||
tags: ["proxdeploy", d.id, ...a.tags].join(";"),
|
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(", ") || "–"}`,
|
description: beschreibung(),
|
||||||
});
|
});
|
||||||
// Vergrößern nur, wenn größer: Proxmox kann Disks nicht verkleinern.
|
// Vergrößern nur, wenn größer: Proxmox kann Disks nicht verkleinern.
|
||||||
const konfig = client.trocken ? {} : await client.get<Record<string, string>>(`${vmPfad()}/config`);
|
const konfig = client.trocken ? {} : await client.get<Record<string, string>>(`${vmPfad()}/config`);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import ssh2 from "ssh2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein ed25519-Schlüsselpaar im OpenSSH-Format, das ssh2 auch selbst wieder lesen kann.
|
||||||
|
*
|
||||||
|
* `ssh2.utils.generateKeyPairSync("ed25519")` liefert in etwa 4 von 1000 Fällen einen privaten
|
||||||
|
* Schlüssel, an dem der eigene Parser mit „Malformed OpenSSH private key“ scheitert (gemessen mit
|
||||||
|
* ssh2 1.17.0). Gespeichert wäre so ein Server-Schlüssel dauerhaft unbrauchbar – deshalb wird
|
||||||
|
* geprüft und notfalls neu erzeugt.
|
||||||
|
*/
|
||||||
|
export function ed25519Erzeugen(kommentar?: string): { privat: string; oeffentlich: string } {
|
||||||
|
for (let versuch = 0; versuch < 20; versuch += 1) {
|
||||||
|
const paar = ssh2.utils.generateKeyPairSync("ed25519", kommentar ? { comment: kommentar } : {});
|
||||||
|
if (!(ssh2.utils.parseKey(paar.private) instanceof Error)) {
|
||||||
|
return { privat: paar.private, oeffentlich: paar.public.trim() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Kein lesbarer ed25519-Schlüssel erzeugt.");
|
||||||
|
}
|
||||||
+45
-1
@@ -1,5 +1,6 @@
|
|||||||
import type { StatusArt } from "@/components/status";
|
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. */
|
/** Beschriftungen für Aufträge – geteilt von Verlauf, Übersicht und Live-Ansicht. */
|
||||||
export const STATUS_ANZEIGE: Record<AuftragSicht["status"], [StatusArt, string]> = {
|
export const STATUS_ANZEIGE: Record<AuftragSicht["status"], [StatusArt, string]> = {
|
||||||
@@ -16,3 +17,46 @@ export const ART_TEXT: Record<AuftragSicht["art"], string> = {
|
|||||||
TEMPLATE_BAUEN: "Template",
|
TEMPLATE_BAUEN: "Template",
|
||||||
AUFRAEUMEN: "Aufräumen",
|
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" | "aufraeumbar">): 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} ${a.art === "AUFRAEUMEN" ? "gelöscht" : "bereit"}`,
|
||||||
|
dauer: gesamt,
|
||||||
|
zeilen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ export type NavigationsEintrag = {
|
|||||||
| "vorlagen"
|
| "vorlagen"
|
||||||
| "katalog"
|
| "katalog"
|
||||||
| "server"
|
| "server"
|
||||||
|
| "protokoll"
|
||||||
| "einstellungen";
|
| "einstellungen";
|
||||||
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge";
|
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge" | "protokollLesen";
|
||||||
/** Erscheint erst, wenn das Issue umgesetzt ist – bis dahin nicht anzeigen statt ins Leere führen. */
|
/** Erscheint erst, wenn das Issue umgesetzt ist – bis dahin nicht anzeigen statt ins Leere führen. */
|
||||||
ab?: string;
|
ab?: string;
|
||||||
};
|
};
|
||||||
@@ -28,13 +29,8 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
|
|||||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
|
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
|
||||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
|
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
|
||||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
||||||
{
|
{ pfad: "/protokoll", titel: "Audit-Log", symbol: "protokoll", recht: "protokollLesen" },
|
||||||
pfad: "/einstellungen",
|
{ pfad: "/einstellungen", titel: "Einstellungen", symbol: "einstellungen", recht: "eigeneZugaenge" },
|
||||||
titel: "Einstellungen",
|
|
||||||
symbol: "einstellungen",
|
|
||||||
recht: "eigeneZugaenge",
|
|
||||||
ab: "#015",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Aktiv ist ein Eintrag auch für Unterseiten – außer der Übersicht, sonst wäre sie immer aktiv. */
|
/** Aktiv ist ein Eintrag auch für Unterseiten – außer der Übersicht, sonst wäre sie immer aktiv. */
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Antwortformen der REST-API, soweit die CLI sie liest. Aus ihnen und den Anfrage-Schemas
|
||||||
|
* entsteht `openapi.json` und daraus die Go-Typen der CLI (`cli/internal/api`).
|
||||||
|
*
|
||||||
|
* Objekte sind offen (`looseObject`): die API darf Felder ergänzen, ohne dass eine ältere CLI
|
||||||
|
* bricht. Die Tests in `tests/openapi.test.ts` prüfen echte Antworten gegen diese Schemas.
|
||||||
|
*/
|
||||||
|
const Zeit = z.string().describe("ISO 8601");
|
||||||
|
|
||||||
|
export const FehlerAntwort = z.object({
|
||||||
|
fehler: z.looseObject({
|
||||||
|
code: z.string(),
|
||||||
|
nachricht: z.string(),
|
||||||
|
hinweis: z.string().optional(),
|
||||||
|
felder: z.record(z.string(), z.string()).optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Ich = z.looseObject({
|
||||||
|
benutzername: z.string(),
|
||||||
|
anzeigename: z.string(),
|
||||||
|
rolle: z.enum(["ADMIN", "OPERATOR"]),
|
||||||
|
rechte: z.array(z.string()),
|
||||||
|
version: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NeuesToken = z.looseObject({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
praefix: z.string(),
|
||||||
|
token: z.string(),
|
||||||
|
erstelltAm: Zeit,
|
||||||
|
ablaufAm: Zeit.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TokenAnmeldung = z.object({
|
||||||
|
benutzername: z.string(),
|
||||||
|
passwort: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
tage: z.number().int().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ServerEintrag = z.looseObject({
|
||||||
|
name: z.string(),
|
||||||
|
titel: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
erreichbar: z.boolean().nullable(),
|
||||||
|
pveVersion: z.string().nullable(),
|
||||||
|
geprueftAm: Zeit.nullable(),
|
||||||
|
sshAktiv: z.boolean(),
|
||||||
|
letzterTest: z
|
||||||
|
.looseObject({
|
||||||
|
cluster: z.looseObject({ name: z.string() }).nullable(),
|
||||||
|
knoten: z.array(
|
||||||
|
z.looseObject({
|
||||||
|
name: z.string(),
|
||||||
|
online: z.boolean(),
|
||||||
|
speicherBelegt: z.number().nullable(),
|
||||||
|
speicherGesamt: z.number().nullable(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DistributionEintrag = z.looseObject({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
familie: z.string(),
|
||||||
|
aktiv: z.boolean(),
|
||||||
|
vm: z.boolean(),
|
||||||
|
lxc: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProfilVariable = z.looseObject({
|
||||||
|
name: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
type: z.enum(["string", "number", "boolean", "select"]),
|
||||||
|
required: z.boolean(),
|
||||||
|
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
||||||
|
options: z.array(z.string()).optional(),
|
||||||
|
help: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProfilEintrag = z.looseObject({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
beschreibung: z.string().nullable(),
|
||||||
|
familien: z.array(z.string()),
|
||||||
|
herkunft: z.enum(["KATALOG", "EIGEN"]),
|
||||||
|
aktiv: z.boolean(),
|
||||||
|
definition: z.looseObject({ variables: z.array(ProfilVariable).optional() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VorlageEintrag = z.looseObject({
|
||||||
|
name: z.string(),
|
||||||
|
titel: z.string(),
|
||||||
|
beschreibung: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TemplateEintrag = z.looseObject({
|
||||||
|
vmid: z.number().int(),
|
||||||
|
name: z.string(),
|
||||||
|
knoten: z.string(),
|
||||||
|
distributionId: z.string().nullable(),
|
||||||
|
gebautAm: Zeit.nullable(),
|
||||||
|
profile: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AuftragAngenommen = z.object({ id: z.string(), kurzId: z.string() });
|
||||||
|
|
||||||
|
const PhasenStatus = z.enum(["OFFEN", "LAEUFT", "ERLEDIGT", "FEHLER", "UEBERSPRUNGEN"]);
|
||||||
|
|
||||||
|
export const PhaseSicht = z.looseObject({
|
||||||
|
schluessel: z.string(),
|
||||||
|
titel: z.string(),
|
||||||
|
status: PhasenStatus,
|
||||||
|
detail: z.string().nullable(),
|
||||||
|
ursache: z.string().nullable(),
|
||||||
|
hinweis: z.string().nullable(),
|
||||||
|
gestartetAm: Zeit.nullable().optional(),
|
||||||
|
beendetAm: Zeit.nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Ergebnis eines fertigen Auftrags – Inhalt der Abschlussbox. */
|
||||||
|
export const AuftragErgebnis = z.looseObject({
|
||||||
|
vmid: z.number().int().optional(),
|
||||||
|
knoten: z.string().optional(),
|
||||||
|
ip: z.string().nullable().optional(),
|
||||||
|
ssh: z.string().nullable().optional(),
|
||||||
|
/** Nur beim Template-Bau: Name des Templates. */
|
||||||
|
template: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Abschluss = z.looseObject({
|
||||||
|
ok: z.boolean(),
|
||||||
|
titel: z.string(),
|
||||||
|
dauer: z.string().nullable(),
|
||||||
|
/** Je Zeile `[Bezeichnung, Wert]`. */
|
||||||
|
zeilen: z.array(z.array(z.string()).length(2)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AuftragSicht = z.looseObject({
|
||||||
|
id: z.string(),
|
||||||
|
kurzId: z.string(),
|
||||||
|
art: z.enum(["AUSROLLEN", "TEMPLATE_BAUEN", "AUFRAEUMEN"]),
|
||||||
|
status: z.enum(["WARTEND", "LAEUFT", "ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]),
|
||||||
|
kopf: z.string(),
|
||||||
|
server: z.looseObject({ name: z.string(), titel: z.string() }),
|
||||||
|
knoten: z.string().nullable(),
|
||||||
|
gastTyp: z.enum(["VM", "LXC"]).nullable(),
|
||||||
|
vmid: z.number().int().nullable(),
|
||||||
|
name: z.string().nullable(),
|
||||||
|
distributionId: z.string().nullable(),
|
||||||
|
profile: z.array(z.string()),
|
||||||
|
ergebnis: AuftragErgebnis.nullable(),
|
||||||
|
fehler: z
|
||||||
|
.looseObject({ phase: z.string().optional(), ursache: z.string(), hinweis: z.string().optional() })
|
||||||
|
.nullable(),
|
||||||
|
erstelltVon: z.string(),
|
||||||
|
erstelltAm: Zeit,
|
||||||
|
gestartetAm: Zeit.nullable(),
|
||||||
|
beendetAm: Zeit.nullable(),
|
||||||
|
abschluss: Abschluss.nullable(),
|
||||||
|
aufraeumbar: z
|
||||||
|
.looseObject({ typ: z.enum(["VM", "LXC"]), vmid: z.number().int(), knoten: z.string().nullable() })
|
||||||
|
.nullable(),
|
||||||
|
phasen: z.array(PhaseSicht),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LogZeile = z.looseObject({
|
||||||
|
id: z.string(),
|
||||||
|
zeit: Zeit,
|
||||||
|
stufe: z.string(),
|
||||||
|
phase: z.string().nullable(),
|
||||||
|
nachricht: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GeplanterAufruf = z.looseObject({
|
||||||
|
methode: z.string(),
|
||||||
|
pfad: z.string(),
|
||||||
|
parameter: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Plan = z.looseObject({
|
||||||
|
kopf: z.string(),
|
||||||
|
phasen: z.array(PhaseSicht),
|
||||||
|
aufrufe: z.array(GeplanterAufruf),
|
||||||
|
skript: z.string().nullable(),
|
||||||
|
cliBefehl: z.string(),
|
||||||
|
automatisch: z.array(z.string()),
|
||||||
|
ok: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Verlauf = z.looseObject({
|
||||||
|
eintraege: z.array(AuftragSicht),
|
||||||
|
gesamt: z.number().int(),
|
||||||
|
seite: z.number().int(),
|
||||||
|
seiten: z.number().int(),
|
||||||
|
});
|
||||||
@@ -20,6 +20,10 @@ export type AuftragSicht = {
|
|||||||
erstelltAm: string;
|
erstelltAm: string;
|
||||||
gestartetAm: string | null;
|
gestartetAm: string | null;
|
||||||
beendetAm: string | null;
|
beendetAm: string | null;
|
||||||
|
/** Abschlussbox, sobald der Auftrag fertig ist. */
|
||||||
|
abschluss: Abschluss | null;
|
||||||
|
/** Bei Fehlschlag/Abbruch: was „Aufräumen“ löschen würde. */
|
||||||
|
aufraeumbar: { typ: "VM" | "LXC"; vmid: number; knoten: string | null } | null;
|
||||||
phasen: Array<{
|
phasen: Array<{
|
||||||
schluessel: string;
|
schluessel: string;
|
||||||
titel: string;
|
titel: string;
|
||||||
@@ -32,4 +36,11 @@ export type AuftragSicht = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Abschluss = {
|
||||||
|
ok: boolean;
|
||||||
|
titel: string;
|
||||||
|
dauer: string | null;
|
||||||
|
zeilen: Array<[string, string]>;
|
||||||
|
};
|
||||||
|
|
||||||
export type LogZeile = { id: string; zeit: string; stufe: string; phase: string | null; nachricht: string };
|
export type LogZeile = { id: string; zeit: string; stufe: string; phase: string | null; nachricht: string };
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
||||||
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
||||||
*/
|
*/
|
||||||
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.3.0";
|
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.5.0";
|
||||||
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
||||||
|
|
||||||
export function versionText(): string {
|
export function versionText(): string {
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { jwtVerify, SignJWT } from "jose";
|
||||||
|
import * as client from "openid-client";
|
||||||
|
import type { Benutzer, Rolle } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldung per OpenID Connect (z. B. authentik): Authorization Code Flow mit PKCE, State und
|
||||||
|
* Nonce. Die Rolle kommt bei jeder Anmeldung aus dem Gruppen-Claim – der IdP ist maßgeblich.
|
||||||
|
*
|
||||||
|
* Konten: per `oidcSubjekt` (sub) zugeordnet. Ein lokales Konto wird **nie automatisch** über den
|
||||||
|
* Benutzernamen übernommen – sonst könnte, wer beim IdP „admin“ heißt, den lokalen Admin
|
||||||
|
* übernehmen. Verknüpfen geht nur angemeldet, aus den Einstellungen heraus.
|
||||||
|
*/
|
||||||
|
export type OidcEinstellungen = {
|
||||||
|
issuer: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
anzeigename: string;
|
||||||
|
gruppenClaim: string;
|
||||||
|
gruppeAdmin: string;
|
||||||
|
gruppeOperator: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function oidcEinstellungen(
|
||||||
|
umgebung: Record<string, string | undefined> = process.env,
|
||||||
|
): OidcEinstellungen | null {
|
||||||
|
if (umgebung["OIDC_AKTIV"] !== "true") return null;
|
||||||
|
const issuer = umgebung["OIDC_ISSUER"]?.trim();
|
||||||
|
const clientId = umgebung["OIDC_CLIENT_ID"]?.trim();
|
||||||
|
if (!issuer || !clientId) {
|
||||||
|
logger.warn("OIDC_AKTIV=true, aber OIDC_ISSUER oder OIDC_CLIENT_ID fehlt – OIDC bleibt aus.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
issuer,
|
||||||
|
clientId,
|
||||||
|
clientSecret: umgebung["OIDC_CLIENT_SECRET"] ?? "",
|
||||||
|
anzeigename: umgebung["OIDC_ANZEIGENAME"]?.trim() || "SSO",
|
||||||
|
gruppenClaim: umgebung["OIDC_GRUPPEN_CLAIM"]?.trim() || "groups",
|
||||||
|
gruppeAdmin: umgebung["OIDC_GRUPPE_ADMIN"]?.trim() ?? "",
|
||||||
|
gruppeOperator: umgebung["OIDC_GRUPPE_OPERATOR"]?.trim() ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let konfiguration: { schluessel: string; wert: Promise<client.Configuration> } | undefined;
|
||||||
|
|
||||||
|
/** Discovery einmal je Issuer; schlägt sie fehl, wird beim nächsten Mal neu versucht. */
|
||||||
|
async function konfigurationHolen(e: OidcEinstellungen): Promise<client.Configuration> {
|
||||||
|
const schluessel = `${e.issuer}|${e.clientId}`;
|
||||||
|
if (konfiguration?.schluessel !== schluessel) {
|
||||||
|
const url = new URL(e.issuer);
|
||||||
|
const wert = client.discovery(
|
||||||
|
url,
|
||||||
|
e.clientId,
|
||||||
|
e.clientSecret || undefined,
|
||||||
|
e.clientSecret ? undefined : client.None(),
|
||||||
|
// http nur für Tests und Labore auf localhost – sonst verlangt openid-client zu Recht https.
|
||||||
|
url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname)
|
||||||
|
? { execute: [client.allowInsecureRequests] }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
konfiguration = { schluessel, wert };
|
||||||
|
wert.catch(() => {
|
||||||
|
if (konfiguration?.wert === wert) konfiguration = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await konfiguration.wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur für Tests. */
|
||||||
|
export function oidcZwischenspeicherLeeren(): void {
|
||||||
|
konfiguration = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zustand zwischen Start und Rückruf: kurzlebiges, signiertes Cookie ───────────────────────────
|
||||||
|
|
||||||
|
export const OIDC_COOKIE = "pd_oidc";
|
||||||
|
const OIDC_LAUFZEIT_S = 600;
|
||||||
|
|
||||||
|
type Ablauf = { verifier: string; state: string; nonce: string; ziel: string; verknuepfen: string | null };
|
||||||
|
|
||||||
|
function geheimnis(): Uint8Array {
|
||||||
|
return new TextEncoder().encode(process.env["SITZUNG_GEHEIMNIS"] ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RUECKRUF_PFAD = "/api/v1/auth/oidc/rueckruf";
|
||||||
|
|
||||||
|
/** Startet die Anmeldung: Weiterleitungsziel beim IdP und das Cookie für den Rückruf. */
|
||||||
|
export async function oidcStarten(
|
||||||
|
e: OidcEinstellungen,
|
||||||
|
rueckruf: URL,
|
||||||
|
ziel: string,
|
||||||
|
verknuepfen: string | null,
|
||||||
|
): Promise<{ weiter: URL; cookie: string }> {
|
||||||
|
const config = await konfigurationHolen(e);
|
||||||
|
const verifier = client.randomPKCECodeVerifier();
|
||||||
|
const ablauf: Ablauf = {
|
||||||
|
verifier,
|
||||||
|
state: client.randomState(),
|
||||||
|
nonce: client.randomNonce(),
|
||||||
|
ziel,
|
||||||
|
verknuepfen,
|
||||||
|
};
|
||||||
|
const weiter = client.buildAuthorizationUrl(config, {
|
||||||
|
redirect_uri: rueckruf.href,
|
||||||
|
scope: "openid profile email",
|
||||||
|
code_challenge: await client.calculatePKCECodeChallenge(verifier),
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
state: ablauf.state,
|
||||||
|
nonce: ablauf.nonce,
|
||||||
|
});
|
||||||
|
const cookie = await new SignJWT(ablauf)
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setAudience("proxdeploy-oidc")
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${OIDC_LAUFZEIT_S}s`)
|
||||||
|
.sign(geheimnis());
|
||||||
|
return { weiter, cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OidcFehler extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: "abgelaufen" | "idp" | "keine_gruppe" | "gesperrt" | "name_vergeben" | "schon_verknuepft",
|
||||||
|
nachricht: string,
|
||||||
|
) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "OidcFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ansprueche = Record<string, unknown> & { sub: string };
|
||||||
|
|
||||||
|
export function rolleAusGruppen(e: OidcEinstellungen, ansprueche: Ansprueche): Rolle | null {
|
||||||
|
const roh = ansprueche[e.gruppenClaim];
|
||||||
|
const gruppen = Array.isArray(roh) ? roh.map(String) : typeof roh === "string" ? [roh] : [];
|
||||||
|
if (e.gruppeAdmin && gruppen.includes(e.gruppeAdmin)) return "ADMIN";
|
||||||
|
if (e.gruppeOperator && gruppen.includes(e.gruppeOperator)) return "OPERATOR";
|
||||||
|
// Ohne konfigurierte Gruppen genügt die Anmeldung beim IdP für „Operator“.
|
||||||
|
if (!e.gruppeAdmin && !e.gruppeOperator) return "OPERATOR";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aus preferred_username/E-Mail einen gültigen, freien Benutzernamen machen. */
|
||||||
|
async function freierName(ansprueche: Ansprueche): Promise<string> {
|
||||||
|
const quelle = String(
|
||||||
|
ansprueche["preferred_username"] ?? String(ansprueche["email"] ?? "").split("@")[0] ?? "",
|
||||||
|
);
|
||||||
|
let basis = quelle
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^a-z0-9._-]/g, "-")
|
||||||
|
.replace(/^[^a-z]+/, "")
|
||||||
|
.slice(0, 28);
|
||||||
|
if (basis.length < 2) basis = "sso-benutzer";
|
||||||
|
for (let n = 0; n < 50; n++) {
|
||||||
|
const name = n === 0 ? basis : `${basis}-${n + 1}`;
|
||||||
|
if (!(await prisma.benutzer.findUnique({ where: { benutzername: name } }))) return name;
|
||||||
|
}
|
||||||
|
throw new OidcFehler("name_vergeben", "Kein freier Benutzername gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rückruf vom IdP: Code tauschen, ID-Token prüfen (Signatur, Issuer, Audience, Nonce), Konto
|
||||||
|
* finden, anlegen oder verknüpfen, Rolle aus den Gruppen setzen.
|
||||||
|
*/
|
||||||
|
export async function oidcAbschliessen(
|
||||||
|
e: OidcEinstellungen,
|
||||||
|
aktuelleUrl: URL,
|
||||||
|
cookie: string | undefined,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<{ benutzer: Benutzer; ziel: string }> {
|
||||||
|
let ablauf: Ablauf;
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(cookie ?? "", geheimnis(), {
|
||||||
|
algorithms: ["HS256"],
|
||||||
|
audience: "proxdeploy-oidc",
|
||||||
|
});
|
||||||
|
ablauf = payload as unknown as Ablauf;
|
||||||
|
} catch {
|
||||||
|
throw new OidcFehler(
|
||||||
|
"abgelaufen",
|
||||||
|
"Die Anmeldung hat zu lange gedauert oder kam nicht von hier. Bitte neu starten.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await konfigurationHolen(e);
|
||||||
|
let ansprueche: Ansprueche;
|
||||||
|
try {
|
||||||
|
const antwort = await client.authorizationCodeGrant(config, aktuelleUrl, {
|
||||||
|
pkceCodeVerifier: ablauf.verifier,
|
||||||
|
expectedState: ablauf.state,
|
||||||
|
expectedNonce: ablauf.nonce,
|
||||||
|
idTokenExpected: true,
|
||||||
|
});
|
||||||
|
ansprueche = antwort.claims() as Ansprueche;
|
||||||
|
} catch (fehler) {
|
||||||
|
logger.warn({ err: fehler }, "OIDC-Rückruf abgelehnt");
|
||||||
|
throw new OidcFehler("idp", "Der Identity Provider hat die Anmeldung nicht bestätigt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolle = rolleAusGruppen(e, ansprueche);
|
||||||
|
const anzeigename = String(ansprueche["name"] ?? ansprueche["preferred_username"] ?? "SSO-Benutzer").slice(
|
||||||
|
0,
|
||||||
|
80,
|
||||||
|
);
|
||||||
|
const email = typeof ansprueche["email"] === "string" ? ansprueche["email"] : null;
|
||||||
|
|
||||||
|
// Verknüpfen: ein angemeldeter lokaler Benutzer hängt sein IdP-Konto an.
|
||||||
|
if (ablauf.verknuepfen) {
|
||||||
|
const vorhanden = await prisma.benutzer.findUnique({ where: { oidcSubjekt: ansprueche.sub } });
|
||||||
|
if (vorhanden && vorhanden.id !== ablauf.verknuepfen) {
|
||||||
|
throw new OidcFehler(
|
||||||
|
"schon_verknuepft",
|
||||||
|
`Dieses SSO-Konto gehört schon zu „${vorhanden.benutzername}“.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const benutzer = await prisma.$transaction(async (tx) => {
|
||||||
|
const b = await tx.benutzer.update({
|
||||||
|
where: { id: ablauf.verknuepfen! },
|
||||||
|
data: { oidcSubjekt: ansprueche.sub },
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: b.id,
|
||||||
|
quelle: "WEB",
|
||||||
|
aktion: "SSO-Konto verknüpft",
|
||||||
|
ziel: `benutzer:${b.benutzername}`,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
return { benutzer, ziel: "/einstellungen" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rolle) {
|
||||||
|
await protokollieren({
|
||||||
|
benutzerId: null,
|
||||||
|
quelle: "WEB",
|
||||||
|
aktion: "SSO-Anmeldung ohne passende Gruppe",
|
||||||
|
ziel: `oidc:${ansprueche.sub}`,
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
throw new OidcFehler(
|
||||||
|
"keine_gruppe",
|
||||||
|
"Dein SSO-Konto ist für ProxDeploy nicht freigegeben (Gruppe fehlt).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bekannt = await prisma.benutzer.findUnique({ where: { oidcSubjekt: ansprueche.sub } });
|
||||||
|
if (bekannt) {
|
||||||
|
if (!bekannt.aktiv) throw new OidcFehler("gesperrt", "Dein Konto ist gesperrt.");
|
||||||
|
const benutzer = await prisma.$transaction(async (tx) => {
|
||||||
|
const b = await tx.benutzer.update({
|
||||||
|
where: { id: bekannt.id },
|
||||||
|
data: { rolle, anzeigename, ...(email ? { email } : {}) },
|
||||||
|
});
|
||||||
|
if (bekannt.rolle !== rolle) {
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: b.id,
|
||||||
|
quelle: "WEB",
|
||||||
|
aktion: "Rolle aus SSO-Gruppen übernommen",
|
||||||
|
ziel: `benutzer:${b.benutzername}`,
|
||||||
|
details: { rolle: [bekannt.rolle, rolle] },
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await protokollieren({ benutzerId: b.id, quelle: "WEB", aktion: "Angemeldet (SSO)", ip }, tx);
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
return { benutzer, ziel: ablauf.ziel };
|
||||||
|
}
|
||||||
|
|
||||||
|
const benutzername = await freierName(ansprueche);
|
||||||
|
const benutzer = await prisma.$transaction(async (tx) => {
|
||||||
|
const b = await tx.benutzer.create({
|
||||||
|
data: { benutzername, anzeigename, email, rolle, oidcSubjekt: ansprueche.sub },
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: b.id,
|
||||||
|
quelle: "WEB",
|
||||||
|
aktion: "Benutzer per SSO angelegt",
|
||||||
|
ziel: `benutzer:${b.benutzername}`,
|
||||||
|
details: { rolle },
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
return { benutzer, ziel: ablauf.ziel };
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { prisma } from "@/lib/db";
|
|||||||
import { SITZUNG_COOKIE, sitzungLesen } from "@/lib/sitzung";
|
import { SITZUNG_COOKIE, sitzungLesen } from "@/lib/sitzung";
|
||||||
import { darf, type Recht } from "@/server/auth/rechte";
|
import { darf, type Recht } from "@/server/auth/rechte";
|
||||||
import { keinRecht, nichtAngemeldet } from "@/server/fehler";
|
import { keinRecht, nichtAngemeldet } from "@/server/fehler";
|
||||||
|
import { benutzerAusToken } from "@/server/services/tokens";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wer fragt an? Rolle und Aktiv-Status kommen **je Anfrage aus der Datenbank**, nie aus dem
|
* Wer fragt an? Rolle und Aktiv-Status kommen **je Anfrage aus der Datenbank**, nie aus dem
|
||||||
@@ -18,7 +19,10 @@ async function benutzerAusSitzung(jwt: string | undefined): Promise<Benutzer | n
|
|||||||
const sitzung = await sitzungLesen(jwt);
|
const sitzung = await sitzungLesen(jwt);
|
||||||
if (!sitzung) return null;
|
if (!sitzung) return null;
|
||||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
||||||
return benutzer?.aktiv ? benutzer : null;
|
if (!benutzer?.aktiv) return null;
|
||||||
|
// Nach Passwortwechsel oder Sperre gelten ältere Sitzungen nicht mehr.
|
||||||
|
if (benutzer.sitzungenAb && sitzung.ausgestelltAm * 1000 < benutzer.sitzungenAb.getTime()) return null;
|
||||||
|
return benutzer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Für Server Components. */
|
/** Für Server Components. */
|
||||||
@@ -45,10 +49,16 @@ function cookieAusKopf(kopf: string | null, name: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Für Route Handler: Sitzungscookie (Web). Bearer-Tokens der CLI kommen mit #013 dazu und
|
* Für Route Handler: Bearer-Token (CLI) oder Sitzungscookie (Web) – hier an einer Stelle, damit
|
||||||
* landen hier, damit kein Handler zwei Wege kennen muss.
|
* kein Handler zwei Wege kennen muss. Ein angegebenes, aber ungültiges Token gilt als nicht
|
||||||
|
* angemeldet, auch wenn zusätzlich ein Cookie mitkommt.
|
||||||
*/
|
*/
|
||||||
export async function anfrageAufrufer(anfrage: Request): Promise<Aufrufer | null> {
|
export async function anfrageAufrufer(anfrage: Request): Promise<Aufrufer | null> {
|
||||||
|
const kopf = anfrage.headers.get("authorization");
|
||||||
|
if (kopf?.startsWith("Bearer ")) {
|
||||||
|
const ausToken = await benutzerAusToken(kopf.slice("Bearer ".length).trim());
|
||||||
|
return ausToken ? { benutzer: ausToken, quelle: "CLI" } : null;
|
||||||
|
}
|
||||||
const benutzer = await benutzerAusSitzung(cookieAusKopf(anfrage.headers.get("cookie"), SITZUNG_COOKIE));
|
const benutzer = await benutzerAusSitzung(cookieAusKopf(anfrage.headers.get("cookie"), SITZUNG_COOKIE));
|
||||||
return benutzer ? { benutzer, quelle: "WEB" } : null;
|
return benutzer ? { benutzer, quelle: "WEB" } : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import * as A from "@/lib/schemas/api";
|
||||||
|
import { AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||||
|
import { TemplateBauAnfrage } from "@/lib/schemas/template";
|
||||||
|
import { VERSION } from "@/lib/version";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI 3.0 der REST-API, soweit die CLI sie nutzt – aus denselben zod-Schemas, mit denen die
|
||||||
|
* API prüft. `pnpm openapi` schreibt sie nach `cli/api/openapi.json`; daraus erzeugt
|
||||||
|
* oapi-codegen die Go-Typen. Ein Test vergleicht die Datei mit diesem Stand.
|
||||||
|
*/
|
||||||
|
type Schema = z.ZodType;
|
||||||
|
|
||||||
|
const KOMPONENTEN: Record<string, Schema> = {
|
||||||
|
FehlerAntwort: A.FehlerAntwort,
|
||||||
|
Ich: A.Ich,
|
||||||
|
TokenAnmeldung: A.TokenAnmeldung,
|
||||||
|
NeuesToken: A.NeuesToken,
|
||||||
|
Server: A.ServerEintrag,
|
||||||
|
Distribution: A.DistributionEintrag,
|
||||||
|
ProfilVariable: A.ProfilVariable,
|
||||||
|
Profil: A.ProfilEintrag,
|
||||||
|
Vorlage: A.VorlageEintrag,
|
||||||
|
Template: A.TemplateEintrag,
|
||||||
|
AusrollAnfrage,
|
||||||
|
TemplateBauAnfrage,
|
||||||
|
AuftragAngenommen: A.AuftragAngenommen,
|
||||||
|
Phase: A.PhaseSicht,
|
||||||
|
AuftragErgebnis: A.AuftragErgebnis,
|
||||||
|
Abschluss: A.Abschluss,
|
||||||
|
Auftrag: A.AuftragSicht,
|
||||||
|
Verlauf: A.Verlauf,
|
||||||
|
LogZeile: A.LogZeile,
|
||||||
|
GeplanterAufruf: A.GeplanterAufruf,
|
||||||
|
Plan: A.Plan,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ref = (name: keyof typeof KOMPONENTEN | string) => ({ $ref: `#/components/schemas/${name}` });
|
||||||
|
const json = (schema: object) => ({ content: { "application/json": { schema } } });
|
||||||
|
const liste = (name: string) => ({ type: "array", items: ref(name) });
|
||||||
|
|
||||||
|
type Operation = {
|
||||||
|
operationId: string;
|
||||||
|
summary: string;
|
||||||
|
parameters?: object[];
|
||||||
|
requestBody?: object;
|
||||||
|
responses: Record<string, object>;
|
||||||
|
security?: object[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEHLER = { description: "Fehler", ...json(ref("FehlerAntwort")) };
|
||||||
|
const pfadParameter = (name: string, beschreibung: string) => ({
|
||||||
|
name,
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
description: beschreibung,
|
||||||
|
schema: { type: "string" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const PFADE: Record<string, Record<string, Operation>> = {
|
||||||
|
"/auth/token": {
|
||||||
|
post: {
|
||||||
|
operationId: "tokenAnmelden",
|
||||||
|
summary: "Benutzername und Passwort gegen ein API-Token tauschen (proxdeploy login)",
|
||||||
|
security: [],
|
||||||
|
requestBody: { required: true, ...json(ref("TokenAnmeldung")) },
|
||||||
|
responses: { "201": { description: "Token", ...json(ref("NeuesToken")) }, default: FEHLER },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/ich": {
|
||||||
|
get: {
|
||||||
|
operationId: "ich",
|
||||||
|
summary: "Angemeldeter Benutzer, Rechte und Version",
|
||||||
|
responses: { "200": { description: "Benutzer", ...json(ref("Ich")) }, default: FEHLER },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/server": {
|
||||||
|
get: {
|
||||||
|
operationId: "serverListe",
|
||||||
|
summary: "Alle Proxmox-Server mit dem letzten Verbindungstest",
|
||||||
|
responses: { "200": { description: "Server", ...json(liste("Server")) }, default: FEHLER },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/server/{name}/templates": {
|
||||||
|
get: {
|
||||||
|
operationId: "templatesListe",
|
||||||
|
summary: "ProxDeploy-Templates eines Servers",
|
||||||
|
parameters: [pfadParameter("name", "Kurzname des Servers")],
|
||||||
|
responses: { "200": { description: "Templates", ...json(liste("Template")) }, default: FEHLER },
|
||||||
|
},
|
||||||
|
post: {
|
||||||
|
operationId: "templateBauen",
|
||||||
|
summary: "Template bauen (Auftrag)",
|
||||||
|
parameters: [pfadParameter("name", "Kurzname des Servers")],
|
||||||
|
requestBody: { required: true, ...json(ref("TemplateBauAnfrage")) },
|
||||||
|
responses: {
|
||||||
|
"202": { description: "Auftrag angenommen", ...json(ref("AuftragAngenommen")) },
|
||||||
|
default: FEHLER,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/distributionen": {
|
||||||
|
get: {
|
||||||
|
operationId: "distributionenListe",
|
||||||
|
summary: "Aktive Distributionen",
|
||||||
|
parameters: [
|
||||||
|
{ name: "typ", in: "query", required: false, schema: { type: "string", enum: ["vm", "lxc"] } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
"200": { description: "Distributionen", ...json(liste("Distribution")) },
|
||||||
|
default: FEHLER,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/profile": {
|
||||||
|
get: {
|
||||||
|
operationId: "profileListe",
|
||||||
|
summary: "Aktive Profile mit ihren Variablen",
|
||||||
|
responses: { "200": { description: "Profile", ...json(liste("Profil")) }, default: FEHLER },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/vorlagen": {
|
||||||
|
get: {
|
||||||
|
operationId: "vorlagenListe",
|
||||||
|
summary: "Vorlagen (Presets)",
|
||||||
|
responses: { "200": { description: "Vorlagen", ...json(liste("Vorlage")) }, default: FEHLER },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/auftraege": {
|
||||||
|
get: {
|
||||||
|
operationId: "verlauf",
|
||||||
|
summary: "Verlauf, neueste zuerst, 25 je Seite",
|
||||||
|
parameters: [
|
||||||
|
{ name: "server", in: "query", required: false, schema: { type: "string" } },
|
||||||
|
{
|
||||||
|
name: "typ",
|
||||||
|
in: "query",
|
||||||
|
required: false,
|
||||||
|
schema: { type: "string", enum: ["vm", "lxc", "template", "aufraeumen"] },
|
||||||
|
},
|
||||||
|
{ name: "distribution", in: "query", required: false, schema: { type: "string" } },
|
||||||
|
{ name: "status", in: "query", required: false, schema: { type: "string" } },
|
||||||
|
{ name: "seite", in: "query", required: false, schema: { type: "integer", minimum: 1 } },
|
||||||
|
],
|
||||||
|
responses: { "200": { description: "Seite des Verlaufs", ...json(ref("Verlauf")) }, default: FEHLER },
|
||||||
|
},
|
||||||
|
post: {
|
||||||
|
operationId: "ausrollen",
|
||||||
|
summary: "VM oder LXC ausrollen (Auftrag)",
|
||||||
|
requestBody: { required: true, ...json(ref("AusrollAnfrage")) },
|
||||||
|
responses: {
|
||||||
|
"202": { description: "Auftrag angenommen", ...json(ref("AuftragAngenommen")) },
|
||||||
|
default: FEHLER,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/auftraege/plan": {
|
||||||
|
post: {
|
||||||
|
operationId: "ausrollenPlanen",
|
||||||
|
summary: "Dry-Run: Phasen und geplante Proxmox-Aufrufe, ohne etwas anzulegen",
|
||||||
|
requestBody: { required: true, ...json(ref("AusrollAnfrage")) },
|
||||||
|
responses: { "200": { description: "Plan", ...json(ref("Plan")) }, default: FEHLER },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/auftraege/{id}": {
|
||||||
|
get: {
|
||||||
|
operationId: "auftragHolen",
|
||||||
|
summary: "Stand eines Auftrags mit allen Phasen",
|
||||||
|
parameters: [pfadParameter("id", "ID oder Kurz-ID")],
|
||||||
|
responses: { "200": { description: "Auftrag", ...json(ref("Auftrag")) }, default: FEHLER },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/auftraege/{id}/log": {
|
||||||
|
get: {
|
||||||
|
operationId: "auftragLog",
|
||||||
|
summary: "Detail-Log ab einer Zeilen-ID",
|
||||||
|
parameters: [
|
||||||
|
pfadParameter("id", "ID oder Kurz-ID"),
|
||||||
|
{ name: "ab", in: "query", required: false, schema: { type: "string" } },
|
||||||
|
],
|
||||||
|
responses: { "200": { description: "Zeilen", ...json(liste("LogZeile")) }, default: FEHLER },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/auftraege/{id}/ereignisse": {
|
||||||
|
get: {
|
||||||
|
operationId: "auftragEreignisse",
|
||||||
|
summary:
|
||||||
|
"Server-Sent Events: `stand` (Auftrag), `log` (LogZeile[], Event-ID = letzte Zeilen-ID), `ende`",
|
||||||
|
parameters: [
|
||||||
|
pfadParameter("id", "ID oder Kurz-ID"),
|
||||||
|
{ name: "Last-Event-ID", in: "header", required: false, schema: { type: "string" } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "Ereignisstrom",
|
||||||
|
content: { "text/event-stream": { schema: { type: "string" } } },
|
||||||
|
},
|
||||||
|
default: FEHLER,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/auftraege/{id}/aufraeumen": {
|
||||||
|
post: {
|
||||||
|
operationId: "auftragAufraeumen",
|
||||||
|
summary: "Halb angelegten Gast eines fehlgeschlagenen/abgebrochenen Auftrags löschen (Auftrag)",
|
||||||
|
parameters: [pfadParameter("id", "ID oder Kurz-ID")],
|
||||||
|
responses: {
|
||||||
|
"202": { description: "Auftrag angenommen", ...json(ref("AuftragAngenommen")) },
|
||||||
|
default: FEHLER,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/auftraege/{id}/abbrechen": {
|
||||||
|
post: {
|
||||||
|
operationId: "auftragAbbrechen",
|
||||||
|
summary: "Abbruch anfordern; der worker hält an der nächsten Phasengrenze an",
|
||||||
|
parameters: [pfadParameter("id", "ID oder Kurz-ID")],
|
||||||
|
responses: { "202": { description: "Abbruch angefordert" }, default: FEHLER },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `additionalProperties: {}` (offene Objekte) heißt in JSON Schema dasselbe wie gar keine Angabe;
|
||||||
|
* ohne den Eintrag erzeugt oapi-codegen schlichte Structs statt Map-Hüllen.
|
||||||
|
*/
|
||||||
|
function aufraeumen(wert: unknown): unknown {
|
||||||
|
if (Array.isArray(wert)) return wert.map(aufraeumen);
|
||||||
|
if (!wert || typeof wert !== "object") return wert;
|
||||||
|
const ergebnis: Record<string, unknown> = {};
|
||||||
|
for (const [schluessel, inhalt] of Object.entries(wert)) {
|
||||||
|
if (schluessel === "$schema" || schluessel === "$id") continue;
|
||||||
|
if (
|
||||||
|
schluessel === "additionalProperties" &&
|
||||||
|
inhalt &&
|
||||||
|
typeof inhalt === "object" &&
|
||||||
|
!Object.keys(inhalt).length
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
ergebnis[schluessel] = aufraeumen(inhalt);
|
||||||
|
}
|
||||||
|
return ergebnis;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openApi(): object {
|
||||||
|
// Eine Registry: verschachtelte Schemas (Phase in Auftrag und Plan) werden zu $ref statt Kopien.
|
||||||
|
const registry = z.registry<{ id: string }>();
|
||||||
|
for (const [name, schema] of Object.entries(KOMPONENTEN)) registry.add(schema, { id: name });
|
||||||
|
// `input`: Anfragen beschreiben, was man schicken darf – Felder mit Standard sind optional.
|
||||||
|
const { schemas } = z.toJSONSchema(registry, {
|
||||||
|
target: "openapi-3.0",
|
||||||
|
io: "input",
|
||||||
|
unrepresentable: "any",
|
||||||
|
uri: (id) => `#/components/schemas/${id}`,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
openapi: "3.0.3",
|
||||||
|
info: {
|
||||||
|
title: "ProxDeploy API",
|
||||||
|
version: VERSION.replace(/-.*$/, ""),
|
||||||
|
description:
|
||||||
|
"REST-API von ProxDeploy – dieselbe für Web-GUI und CLI. Fehler: `{ fehler: { code, nachricht, hinweis?, felder? } }`.",
|
||||||
|
},
|
||||||
|
servers: [{ url: "/api/v1" }],
|
||||||
|
security: [{ token: [] }],
|
||||||
|
components: {
|
||||||
|
securitySchemes: { token: { type: "http", scheme: "bearer", description: "API-Token (pd_…)" } },
|
||||||
|
schemas: aufraeumen(schemas),
|
||||||
|
},
|
||||||
|
paths: PFADE,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export async function mitPasswortAnmelden(
|
|||||||
benutzername: string,
|
benutzername: string,
|
||||||
passwort: string,
|
passwort: string,
|
||||||
ip: string | null,
|
ip: string | null,
|
||||||
|
quelle: "WEB" | "CLI" = "WEB",
|
||||||
): Promise<Benutzer> {
|
): Promise<Benutzer> {
|
||||||
const name = benutzername.trim().toLowerCase();
|
const name = benutzername.trim().toLowerCase();
|
||||||
const schluessel = `${name}|${ip ?? "-"}`;
|
const schluessel = `${name}|${ip ?? "-"}`;
|
||||||
@@ -52,7 +53,7 @@ export async function mitPasswortAnmelden(
|
|||||||
fehlversuche.get(schluessel)?.push(jetzt);
|
fehlversuche.get(schluessel)?.push(jetzt);
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
benutzerId: benutzer?.id ?? null,
|
benutzerId: benutzer?.id ?? null,
|
||||||
quelle: "WEB",
|
quelle,
|
||||||
aktion: "Anmeldung fehlgeschlagen",
|
aktion: "Anmeldung fehlgeschlagen",
|
||||||
ziel: `benutzer:${name}`,
|
ziel: `benutzer:${name}`,
|
||||||
ip,
|
ip,
|
||||||
@@ -62,7 +63,7 @@ export async function mitPasswortAnmelden(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fehlversuche.delete(schluessel);
|
fehlversuche.delete(schluessel);
|
||||||
await protokollieren({ benutzerId: benutzer.id, quelle: "WEB", aktion: "Angemeldet", ip });
|
await protokollieren({ benutzerId: benutzer.id, quelle, aktion: "Angemeldet", ip });
|
||||||
return benutzer;
|
return benutzer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { randomInt } from "node:crypto";
|
import { randomInt } from "node:crypto";
|
||||||
import type { Auftrag, Prisma } from "@/generated/prisma/client";
|
import type { Auftrag, Prisma } from "@/generated/prisma/client";
|
||||||
import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums";
|
import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums";
|
||||||
|
import { abschlussAus } from "@/lib/auftrag-text";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
import type { Aufrufer } from "@/server/auth/zugang";
|
import type { Aufrufer } from "@/server/auth/zugang";
|
||||||
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
||||||
import { melden } from "@/server/ereignisse/senke";
|
import { melden } from "@/server/ereignisse/senke";
|
||||||
@@ -90,9 +92,21 @@ const MIT = {
|
|||||||
|
|
||||||
type MitBeziehungen = Prisma.AuftragGetPayload<{ include: typeof MIT }>;
|
type MitBeziehungen = Prisma.AuftragGetPayload<{ include: typeof MIT }>;
|
||||||
|
|
||||||
|
/** Was „Aufräumen“ löschen würde – nur bei Fehlschlag oder Abbruch und nur, wenn etwas angelegt wurde. */
|
||||||
|
function aufraeumbarAus(
|
||||||
|
a: Pick<Auftrag, "art" | "status" | "angelegt" | "gastTyp" | "knoten">,
|
||||||
|
): AuftragSicht["aufraeumbar"] {
|
||||||
|
if (a.art === "AUFRAEUMEN" || (a.status !== "FEHLGESCHLAGEN" && a.status !== "ABGEBROCHEN")) return null;
|
||||||
|
const angelegt = (a.angelegt ?? {}) as { vmid?: unknown; knoten?: unknown; typ?: unknown };
|
||||||
|
if (typeof angelegt.vmid !== "number") return null;
|
||||||
|
const typ = angelegt.typ === "LXC" || a.gastTyp === "LXC" ? "LXC" : "VM";
|
||||||
|
const knoten = typeof angelegt.knoten === "string" ? angelegt.knoten : a.knoten;
|
||||||
|
return { typ, vmid: angelegt.vmid, knoten };
|
||||||
|
}
|
||||||
|
|
||||||
export function auftragSicht(a: MitBeziehungen): AuftragSicht {
|
export function auftragSicht(a: MitBeziehungen): AuftragSicht {
|
||||||
const iso = (d: Date | null) => d?.toISOString() ?? null;
|
const iso = (d: Date | null) => d?.toISOString() ?? null;
|
||||||
return {
|
const sicht: Omit<AuftragSicht, "abschluss" | "aufraeumbar"> = {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
kurzId: a.kurzId,
|
kurzId: a.kurzId,
|
||||||
art: a.art,
|
art: a.art,
|
||||||
@@ -123,6 +137,7 @@ export function auftragSicht(a: MitBeziehungen): AuftragSicht {
|
|||||||
beendetAm: iso(p.beendetAm),
|
beendetAm: iso(p.beendetAm),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
return { ...sicht, abschluss: abschlussAus(sicht), aufraeumbar: aufraeumbarAus(a) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sucht per Kurz-ID oder interner ID – die CLI nimmt, was der Benutzer tippt. */
|
/** Sucht per Kurz-ID oder interner ID – die CLI nimmt, was der Benutzer tippt. */
|
||||||
@@ -135,16 +150,66 @@ export async function auftragHolen(kennung: string): Promise<AuftragSicht> {
|
|||||||
return auftragSicht(auftrag);
|
return auftragSicht(auftrag);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function auftraegeListe(
|
/** Filter des Verlaufs – dieselben in Web (`/auftraege?…`) und API (`GET /api/v1/auftraege?…`). */
|
||||||
filter: { serverId?: string; grenze?: number } = {},
|
export const VerlaufFilter = z.object({
|
||||||
): Promise<AuftragSicht[]> {
|
server: z.string().trim().optional(),
|
||||||
const zeilen = await prisma.auftrag.findMany({
|
typ: z.enum(["vm", "lxc", "template", "aufraeumen"]).optional(),
|
||||||
where: filter.serverId ? { serverId: filter.serverId } : {},
|
distribution: z.string().trim().optional(),
|
||||||
|
status: z
|
||||||
|
.enum(["WARTEND", "LAEUFT", "ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"])
|
||||||
|
.optional(),
|
||||||
|
seite: z.coerce.number().int().min(1).max(10_000).default(1),
|
||||||
|
});
|
||||||
|
export type VerlaufFilter = z.infer<typeof VerlaufFilter>;
|
||||||
|
|
||||||
|
export const VERLAUF_PRO_SEITE = 25;
|
||||||
|
|
||||||
|
/** Liest Filter aus Suchparametern; Unbekanntes und Leeres fällt still weg statt zu stören. */
|
||||||
|
export function verlaufFilterAus(parameter: Record<string, string | string[] | undefined>): VerlaufFilter {
|
||||||
|
const roh: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(parameter)) {
|
||||||
|
const wert = Array.isArray(v) ? v[0] : v;
|
||||||
|
if (wert) roh[k] = wert;
|
||||||
|
}
|
||||||
|
const geprueft: Record<string, unknown> = {};
|
||||||
|
for (const schluessel of Object.keys(VerlaufFilter.shape) as Array<keyof VerlaufFilter>) {
|
||||||
|
const feld = VerlaufFilter.shape[schluessel].safeParse(roh[schluessel]);
|
||||||
|
if (feld.success && feld.data !== undefined) geprueft[schluessel] = feld.data;
|
||||||
|
}
|
||||||
|
return VerlaufFilter.parse(geprueft);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function auftraegeSuchen(filter: VerlaufFilter): Promise<{
|
||||||
|
eintraege: AuftragSicht[];
|
||||||
|
gesamt: number;
|
||||||
|
seite: number;
|
||||||
|
seiten: number;
|
||||||
|
}> {
|
||||||
|
const where: Prisma.AuftragWhereInput = {
|
||||||
|
...(filter.server ? { server: { name: filter.server } } : {}),
|
||||||
|
...(filter.distribution ? { distributionId: filter.distribution } : {}),
|
||||||
|
...(filter.status ? { status: filter.status } : {}),
|
||||||
|
...(filter.typ === "vm" ? { art: "AUSROLLEN", gastTyp: "VM" } : {}),
|
||||||
|
...(filter.typ === "lxc" ? { art: "AUSROLLEN", gastTyp: "LXC" } : {}),
|
||||||
|
...(filter.typ === "template" ? { art: "TEMPLATE_BAUEN" } : {}),
|
||||||
|
...(filter.typ === "aufraeumen" ? { art: "AUFRAEUMEN" } : {}),
|
||||||
|
};
|
||||||
|
const [gesamt, zeilen] = await prisma.$transaction([
|
||||||
|
prisma.auftrag.count({ where }),
|
||||||
|
prisma.auftrag.findMany({
|
||||||
|
where,
|
||||||
include: MIT,
|
include: MIT,
|
||||||
orderBy: { erstelltAm: "desc" },
|
orderBy: { erstelltAm: "desc" },
|
||||||
take: filter.grenze ?? 50,
|
skip: (filter.seite - 1) * VERLAUF_PRO_SEITE,
|
||||||
});
|
take: VERLAUF_PRO_SEITE,
|
||||||
return zeilen.map(auftragSicht);
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
eintraege: zeilen.map(auftragSicht),
|
||||||
|
gesamt,
|
||||||
|
seite: filter.seite,
|
||||||
|
seiten: Math.max(1, Math.ceil(gesamt / VERLAUF_PRO_SEITE)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logAb(auftragId: string, abId: bigint, grenze = 500): Promise<LogZeile[]> {
|
export async function logAb(auftragId: string, abId: bigint, grenze = 500): Promise<LogZeile[]> {
|
||||||
@@ -165,6 +230,50 @@ export async function logAb(auftragId: string, abId: bigint, grenze = 500): Prom
|
|||||||
export const ENDZUSTAENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
export const ENDZUSTAENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
||||||
|
|
||||||
/** Bittet den worker um Abbruch; er prüft das zwischen den Phasen. */
|
/** Bittet den worker um Abbruch; er prüft das zwischen den Phasen. */
|
||||||
|
/**
|
||||||
|
* Aufräumen starten: eigener Auftrag, der den halb angelegten Gast eines fehlgeschlagenen bzw.
|
||||||
|
* abgebrochenen Auftrags löscht. Was genau gelöscht wird, prüft der worker gegen Proxmox.
|
||||||
|
*/
|
||||||
|
export async function aufraeumenStarten(
|
||||||
|
kennung: string,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<Auftrag> {
|
||||||
|
const sicht = await auftragHolen(kennung);
|
||||||
|
if (!sicht.aufraeumbar) {
|
||||||
|
throw new AnwendungsFehler(
|
||||||
|
409,
|
||||||
|
"nicht_aufraeumbar",
|
||||||
|
sicht.status === "AUFGERAEUMT"
|
||||||
|
? "Dieser Auftrag ist schon aufgeräumt."
|
||||||
|
: "Hier gibt es nichts aufzuräumen – nur fehlgeschlagene oder abgebrochene Aufträge, die etwas angelegt haben.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const laufend = await prisma.auftrag.findFirst({
|
||||||
|
where: { art: "AUFRAEUMEN", bezugId: sicht.id, status: { in: ["WARTEND", "LAEUFT"] } },
|
||||||
|
select: { kurzId: true },
|
||||||
|
});
|
||||||
|
if (laufend) {
|
||||||
|
throw new AnwendungsFehler(409, "laeuft_schon", `Das Aufräumen läuft schon (${laufend.kurzId}).`);
|
||||||
|
}
|
||||||
|
const original = await prisma.auftrag.findUniqueOrThrow({ where: { id: sicht.id } });
|
||||||
|
const { typ, vmid, knoten } = sicht.aufraeumbar;
|
||||||
|
return await auftragAnlegen(
|
||||||
|
{
|
||||||
|
art: "AUFRAEUMEN",
|
||||||
|
serverId: original.serverId,
|
||||||
|
...(knoten ? { knoten } : {}),
|
||||||
|
gastTyp: typ,
|
||||||
|
...(original.name ? { name: original.name } : {}),
|
||||||
|
...(original.distributionId ? { distributionId: original.distributionId } : {}),
|
||||||
|
bezugId: original.id,
|
||||||
|
anfrage: { auftrag: original.kurzId, typ, vmid, knoten },
|
||||||
|
},
|
||||||
|
aufrufer,
|
||||||
|
ip,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function auftragAbbrechen(
|
export async function auftragAbbrechen(
|
||||||
kennung: string,
|
kennung: string,
|
||||||
aufrufer: Aufrufer,
|
aufrufer: Aufrufer,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { Distribution, Profil } from "@/engine/katalog/schema";
|
|||||||
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||||
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
|
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen";
|
import { AusrollAnfrage, cliBefehl, Zugang } from "@/lib/schemas/ausrollen";
|
||||||
import type { BauNetz } from "@/lib/schemas/server";
|
import type { BauNetz } from "@/lib/schemas/server";
|
||||||
import { vorlageAnwenden } from "@/lib/schemas/vorlage";
|
import { vorlageAnwenden } from "@/lib/schemas/vorlage";
|
||||||
import { vorlageHolen } from "@/server/services/vorlagen";
|
import { vorlageHolen } from "@/server/services/vorlagen";
|
||||||
@@ -15,6 +15,7 @@ import type { Aufrufer } from "@/server/auth/zugang";
|
|||||||
import { AnwendungsFehler } from "@/server/fehler";
|
import { AnwendungsFehler } from "@/server/fehler";
|
||||||
import { auftragAnlegen } from "@/server/services/auftraege";
|
import { auftragAnlegen } from "@/server/services/auftraege";
|
||||||
import { serverHolen, verbindungAus } from "@/server/services/server";
|
import { serverHolen, verbindungAus } from "@/server/services/server";
|
||||||
|
import { standardsFuer } from "@/server/services/standards";
|
||||||
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||||
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
|
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
|
||||||
import { schluesselFuer } from "@/server/services/schluessel";
|
import { schluesselFuer } from "@/server/services/schluessel";
|
||||||
@@ -94,8 +95,49 @@ export async function schluesselErgaenzen(roh: unknown, aufrufer: Aufrufer): Pro
|
|||||||
return { ...anfrage, zugang: { ...anfrage.zugang, sshSchluessel: hinterlegt } };
|
return { ...anfrage, zugang: { ...anfrage.zugang, sshSchluessel: hinterlegt } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Füllt, was eine kurze Anfrage (CLI: `proxdeploy vm web01 --server rz1 --distro debian-13`) offen
|
||||||
|
* lässt – nach der Vorlage, damit deren Werte Vorrang vor den Standards haben:
|
||||||
|
*
|
||||||
|
* - SSH-Schlüssel: die hinterlegten (siehe `schluesselErgaenzen`)
|
||||||
|
* - Storage: der Standard des Servers, sonst der erste aktive für VM-Disks bzw. Container
|
||||||
|
* - Bridge: Standard des Servers, sonst vmbr0, sonst die erste des Nodes
|
||||||
|
* - Benutzer im Gast: der eigene Benutzername, wenn er als Unix-Name taugt
|
||||||
|
*
|
||||||
|
* Die Grundlage ist der letzte Verbindungstest; fehlt er, bleibt das Feld leer und die Prüfung
|
||||||
|
* meldet es.
|
||||||
|
*/
|
||||||
|
export async function anfrageErgaenzen(roh: unknown, aufrufer: Aufrufer): Promise<unknown> {
|
||||||
|
if (!roh || typeof roh !== "object") return roh;
|
||||||
|
let anfrage = roh as Record<string, unknown>;
|
||||||
|
if (typeof anfrage["vorlage"] === "string" && anfrage["vorlage"]) {
|
||||||
|
anfrage = vorlageAnwenden((await vorlageHolen(anfrage["vorlage"])).parameter, anfrage);
|
||||||
|
}
|
||||||
|
anfrage = (await schluesselErgaenzen(anfrage, aufrufer)) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const zugang = { ...(anfrage["zugang"] as Record<string, unknown> | undefined) };
|
||||||
|
if (!zugang["benutzer"] && Zugang.shape.benutzer.safeParse(aufrufer.benutzer.benutzername).success) {
|
||||||
|
zugang["benutzer"] = aufrufer.benutzer.benutzername;
|
||||||
|
}
|
||||||
|
anfrage = { ...anfrage, zugang };
|
||||||
|
|
||||||
|
const server =
|
||||||
|
typeof anfrage["server"] === "string"
|
||||||
|
? await prisma.server.findUnique({ where: { name: anfrage["server"] } })
|
||||||
|
: null;
|
||||||
|
if (!server) return anfrage;
|
||||||
|
const standards = standardsFuer(server, {
|
||||||
|
knoten: typeof anfrage["knoten"] === "string" && anfrage["knoten"] ? anfrage["knoten"] : undefined,
|
||||||
|
inhalt: anfrage["typ"] === "lxc" ? "rootdir" : "images",
|
||||||
|
});
|
||||||
|
if (!anfrage["storage"] && standards.storage) anfrage = { ...anfrage, storage: standards.storage };
|
||||||
|
const netz = { ...(anfrage["netz"] as Record<string, unknown> | undefined) };
|
||||||
|
if (!netz["bridge"] && standards.bridge) netz["bridge"] = standards.bridge;
|
||||||
|
return { ...anfrage, netz };
|
||||||
|
}
|
||||||
|
|
||||||
export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: string | null) {
|
export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: string | null) {
|
||||||
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
|
const v = await ausrollenVorbereiten(await anfrageErgaenzen(roh, aufrufer));
|
||||||
return await auftragAnlegen(
|
return await auftragAnlegen(
|
||||||
{
|
{
|
||||||
art: "AUSROLLEN",
|
art: "AUSROLLEN",
|
||||||
@@ -201,7 +243,7 @@ export type Plan = {
|
|||||||
* aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde.
|
* aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde.
|
||||||
*/
|
*/
|
||||||
export async function ausrollenPlanen(roh: unknown, aufrufer: Aufrufer): Promise<Plan> {
|
export async function ausrollenPlanen(roh: unknown, aufrufer: Aufrufer): Promise<Plan> {
|
||||||
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
|
const v = await ausrollenVorbereiten(await anfrageErgaenzen(roh, aufrufer));
|
||||||
const client = new ProxmoxClient(verbindungAus(v.server), { trocken: true, zeitlimitMs: 15_000 });
|
const client = new ProxmoxClient(verbindungAus(v.server), { trocken: true, zeitlimitMs: 15_000 });
|
||||||
const phasen: Plan["phasen"] = [];
|
const phasen: Plan["phasen"] = [];
|
||||||
let skript: string | null = null;
|
let skript: string | null = null;
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import type { Benutzer, Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { passwortHashen, passwortPruefen, PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import type { Aufrufer } from "@/server/auth/zugang";
|
||||||
|
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzerverwaltung (nur Admins) und das eigene Passwort. Zwei Schutzregeln: der letzte aktive
|
||||||
|
* Admin bleibt Admin und aktiv, und niemand sperrt sich selbst aus. Passwortwechsel und Sperre
|
||||||
|
* beenden alle bestehenden Sitzungen (`sitzungenAb`); API-Tokens prüfen den Aktiv-Status je Anfrage.
|
||||||
|
*/
|
||||||
|
export type BenutzerSicht = {
|
||||||
|
id: string;
|
||||||
|
benutzername: string;
|
||||||
|
anzeigename: string;
|
||||||
|
email: string | null;
|
||||||
|
rolle: "ADMIN" | "OPERATOR";
|
||||||
|
aktiv: boolean;
|
||||||
|
/** Lokales Passwort gesetzt (sonst nur OIDC). */
|
||||||
|
passwort: boolean;
|
||||||
|
oidc: boolean;
|
||||||
|
erstelltAm: string;
|
||||||
|
tokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Passwort = z
|
||||||
|
.string()
|
||||||
|
.min(PASSWORT_MINDESTLAENGE, `Mindestens ${PASSWORT_MINDESTLAENGE} Zeichen.`)
|
||||||
|
.max(1024, "Höchstens 1024 Zeichen.");
|
||||||
|
|
||||||
|
export const BenutzerAnlegen = z.object({
|
||||||
|
benutzername: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(
|
||||||
|
/^[a-z][a-z0-9._-]{1,31}$/,
|
||||||
|
"2–32 Zeichen: Kleinbuchstaben, Ziffern, Punkt, _ und -; vorne ein Buchstabe.",
|
||||||
|
),
|
||||||
|
anzeigename: z.string().trim().min(1, "Anzeigename fehlt.").max(80),
|
||||||
|
email: z
|
||||||
|
.email("Keine gültige E-Mail-Adresse.")
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.or(z.literal("").transform(() => undefined)),
|
||||||
|
rolle: z.enum(["ADMIN", "OPERATOR"]).default("OPERATOR"),
|
||||||
|
passwort: Passwort,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BenutzerAendern = z.object({
|
||||||
|
anzeigename: z.string().trim().min(1, "Anzeigename fehlt.").max(80).optional(),
|
||||||
|
email: z
|
||||||
|
.email("Keine gültige E-Mail-Adresse.")
|
||||||
|
.trim()
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.or(z.literal("").transform(() => null)),
|
||||||
|
rolle: z.enum(["ADMIN", "OPERATOR"]).optional(),
|
||||||
|
aktiv: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MIT_TOKENS = { _count: { select: { tokens: true } } } as const;
|
||||||
|
|
||||||
|
function sicht(b: Benutzer & { _count: { tokens: number } }): BenutzerSicht {
|
||||||
|
return {
|
||||||
|
id: b.id,
|
||||||
|
benutzername: b.benutzername,
|
||||||
|
anzeigename: b.anzeigename,
|
||||||
|
email: b.email,
|
||||||
|
rolle: b.rolle,
|
||||||
|
aktiv: b.aktiv,
|
||||||
|
passwort: Boolean(b.passwortHash),
|
||||||
|
oidc: Boolean(b.oidcSubjekt),
|
||||||
|
erstelltAm: b.erstelltAm.toISOString(),
|
||||||
|
tokens: b._count.tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auf volle Sekunden: JWT-`iat` hat Sekunden, eine im selben Augenblick neu ausgestellte Sitzung gilt. */
|
||||||
|
const jetztSekunde = () => new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||||
|
|
||||||
|
export async function benutzerListe(): Promise<BenutzerSicht[]> {
|
||||||
|
const liste = await prisma.benutzer.findMany({ include: MIT_TOKENS, orderBy: { benutzername: "asc" } });
|
||||||
|
return liste.map(sicht);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function benutzerAnlegen(
|
||||||
|
roh: unknown,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<BenutzerSicht> {
|
||||||
|
const e = BenutzerAnlegen.parse(roh);
|
||||||
|
try {
|
||||||
|
const neu = await prisma.$transaction(async (tx) => {
|
||||||
|
const b = await tx.benutzer.create({
|
||||||
|
data: {
|
||||||
|
benutzername: e.benutzername,
|
||||||
|
anzeigename: e.anzeigename,
|
||||||
|
email: e.email ?? null,
|
||||||
|
rolle: e.rolle,
|
||||||
|
passwortHash: await passwortHashen(e.passwort),
|
||||||
|
},
|
||||||
|
include: MIT_TOKENS,
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: aufrufer.benutzer.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion: "Benutzer angelegt",
|
||||||
|
ziel: `benutzer:${b.benutzername}`,
|
||||||
|
details: { rolle: b.rolle },
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
return sicht(neu);
|
||||||
|
} catch (fehler) {
|
||||||
|
if ((fehler as { code?: string }).code === "P2002") {
|
||||||
|
throw new AnwendungsFehler(409, "vergeben", `„${e.benutzername}“ gibt es schon.`, undefined, {
|
||||||
|
benutzername: "Benutzername schon vergeben.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function letzterAdmin(tx: Prisma.TransactionClient, ohne: string): Promise<boolean> {
|
||||||
|
return (await tx.benutzer.count({ where: { rolle: "ADMIN", aktiv: true, id: { not: ohne } } })) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function benutzerAendern(
|
||||||
|
id: string,
|
||||||
|
roh: unknown,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<BenutzerSicht> {
|
||||||
|
const e = BenutzerAendern.parse(roh);
|
||||||
|
const ergebnis = await prisma.$transaction(async (tx) => {
|
||||||
|
// Sperre auf die Admin-Zeilen: zwei gleichzeitige Herabstufungen dürfen nicht beide durchgehen.
|
||||||
|
await tx.$queryRaw`SELECT id FROM "Benutzer" WHERE rolle = 'ADMIN' FOR UPDATE`;
|
||||||
|
const alt = await tx.benutzer.findUnique({ where: { id } });
|
||||||
|
if (!alt) throw nichtGefunden("Den Benutzer");
|
||||||
|
const selbst = alt.id === aufrufer.benutzer.id;
|
||||||
|
if (selbst && e.aktiv === false) {
|
||||||
|
throw new AnwendungsFehler(409, "selbst", "Das eigene Konto lässt sich nicht sperren.");
|
||||||
|
}
|
||||||
|
const verliertAdmin = alt.rolle === "ADMIN" && alt.aktiv && (e.rolle === "OPERATOR" || e.aktiv === false);
|
||||||
|
if (verliertAdmin && (await letzterAdmin(tx, alt.id))) {
|
||||||
|
throw new AnwendungsFehler(
|
||||||
|
409,
|
||||||
|
"letzter_admin",
|
||||||
|
"Das ist der letzte aktive Admin.",
|
||||||
|
"Erst einen anderen Benutzer zum Admin machen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const gesperrt = e.aktiv === false && alt.aktiv;
|
||||||
|
const neu = await tx.benutzer.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(e.anzeigename !== undefined ? { anzeigename: e.anzeigename } : {}),
|
||||||
|
...(e.email !== undefined ? { email: e.email } : {}),
|
||||||
|
...(e.rolle !== undefined ? { rolle: e.rolle } : {}),
|
||||||
|
...(e.aktiv !== undefined ? { aktiv: e.aktiv } : {}),
|
||||||
|
...(gesperrt ? { sitzungenAb: jetztSekunde() } : {}),
|
||||||
|
},
|
||||||
|
include: MIT_TOKENS,
|
||||||
|
});
|
||||||
|
const aenderungen: Record<string, unknown> = {};
|
||||||
|
if (e.rolle !== undefined && e.rolle !== alt.rolle) aenderungen["rolle"] = [alt.rolle, e.rolle];
|
||||||
|
if (e.aktiv !== undefined && e.aktiv !== alt.aktiv) aenderungen["aktiv"] = [alt.aktiv, e.aktiv];
|
||||||
|
if (e.anzeigename !== undefined && e.anzeigename !== alt.anzeigename) aenderungen["anzeigename"] = true;
|
||||||
|
if (e.email !== undefined && e.email !== alt.email) aenderungen["email"] = true;
|
||||||
|
if (Object.keys(aenderungen).length > 0) {
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: aufrufer.benutzer.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion: gesperrt
|
||||||
|
? "Benutzer gesperrt"
|
||||||
|
: e.aktiv === true && !alt.aktiv
|
||||||
|
? "Benutzer entsperrt"
|
||||||
|
: "Benutzer geändert",
|
||||||
|
ziel: `benutzer:${alt.benutzername}`,
|
||||||
|
details: aenderungen as Prisma.InputJsonValue,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return neu;
|
||||||
|
});
|
||||||
|
return sicht(ergebnis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin setzt ein neues Passwort (z. B. vergessen); alle Sitzungen des Benutzers enden. */
|
||||||
|
export async function passwortZuruecksetzen(
|
||||||
|
id: string,
|
||||||
|
roh: unknown,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const { passwort } = z.object({ passwort: Passwort }).parse(roh);
|
||||||
|
const ziel = await prisma.benutzer.findUnique({ where: { id } });
|
||||||
|
if (!ziel) throw nichtGefunden("Den Benutzer");
|
||||||
|
const hashWert = await passwortHashen(passwort);
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.benutzer.update({
|
||||||
|
where: { id },
|
||||||
|
data: { passwortHash: hashWert, sitzungenAb: jetztSekunde() },
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: aufrufer.benutzer.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion: "Passwort zurückgesetzt",
|
||||||
|
ziel: `benutzer:${ziel.benutzername}`,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenes Passwort ändern – mit dem alten. Andere Sitzungen enden; die aufrufende stellt der Handler neu aus. */
|
||||||
|
export async function eigenesPasswortAendern(
|
||||||
|
roh: unknown,
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
ip: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const e = z
|
||||||
|
.object({ alt: z.string().min(1, "Bisheriges Passwort fehlt.").max(1024), neu: Passwort })
|
||||||
|
.parse(roh);
|
||||||
|
const ich = await prisma.benutzer.findUniqueOrThrow({ where: { id: aufrufer.benutzer.id } });
|
||||||
|
if (!ich.passwortHash || !(await passwortPruefen(ich.passwortHash, e.alt))) {
|
||||||
|
throw new AnwendungsFehler(400, "ungueltig", "Das bisherige Passwort stimmt nicht.", undefined, {
|
||||||
|
alt: "Stimmt nicht.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (e.alt === e.neu) {
|
||||||
|
throw new AnwendungsFehler(400, "ungueltig", "Das neue Passwort ist das alte.", undefined, {
|
||||||
|
neu: "Bitte ein anderes Passwort wählen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const hashWert = await passwortHashen(e.neu);
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.benutzer.update({
|
||||||
|
where: { id: ich.id },
|
||||||
|
data: { passwortHash: hashWert, sitzungenAb: jetztSekunde() },
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
benutzerId: ich.id,
|
||||||
|
quelle: aufrufer.quelle,
|
||||||
|
aktion: "Eigenes Passwort geändert",
|
||||||
|
ziel: `benutzer:${ich.benutzername}`,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import ssh2 from "ssh2";
|
|
||||||
import type { Bereitstellungsschluessel } from "@/engine/ablaeufe/lxc-ausrollen";
|
import type { Bereitstellungsschluessel } from "@/engine/ablaeufe/lxc-ausrollen";
|
||||||
|
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@ export async function bereitstellungsschluessel(): Promise<Bereitstellungsschlue
|
|||||||
const wert = vorhanden.wert as { privat: string; oeffentlich: string };
|
const wert = vorhanden.wert as { privat: string; oeffentlich: string };
|
||||||
return { privat: entschluesseln(wert.privat), oeffentlich: wert.oeffentlich };
|
return { privat: entschluesseln(wert.privat), oeffentlich: wert.oeffentlich };
|
||||||
}
|
}
|
||||||
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: KOMMENTAR });
|
const paar = ed25519Erzeugen(KOMMENTAR);
|
||||||
const neu = { privat: verschluesseln(paar.private), oeffentlich: paar.public.trim() };
|
const neu = { privat: verschluesseln(paar.privat), oeffentlich: paar.oeffentlich };
|
||||||
// Zwei gleichzeitige Aufträge: der erste gewinnt, der zweite liest dessen Schlüssel.
|
// Zwei gleichzeitige Aufträge: der erste gewinnt, der zweite liest dessen Schlüssel.
|
||||||
await prisma.einstellung.upsert({
|
await prisma.einstellung.upsert({
|
||||||
where: { schluessel: SCHLUESSEL },
|
where: { schluessel: SCHLUESSEL },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Quelle } from "@/generated/prisma/enums";
|
import type { Quelle } from "@/generated/prisma/enums";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
@@ -28,3 +29,92 @@ export async function protokollieren(eintrag: ProtokollEintrag, client: Client =
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Filter der Audit-Log-Ansicht (`/protokoll?…`, `GET /api/v1/protokoll?…`). */
|
||||||
|
export const ProtokollFilter = z.object({
|
||||||
|
benutzer: z.string().trim().optional(),
|
||||||
|
quelle: z.enum(["WEB", "CLI", "API", "SYSTEM"]).optional(),
|
||||||
|
/** Teilwort in Aktion oder Ziel, z. B. „Server“ oder „d-7f3k2“. */
|
||||||
|
suche: z.string().trim().max(100).optional(),
|
||||||
|
von: z.iso.date().optional(),
|
||||||
|
bis: z.iso.date().optional(),
|
||||||
|
seite: z.coerce.number().int().min(1).max(100_000).default(1),
|
||||||
|
});
|
||||||
|
export type ProtokollFilter = z.infer<typeof ProtokollFilter>;
|
||||||
|
|
||||||
|
export const PROTOKOLL_PRO_SEITE = 50;
|
||||||
|
|
||||||
|
export type ProtokollZeile = {
|
||||||
|
id: string;
|
||||||
|
zeit: string;
|
||||||
|
benutzer: string | null;
|
||||||
|
quelle: Quelle;
|
||||||
|
aktion: string;
|
||||||
|
ziel: string | null;
|
||||||
|
details: unknown;
|
||||||
|
ip: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Wie beim Verlauf: Gültiges übernehmen, Unsinn still fallen lassen. */
|
||||||
|
export function protokollFilterAus(
|
||||||
|
parameter: Record<string, string | string[] | undefined>,
|
||||||
|
): ProtokollFilter {
|
||||||
|
const geprueft: Record<string, unknown> = {};
|
||||||
|
for (const schluessel of Object.keys(ProtokollFilter.shape) as Array<keyof ProtokollFilter>) {
|
||||||
|
const roh = parameter[schluessel];
|
||||||
|
const wert = Array.isArray(roh) ? roh[0] : roh;
|
||||||
|
if (!wert) continue;
|
||||||
|
const feld = ProtokollFilter.shape[schluessel].safeParse(wert);
|
||||||
|
if (feld.success && feld.data !== undefined) geprueft[schluessel] = feld.data;
|
||||||
|
}
|
||||||
|
return ProtokollFilter.parse(geprueft);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function protokollSuchen(filter: ProtokollFilter): Promise<{
|
||||||
|
eintraege: ProtokollZeile[];
|
||||||
|
gesamt: number;
|
||||||
|
seite: number;
|
||||||
|
seiten: number;
|
||||||
|
}> {
|
||||||
|
const zeit: Prisma.DateTimeFilter = {};
|
||||||
|
if (filter.von) zeit.gte = new Date(`${filter.von}T00:00:00`);
|
||||||
|
if (filter.bis) zeit.lt = new Date(new Date(`${filter.bis}T00:00:00`).getTime() + 86_400_000);
|
||||||
|
const where: Prisma.ProtokollWhereInput = {
|
||||||
|
...(filter.benutzer ? { benutzer: { benutzername: filter.benutzer } } : {}),
|
||||||
|
...(filter.quelle ? { quelle: filter.quelle } : {}),
|
||||||
|
...(filter.suche
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ aktion: { contains: filter.suche, mode: "insensitive" as const } },
|
||||||
|
{ ziel: { contains: filter.suche, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(filter.von || filter.bis ? { zeit } : {}),
|
||||||
|
};
|
||||||
|
const [gesamt, zeilen] = await prisma.$transaction([
|
||||||
|
prisma.protokoll.count({ where }),
|
||||||
|
prisma.protokoll.findMany({
|
||||||
|
where,
|
||||||
|
include: { benutzer: { select: { benutzername: true } } },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
skip: (filter.seite - 1) * PROTOKOLL_PRO_SEITE,
|
||||||
|
take: PROTOKOLL_PRO_SEITE,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
eintraege: zeilen.map((z) => ({
|
||||||
|
id: z.id.toString(),
|
||||||
|
zeit: z.zeit.toISOString(),
|
||||||
|
benutzer: z.benutzer?.benutzername ?? null,
|
||||||
|
quelle: z.quelle,
|
||||||
|
aktion: z.aktion,
|
||||||
|
ziel: z.ziel,
|
||||||
|
details: z.details,
|
||||||
|
ip: z.ip,
|
||||||
|
})),
|
||||||
|
gesamt,
|
||||||
|
seite: filter.seite,
|
||||||
|
seiten: Math.max(1, Math.ceil(gesamt / PROTOKOLL_PRO_SEITE)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import ssh2 from "ssh2";
|
|
||||||
import { Prisma, type Server } from "@/generated/prisma/client";
|
import { Prisma, type Server } from "@/generated/prisma/client";
|
||||||
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
||||||
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||||
|
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
|
||||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||||
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
|
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
@@ -180,13 +180,13 @@ export async function sshSchluesselErzeugen(
|
|||||||
ip: string | null,
|
ip: string | null,
|
||||||
): Promise<ServerSicht> {
|
): Promise<ServerSicht> {
|
||||||
const server = await serverHolen(name);
|
const server = await serverHolen(name);
|
||||||
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
|
const paar = ed25519Erzeugen(`proxdeploy@${server.name}`);
|
||||||
const neu = await prisma.$transaction(async (tx) => {
|
const neu = await prisma.$transaction(async (tx) => {
|
||||||
const aktualisiert = await tx.server.update({
|
const aktualisiert = await tx.server.update({
|
||||||
where: { id: server.id },
|
where: { id: server.id },
|
||||||
data: {
|
data: {
|
||||||
sshPrivat: verschluesseln(paar.private),
|
sshPrivat: verschluesseln(paar.privat),
|
||||||
sshOeffentlich: paar.public.trim(),
|
sshOeffentlich: paar.oeffentlich,
|
||||||
// Ein neuer Schlüssel macht gepinnte Host-Keys nicht ungültig, aber der alte ist auf
|
// Ein neuer Schlüssel macht gepinnte Host-Keys nicht ungültig, aber der alte ist auf
|
||||||
// den Nodes noch hinterlegt – das sagt die Oberfläche dazu.
|
// den Nodes noch hinterlegt – das sagt die Oberfläche dazu.
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Server } from "@/generated/prisma/client";
|
||||||
|
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was eine kurze Anfrage offen lässt (Node, Storage, Bridge), aus den Standards des Servers und
|
||||||
|
* dem letzten Verbindungstest. Fehlt der Test, bleibt alles leer und die Prüfung meldet es.
|
||||||
|
*/
|
||||||
|
export function standardsFuer(
|
||||||
|
server: Pick<Server, "standards" | "letzterTest">,
|
||||||
|
wunsch: { knoten?: string | undefined; inhalt: "images" | "rootdir" },
|
||||||
|
): { knoten?: string; storage?: string; bridge?: string } {
|
||||||
|
const bericht = server.letzterTest as VerbindungsBericht | null;
|
||||||
|
if (!bericht) return {};
|
||||||
|
const standards = (server.standards ?? {}) as { knoten?: string; storage?: string; bridge?: string };
|
||||||
|
const online = bericht.knoten.filter((k) => k.online);
|
||||||
|
const knoten = wunsch.knoten
|
||||||
|
? online.find((k) => k.name === wunsch.knoten)
|
||||||
|
: (online.find((k) => k.name === standards.knoten) ?? online[0]);
|
||||||
|
if (!knoten) return {};
|
||||||
|
const passend = knoten.storages.filter((st) => st.aktiv && st.inhalte.includes(wunsch.inhalt));
|
||||||
|
const storage = (passend.find((st) => st.storage === standards.storage) ?? passend[0])?.storage;
|
||||||
|
const namen = knoten.bridges.map((b) => b.name);
|
||||||
|
const bridge = [standards.bridge, "vmbr0"].find((b) => b && namen.includes(b)) ?? namen[0];
|
||||||
|
return {
|
||||||
|
knoten: knoten.name,
|
||||||
|
...(storage ? { storage } : {}),
|
||||||
|
...(bridge ? { bridge } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user