Compare commits
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
paket
|
||||||
|
proxdeploy-*.tar.gz
|
||||||
|
cli/bin
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.tsbuildinfo
|
||||||
|
src/generated
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
# ── Datenbank ────────────────────────────────────────────────────────────────
|
# ── Datenbank ────────────────────────────────────────────────────────────────
|
||||||
DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy
|
DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy
|
||||||
|
# Nur Docker Compose: Passwort der mitgelieferten PostgreSQL (DATABASE_URL setzt Compose selbst).
|
||||||
|
POSTGRES_PASSWORT=
|
||||||
# Nur für Tests; wird vor jedem Test geleert.
|
# Nur für Tests; wird vor jedem Test geleert.
|
||||||
TEST_DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy_test
|
TEST_DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy
|
||||||
|
# Nur Docker Compose: Passwort der mitgelieferten PostgreSQL (DATABASE_URL setzt Compose selbst).
|
||||||
|
POSTGRES_PASSWORT=_test
|
||||||
|
|
||||||
# ── Geheimnisse ──────────────────────────────────────────────────────────────
|
# ── Geheimnisse ──────────────────────────────────────────────────────────────
|
||||||
# Schlüssel für AES-256-GCM (Proxmox-Token, SSH-Schlüssel, OIDC-Secret): genau 32 Byte, base64.
|
# Schlüssel für AES-256-GCM (Proxmox-Token, SSH-Schlüssel, OIDC-Secret): genau 32 Byte, base64.
|
||||||
@@ -22,12 +26,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
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ cli/bin/
|
|||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
paket/
|
||||||
|
proxdeploy-*.tar.gz
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ cli/
|
|||||||
catalog/
|
catalog/
|
||||||
test-results
|
test-results
|
||||||
public/brand
|
public/brand
|
||||||
|
paket
|
||||||
|
|||||||
@@ -17,6 +17,75 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.6.1] – 2026-10-12
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Debian-12-Templates bekamen keine cloud-init-Konfiguration: das cloud-init-Laufwerk hing an ide2 (SATA bei q35), den der Debian-12-Cloud-Kernel nicht kennt. Neue Templates nutzen scsi1 (#021)
|
||||||
|
|
||||||
|
## [0.6.0] – 2026-10-12
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Betrieb per Docker Compose (postgres, migrate, app, worker) mit einem Image für alles, non-root (#019)
|
||||||
|
- LXC-Installer `deploy/install-lxc.sh`: legt auf dem Proxmox-Host einen Debian-13-Container an, installiert Node.js und PostgreSQL, richtet systemd-Dienste ein; mit Update-Pfad (#019)
|
||||||
|
- Migrationen im Betrieb ohne Prisma-CLI, kompatibel zu Prisma (#019)
|
||||||
|
- Übersicht zeigt Nodes, CPUs und freien RAM je Server (#018)
|
||||||
|
- README mit Banner, Screenshots, Installation, ersten Schritten und Katalog-Anleitungen (#018)
|
||||||
|
- Marken-Dateien nach Vorgabe unter `public/brand/`: Symbol (dunkel/hell), kleines Icon, Avatar, Logo (dunkel/hell), Banner 1280×640, Favicons, Apple-Touch- und Manifest-Icons; erzeugt mit `pnpm marke` (#017)
|
||||||
|
- Web-App-Manifest; Favicon passt sich hellem und dunklem Browser an (#017)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Die Übersicht zeigte statt der letzten Deployments nur einen Platzhalter (#018)
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Template-Bau ohne SSH zum Node: über eine Bau-Adresse installiert ProxDeploy den Guest-Agent per SSH in der Vorbereitungs-VM (#021)
|
||||||
|
- SSH-Schlüssel in den Einstellungen hinterlegen (persönlich und, für Admins, Team-Schlüssel); sie kommen automatisch in jeden Gast – auch per API/CLI ohne Angabe – und sind im Assistenten abwählbar (#015)
|
||||||
|
- Templates mit eingebackenen Profilen: beim Template-Bau Profile wählen; Ausrollen nimmt das passende Template und überspringt, was schon drin ist (#022)
|
||||||
|
- Profil-Editor: eigene Profile als YAML mit Live-Prüfung und Skriptvorschau, Katalogprofile als Kopie bearbeiten (#011)
|
||||||
|
- Vorlagen (Presets): im Assistenten wählen und speichern, Vorlagenseite, `vorlage` in der API; Beispiel „Docker-Host klein“ (#012)
|
||||||
|
- Ubuntu 22.04/24.04/26.04 als VM und LXC; Fortschritt beim Klonen aus dem Task-Log (#010)
|
||||||
|
- LXC-Container ausrollen: Container-Template prüfen und laden, anlegen (unprivilegiert, nesting für docker), Netz, Provisionierung per `pct exec` über Node-SSH oder per SSH in den Container mit einem Bereitstellungsschlüssel (#009)
|
||||||
|
- Profile kennen die festen Bedingungen `if: vm` und `if: lxc` (#009)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Verbindungstest meldete „Template-Bau per API“ auch dann, wenn der Guest-Agent mangels SSH gar nicht ins Template käme (#021)
|
||||||
|
- chrony wird in Containern nicht mehr installiert – ohne CAP_SYS_TIME scheiterte dort das Profil base (#009)
|
||||||
|
- SSH-Härtung auf Ubuntu 24.04 scheiterte an fehlendem /run/sshd (#009)
|
||||||
|
|
||||||
## [0.2.0] – 2026-10-12
|
## [0.2.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:** 2 h 50 min (Sitzung 1)
|
**Gesamt:** 8 h 14 min (Sitzung 1–4)
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -16,11 +16,103 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
| #006 Aufträge und Live-Fortschritt | 0 h 15 min |
|
| #006 Aufträge und Live-Fortschritt | 0 h 15 min |
|
||||||
| #007 Template-Bau | 0 h 12 min |
|
| #007 Template-Bau | 0 h 12 min |
|
||||||
| #008 VM ausrollen und Assistent | 0 h 36 min |
|
| #008 VM ausrollen und Assistent | 0 h 36 min |
|
||||||
|
| #009 LXC | 0 h 31 min |
|
||||||
|
| #010 Ubuntu | 0 h 05 min |
|
||||||
|
| #011 Profil-Editor | 0 h 10 min |
|
||||||
|
| #012 Vorlagen | 0 h 11 min |
|
||||||
|
| #022 Profile im Template | 0 h 22 min |
|
||||||
|
| #015 (Teil) SSH-Schlüssel | 0 h 10 min |
|
||||||
|
| #021 Template-Bau ohne SSH | 0 h 19 min + 0 h 35 min Abnahme |
|
||||||
|
| #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 |
|
||||||
|
| #017 Marken-Dateien | 0 h 17 min |
|
||||||
|
| #018 README | 0 h 02 min (parallel zu #019) |
|
||||||
|
| #019 Docker und LXC-Installer | 0 h 20 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**
|
||||||
|
| v0.4.0 | 0 h 42 min | **abgeschlossen**
|
||||||
|
| v0.5.0 | 1 h 39 min | **abgeschlossen**
|
||||||
|
| v0.6.0 | 0 h 40 min | **abgeschlossen**
|
||||||
|
| v0.6.1 | 0 h 35 min | **abgeschlossen**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-12 · Sitzung 4 · 19:50–20:26
|
||||||
|
**Issue:** #021 (Abnahme), Vorüberlegung #020
|
||||||
|
**Version:** 0.6.1
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
- Freie Bau-Adresse gesucht (Ping/ARP-Probe vom Testhost, .150–.199 stumm) → 10.0.10.180
|
||||||
|
- #021 abgenommen: Template 9003 (Debian 12) ohne Node-SSH über die Bau-Adresse gebaut, daraus VM 111 `pd-deb12` ohne Node-SSH ausgerollt
|
||||||
|
- Dabei gefunden und behoben: cloud-init-Laufwerk an `ide2` wird vom Debian-12-Cloud-Kernel nicht gesehen → `scsi1`
|
||||||
|
- Fehlgeschlagenen ersten Bau mit `proxdeploy cleanup` aufgeräumt (VM 9003 gestoppt und gelöscht)
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
- **cloud-init-Laufwerk an virtio-SCSI** für neue Templates; vorhandene (9000–9002) bleiben – deren Kernel kennen SATA.
|
||||||
|
- **Feldtest 8.x (#020) nicht als verschachtelte VM auf pve-demo:** 7 GB RAM, VMs liefen dort auf dritter Virtualisierungsebene; braucht einen echten 8.x-Host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-12 · Sitzung 3 · 14:39–17:40
|
||||||
|
**Issue:** #013–#019
|
||||||
|
**Version:** 0.4.0, 0.5.0, 0.6.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
|
||||||
|
- Etappe 6 (v0.6.0): Marken-Dateien per Skript (Texte als Pfade), README mit Banner und Screenshots, Docker Compose (lokal hochgefahren) und LXC-Installer – auf pve-demo in CT 109 installiert, darin Server angelegt, CLI-Login, Dry-Run und ein echter LXC (pd-abnahme, CT 110); Update-Pfad geprüft
|
||||||
|
- Gefunden: die Übersicht war seit Etappe 1 ein Platzhalter; opentype.js schreibt gelegentlich „NaN“ in Pfade
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- **Migrationen im Betrieb ohne Prisma-CLI**, kompatibel zu `_prisma_migrations`; ein gemeinsames Laufzeit-Paket für Docker und LXC; der worker bekommt nur die Pakete, die er importiert (103 statt 741 MB).
|
||||||
|
- Gefunden durch Tests: SSE-Daten zeigten in einen wiederverwendeten Puffer (jetzt kopiert); mehrzeilige Proxmox-Parameter brachen die Dry-Run-Zeile (jetzt `⏎`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-12 · Sitzung 2 · 12:48–14:39
|
||||||
|
**Issue:** #009–#012, #015 (Teil), #021, #022
|
||||||
|
**Version:** 0.3.0-dev
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
- #009: LXC per pct exec (pd-ct01, Debian 13, docker im unprivilegierten CT) und per SSH in den Container (pd-ct02, Ubuntu 24.04) auf pve-demo
|
||||||
|
- #010: Ubuntu 24.04 als VM (pd-ubu01, Template 9001 im selben Auftrag gebaut)
|
||||||
|
- #011: Profil-Editor mit Live-Prüfung und Skriptvorschau
|
||||||
|
- #012: Vorlagen inkl. „Docker-Host klein“, im Assistenten und per API
|
||||||
|
- #022 (Wunsch Florian): Profile ins Template einbacken – Template 9002 tpl-debian-13-docker; Provisionierung 141 s → ~30 s
|
||||||
|
- #015 vorgezogen: SSH-Schlüssel hinterlegen (persönlich/Team), automatisch in jedem Gast
|
||||||
|
- #021: Template-Bau ohne SSH über Bau-Adresse (Mock-getestet, Abnahme auf pve-demo braucht eine freie IP)
|
||||||
|
- Florians Fragen beantwortet: was am Template anpassbar ist; Schlüssel/Anwendungen ins Template
|
||||||
|
- 253 Tests, 18 Playwright-Abläufe; main gepusht
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
- **chrony nur in VMs** (`if: vm`): unprivilegierte Container dürfen die Uhr nicht stellen.
|
||||||
|
- **Profile mit `{{ guest.* }}` laufen beim Deploy erneut**, auch wenn eingebacken – sonst fehlt z. B. der Benutzer in der docker-Gruppe (gefunden mit pd-test03).
|
||||||
|
- **SSH-Schlüssel nicht ins Template**, sondern in die Verwaltung (`SchluesselQuelle`) – beim Schlüsselwechsel bleibt es eine Stelle.
|
||||||
|
- **Fähigkeitserkennung korrigiert:** Template-Bau per API nur, wenn der Guest-Agent auch hineinkommt.
|
||||||
|
- **Voll-Klon bleibt Standard**; der Testhost kopiert zurzeit nur ~3–8 MB/s (Klon 8 min) – Linked Clones als spätere Option notiert.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ProxDeploy – ein Image für app, worker und migrate. Gebaut wird mit scripts/paket-bauen.sh,
|
||||||
|
# derselben Struktur wie im LXC-Installer.
|
||||||
|
#
|
||||||
|
# docker build --build-arg VERSION=0.6.0 -t proxdeploy:0.6.0 .
|
||||||
|
|
||||||
|
FROM node:24.21.0-trixie-slim AS bau
|
||||||
|
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH CI=true
|
||||||
|
RUN corepack enable && corepack prepare pnpm@12.6.0 --activate
|
||||||
|
WORKDIR /quelle
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY prisma ./prisma
|
||||||
|
COPY prisma.config.ts ./
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
ARG VERSION
|
||||||
|
# Ohne .git im Kontext gibt es keinen Commit; dann steht nur die Version da.
|
||||||
|
RUN scripts/paket-bauen.sh "${VERSION:-$(node -p 'require("./package.json").version')}"
|
||||||
|
|
||||||
|
FROM node:24.21.0-trixie-slim
|
||||||
|
LABEL org.opencontainers.image.title="ProxDeploy" \
|
||||||
|
org.opencontainers.image.description="VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI." \
|
||||||
|
org.opencontainers.image.source="https://git.wilde-server.de/florian.wilde/proxdeploy"
|
||||||
|
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0 NEXT_TELEMETRY_DISABLED=1
|
||||||
|
WORKDIR /opt/proxdeploy
|
||||||
|
COPY --from=bau --chown=root:root /quelle/paket ./
|
||||||
|
# Läuft als „node“ (UID 1000), nie als root; der Code gehört root und ist nicht beschreibbar.
|
||||||
|
USER node
|
||||||
|
WORKDIR /opt/proxdeploy/app
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/v1/gesundheit').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
# app; worker und migrate setzen ihr Kommando in docker-compose.yml.
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -1,28 +1,80 @@
|
|||||||
# ProxDeploy
|
<p align="center"><img src="public/brand/banner.svg" alt="ProxDeploy – VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI." width="100%"></p>
|
||||||
|
|
||||||
**VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.**
|
**ProxDeploy** ist eine selbst gehostete Webanwendung mit einer CLI (`proxdeploy`). Damit wählt man
|
||||||
|
einen von mehreren Proxmox-VE-Servern (8.x/9.x, Einzelhost oder Cluster) und rollt darauf vollständig
|
||||||
|
unattended eine VM oder einen LXC-Container mit Debian oder Ubuntu aus – mit Profilen wie `base`,
|
||||||
|
`docker` oder `zabbix-agent`. Distributionen und Profile sind YAML-Dateien im Katalog, kein Code.
|
||||||
|
|
||||||
ProxDeploy ist eine selbst gehostete Webanwendung mit einer CLI (`proxdeploy`). Damit wählt man einen von
|
> ProxDeploy ist ein unabhängiges Werkzeug und stammt nicht von der Proxmox Server Solutions GmbH.
|
||||||
mehreren Proxmox-VE-Servern (8.x/9.x, Einzelhost oder Cluster) und rollt darauf vollständig unattended
|
|
||||||
eine VM oder einen LXC-Container mit Debian oder Ubuntu aus, inklusive Profilen wie `base`, `docker`
|
|
||||||
oder `zabbix-agent`. Weitere Distributionen und Profile sind YAML-Dateien im Katalog, kein Code.
|
|
||||||
|
|
||||||
> ProxDeploy ist ein unabhängiges Werkzeug und stammt nicht von Proxmox Server Solutions GmbH.
|
|
||||||
> „Proxmox“ ist eine Marke der Proxmox Server Solutions GmbH.
|
> „Proxmox“ ist eine Marke der Proxmox Server Solutions GmbH.
|
||||||
|
|
||||||
## Stand
|
<p align="center"><img src="docs/bilder/live-ansicht.png" alt="Live-Ansicht eines Deployments: Phasen mit Status, Abschlussbox mit IP und SSH-Befehl" width="100%"></p>
|
||||||
|
|
||||||
In Entwicklung. Version und Fortschritt stehen im [CHANGELOG](CHANGELOG.md), der Plan in
|
## Was es kann
|
||||||
[docs/architektur.md](docs/architektur.md), die Aufgaben in [docs/issues/](docs/issues/README.md).
|
|
||||||
|
|
||||||
| Etappe | Inhalt | Stand |
|
- **Assistent in neun Schritten** – Server, Typ, Distribution, Name, Ressourcen, Netzwerk (DHCP/statisch,
|
||||||
|
VLAN), Zugang (SSH-Schlüssel), Profile mit Variablen – und am Ende ein **Dry-Run** mit allen geplanten
|
||||||
|
Proxmox-Aufrufen und dem passenden CLI-Befehl.
|
||||||
|
- **Live-Fortschritt** je Phase (Server-Sent Events), in Web und CLI mit denselben Texten; bei Fehlern
|
||||||
|
Ursache und Lösungshinweis, danach auf Wunsch **Aufräumen** des halb angelegten Gasts.
|
||||||
|
- **Templates** aus offiziellen Cloud-Images (Prüfsumme geprüft, UEFI, Guest-Agent eingebaut), auf
|
||||||
|
Wunsch mit eingebackenen Profilen – ein Docker-Host ist dann in Sekunden statt Minuten fertig.
|
||||||
|
- **Ohne SSH zum Node** per API und Guest-Agent; mit SSH zusätzlich LXC-Provisionierung und
|
||||||
|
cloud-init-Snippets. Was ein Server kann, zeigt der Verbindungstest.
|
||||||
|
- **Verlauf** mit Filtern und Wiederholen, **Vorlagen** („Docker-Host klein“), hinterlegte
|
||||||
|
**SSH-Schlüssel**, **Rollen** Admin/Operator, **Audit-Log**, Anmeldung lokal oder per **OIDC**.
|
||||||
|
- **CLI** als einzelnes Binary für Linux, macOS und Windows – mit Batch-Dateien für viele Gäste.
|
||||||
|
|
||||||
|
| Übersicht | Assistent mit Dry-Run | Am Handy |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft |
|
|  |  |  |
|
||||||
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt, Assistent | ✓ fertig, auf PVE 9.2 geprüft |
|
|
||||||
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen | ◐ als Nächstes |
|
Weitere Ansichten: [Verlauf](docs/bilder/verlauf.png) · [Server mit Verbindungstest](docs/bilder/server.png) · [Katalog](docs/bilder/katalog.png).
|
||||||
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ○ geplant |
|
Erzeugt mit `scripts/screenshots.ts` aus dem gebauten Stand.
|
||||||
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ geplant |
|
|
||||||
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
|
## Installation
|
||||||
|
|
||||||
|
ProxDeploy braucht PostgreSQL und läuft als zwei Prozesse (app und worker). Es gehört hinter einen
|
||||||
|
Reverse-Proxy mit TLS; dann `TRUST_PROXY=true` und `APP_URL` auf die öffentliche Adresse setzen.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.wilde-server.de/florian.wilde/proxdeploy.git && cd proxdeploy
|
||||||
|
cp .env.example .env
|
||||||
|
# POSTGRES_PASSWORT=$(openssl rand -hex 24)
|
||||||
|
# PROXDEPLOY_SCHLUESSEL=$(openssl rand -base64 32) ← sichern! ohne ihn sind Tokens und Schlüssel verloren
|
||||||
|
# SITZUNG_GEHEIMNIS=$(openssl rand -base64 48)
|
||||||
|
# APP_URL=https://proxdeploy.example
|
||||||
|
# ADMIN_PASSWORT=… ← erster Admin, nur beim allerersten Start
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose startet `postgres`, spielt mit `migrate` die Datenbank ein und startet dann `app` (Port 3000 auf
|
||||||
|
127.0.0.1) und `worker`. Aktualisieren: `git pull && docker compose up -d --build`.
|
||||||
|
|
||||||
|
### In einem Debian-LXC auf dem Proxmox-Host
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/paket-bauen.sh 0.6.0 --tar # im Repository: proxdeploy-0.6.0.tar.gz
|
||||||
|
scp proxdeploy-0.6.0.tar.gz deploy/install-lxc.sh deploy/im-container.sh root@pve01:/root/
|
||||||
|
ssh root@pve01 ./install-lxc.sh --paket proxdeploy-0.6.0.tar.gz --url https://proxdeploy.example
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Installer legt einen unprivilegierten Debian-13-Container an (DHCP auf `vmbr0`; `--ip`, `--gw`,
|
||||||
|
`--vlan`, `--storage`, `--ctid` u. a. siehe `--help`), installiert Node.js 24 (Prüfsumme geprüft) und
|
||||||
|
PostgreSQL, richtet systemd-Dienste ein und zeigt am Ende Adresse und das Passwort des ersten Admins.
|
||||||
|
Aktualisieren mit `./install-lxc.sh --aktualisieren <ctid> --paket proxdeploy-0.6.1.tar.gz` – die
|
||||||
|
letzten drei Fassungen bleiben unter `/opt/proxdeploy/versionen/`.
|
||||||
|
|
||||||
|
## Erste Schritte
|
||||||
|
|
||||||
|
1. **Token in Proxmox:** eigenen Benutzer und die Rolle `ProxDeploy` anlegen, Token ohne
|
||||||
|
Privilege Separation – die Befehle für 8.x und 9.x stehen in [docs/proxmox-rechte.md](docs/proxmox-rechte.md).
|
||||||
|
2. **Server anlegen:** URL, Token, Zertifikat bestätigen (Fingerprint-Pinning für selbst signierte
|
||||||
|
Zertifikate), „Verbindung testen“. Optional SSH-Zugang zum Node hinterlegen.
|
||||||
|
3. **SSH-Schlüssel** unter Einstellungen hinterlegen – sie kommen automatisch in jeden Gast.
|
||||||
|
4. **Ausrollen:** fehlt das Template, baut ProxDeploy es vorher (oder unter „Templates“).
|
||||||
|
|
||||||
## Was ProxDeploy auf einem Server kann
|
## Was ProxDeploy auf einem Server kann
|
||||||
|
|
||||||
@@ -37,6 +89,120 @@ 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.
|
||||||
|
|
||||||
|
## Katalog erweitern
|
||||||
|
|
||||||
|
- **Neue Distribution:** eine YAML-Datei unter `catalog/distros/` – Image-URL, Prüfsummen-Quelle,
|
||||||
|
Standardbenutzer, LXC-Template-Muster. Für eine neue Familie (z. B. `rhel` mit dnf) kommt ein
|
||||||
|
Adapter unter `src/engine/adapter/` dazu. → [docs/katalog.md](docs/katalog.md#neue-distribution-hinzufügen)
|
||||||
|
- **Eigenes Profil:** in der Web-GUI unter Katalog → „Profil anlegen“ (YAML mit Live-Prüfung und
|
||||||
|
Skriptvorschau) oder als Datei unter `catalog/profiles/`. → [docs/katalog.md](docs/katalog.md#eigenes-profil-anlegen)
|
||||||
|
|
||||||
|
## Stand
|
||||||
|
|
||||||
|
Version und Änderungen stehen im [CHANGELOG](CHANGELOG.md), der Plan in
|
||||||
|
[docs/architektur.md](docs/architektur.md), die Aufgaben in [docs/issues/](docs/issues/README.md).
|
||||||
|
|
||||||
|
| Etappe | Inhalt | Stand |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft |
|
||||||
|
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt, Assistent | ✓ fertig, auf PVE 9.2 geprüft |
|
||||||
|
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen, Profile im Template, SSH-Schlüssel, Template-Bau ohne SSH | ✓ fertig, auf PVE 9.2 geprüft |
|
||||||
|
| 4 · v0.4.0 | CLI `proxdeploy` (Go), API-Tokens | ✓ fertig, auf PVE 9.2 geprüft |
|
||||||
|
| 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, Docker Compose, LXC-Installer | ✓ fertig, Installer auf PVE 9.2 geprüft |
|
||||||
|
|
||||||
## 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,16 +220,14 @@ 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
|
|
||||||
|
|
||||||
Docker Compose (app, worker, postgres) und ein Installer für einen Debian-LXC auf Proxmox folgen mit
|
|
||||||
Etappe 6. ProxDeploy ist für den Betrieb hinter einem Reverse-Proxy mit TLS gedacht
|
|
||||||
(`TRUST_PROXY=true`, `APP_URL` auf die öffentliche Adresse).
|
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
Noch nicht festgelegt.
|
Noch nicht festgelegt.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Grundausstattung für jeden Gast. Platzhalter: {{ var.<name> }} aus den Variablen unten,
|
# Grundausstattung für jeden Gast. Platzhalter: {{ var.<name> }} aus den Variablen unten,
|
||||||
# {{ os.id }}, {{ os.version_id }}, {{ os.codename }} aus der Distribution,
|
# {{ os.id }}, {{ os.version_id }}, {{ os.codename }} aus der Distribution,
|
||||||
# {{ guest.name }}, {{ guest.user }} aus dem Deployment. `a ?? b` nimmt b, wenn a leer ist.
|
# {{ guest.name }}, {{ guest.user }} aus dem Deployment. `a ?? b` nimmt b, wenn a leer ist.
|
||||||
|
# Bedingungen: `if: <variable>`, dazu fest `if: vm` / `if: lxc`.
|
||||||
id: base
|
id: base
|
||||||
name: Basis
|
name: Basis
|
||||||
description: Updates, Guest-Agent, Zeitzone, Locale, Tastatur, automatische Sicherheitsupdates, Zeitsync, SSH-Härtung
|
description: Updates, Guest-Agent, Zeitzone, Locale, Tastatur, automatische Sicherheitsupdates, Zeitsync, SSH-Härtung
|
||||||
@@ -16,8 +17,12 @@ families:
|
|||||||
- timezone: "{{ var.zeitzone }}"
|
- timezone: "{{ var.zeitzone }}"
|
||||||
- locale: "{{ var.locale }}"
|
- locale: "{{ var.locale }}"
|
||||||
- keyboard: "{{ var.tastatur }}"
|
- keyboard: "{{ var.tastatur }}"
|
||||||
- packages: [chrony, sudo, curl, ca-certificates]
|
- packages: [sudo, curl, ca-certificates]
|
||||||
|
# Zeitsync nur in VMs: ein unprivilegierter Container darf die Uhr nicht stellen, er nimmt die des Hosts.
|
||||||
|
- packages: [chrony]
|
||||||
|
if: vm
|
||||||
- service: { name: chrony, enable: true, start: true }
|
- service: { name: chrony, enable: true, start: true }
|
||||||
|
if: vm
|
||||||
- unattended_upgrades: true
|
- unattended_upgrades: true
|
||||||
- ssh_harden: { password_auth: false, root_login: false }
|
- ssh_harden: { password_auth: false, root_login: false }
|
||||||
- packages: [fail2ban]
|
- packages: [fail2ban]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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 |
|
||||||
|
+--------------------------------------+
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Läuft im Debian-13-Container (von install-lxc.sh per `pct exec` aufgerufen):
|
||||||
|
#
|
||||||
|
# im-container.sh installieren --url http://10.0.10.50:3000
|
||||||
|
# im-container.sh aktualisieren
|
||||||
|
#
|
||||||
|
# Erwartet das Paket unter /root/proxdeploy-paket.tar.gz.
|
||||||
|
set -euo pipefail
|
||||||
|
# Über SSH/pct exec reisen die Locales des Aufrufers mit; im frischen Container gibt es sie nicht.
|
||||||
|
export LC_ALL=C.UTF-8 LANG=C.UTF-8
|
||||||
|
unset LANGUAGE
|
||||||
|
|
||||||
|
NODE_VERSION="24.21.0"
|
||||||
|
BASIS="/opt/proxdeploy"
|
||||||
|
ENV_DATEI="/etc/proxdeploy/proxdeploy.env"
|
||||||
|
PAKET="/root/proxdeploy-paket.tar.gz"
|
||||||
|
|
||||||
|
fehler() { echo "✗ $*" >&2; exit 1; }
|
||||||
|
schritt() { echo "◐ $*"; }
|
||||||
|
fertig() { echo "✓ $*"; }
|
||||||
|
|
||||||
|
node_installieren() {
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) arch=x64 ;;
|
||||||
|
aarch64) arch=arm64 ;;
|
||||||
|
*) fehler "Architektur $(uname -m) wird nicht unterstützt." ;;
|
||||||
|
esac
|
||||||
|
if [ -x /opt/node/bin/node ] && [ "$(/opt/node/bin/node --version)" = "v$NODE_VERSION" ]; then return; fi
|
||||||
|
schritt "Node.js $NODE_VERSION"
|
||||||
|
local datei="node-v$NODE_VERSION-linux-$arch.tar.xz" tmp
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fsSL -o "$tmp/$datei" "https://nodejs.org/dist/v$NODE_VERSION/$datei"
|
||||||
|
curl -fsSL -o "$tmp/SHASUMS256.txt" "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt"
|
||||||
|
(cd "$tmp" && grep " $datei\$" SHASUMS256.txt | sha256sum -c --quiet) || fehler "Prüfsumme von $datei stimmt nicht."
|
||||||
|
rm -rf /opt/node && mkdir -p /opt/node
|
||||||
|
tar -xJf "$tmp/$datei" -C /opt/node --strip-components=1
|
||||||
|
rm -rf "$tmp"
|
||||||
|
fertig "Node.js $(/opt/node/bin/node --version)"
|
||||||
|
}
|
||||||
|
|
||||||
|
paket_auspacken() {
|
||||||
|
[ -f "$PAKET" ] || fehler "$PAKET fehlt."
|
||||||
|
local version ziel
|
||||||
|
version="$(tar -xzOf "$PAKET" ./VERSION)"
|
||||||
|
ziel="$BASIS/versionen/$version"
|
||||||
|
rm -rf "$ziel" && mkdir -p "$ziel"
|
||||||
|
tar -xzf "$PAKET" -C "$ziel"
|
||||||
|
chown -R root:root "$ziel"
|
||||||
|
ln -sfn "$ziel" "$BASIS/aktuell"
|
||||||
|
rm -f "$PAKET"
|
||||||
|
# Die letzten drei Fassungen behalten – für ein Zurück per Symlink.
|
||||||
|
find "$BASIS/versionen" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' | sort -rn | tail -n +4 |
|
||||||
|
cut -d' ' -f2- | xargs -r rm -rf
|
||||||
|
echo "$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
dienste_schreiben() {
|
||||||
|
cat > /etc/systemd/system/proxdeploy-migrate.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=ProxDeploy – Datenbank-Migrationen
|
||||||
|
After=postgresql.service network-online.target
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
User=proxdeploy
|
||||||
|
EnvironmentFile=$ENV_DATEI
|
||||||
|
WorkingDirectory=$BASIS/aktuell/app
|
||||||
|
ExecStart=/opt/node/bin/node ../worker/migrieren.mjs
|
||||||
|
EOF
|
||||||
|
for dienst in app worker; do
|
||||||
|
if [ "$dienst" = app ]; then start="server.js"; beschreibung="Web-GUI und REST-API"; else start="../worker/worker.mjs"; beschreibung="worker (Aufträge)"; fi
|
||||||
|
cat > "/etc/systemd/system/proxdeploy-$dienst.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=ProxDeploy – $beschreibung
|
||||||
|
After=proxdeploy-migrate.service
|
||||||
|
Requires=proxdeploy-migrate.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=proxdeploy
|
||||||
|
EnvironmentFile=$ENV_DATEI
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
WorkingDirectory=$BASIS/aktuell/app
|
||||||
|
ExecStart=/opt/node/bin/node $start
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=40
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
done
|
||||||
|
systemctl daemon-reload
|
||||||
|
}
|
||||||
|
|
||||||
|
auf_gesundheit_warten() {
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if curl -fsS -o /dev/null "http://127.0.0.1:3000/api/v1/gesundheit" 2>/dev/null; then return 0; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
journalctl -u proxdeploy-app -u proxdeploy-migrate --no-pager -n 30 >&2
|
||||||
|
fehler "Die app antwortet nicht."
|
||||||
|
}
|
||||||
|
|
||||||
|
installieren() {
|
||||||
|
local url=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--url) url="$2"; shift 2 ;;
|
||||||
|
*) fehler "Unbekannte Option $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$url" ] || fehler "--url fehlt."
|
||||||
|
[ -f "$ENV_DATEI" ] && fehler "ProxDeploy ist hier schon eingerichtet – zum Aktualisieren: install-lxc.sh --aktualisieren <ctid>"
|
||||||
|
|
||||||
|
schritt "Pakete (PostgreSQL, curl)"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq --no-install-recommends ca-certificates curl xz-utils openssl postgresql >/dev/null
|
||||||
|
fertig "PostgreSQL $(psql --version | awk '{print $3}')"
|
||||||
|
node_installieren
|
||||||
|
|
||||||
|
schritt "Benutzer und Datenbank"
|
||||||
|
id proxdeploy >/dev/null 2>&1 || useradd --system --home-dir /var/lib/proxdeploy --create-home --shell /usr/sbin/nologin proxdeploy
|
||||||
|
local db_passwort admin_passwort
|
||||||
|
db_passwort="$(openssl rand -hex 24)"
|
||||||
|
admin_passwort="$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-20)"
|
||||||
|
su postgres -c "psql -q -v ON_ERROR_STOP=1" <<SQL
|
||||||
|
DO \$\$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'proxdeploy') THEN
|
||||||
|
CREATE ROLE proxdeploy LOGIN PASSWORD '$db_passwort';
|
||||||
|
ELSE
|
||||||
|
ALTER ROLE proxdeploy PASSWORD '$db_passwort';
|
||||||
|
END IF;
|
||||||
|
END \$\$;
|
||||||
|
SQL
|
||||||
|
su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname = 'proxdeploy'\"" | grep -q 1 ||
|
||||||
|
su postgres -c "createdb -O proxdeploy proxdeploy"
|
||||||
|
fertig "Datenbank proxdeploy"
|
||||||
|
|
||||||
|
schritt "Konfiguration $ENV_DATEI"
|
||||||
|
mkdir -p /etc/proxdeploy
|
||||||
|
umask 027
|
||||||
|
cat > "$ENV_DATEI" <<EOF
|
||||||
|
# ProxDeploy – erzeugt von im-container.sh am $(date -Iseconds)
|
||||||
|
DATABASE_URL=postgresql://proxdeploy:$db_passwort@127.0.0.1:5432/proxdeploy
|
||||||
|
PROXDEPLOY_SCHLUESSEL=$(openssl rand -base64 32)
|
||||||
|
SITZUNG_GEHEIMNIS=$(openssl rand -base64 48)
|
||||||
|
APP_URL=$url
|
||||||
|
# Hinter einem Reverse-Proxy auf true setzen (wertet X-Forwarded-* aus).
|
||||||
|
TRUST_PROXY=false
|
||||||
|
PORT=3000
|
||||||
|
HOSTNAME=0.0.0.0
|
||||||
|
LOG_LEVEL=info
|
||||||
|
WORKER_PARALLEL=4
|
||||||
|
ADMIN_BENUTZER=admin
|
||||||
|
ADMIN_PASSWORT=$admin_passwort
|
||||||
|
EOF
|
||||||
|
chown root:proxdeploy "$ENV_DATEI"
|
||||||
|
chmod 0640 "$ENV_DATEI"
|
||||||
|
umask 022
|
||||||
|
|
||||||
|
schritt "ProxDeploy"
|
||||||
|
local version
|
||||||
|
version="$(paket_auspacken)"
|
||||||
|
dienste_schreiben
|
||||||
|
systemctl enable --now proxdeploy-app proxdeploy-worker >/dev/null 2>&1
|
||||||
|
auf_gesundheit_warten
|
||||||
|
# Der erste Admin ist angelegt – das Passwort hat in der Datei nichts mehr zu suchen.
|
||||||
|
sed -i '/^ADMIN_PASSWORT=/d' "$ENV_DATEI"
|
||||||
|
fertig "ProxDeploy $version läuft"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
ProxDeploy ist bereit: $url
|
||||||
|
Benutzer: admin
|
||||||
|
Passwort: $admin_passwort
|
||||||
|
(wird nur jetzt angezeigt – nach der Anmeldung unter
|
||||||
|
Einstellungen → Passwort ändern)
|
||||||
|
Konfiguration: $ENV_DATEI
|
||||||
|
Logs: journalctl -u proxdeploy-app -u proxdeploy-worker
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
aktualisieren() {
|
||||||
|
[ -f "$ENV_DATEI" ] || fehler "Hier ist ProxDeploy noch nicht eingerichtet."
|
||||||
|
node_installieren
|
||||||
|
local alt version
|
||||||
|
alt="$(cat "$BASIS/aktuell/VERSION" 2>/dev/null || echo –)"
|
||||||
|
systemctl stop proxdeploy-app proxdeploy-worker
|
||||||
|
version="$(paket_auspacken)"
|
||||||
|
dienste_schreiben
|
||||||
|
# migrate bleibt nach dem ersten Lauf „aktiv“ – für die neue Fassung ausdrücklich neu starten.
|
||||||
|
systemctl restart proxdeploy-migrate
|
||||||
|
systemctl start proxdeploy-app proxdeploy-worker
|
||||||
|
auf_gesundheit_warten
|
||||||
|
fertig "ProxDeploy $alt → $version"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
installieren) shift; installieren "$@" ;;
|
||||||
|
aktualisieren) shift; aktualisieren "$@" ;;
|
||||||
|
*) fehler "Aufruf: $0 installieren --url URL | aktualisieren" ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ProxDeploy in einem Debian-13-LXC einrichten – auf dem Proxmox-Host als root ausführen.
|
||||||
|
#
|
||||||
|
# ./install-lxc.sh --paket proxdeploy-0.6.0.tar.gz [Optionen]
|
||||||
|
# ./install-lxc.sh --aktualisieren <ctid> --paket proxdeploy-0.6.1.tar.gz
|
||||||
|
#
|
||||||
|
# Optionen (alle optional außer --paket):
|
||||||
|
# --ctid N Container-ID (Standard: nächste freie)
|
||||||
|
# --hostname NAME Hostname (Standard: proxdeploy)
|
||||||
|
# --storage NAME Storage für die Disk (Standard: local-lvm)
|
||||||
|
# --disk GB Disk in GB (Standard: 8)
|
||||||
|
# --cores N CPU-Kerne (Standard: 2)
|
||||||
|
# --memory MB RAM (Standard: 2048)
|
||||||
|
# --bridge NAME Bridge (Standard: vmbr0)
|
||||||
|
# --vlan TAG VLAN-Tag
|
||||||
|
# --ip CIDR feste Adresse, z. B. 10.0.10.50/24 (Standard: DHCP); dazu --gw
|
||||||
|
# --gw ADRESSE Gateway bei fester Adresse
|
||||||
|
# --url URL öffentliche Adresse (APP_URL), z. B. https://proxdeploy.example
|
||||||
|
# (Standard: http://<IP des Containers>:3000)
|
||||||
|
# --tags LISTE zusätzliche Proxmox-Tags, z. B. proxdeploy-test
|
||||||
|
#
|
||||||
|
# Das Paket baut `scripts/paket-bauen.sh <version> --tar` im Repository.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
fehler() { echo "✗ $*" >&2; exit 1; }
|
||||||
|
schritt() { echo "◐ $*"; }
|
||||||
|
fertig() { echo "✓ $*"; }
|
||||||
|
|
||||||
|
[ "$(id -u)" = 0 ] || fehler "Bitte als root auf dem Proxmox-Host ausführen."
|
||||||
|
command -v pct >/dev/null || fehler "pct fehlt – läuft das auf einem Proxmox-VE-Host?"
|
||||||
|
|
||||||
|
ctid="" hostname="proxdeploy" storage="local-lvm" disk=8 cores=2 memory=2048 bridge="vmbr0"
|
||||||
|
vlan="" ip="dhcp" gw="" url="" paket="" aktualisieren="" tags=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--ctid) ctid="$2"; shift 2 ;;
|
||||||
|
--hostname) hostname="$2"; shift 2 ;;
|
||||||
|
--storage) storage="$2"; shift 2 ;;
|
||||||
|
--disk) disk="$2"; shift 2 ;;
|
||||||
|
--cores) cores="$2"; shift 2 ;;
|
||||||
|
--memory) memory="$2"; shift 2 ;;
|
||||||
|
--bridge) bridge="$2"; shift 2 ;;
|
||||||
|
--vlan) vlan="$2"; shift 2 ;;
|
||||||
|
--ip) ip="$2"; shift 2 ;;
|
||||||
|
--gw) gw="$2"; shift 2 ;;
|
||||||
|
--url) url="$2"; shift 2 ;;
|
||||||
|
--tags) tags="$2"; shift 2 ;;
|
||||||
|
--paket) paket="$2"; shift 2 ;;
|
||||||
|
--aktualisieren) aktualisieren="$2"; shift 2 ;;
|
||||||
|
-h|--help) sed -n '2,25p' "$0"; exit 0 ;;
|
||||||
|
*) fehler "Unbekannte Option $1 (siehe --help)" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$paket" ] || fehler "--paket fehlt (proxdeploy-<version>.tar.gz)."
|
||||||
|
[ -f "$paket" ] || fehler "$paket gibt es nicht."
|
||||||
|
tar -tzf "$paket" ./VERSION >/dev/null 2>&1 || fehler "$paket ist kein ProxDeploy-Paket (VERSION fehlt)."
|
||||||
|
version="$(tar -xzOf "$paket" ./VERSION)"
|
||||||
|
innen="$(dirname "$0")/im-container.sh"
|
||||||
|
[ -f "$innen" ] || fehler "im-container.sh fehlt neben diesem Skript."
|
||||||
|
|
||||||
|
pushen() {
|
||||||
|
pct push "$1" "$paket" /root/proxdeploy-paket.tar.gz
|
||||||
|
pct push "$1" "$innen" /root/proxdeploy-im-container.sh --perms 0700
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Aktualisieren ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
if [ -n "$aktualisieren" ]; then
|
||||||
|
pct status "$aktualisieren" | grep -q running || fehler "Container $aktualisieren läuft nicht."
|
||||||
|
schritt "Aktualisiere Container $aktualisieren auf $version"
|
||||||
|
pushen "$aktualisieren"
|
||||||
|
pct exec "$aktualisieren" -- bash /root/proxdeploy-im-container.sh aktualisieren
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Neu einrichten ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
[ -n "$ctid" ] || ctid="$(pvesh get /cluster/nextid)"
|
||||||
|
pct status "$ctid" >/dev/null 2>&1 && fehler "Container $ctid gibt es schon."
|
||||||
|
if [ "$ip" != dhcp ]; then
|
||||||
|
[[ "$ip" =~ ^[0-9.]+/[0-9]+$ ]] || fehler "--ip: Format 10.0.10.50/24"
|
||||||
|
[ -n "$gw" ] || fehler "Bei fester Adresse fehlt --gw."
|
||||||
|
fi
|
||||||
|
|
||||||
|
schritt "LXC-Template Debian 13"
|
||||||
|
pveam update >/dev/null
|
||||||
|
vorlage="$(pveam available --section system | awk '/debian-13-standard_.*_amd64\.tar\.zst/ {print $2}' | sort -V | tail -1)"
|
||||||
|
[ -n "$vorlage" ] || fehler "Kein Debian-13-Template in pveam gefunden."
|
||||||
|
template_storage="$(pvesm status --content vztmpl | awk 'NR>1 {print $1; exit}')"
|
||||||
|
[ -n "$template_storage" ] || fehler "Kein Storage für Container-Templates (Inhalt vztmpl)."
|
||||||
|
if ! pveam list "$template_storage" | grep -q "$vorlage"; then
|
||||||
|
pveam download "$template_storage" "$vorlage" >/dev/null
|
||||||
|
fi
|
||||||
|
fertig "$vorlage auf $template_storage"
|
||||||
|
|
||||||
|
netz="name=eth0,bridge=$bridge,firewall=1"
|
||||||
|
[ -n "$vlan" ] && netz="$netz,tag=$vlan"
|
||||||
|
if [ "$ip" = dhcp ]; then netz="$netz,ip=dhcp"; else netz="$netz,ip=$ip,gw=$gw"; fi
|
||||||
|
|
||||||
|
schritt "Container $ctid anlegen"
|
||||||
|
pct create "$ctid" "$template_storage:vztmpl/$vorlage" \
|
||||||
|
--hostname "$hostname" --cores "$cores" --memory "$memory" --swap 512 \
|
||||||
|
--rootfs "$storage:$disk" --net0 "$netz" \
|
||||||
|
--unprivileged 1 --features nesting=1 --onboot 1 \
|
||||||
|
--tags "proxdeploy${tags:+;$tags}" \
|
||||||
|
--description "ProxDeploy $version – eingerichtet mit install-lxc.sh" >/dev/null
|
||||||
|
pct start "$ctid"
|
||||||
|
fertig "Container $ctid ($hostname) läuft"
|
||||||
|
|
||||||
|
schritt "Netzwerk"
|
||||||
|
adresse=""
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
adresse="$(pct exec "$ctid" -- ip -4 -o addr show dev eth0 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)"
|
||||||
|
[ -n "$adresse" ] && pct exec "$ctid" -- getent hosts deb.debian.org >/dev/null 2>&1 && break
|
||||||
|
adresse=""
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$adresse" ] || fehler "Container $ctid bekommt keine Adresse oder kein DNS."
|
||||||
|
fertig "IP $adresse"
|
||||||
|
|
||||||
|
[ -n "$url" ] || url="http://$adresse:3000"
|
||||||
|
pushen "$ctid"
|
||||||
|
pct exec "$ctid" -- bash /root/proxdeploy-im-container.sh installieren --url "$url"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# ProxDeploy im Betrieb: postgres, migrate (einmalig), app, worker.
|
||||||
|
#
|
||||||
|
# cp .env.example .env # Schlüssel erzeugen, ADMIN_PASSWORT und APP_URL setzen
|
||||||
|
# docker compose up -d --build
|
||||||
|
#
|
||||||
|
# Die app lauscht auf 127.0.0.1:3000 – davor gehört ein Reverse-Proxy mit TLS (TRUST_PROXY=true).
|
||||||
|
|
||||||
|
name: proxdeploy
|
||||||
|
|
||||||
|
x-proxdeploy: &proxdeploy
|
||||||
|
image: proxdeploy:${PROXDEPLOY_VERSION:-latest}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
VERSION: ${PROXDEPLOY_VERSION:-}
|
||||||
|
env_file: ${PROXDEPLOY_ENV_DATEI:-.env}
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://proxdeploy:${POSTGRES_PASSWORT:?POSTGRES_PASSWORT in .env setzen}@postgres:5432/proxdeploy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17.11-trixie
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: proxdeploy
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORT:?POSTGRES_PASSWORT in .env setzen}
|
||||||
|
POSTGRES_DB: proxdeploy
|
||||||
|
volumes:
|
||||||
|
- datenbank:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U proxdeploy"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
<<: *proxdeploy
|
||||||
|
command: ["node", "../worker/migrieren.mjs"]
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
app:
|
||||||
|
<<: *proxdeploy
|
||||||
|
ports:
|
||||||
|
- "${PROXDEPLOY_BIND:-127.0.0.1}:${PROXDEPLOY_PORT:-3000}:3000"
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
worker:
|
||||||
|
<<: *proxdeploy
|
||||||
|
command: ["node", "../worker/worker.mjs"]
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
# Lange Aufträge (Template-Bau) dürfen beim Stoppen zu Ende laufen.
|
||||||
|
stop_grace_period: 40s
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
datenbank:
|
||||||
@@ -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).
|
||||||
|
|||||||
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 137 KiB |
@@ -1,21 +1,26 @@
|
|||||||
# #009 LXC-Container ausrollen
|
# #009 LXC-Container ausrollen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** feature, proxmox
|
**Labels:** feature, proxmox
|
||||||
**Geschätzt:** 8 h **Tatsächlich:** –
|
**Geschätzt:** 8 h **Tatsächlich:** 0 h 30 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container.
|
LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH)
|
- [x] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH)
|
||||||
- [ ] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen)
|
- [x] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] LXC mit base auf echtem Proxmox, einmal mit und einmal ohne Node-SSH
|
- [x] Auf pve-demo: pd-ct01 (Debian 13, base+docker, pct exec, docker hello-world im unprivilegierten CT mit nesting) und pd-ct02 (Ubuntu 24.04, base, SSH in den Container, Bereitstellungsschlüssel danach entfernt)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#008
|
#008
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Fund:** chrony läuft in unprivilegierten Containern nicht (keine CAP_SYS_TIME). Profile kennen jetzt die festen Bedingungen `if: vm` / `if: lxc`; base installiert chrony nur in VMs.
|
||||||
|
- **Fund:** Ubuntu 24.04 startet sshd per Socket, `/run/sshd` fehlt bis zur ersten Verbindung – `sshd -t` scheiterte. Der Adapter legt das Verzeichnis vorher an.
|
||||||
|
- **Docker im unprivilegierten Container braucht nur nesting**, kein keyctl (geprüft auf PVE 9.2.2 mit Debian 13).
|
||||||
|
- **keyctl** darf per Token nicht gesetzt werden; mit Node-SSH setzt ProxDeploy es per `pct set`, ohne lehnt die Prüfung ab.
|
||||||
|
- **Bereitstellungsschlüssel** (eigener ed25519, verschlüsselt in den Einstellungen) nur für den Weg ohne Node-SSH; die Schlüssel des Benutzers gehen nie an root.
|
||||||
|
- Bei mehreren passenden Container-Templates wird das neueste aus `aplinfo` geladen, wenn lokal nur ein älteres liegt.
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
# #010 Ubuntu 22.04, 24.04 und 26.04
|
# #010 Ubuntu 22.04, 24.04 und 26.04
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** feature, katalog
|
**Labels:** feature, katalog
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 05 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Ubuntu als VM und LXC über den debian-Adapter.
|
Ubuntu als VM und LXC über den debian-Adapter.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades)
|
- [x] Unterschiede im Adapter (Codename-Ermittlung, Locale-Paket, unattended-upgrades)
|
||||||
- [ ] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12)
|
- [x] Ubuntu 26.04 „Resolute“ (entschieden 2026-10-12)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Je eine Ubuntu-VM und ein Ubuntu-LXC ausgerollt
|
- [x] Auf pve-demo: pd-ubu01 (Ubuntu 24.04 VM, Template 9001 im selben Auftrag gebaut) und pd-ct02 (Ubuntu 24.04 LXC)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#009
|
#009
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- Der Adapter brauchte für Ubuntu nur eine Änderung: `/run/sshd` anlegen (sshd per Socket, #009). Locale-Erzeugung unterscheidet Debian/Ubuntu.
|
||||||
|
- Ubuntu 22.04 und 26.04 laufen im Containertest (`pnpm test:container`); auf dem Testhost geprüft ist 24.04.
|
||||||
|
- Der Voll-Klon dauerte auf pve-demo ~9 min (3,5 GB, Storage ~3 MB/s) – die Phase „Klonen“ zeigt jetzt den Fortschritt aus dem Task-Log.
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
# #011 Profile docker und zabbix-agent, Variablen, Editor
|
# #011 Profile docker und zabbix-agent, Variablen, Editor
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** feature
|
**Labels:** feature
|
||||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
**Geschätzt:** 6 h **Tatsächlich:** 0 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Kombinierbare Profile mit Variablen; eigene Profile im YAML-Editor.
|
Kombinierbare Profile mit Variablen; eigene Profile im YAML-Editor.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive)
|
- [x] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive)
|
||||||
- [ ] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie
|
- [x] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie
|
||||||
- [ ] YAML-Editor mit Validierung und Fehlerstelle
|
- [x] YAML-Editor mit Validierung und Fehlerstelle
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] docker run hello-world im ausgerollten Gast erfolgreich
|
- [x] docker hello-world in pd-test01 (VM) und pd-ct01 (LXC); zabbix-agent2 aktiv in pd-test02
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#005
|
#005
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- Editor prüft beim Tippen (400 ms Pause) gegen dasselbe Schema wie der Katalog und zeigt das erzeugte Skript.
|
||||||
|
- Katalogprofile sind schreibgeschützt; „Als Kopie bearbeiten“ legt `<id>-eigen` an. Eigene Profile mit Katalog-id werden abgewiesen.
|
||||||
|
- Profile anlegen dürfen nur Admins – sie führen Befehle als root aus.
|
||||||
|
- Löschen verweigert, solange ein anderes Profil es per `requires` braucht.
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
# #012 Vorlagen (Presets)
|
# #012 Vorlagen (Presets)
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** feature
|
**Labels:** feature
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 11 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar.
|
Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] CRUD, „aus diesem Auftrag als Vorlage speichern“, Beispiel „Docker-Host klein“
|
- [x] CRUD, „Als Vorlage speichern“ im Assistenten, Beispiel „Docker-Host klein“ (einmalig angelegt)
|
||||||
|
- [x] Vorlagen-Auswahl im Assistenten, `?vorlage=` von der Vorlagenseite, `vorlage` in der API (für `--preset` der CLI)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] `--preset` in API und Assistent identisch aufgelöst
|
- [x] `--preset` in API und Assistent identisch aufgelöst
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#008
|
#008
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- Eine Vorlage liegt **unter** der Anfrage: was die Anfrage sagt, gewinnt; netz/features/variablen feldweise, Listen ersetzt.
|
||||||
|
- Name, VMID, IP und SSH-Schlüssel gehören nicht in eine Vorlage (Schema `strict`).
|
||||||
|
- Operatoren dürfen Vorlagen pflegen (Recht `vorlagenPflegen`).
|
||||||
|
|||||||
@@ -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,22 +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, SSH-Keys über `SchluesselQuelle`
|
- [x] Benutzerverwaltung (anlegen, Rolle, sperren, Passwort setzen), eigenes Passwort ändern; CLI-Tokens seit v0.4.0
|
||||||
- [ ] Audit-Log-Ansicht mit Filter
|
- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar
|
||||||
|
- [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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
# #017 Branding-Assets
|
# #017 Branding-Assets
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.6.0
|
**Meilenstein:** v0.6.0
|
||||||
**Labels:** design
|
**Labels:** design
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 17 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Alle Logo-Varianten, Banner, Favicons, Manifest-Icons nach Vorgabe.
|
Alle Logo-Varianten, Banner, Favicons, Manifest-Icons nach Vorgabe.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] symbol(-light), icon-small, avatar, logo(-light), banner 1280×640
|
- [x] symbol(-light), icon-small, avatar, logo(-light), banner 1280×640 (+ banner.png für die Social Preview)
|
||||||
- [ ] Favicon 16/32, Apple-Touch-Icon, Manifest-Icons per Skript aus SVG
|
- [x] Favicon 16/32, Apple-Touch-Icon, Manifest-Icons (192, 512, maskierbar) per Skript aus SVG (`pnpm marke`)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Darstellung in beiden Themes geprüft
|
- [x] Darstellung in beiden Themes geprüft (Vorschau-Tafel dunkel/hell, Favicons auf hellem und dunklem Tab)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Texte als Pfade:** Wortmarke und Banner sehen überall gleich aus, auch ohne installierte Schrift.
|
||||||
|
Die TTFs (JetBrains Mono 2.304, Space Grotesk 2.0.0, OFL) lädt `pnpm marke` mit SHA-256-Prüfung
|
||||||
|
nach `node_modules/.cache`; eingecheckt werden nur die Ergebnisse.
|
||||||
|
- opentype.js 1.3.5 kennt die Ligatur-Regeln von JetBrains Mono nicht und schreibt in `toPathData`
|
||||||
|
gelegentlich „NaN“ – deshalb Glyph für Glyph gesetzt und Pfaddaten selbst geschrieben.
|
||||||
|
- `icon-small.svg` passt sich per `prefers-color-scheme` an; die PNG-Favicons sind dicke Linien auf
|
||||||
|
Teal, weil 16 px mit den dünnen Linien des Symbols nicht lesbar waren.
|
||||||
|
- Unterzeile im Banner 32 statt 34 px – sonst breiter als die Ränder.
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
# #018 README und Dokumentation
|
# #018 README und Dokumentation
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.6.0
|
**Meilenstein:** v0.6.0
|
||||||
**Labels:** doku
|
**Labels:** doku
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 02 min (überwiegend parallel zu #019, während der Installer lief)
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
README mit Banner, Screenshots, Installation, CLI, Proxmox-Rechten, neuer Distribution, eigenem Profil.
|
README mit Banner, Screenshots, Installation, CLI, Proxmox-Rechten, neuer Distribution, eigenem Profil.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Screenshots aus dem gebauten Stand
|
- [x] Screenshots aus dem gebauten Stand (`scripts/screenshots.ts`, dunkel, 1440 px und 390 px, echte Daten von pve-demo)
|
||||||
- [ ] Hinweis: unabhängiges Tool, nicht von Proxmox Server Solutions
|
- [x] Hinweis: unabhängiges Tool, nicht von Proxmox Server Solutions
|
||||||
|
- [x] Banner oben, Funktionen, Installation (Compose, LXC), Erste Schritte, CLI, OIDC, Katalog erweitern
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Anleitungen einmal Schritt für Schritt nachvollzogen
|
- [x] Anleitungen einmal Schritt für Schritt nachvollzogen: Compose (eigene Env-Datei, frische DB), LXC-Installer auf pve-demo, „Erste Schritte“ in der frisch installierten Instanz (Server anlegen, testen, Token, CLI, Dry-Run, echter LXC). „Neue Distribution“ ist der Weg, auf dem Ubuntu 26.04 dazukam (#010); „Eigenes Profil“ prüft der Playwright-Ablauf `profil-editor`.
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#013
|
#013
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- Beim Erstellen der Screenshots fiel auf: die Übersicht zeigte seit Etappe 1 nur einen Platzhalter
|
||||||
|
(„Noch keine Deployments“) – jetzt Server mit Nodes, CPUs und freiem RAM sowie die letzten Aufträge.
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
# #019 Docker-Image und LXC-Installer
|
# #019 Docker-Image und LXC-Installer
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.6.0
|
**Meilenstein:** v0.6.0
|
||||||
**Labels:** infrastruktur
|
**Labels:** infrastruktur
|
||||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
**Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Produktionsbetrieb per Docker Compose oder in einem Debian-LXC.
|
Produktionsbetrieb per Docker Compose oder in einem Debian-LXC.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Dockerfile (standalone, non-root), Compose mit migrate
|
- [x] Dockerfile (standalone, non-root), Compose mit migrate – lokal komplett hochgefahren (migrate → app healthy, worker bereit, Anmeldung)
|
||||||
- [ ] `deploy/install-lxc.sh` (auf dem Proxmox-Host), Update-Pfad
|
- [x] `deploy/install-lxc.sh` (auf dem Proxmox-Host) + `deploy/im-container.sh`, Update-Pfad (`--aktualisieren`, drei Fassungen zum Zurückgehen)
|
||||||
|
- [x] Gemeinsames Laufzeit-Paket `scripts/paket-bauen.sh` für beide Wege
|
||||||
|
- [x] Migrationen ohne Prisma-CLI (`src/betrieb/migrieren.ts`), kompatibel zu `_prisma_migrations`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Installer in echtem LXC durchgelaufen
|
- [x] Installer in echtem LXC durchgelaufen (pve-demo, CT 109 `pd-installer`); darin Server angelegt, Verbindungstest, CLI-Login, Dry-Run und ein echter LXC-Auftrag
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Eigener Migrationslauf statt `prisma migrate deploy`:** spart Prisma-CLI und Schema-Engine im
|
||||||
|
Betrieb; schreibt `_prisma_migrations` wie Prisma (Test prüft `prisma migrate status`).
|
||||||
|
- **worker als esbuild-Bundle** mit nur den Paketen, die er importiert (aus der Metadatei, exakte
|
||||||
|
Versionen aus der Entwicklung): 103 MB statt 741 MB.
|
||||||
|
- **Node im LXC von nodejs.org** mit SHA-256-Prüfung (Debian 13 hat nur Node 20).
|
||||||
|
- `ADMIN_PASSWORT` wird nach dem ersten Start aus der Env-Datei entfernt; das Passwort steht nur in der Ausgabe.
|
||||||
|
- Image 559 MB (node:24.21.0-trixie-slim), läuft als UID 1000; PostgreSQL 17.11.
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
# #021 Template-Bau ohne SSH (Bau-Adresse)
|
# #021 Template-Bau ohne SSH (Bau-Adresse)
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** feature, proxmox
|
**Labels:** feature, proxmox
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 19 min + 0 h 35 min Abnahme
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet,
|
Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet,
|
||||||
sondern über SSH **in die Vorbereitungs-VM** eingebaut.
|
sondern über SSH **in die Vorbereitungs-VM** eingebaut.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Server-Einstellung `bauNetz`: Bridge, VLAN, statische IP/CIDR, Gateway, DNS
|
- [x] Server-Einstellung `bauNetz`: statische IP/CIDR, Gateway, DNS (Bridge/VLAN wie beim Template-Bau gewählt)
|
||||||
- [ ] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys`
|
- [x] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys`
|
||||||
- [ ] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten
|
- [x] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten
|
||||||
- [ ] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
|
- [x] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH
|
- [x] Template-Bau auf pve-demo mit abgeschaltetem SSH (2026-10-12, Bau-Adresse 10.0.10.180): Template 9003 tpl-debian-12; daraus VM 111 pd-deb12 ebenfalls ohne Node-SSH ausgerollt, Agent aus dem Template, eigene Maschinen-ID
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#007
|
#007
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Fähigkeitserkennung war zu optimistisch:** „Template-Bau per API“ stand auch ohne Node-SSH da, obwohl der Guest-Agent dann nicht hineinkam. Jetzt: per API nur mit Snippet-Weg oder Bau-Adresse.
|
||||||
|
- Der Bereitstellungsschlüssel kommt per cloud-init an den Standardbenutzer des Images; danach werden alle authorized_keys, ciuser, sshkeys und die feste Adresse entfernt.
|
||||||
|
- Host-Key der frischen VM ist vorher unbekannt (TOFU, nur für diese eine Verbindung).
|
||||||
- Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH.
|
- Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH.
|
||||||
|
- **Abnahme fand einen echten Fehler:** Das cloud-init-Laufwerk hing als `ide2` an der q35-Maschine
|
||||||
|
(also am SATA-Controller). Der „cloud“-Kernel von Debian 12 hat dafür keinen Treiber – cloud-init
|
||||||
|
fand keine Datenquelle, die VM bootete als „localhost“ ohne Adresse. Debian 13 und Ubuntu kennen
|
||||||
|
den Controller, deshalb fiel es bisher nicht auf. Neue Templates hängen das Laufwerk an `scsi1`
|
||||||
|
(virtio-SCSI wie die System-Disk). Vorhandene Templates bleiben, wie sie sind.
|
||||||
|
- Bau-Adresse per Ping- und ARP-Probe vom Testhost gewählt (10.0.10.150–199 stumm, Florians Freigabe „such dir eine IP“).
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# #022 Templates mit eingebackenen Profilen
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.3.0
|
||||||
|
**Labels:** feature, proxmox
|
||||||
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 22 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Beim Template-Bau Profile (z. B. base, docker) gleich mit installieren, damit Deployments schneller
|
||||||
|
fertig sind. Wunsch von Florian am 2026-10-12.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] Template-Bau-Anfrage um `profile` erweitern (nur Profile ohne Pflichtvariablen bzw. mit Standardwerten)
|
||||||
|
- [x] Nach dem Vorbereitungsstart: VM erneut starten, Profile über den Guest-Agent ausführen (gleicher Code wie beim Deployment)
|
||||||
|
- [x] Aufräumen vor dem Template: Maschinen-ID leeren, `cloud-init clean`, SSH-Host-Keys löschen, `apt-get clean`, Logdatei entfernen
|
||||||
|
- [x] Name/Tags: `tpl-debian-13-docker`, Tags `proxdeploy-template;debian-13;profil-docker`
|
||||||
|
- [x] Ausrollen: passendes Template bevorzugen (eingebackene Profile ⊆ gewählte); eingebackene Profile beim Deploy überspringen, wenn ihre Variablen den Standardwerten entsprechen
|
||||||
|
- [ ] Optional: zusätzlicher Admin-/Notfall-Schlüssel im Template – zurückgestellt; Standard-Schlüssel je Benutzer/Team kommen stattdessen (#015)
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- [x] Template 9002 `tpl-debian-13-docker` auf pve-demo (8 min). Provisionierung beim Deploy: 141 s ohne, 29–32 s mit eingebackenem base+docker
|
||||||
|
|
||||||
|
## Abhängigkeiten
|
||||||
|
#007, #008
|
||||||
|
|
||||||
|
## Notizen / Entscheidungen
|
||||||
|
- SSH-Schlüssel gehören grundsätzlich nicht ins Template: beim Wechsel müsste jedes Template und jeder Klon angefasst werden. Dafür kommen Standard-Schlüssel je Benutzer/Team (aus #015 vorgezogen).
|
||||||
|
- **Fund:** Profile mit `{{ guest.* }}` hängen vom einzelnen Gast ab (docker nimmt den Benutzer in die Gruppe auf). Sie laufen beim Deploy trotzdem noch einmal – idempotent und schnell. Übersprungen wird nur, was gastunabhängig ist und Standardwerte hat.
|
||||||
|
- Vor dem Template: Maschinen-ID leeren, SSH-Host-Keys und Docker-engine-id löschen, apt-Cache leeren, `cloud-init clean`; geprüft: Klone haben eigene Maschinen-ID und eigene Host-Keys.
|
||||||
|
- Ausrollen wählt das Template mit den meisten eingebackenen Profilen, deren Profile alle gewählt sind – nie eins mit ungewählter Software.
|
||||||
|
- Gemeinsames Modul `gastagent.ts` für Provisionierung über den Guest-Agent (Deploy und Template).
|
||||||
@@ -12,16 +12,17 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min |
|
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min |
|
||||||
| [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min |
|
| [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min |
|
||||||
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min |
|
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min |
|
||||||
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – |
|
| [#009](009-lxc.md) | LXC-Container ausrollen | erledigt | v0.3.0 | 8 h | 0 h 30 min |
|
||||||
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – |
|
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | erledigt | v0.3.0 | 3 h | 0 h 05 min |
|
||||||
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – |
|
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | erledigt | v0.3.0 | 6 h | 0 h 10 min |
|
||||||
| [#012](012-vorlagen.md) | Vorlagen (Presets) | offen | v0.3.0 | 3 h | – |
|
| [#012](012-vorlagen.md) | Vorlagen (Presets) | erledigt | v0.3.0 | 3 h | 0 h 11 min |
|
||||||
| [#013](013-cli.md) | CLI proxdeploy (Go) | offen | v0.4.0 | 12 h | – |
|
| [#013](013-cli.md) | CLI proxdeploy (Go) | erledigt | v0.4.0 | 12 h | 0 h 42 min |
|
||||||
| [#014](014-verlauf.md) | Verlauf und Wiederholen | offen | v0.5.0 | 4 h | – |
|
| [#014](014-verlauf.md) | Verlauf und Wiederholen | 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 | erledigt | v0.6.0 | 4 h | 0 h 17 min |
|
||||||
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
|
| [#018](018-readme.md) | README und Dokumentation | erledigt | v0.6.0 | 4 h | 0 h 02 min |
|
||||||
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
| [#019](019-installer.md) | Docker-Image und LXC-Installer | erledigt | v0.6.0 | 6 h | 0 h 20 min |
|
||||||
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
|
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
|
||||||
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | offen | v0.3.0 | 3 h | – |
|
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | erledigt | v0.3.0 | 3 h | 0 h 54 min |
|
||||||
|
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 22 min |
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ families:
|
|||||||
| `run: <befehl>` | freier Shell-Befehl – nicht portabel, nur wenn nichts anderes passt |
|
| `run: <befehl>` | freier Shell-Befehl – nicht portabel, nur wenn nichts anderes passt |
|
||||||
|
|
||||||
Jeder Schritt kann `if: <variable>` (oder `if: "!<variable>"`) tragen und läuft dann nur, wenn die
|
Jeder Schritt kann `if: <variable>` (oder `if: "!<variable>"`) tragen und läuft dann nur, wenn die
|
||||||
Variable wahr bzw. falsch ist.
|
Variable wahr bzw. falsch ist. Fest eingebaut sind `if: vm` und `if: lxc` – etwa für Zeitsync, die
|
||||||
|
in einem unprivilegierten Container nicht laufen kann.
|
||||||
|
|
||||||
### Platzhalter
|
### Platzhalter
|
||||||
|
|
||||||
|
|||||||
@@ -43,5 +43,7 @@ export default defineConfig([
|
|||||||
"playwright-report/**",
|
"playwright-report/**",
|
||||||
"dist/**",
|
"dist/**",
|
||||||
"cli/**",
|
"cli/**",
|
||||||
|
// Laufzeit-Paket aus scripts/paket-bauen.sh.
|
||||||
|
"paket/**",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxdeploy",
|
"name": "proxdeploy",
|
||||||
"version": "0.2.0",
|
"version": "0.6.1",
|
||||||
"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,10 @@
|
|||||||
"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",
|
||||||
|
"marke": "tsx scripts/marke-erzeugen.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
@@ -38,6 +41,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",
|
||||||
@@ -52,8 +56,10 @@
|
|||||||
"@commitlint/cli": "^21.2.3",
|
"@commitlint/cli": "^21.2.3",
|
||||||
"@commitlint/config-conventional": "^21.2.3",
|
"@commitlint/config-conventional": "^21.2.3",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
|
"@resvg/resvg-js": "2.6.2",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/node": "^24.13.6",
|
"@types/node": "^24.13.6",
|
||||||
|
"@types/opentype.js": "^1.3.10",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
@@ -65,6 +71,7 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^17.5.1",
|
"lint-staged": "^17.5.1",
|
||||||
|
"opentype.js": "1.3.5",
|
||||||
"prettier": "^3.9.9",
|
"prettier": "^3.9.9",
|
||||||
"prisma": "7.10.0",
|
"prisma": "7.10.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -230,12 +233,18 @@ importers:
|
|||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: 1.63.0
|
specifier: 1.63.0
|
||||||
version: 1.63.0
|
version: 1.63.0
|
||||||
|
'@resvg/resvg-js':
|
||||||
|
specifier: 2.6.2
|
||||||
|
version: 2.6.2
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.3.3
|
specifier: ^4.3.3
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.13.6
|
specifier: ^24.13.6
|
||||||
version: 24.19.2
|
version: 24.19.2
|
||||||
|
'@types/opentype.js':
|
||||||
|
specifier: ^1.3.10
|
||||||
|
version: 1.3.10
|
||||||
'@types/pg':
|
'@types/pg':
|
||||||
specifier: ^8.23.1
|
specifier: ^8.23.1
|
||||||
version: 8.23.1
|
version: 8.23.1
|
||||||
@@ -269,6 +278,9 @@ importers:
|
|||||||
lint-staged:
|
lint-staged:
|
||||||
specifier: ^17.5.1
|
specifier: ^17.5.1
|
||||||
version: 17.6.0
|
version: 17.6.0
|
||||||
|
opentype.js:
|
||||||
|
specifier: 1.3.5
|
||||||
|
version: 1.3.5
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.9.9
|
specifier: ^3.9.9
|
||||||
version: 3.9.10
|
version: 3.9.10
|
||||||
@@ -1354,6 +1366,86 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-android-arm-eabi@2.6.2':
|
||||||
|
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-android-arm64@2.6.2':
|
||||||
|
resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-darwin-arm64@2.6.2':
|
||||||
|
resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-darwin-x64@2.6.2':
|
||||||
|
resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
|
||||||
|
resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
|
||||||
|
resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
|
||||||
|
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
|
||||||
|
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-x64-musl@2.6.2':
|
||||||
|
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
|
||||||
|
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
|
||||||
|
resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
|
||||||
|
resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@resvg/resvg-js@2.6.2':
|
||||||
|
resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
'@rolldown/binding-android-arm-eabi@1.2.13':
|
'@rolldown/binding-android-arm-eabi@1.2.13':
|
||||||
resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==}
|
resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1625,6 +1717,9 @@ packages:
|
|||||||
'@types/node@24.19.2':
|
'@types/node@24.19.2':
|
||||||
resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==}
|
resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==}
|
||||||
|
|
||||||
|
'@types/opentype.js@1.3.10':
|
||||||
|
resolution: {integrity: sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ==}
|
||||||
|
|
||||||
'@types/pg@8.23.1':
|
'@types/pg@8.23.1':
|
||||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||||
|
|
||||||
@@ -3183,6 +3278,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 +3324,14 @@ 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==}
|
||||||
|
|
||||||
|
opentype.js@1.3.5:
|
||||||
|
resolution: {integrity: sha512-thKDiELidAApOvXlncrpwDZKJCa9fXLEKM4+FoEWI+qTLDeNb+h7EkN+8a7KQODsB1GZ+Exz9KknkoPrEdXZDw==}
|
||||||
|
deprecated: This version should be ignored, it was meant to be published as 2.0.0 but got published as 1.3.5 by mistake. As of May 6th 2026 the latest v1 release is v1.3.4
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
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'}
|
||||||
@@ -4879,6 +4985,57 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.3.0
|
'@types/react': 19.3.0
|
||||||
|
|
||||||
|
'@resvg/resvg-js-android-arm-eabi@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-android-arm64@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-darwin-arm64@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-darwin-x64@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-x64-musl@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js@2.6.2':
|
||||||
|
optionalDependencies:
|
||||||
|
'@resvg/resvg-js-android-arm-eabi': 2.6.2
|
||||||
|
'@resvg/resvg-js-android-arm64': 2.6.2
|
||||||
|
'@resvg/resvg-js-darwin-arm64': 2.6.2
|
||||||
|
'@resvg/resvg-js-darwin-x64': 2.6.2
|
||||||
|
'@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2
|
||||||
|
'@resvg/resvg-js-linux-arm64-gnu': 2.6.2
|
||||||
|
'@resvg/resvg-js-linux-arm64-musl': 2.6.2
|
||||||
|
'@resvg/resvg-js-linux-x64-gnu': 2.6.2
|
||||||
|
'@resvg/resvg-js-linux-x64-musl': 2.6.2
|
||||||
|
'@resvg/resvg-js-win32-arm64-msvc': 2.6.2
|
||||||
|
'@resvg/resvg-js-win32-ia32-msvc': 2.6.2
|
||||||
|
'@resvg/resvg-js-win32-x64-msvc': 2.6.2
|
||||||
|
|
||||||
'@rolldown/binding-android-arm-eabi@1.2.13':
|
'@rolldown/binding-android-arm-eabi@1.2.13':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -5069,6 +5226,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.24.6
|
undici-types: 7.24.6
|
||||||
|
|
||||||
|
'@types/opentype.js@1.3.10': {}
|
||||||
|
|
||||||
'@types/pg@8.23.1':
|
'@types/pg@8.23.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.19.2
|
'@types/node': 24.19.2
|
||||||
@@ -6784,6 +6943,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 +6993,13 @@ 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
|
||||||
|
|
||||||
|
opentype.js@1.3.5: {}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Vorlage" (
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"titel" TEXT NOT NULL,
|
||||||
|
"beschreibung" TEXT,
|
||||||
|
"parameter" JSONB NOT NULL,
|
||||||
|
"erstelltVonId" TEXT,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Vorlage_pkey" PRIMARY KEY ("name")
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Template" ADD COLUMN "profile" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SshSchluessel" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"oeffentlich" TEXT NOT NULL,
|
||||||
|
"fingerabdruck" TEXT NOT NULL,
|
||||||
|
"quelle" TEXT NOT NULL DEFAULT 'lokal',
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "SshSchluessel_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SshSchluessel_benutzerId_fingerabdruck_key" ON "SshSchluessel"("benutzerId", "fingerabdruck");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SshSchluessel" ADD CONSTRAINT "SshSchluessel_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ApiToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"praefix" TEXT NOT NULL,
|
||||||
|
"hash" TEXT NOT NULL,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"zuletztGenutzt" TIMESTAMP(3),
|
||||||
|
"ablaufAm" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ApiToken_hash_key" ON "ApiToken"("hash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ApiToken_benutzerId_name_key" ON "ApiToken"("benutzerId", "name");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Benutzer" ADD COLUMN "sitzungenAb" TIMESTAMP(3);
|
||||||
@@ -33,12 +33,16 @@ 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())
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
protokoll Protokoll[]
|
protokoll Protokoll[]
|
||||||
auftraege Auftrag[]
|
auftraege Auftrag[]
|
||||||
|
schluessel SshSchluessel[]
|
||||||
|
tokens ApiToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
|
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
|
||||||
@@ -271,9 +275,57 @@ model Template {
|
|||||||
storage String
|
storage String
|
||||||
imageDatei String
|
imageDatei String
|
||||||
pruefsumme String
|
pruefsumme String
|
||||||
|
/// Eingebackene Profile (#022), aufgelöst inkl. Voraussetzungen.
|
||||||
|
profile String[] @default([])
|
||||||
gebautVonAuftragId String?
|
gebautVonAuftragId String?
|
||||||
gebautAm DateTime @default(now())
|
gebautAm DateTime @default(now())
|
||||||
|
|
||||||
@@unique([serverId, vmid])
|
@@unique([serverId, vmid])
|
||||||
@@index([serverId, distributionId])
|
@@index([serverId, distributionId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vorlage (Preset): eine gespeicherte Kombination, die Assistent, CLI (--preset) und API vorbelegen.
|
||||||
|
model Vorlage {
|
||||||
|
/// Kurzname für die CLI: „docker-host-klein“
|
||||||
|
name String @id
|
||||||
|
titel String
|
||||||
|
beschreibung String?
|
||||||
|
/// Teilmenge der Ausroll-Anfrage (typ, distribution, cores, speicherMb, diskGb, profile, variablen, tags …).
|
||||||
|
parameter Json
|
||||||
|
erstelltVonId String?
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
geaendertAm DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Öffentliche SSH-Schlüssel, die in Gäste kommen. benutzerId leer = Team-Schlüssel (in jeden Gast).
|
||||||
|
model SshSchluessel {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
benutzerId String?
|
||||||
|
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
oeffentlich String
|
||||||
|
/// SHA256:… wie ssh-keygen -lf
|
||||||
|
fingerabdruck String
|
||||||
|
/// Kennung der SchluesselQuelle („lokal“; später z. B. eine externe Schlüsselverwaltung).
|
||||||
|
quelle String @default("lokal")
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([benutzerId, fingerabdruck])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API-Token der CLI (`Authorization: Bearer pd_…`). Gespeichert wird nur der SHA-256 des Tokens –
|
||||||
|
/// es hat 256 Bit Zufall, ein langsamer Hash brächte nichts. Rolle kommt je Anfrage vom Benutzer.
|
||||||
|
model ApiToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
benutzerId String
|
||||||
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
/// Die ersten Zeichen zur Wiedererkennung („pd_AbC1…“).
|
||||||
|
praefix String
|
||||||
|
hash String @unique
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
zuletztGenutzt DateTime?
|
||||||
|
ablaufAm DateTime?
|
||||||
|
|
||||||
|
@@unique([benutzerId, name])
|
||||||
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#2DD4BF"/>
|
||||||
|
<g transform="translate(7.039999999999999 7.039999999999999) scale(0.78)">
|
||||||
|
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#11161C" stroke-width="3.5" stroke-linejoin="round"/>
|
||||||
|
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#11161C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M24 47 H40" stroke="#11161C" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 574 B |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 887 B |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||||
|
<style>.h{stroke:#E6EDF3}.p{stroke:#2DD4BF}@media (prefers-color-scheme: light){.h{stroke:#11161C}.p{stroke:#0F766E}}</style>
|
||||||
|
<path class="h" d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke-width="5.5" stroke-linejoin="round"/>
|
||||||
|
<path class="p" d="M32 43 V18 M22 28 L32 18 L42 28" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 458 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 282 64" width="282" height="64" fill="none">
|
||||||
|
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#11161C" stroke-width="3.5" stroke-linejoin="round"/>
|
||||||
|
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#0F766E" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M24 47 H40" stroke="#0F766E" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M83.46 43L80.76 43L80.76 21.1L87.81 21.1Q90.87 21.1 92.69 22.86Q94.5 24.61 94.5 27.55L94.5 27.55Q94.5 30.49 92.69 32.25Q90.87 34 87.81 34L87.81 34L83.46 34L83.46 43M83.46 23.53L83.46 31.57L87.81 31.57Q89.58 31.57 90.65 30.48Q91.71 29.38 91.71 27.55L91.71 27.55Q91.71 25.69 90.65 24.61Q89.58 23.53 87.81 23.53L87.81 23.53L83.46 23.53 M102.06 43L99.36 43L99.36 26.5L102.06 26.5L102.06 29.65L102.12 29.65Q102.33 28.06 103.47 27.13Q104.61 26.2 106.53 26.2L106.53 26.2Q109.11 26.2 110.51 27.75Q111.9 29.29 111.9 32.14L111.9 32.14L111.9 33.55L109.2 33.55L109.2 32.14Q109.2 28.54 105.69 28.54L105.69 28.54Q103.92 28.54 102.99 29.56Q102.06 30.58 102.06 32.5L102.06 32.5L102.06 43 M123 43.24L123 43.24Q120.03 43.24 118.28 41.5Q116.52 39.76 116.52 36.64L116.52 36.64L116.52 32.86Q116.52 29.71 118.26 27.99Q120 26.26 123 26.26L123 26.26Q126 26.26 127.74 27.99Q129.48 29.71 129.48 32.86L129.48 32.86L129.48 36.64Q129.48 39.76 127.73 41.5Q125.97 43.24 123 43.24M123 40.84L123 40.84Q124.77 40.84 125.78 39.85Q126.78 38.86 126.78 36.94L126.78 36.94L126.78 32.56Q126.78 30.64 125.78 29.65Q124.77 28.66 123 28.66L123 28.66Q121.26 28.66 120.24 29.65Q119.22 30.64 119.22 32.56L119.22 32.56L119.22 36.94Q119.22 38.86 120.24 39.85Q121.26 40.84 123 40.84 M136.68 43L133.5 43L139.35 34.51L133.86 26.5L137.04 26.5L140.31 31.6Q140.52 31.93 140.72 32.32Q140.91 32.71 141.03 32.95L141.03 32.95Q141.12 32.71 141.32 32.32Q141.51 31.93 141.72 31.6L141.72 31.6L144.99 26.5L148.17 26.5L142.68 34.54L148.5 43L145.32 43L141.78 37.6Q141.57 37.27 141.38 36.84Q141.18 36.4 141.03 36.13L141.03 36.13Q140.91 36.4 140.69 36.84Q140.46 37.27 140.22 37.6L140.22 37.6L136.68 43" fill="#11161C"/>
|
||||||
|
<path d="M158.76 43L152.25 43L152.25 21.1L158.76 21.1Q160.98 21.1 162.62 21.96Q164.25 22.81 165.15 24.34Q166.05 25.87 166.05 27.94L166.05 27.94L166.05 36.13Q166.05 38.2 165.15 39.74Q164.25 41.29 162.62 42.15Q160.98 43 158.76 43L158.76 43M156 24.46L156 39.64L158.76 39.64Q160.38 39.64 161.34 38.7Q162.3 37.75 162.3 36.13L162.3 36.13L162.3 27.94Q162.3 26.35 161.34 25.41Q160.38 24.46 158.76 24.46L158.76 24.46L156 24.46 M177.03 43.3L177.03 43.3Q174.93 43.3 173.37 42.49Q171.81 41.68 170.96 40.2Q170.1 38.71 170.1 36.7L170.1 36.7L170.1 32.8Q170.1 30.79 170.96 29.31Q171.81 27.82 173.37 27.01Q174.93 26.2 177.03 26.2L177.03 26.2Q179.1 26.2 180.65 27.01Q182.19 27.82 183.05 29.28Q183.9 30.73 183.9 32.68L183.9 32.68L183.9 35.68L173.7 35.68L173.7 36.82Q173.7 38.5 174.57 39.35Q175.44 40.21 177.03 40.21L177.03 40.21Q178.2 40.21 179.03 39.81Q179.85 39.4 180 38.62L180 38.62L183.72 38.62Q183.33 40.75 181.5 42.03Q179.67 43.3 177.03 43.3M173.7 32.68L173.7 32.68L173.7 33.25L180.3 33.22L180.3 32.65Q180.3 30.94 179.46 30.01Q178.62 29.08 177.03 29.08L177.03 29.08Q175.38 29.08 174.54 30.03Q173.7 30.97 173.7 32.68 M192.06 48.4L188.31 48.4L188.31 26.5L191.97 26.5L191.97 29.53Q192.42 27.97 193.59 27.09Q194.76 26.2 196.5 26.2L196.5 26.2Q198.09 26.2 199.31 26.98Q200.52 27.76 201.2 29.19Q201.87 30.61 201.87 32.5L201.87 32.5L201.87 36.97Q201.87 39.82 200.4 41.56Q198.93 43.3 196.5 43.3L196.5 43.3Q194.76 43.3 193.58 42.4Q192.39 41.5 191.94 39.88L191.94 39.88L192.06 43.78L192.06 48.4M195.09 40.06L195.09 40.06Q196.53 40.06 197.33 39.28Q198.12 38.5 198.12 36.88L198.12 36.88L198.12 32.62Q198.12 31 197.33 30.22Q196.53 29.44 195.09 29.44L195.09 29.44Q193.65 29.44 192.86 30.31Q192.06 31.18 192.06 32.77L192.06 32.77L192.06 36.73Q192.06 38.32 192.86 39.19Q193.65 40.06 195.09 40.06 M220.77 43L215.67 43Q213.21 43 211.73 41.53Q210.24 40.06 210.24 37.66L210.24 37.66L210.24 24.49L204.81 24.49L204.81 21.1L213.99 21.1L213.99 37.57Q213.99 38.5 214.52 39.06Q215.04 39.61 215.94 39.61L215.94 39.61L220.77 39.61L220.77 43 M231 43.27L231 43.27Q227.82 43.27 225.96 41.49Q224.1 39.7 224.1 36.67L224.1 36.67L224.1 32.83Q224.1 29.77 225.96 28Q227.82 26.23 231 26.23L231 26.23Q234.18 26.23 236.04 28Q237.9 29.77 237.9 32.83L237.9 32.83L237.9 36.67Q237.9 39.7 236.04 41.49Q234.18 43.27 231 43.27M231 40L231 40Q232.5 40 233.33 39.17Q234.15 38.35 234.15 36.79L234.15 36.79L234.15 32.71Q234.15 31.15 233.33 30.33Q232.5 29.5 231 29.5L231 29.5Q229.5 29.5 228.68 30.33Q227.85 31.15 227.85 32.71L227.85 32.71L227.85 36.79Q227.85 38.35 228.68 39.17Q229.5 40 231 40 M248.88 48.4L244.95 48.4L247.29 42.1L241.08 26.5L245.04 26.5L248.22 34.93Q248.49 35.68 248.76 36.57Q249.03 37.45 249.15 38.2L249.15 38.2Q249.24 37.45 249.5 36.57Q249.75 35.68 250.02 34.93L250.02 34.93L252.96 26.5L256.92 26.5L248.88 48.4" fill="#0F766E"/>
|
||||||
|
<rect x="261.60" y="19.30" width="15.60" height="27.90" fill="#0F766E"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 282 64" width="282" height="64" fill="none">
|
||||||
|
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#E6EDF3" stroke-width="3.5" stroke-linejoin="round"/>
|
||||||
|
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M24 47 H40" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M83.46 43L80.76 43L80.76 21.1L87.81 21.1Q90.87 21.1 92.69 22.86Q94.5 24.61 94.5 27.55L94.5 27.55Q94.5 30.49 92.69 32.25Q90.87 34 87.81 34L87.81 34L83.46 34L83.46 43M83.46 23.53L83.46 31.57L87.81 31.57Q89.58 31.57 90.65 30.48Q91.71 29.38 91.71 27.55L91.71 27.55Q91.71 25.69 90.65 24.61Q89.58 23.53 87.81 23.53L87.81 23.53L83.46 23.53 M102.06 43L99.36 43L99.36 26.5L102.06 26.5L102.06 29.65L102.12 29.65Q102.33 28.06 103.47 27.13Q104.61 26.2 106.53 26.2L106.53 26.2Q109.11 26.2 110.51 27.75Q111.9 29.29 111.9 32.14L111.9 32.14L111.9 33.55L109.2 33.55L109.2 32.14Q109.2 28.54 105.69 28.54L105.69 28.54Q103.92 28.54 102.99 29.56Q102.06 30.58 102.06 32.5L102.06 32.5L102.06 43 M123 43.24L123 43.24Q120.03 43.24 118.28 41.5Q116.52 39.76 116.52 36.64L116.52 36.64L116.52 32.86Q116.52 29.71 118.26 27.99Q120 26.26 123 26.26L123 26.26Q126 26.26 127.74 27.99Q129.48 29.71 129.48 32.86L129.48 32.86L129.48 36.64Q129.48 39.76 127.73 41.5Q125.97 43.24 123 43.24M123 40.84L123 40.84Q124.77 40.84 125.78 39.85Q126.78 38.86 126.78 36.94L126.78 36.94L126.78 32.56Q126.78 30.64 125.78 29.65Q124.77 28.66 123 28.66L123 28.66Q121.26 28.66 120.24 29.65Q119.22 30.64 119.22 32.56L119.22 32.56L119.22 36.94Q119.22 38.86 120.24 39.85Q121.26 40.84 123 40.84 M136.68 43L133.5 43L139.35 34.51L133.86 26.5L137.04 26.5L140.31 31.6Q140.52 31.93 140.72 32.32Q140.91 32.71 141.03 32.95L141.03 32.95Q141.12 32.71 141.32 32.32Q141.51 31.93 141.72 31.6L141.72 31.6L144.99 26.5L148.17 26.5L142.68 34.54L148.5 43L145.32 43L141.78 37.6Q141.57 37.27 141.38 36.84Q141.18 36.4 141.03 36.13L141.03 36.13Q140.91 36.4 140.69 36.84Q140.46 37.27 140.22 37.6L140.22 37.6L136.68 43" fill="#E6EDF3"/>
|
||||||
|
<path d="M158.76 43L152.25 43L152.25 21.1L158.76 21.1Q160.98 21.1 162.62 21.96Q164.25 22.81 165.15 24.34Q166.05 25.87 166.05 27.94L166.05 27.94L166.05 36.13Q166.05 38.2 165.15 39.74Q164.25 41.29 162.62 42.15Q160.98 43 158.76 43L158.76 43M156 24.46L156 39.64L158.76 39.64Q160.38 39.64 161.34 38.7Q162.3 37.75 162.3 36.13L162.3 36.13L162.3 27.94Q162.3 26.35 161.34 25.41Q160.38 24.46 158.76 24.46L158.76 24.46L156 24.46 M177.03 43.3L177.03 43.3Q174.93 43.3 173.37 42.49Q171.81 41.68 170.96 40.2Q170.1 38.71 170.1 36.7L170.1 36.7L170.1 32.8Q170.1 30.79 170.96 29.31Q171.81 27.82 173.37 27.01Q174.93 26.2 177.03 26.2L177.03 26.2Q179.1 26.2 180.65 27.01Q182.19 27.82 183.05 29.28Q183.9 30.73 183.9 32.68L183.9 32.68L183.9 35.68L173.7 35.68L173.7 36.82Q173.7 38.5 174.57 39.35Q175.44 40.21 177.03 40.21L177.03 40.21Q178.2 40.21 179.03 39.81Q179.85 39.4 180 38.62L180 38.62L183.72 38.62Q183.33 40.75 181.5 42.03Q179.67 43.3 177.03 43.3M173.7 32.68L173.7 32.68L173.7 33.25L180.3 33.22L180.3 32.65Q180.3 30.94 179.46 30.01Q178.62 29.08 177.03 29.08L177.03 29.08Q175.38 29.08 174.54 30.03Q173.7 30.97 173.7 32.68 M192.06 48.4L188.31 48.4L188.31 26.5L191.97 26.5L191.97 29.53Q192.42 27.97 193.59 27.09Q194.76 26.2 196.5 26.2L196.5 26.2Q198.09 26.2 199.31 26.98Q200.52 27.76 201.2 29.19Q201.87 30.61 201.87 32.5L201.87 32.5L201.87 36.97Q201.87 39.82 200.4 41.56Q198.93 43.3 196.5 43.3L196.5 43.3Q194.76 43.3 193.58 42.4Q192.39 41.5 191.94 39.88L191.94 39.88L192.06 43.78L192.06 48.4M195.09 40.06L195.09 40.06Q196.53 40.06 197.33 39.28Q198.12 38.5 198.12 36.88L198.12 36.88L198.12 32.62Q198.12 31 197.33 30.22Q196.53 29.44 195.09 29.44L195.09 29.44Q193.65 29.44 192.86 30.31Q192.06 31.18 192.06 32.77L192.06 32.77L192.06 36.73Q192.06 38.32 192.86 39.19Q193.65 40.06 195.09 40.06 M220.77 43L215.67 43Q213.21 43 211.73 41.53Q210.24 40.06 210.24 37.66L210.24 37.66L210.24 24.49L204.81 24.49L204.81 21.1L213.99 21.1L213.99 37.57Q213.99 38.5 214.52 39.06Q215.04 39.61 215.94 39.61L215.94 39.61L220.77 39.61L220.77 43 M231 43.27L231 43.27Q227.82 43.27 225.96 41.49Q224.1 39.7 224.1 36.67L224.1 36.67L224.1 32.83Q224.1 29.77 225.96 28Q227.82 26.23 231 26.23L231 26.23Q234.18 26.23 236.04 28Q237.9 29.77 237.9 32.83L237.9 32.83L237.9 36.67Q237.9 39.7 236.04 41.49Q234.18 43.27 231 43.27M231 40L231 40Q232.5 40 233.33 39.17Q234.15 38.35 234.15 36.79L234.15 36.79L234.15 32.71Q234.15 31.15 233.33 30.33Q232.5 29.5 231 29.5L231 29.5Q229.5 29.5 228.68 30.33Q227.85 31.15 227.85 32.71L227.85 32.71L227.85 36.79Q227.85 38.35 228.68 39.17Q229.5 40 231 40 M248.88 48.4L244.95 48.4L247.29 42.1L241.08 26.5L245.04 26.5L248.22 34.93Q248.49 35.68 248.76 36.57Q249.03 37.45 249.15 38.2L249.15 38.2Q249.24 37.45 249.5 36.57Q249.75 35.68 250.02 34.93L250.02 34.93L252.96 26.5L256.92 26.5L248.88 48.4" fill="#2DD4BF"/>
|
||||||
|
<rect x="261.60" y="19.30" width="15.60" height="27.90" fill="#2DD4BF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||||
|
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#11161C" stroke-width="3.5" stroke-linejoin="round"/>
|
||||||
|
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#0F766E" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M24 47 H40" stroke="#0F766E" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 434 B |
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||||
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#E6EDF3" stroke-width="3.5" stroke-linejoin="round"/>
|
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#E6EDF3" stroke-width="3.5" stroke-linejoin="round"/>
|
||||||
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<path d="M24 47 H40" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round"/>
|
<path d="M24 47 H40" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 411 B After Width: | Height: | Size: 434 B |
@@ -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,296 @@
|
|||||||
|
/**
|
||||||
|
* Erzeugt alle Marken-Dateien unter public/brand/ aus dem Symbol der Vorgabe:
|
||||||
|
*
|
||||||
|
* symbol.svg, symbol-light.svg, icon-small.svg, avatar.svg, logo.svg, logo-light.svg, banner.svg,
|
||||||
|
* favicon-16.png, favicon-32.png, apple-touch-icon.png, icon-192.png, icon-512.png,
|
||||||
|
* icon-maskable-512.png
|
||||||
|
*
|
||||||
|
* Texte (Wortmarke, Banner) werden zu Pfaden – die SVGs sehen damit überall gleich aus, auch ohne
|
||||||
|
* installierte Schrift (README auf Gitea, Social Preview). Die Schriften (JetBrains Mono 2.304,
|
||||||
|
* Space Grotesk 2.0.0, beide SIL OFL) werden dafür einmal geladen, per SHA-256 geprüft und unter
|
||||||
|
* node_modules/.cache abgelegt; eingecheckt werden nur die Ergebnisse.
|
||||||
|
*
|
||||||
|
* pnpm marke
|
||||||
|
*/
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { Resvg } from "@resvg/resvg-js";
|
||||||
|
import opentype from "opentype.js";
|
||||||
|
|
||||||
|
const ZIEL = "public/brand";
|
||||||
|
const CACHE = "node_modules/.cache/proxdeploy-schriften";
|
||||||
|
|
||||||
|
const FARBE = {
|
||||||
|
ink: "#11161C",
|
||||||
|
panel: "#1A2129",
|
||||||
|
linie: "#2A333D",
|
||||||
|
muted: "#8B98A5",
|
||||||
|
paper: "#E6EDF3",
|
||||||
|
teal: "#2DD4BF",
|
||||||
|
tealDunkel: "#0F766E",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Schriften ───────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ARCHIVE = {
|
||||||
|
jetbrains: {
|
||||||
|
url: "https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip",
|
||||||
|
sha256: "6f6376c6ed2960ea8a963cd7387ec9d76e3f629125bc33d1fdcd7eb7012f7bbf",
|
||||||
|
},
|
||||||
|
grotesk: {
|
||||||
|
url: "https://github.com/floriankarsten/space-grotesk/releases/download/2.0.0/SpaceGrotesk-2.0.0.zip",
|
||||||
|
sha256: "53b415577d4139248555300710bea0d268c7a5be67b93de53b716a9736cabffd",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function archivHolen(name: keyof typeof ARCHIVE): Promise<string> {
|
||||||
|
const { url, sha256 } = ARCHIVE[name];
|
||||||
|
mkdirSync(CACHE, { recursive: true });
|
||||||
|
const pfad = join(CACHE, `${name}.zip`);
|
||||||
|
if (!existsSync(pfad)) {
|
||||||
|
const antwort = await fetch(url);
|
||||||
|
if (!antwort.ok) throw new Error(`${url}: HTTP ${antwort.status}`);
|
||||||
|
writeFileSync(pfad, Buffer.from(await antwort.arrayBuffer()));
|
||||||
|
}
|
||||||
|
const ist = createHash("sha256").update(readFileSync(pfad)).digest("hex");
|
||||||
|
if (ist !== sha256) throw new Error(`${pfad}: Prüfsumme ${ist} statt ${sha256}`);
|
||||||
|
return pfad;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schriftAus(archiv: string, datei: string): opentype.Font {
|
||||||
|
const daten = execFileSync("unzip", ["-p", archiv, datei], { maxBuffer: 16 * 1024 * 1024 });
|
||||||
|
return opentype.parse(daten.buffer.slice(daten.byteOffset, daten.byteOffset + daten.byteLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Schriften = { mono: opentype.Font; monoFett: opentype.Font; grotesk: opentype.Font };
|
||||||
|
|
||||||
|
async function schriftenLaden(): Promise<Schriften> {
|
||||||
|
const jb = await archivHolen("jetbrains");
|
||||||
|
const sg = await archivHolen("grotesk");
|
||||||
|
return {
|
||||||
|
mono: schriftAus(jb, "fonts/ttf/JetBrainsMono-Regular.ttf"),
|
||||||
|
monoFett: schriftAus(jb, "fonts/ttf/JetBrainsMono-Bold.ttf"),
|
||||||
|
grotesk: schriftAus(sg, "SpaceGrotesk-2.0.0/ttf/static/SpaceGrotesk-Regular.ttf"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pfaddaten selbst schreiben: `toPathData` von opentype.js 1.3.5 liefert an manchen Positionen
|
||||||
|
* „NaN“ (beim Runden), und resvg bricht den Pfad dort ab.
|
||||||
|
*/
|
||||||
|
function pfadDaten(pfad: opentype.Path): string {
|
||||||
|
const z = (wert: number) => {
|
||||||
|
if (!Number.isFinite(wert)) throw new Error(`Ungültige Koordinate ${wert}`);
|
||||||
|
return String(Math.round(wert * 100) / 100);
|
||||||
|
};
|
||||||
|
return pfad.commands
|
||||||
|
.map((b) => {
|
||||||
|
switch (b.type) {
|
||||||
|
case "M":
|
||||||
|
case "L":
|
||||||
|
return `${b.type}${z(b.x)} ${z(b.y)}`;
|
||||||
|
case "Q":
|
||||||
|
return `Q${z(b.x1)} ${z(b.y1)} ${z(b.x)} ${z(b.y)}`;
|
||||||
|
case "C":
|
||||||
|
return `C${z(b.x1)} ${z(b.y1)} ${z(b.x2)} ${z(b.y2)} ${z(b.x)} ${z(b.y)}`;
|
||||||
|
default:
|
||||||
|
return "Z";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text als Pfad; liefert Pfaddaten und die Breite. */
|
||||||
|
function text(schrift: opentype.Font, inhalt: string, x: number, y: number, groesse: number) {
|
||||||
|
for (const zeichen of inhalt) {
|
||||||
|
if (schrift.charToGlyph(zeichen).index === 0 && zeichen !== " ") {
|
||||||
|
throw new Error(`${schrift.names.fullName?.en}: kein Glyph für „${zeichen}“`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Glyph für Glyph: opentype.js 1.3.5 scheitert an den kontextuellen Ersetzungen (Ligaturen)
|
||||||
|
// von JetBrains Mono; Logo und Banner brauchen keine.
|
||||||
|
const faktor = groesse / schrift.unitsPerEm;
|
||||||
|
let lauf = x;
|
||||||
|
const teile: string[] = [];
|
||||||
|
for (const zeichen of inhalt) {
|
||||||
|
const glyph = schrift.charToGlyph(zeichen);
|
||||||
|
teile.push(pfadDaten(glyph.getPath(lauf, y, groesse)));
|
||||||
|
lauf += (glyph.advanceWidth ?? 0) * faktor;
|
||||||
|
}
|
||||||
|
return { d: teile.filter(Boolean).join(" "), breite: lauf - x };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Symbol ──────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SECHSECK = "M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z";
|
||||||
|
|
||||||
|
function symbolInhalt(rahmen: string, akzent: string): string {
|
||||||
|
return [
|
||||||
|
`<path d="${SECHSECK}" stroke="${rahmen}" stroke-width="3.5" stroke-linejoin="round"/>`,
|
||||||
|
`<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="${akzent}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
`<path d="M24 47 H40" stroke="${akzent}" stroke-width="4" stroke-linecap="round"/>`,
|
||||||
|
].join("\n ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = (breite: number, hoehe: number, inhalt: string, extra = "") =>
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${breite} ${hoehe}" width="${breite}" height="${hoehe}" fill="none"${extra}>\n ${inhalt}\n</svg>\n`;
|
||||||
|
|
||||||
|
/** Favicon-Variante: ohne Sockel, dicke Linien, passt sich dem hellen oder dunklen Browser an. */
|
||||||
|
function iconKlein(): string {
|
||||||
|
return svg(
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
[
|
||||||
|
`<style>.h{stroke:${FARBE.paper}}.p{stroke:${FARBE.teal}}@media (prefers-color-scheme: light){.h{stroke:${FARBE.ink}}.p{stroke:${FARBE.tealDunkel}}}</style>`,
|
||||||
|
`<path class="h" d="${SECHSECK}" stroke-width="5.5" stroke-linejoin="round"/>`,
|
||||||
|
`<path class="p" d="M32 43 V18 M22 28 L32 18 L42 28" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
].join("\n "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Avatar/App-Icon: abgerundetes Quadrat in Teal, Symbol in Ink. `randlos` für maskierbare Icons. */
|
||||||
|
function avatar(randlos = false): string {
|
||||||
|
const hintergrund = randlos
|
||||||
|
? `<rect width="64" height="64" fill="${FARBE.teal}"/>`
|
||||||
|
: `<rect width="64" height="64" rx="14" fill="${FARBE.teal}"/>`;
|
||||||
|
// Maskierbar: Inhalt in der sicheren Zone (80 % in der Mitte).
|
||||||
|
const massstab = randlos ? 0.62 : 0.78;
|
||||||
|
const versatz = 32 * (1 - massstab);
|
||||||
|
return svg(
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
`${hintergrund}\n <g transform="translate(${versatz} ${versatz}) scale(${massstab})">\n ${symbolInhalt(FARBE.ink, FARBE.ink)}\n </g>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Favicon als Rasterbild (16/32 px): dicke Linien ohne Sockel auf Teal – auf jedem Tab-Hintergrund lesbar. */
|
||||||
|
function faviconRaster(): string {
|
||||||
|
return svg(
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
[
|
||||||
|
`<rect width="64" height="64" rx="12" fill="${FARBE.teal}"/>`,
|
||||||
|
`<g transform="translate(4 4) scale(0.875)">`,
|
||||||
|
`<path d="${SECHSECK}" stroke="${FARBE.ink}" stroke-width="6" stroke-linejoin="round"/>`,
|
||||||
|
`<path d="M32 43 V18 M22 28 L32 18 L42 28" stroke="${FARBE.ink}" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
`</g>`,
|
||||||
|
].join("\n "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logo: Symbol + Wortmarke ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function wortmarke(s: Schriften, x: number, grundlinie: number, groesse: number, hell: boolean) {
|
||||||
|
const prox = text(s.mono, "Prox", x, grundlinie, groesse);
|
||||||
|
const deploy = text(s.monoFett, "Deploy", x + prox.breite, grundlinie, groesse);
|
||||||
|
const versalhoehe = (s.mono.tables["os2"]?.sCapHeight ?? 730) / s.mono.unitsPerEm;
|
||||||
|
const cursorX = x + prox.breite + deploy.breite + groesse * 0.12;
|
||||||
|
const cursorBreite = groesse * 0.52;
|
||||||
|
const cursorOben = grundlinie - versalhoehe * groesse - groesse * 0.06;
|
||||||
|
const cursorHoehe = versalhoehe * groesse + groesse * 0.2;
|
||||||
|
const text1 = hell ? FARBE.ink : FARBE.paper;
|
||||||
|
const akzent = hell ? FARBE.tealDunkel : FARBE.teal;
|
||||||
|
return {
|
||||||
|
inhalt: [
|
||||||
|
`<path d="${prox.d}" fill="${text1}"/>`,
|
||||||
|
`<path d="${deploy.d}" fill="${akzent}"/>`,
|
||||||
|
`<rect x="${cursorX.toFixed(2)}" y="${cursorOben.toFixed(2)}" width="${cursorBreite.toFixed(2)}" height="${cursorHoehe.toFixed(2)}" fill="${akzent}"/>`,
|
||||||
|
].join("\n "),
|
||||||
|
ende: cursorX + cursorBreite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logo(s: Schriften, hell: boolean): string {
|
||||||
|
const w = wortmarke(s, 78, 43, 30, hell);
|
||||||
|
const breite = Math.ceil(w.ende + 4);
|
||||||
|
return svg(
|
||||||
|
breite,
|
||||||
|
64,
|
||||||
|
`${symbolInhalt(hell ? FARBE.ink : FARBE.paper, hell ? FARBE.tealDunkel : FARBE.teal)}\n ${w.inhalt}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Banner 1280×640 ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function banner(s: Schriften): string {
|
||||||
|
const teile: string[] = [`<rect width="1280" height="640" fill="${FARBE.ink}"/>`];
|
||||||
|
// Großes, sehr transparentes Sechseck im Hintergrund.
|
||||||
|
teile.push(
|
||||||
|
`<g transform="translate(700 -60) scale(12)" opacity="0.07"><path d="${SECHSECK}" stroke="${FARBE.paper}" stroke-width="1.2" stroke-linejoin="round"/></g>`,
|
||||||
|
);
|
||||||
|
// Logo groß, links.
|
||||||
|
const massstab = 2;
|
||||||
|
teile.push(
|
||||||
|
`<g transform="translate(96 120) scale(${massstab})">\n ${symbolInhalt(FARBE.paper, FARBE.teal)}\n ${wortmarke(s, 78, 43, 30, false).inhalt}\n </g>`,
|
||||||
|
);
|
||||||
|
// Unterzeile in Space Grotesk.
|
||||||
|
const zeile = text(
|
||||||
|
s.grotesk,
|
||||||
|
"VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.",
|
||||||
|
96,
|
||||||
|
330,
|
||||||
|
32,
|
||||||
|
);
|
||||||
|
if (zeile.breite > 1280 - 2 * 96) throw new Error(`Unterzeile zu breit (${zeile.breite})`);
|
||||||
|
teile.push(`<path d="${zeile.d}" fill="${FARBE.paper}"/>`);
|
||||||
|
// Tags als Outline-Pills.
|
||||||
|
let x = 96;
|
||||||
|
for (const tag of ["Web-GUI", "CLI", "cloud-init", "LXC", "Debian", "Ubuntu"]) {
|
||||||
|
const t = text(s.mono, tag, 0, 0, 20);
|
||||||
|
const breite = t.breite + 36;
|
||||||
|
teile.push(
|
||||||
|
`<rect x="${x}" y="378" width="${breite.toFixed(1)}" height="40" rx="20" stroke="${FARBE.linie}" stroke-width="2"/>`,
|
||||||
|
`<path d="${text(s.mono, tag, x + 18, 405, 20).d}" fill="${FARBE.muted}"/>`,
|
||||||
|
);
|
||||||
|
x += breite + 12;
|
||||||
|
}
|
||||||
|
// Kleine Terminal-Karte rechts unten.
|
||||||
|
const karte = { x: 744, y: 470, b: 440, h: 110 };
|
||||||
|
teile.push(
|
||||||
|
`<rect x="${karte.x}" y="${karte.y}" width="${karte.b}" height="${karte.h}" rx="14" fill="${FARBE.panel}" stroke="${FARBE.linie}" stroke-width="2"/>`,
|
||||||
|
`<path d="${text(s.mono, "# proxdeploy lxc dns01 --profile base", karte.x + 24, karte.y + 44, 18).d}" fill="${FARBE.muted}"/>`,
|
||||||
|
);
|
||||||
|
const haken = text(s.mono, "✓", karte.x + 24, karte.y + 80, 18);
|
||||||
|
teile.push(
|
||||||
|
`<path d="${haken.d}" fill="${FARBE.teal}"/>`,
|
||||||
|
`<path d="${text(s.mono, " dns01 bereit", karte.x + 24 + haken.breite, karte.y + 80, 18).d}" fill="${FARBE.paper}"/>`,
|
||||||
|
);
|
||||||
|
return svg(1280, 640, teile.join("\n "));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PNG ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function png(quelle: string, breite: number, datei: string): void {
|
||||||
|
const bild = new Resvg(quelle, { fitTo: { mode: "width", value: breite } }).render();
|
||||||
|
writeFileSync(join(ZIEL, datei), bild.asPng());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const s = await schriftenLaden();
|
||||||
|
mkdirSync(ZIEL, { recursive: true });
|
||||||
|
const dateien: Record<string, string> = {
|
||||||
|
"symbol.svg": svg(64, 64, symbolInhalt(FARBE.paper, FARBE.teal)),
|
||||||
|
"symbol-light.svg": svg(64, 64, symbolInhalt(FARBE.ink, FARBE.tealDunkel)),
|
||||||
|
"icon-small.svg": iconKlein(),
|
||||||
|
"avatar.svg": avatar(),
|
||||||
|
"logo.svg": logo(s, false),
|
||||||
|
"logo-light.svg": logo(s, true),
|
||||||
|
"banner.svg": banner(s),
|
||||||
|
};
|
||||||
|
for (const [name, inhalt] of Object.entries(dateien)) writeFileSync(join(ZIEL, name), inhalt);
|
||||||
|
|
||||||
|
// Raster-Icons aus dem Avatar: auf hellem und dunklem Grund gleich gut sichtbar.
|
||||||
|
png(faviconRaster(), 16, "favicon-16.png");
|
||||||
|
png(faviconRaster(), 32, "favicon-32.png");
|
||||||
|
png(dateien["avatar.svg"]!, 192, "icon-192.png");
|
||||||
|
png(dateien["avatar.svg"]!, 512, "icon-512.png");
|
||||||
|
png(avatar(true), 180, "apple-touch-icon.png");
|
||||||
|
png(avatar(true), 512, "icon-maskable-512.png");
|
||||||
|
// Vorschau des Banners (Social Preview verlangt ein Rasterbild).
|
||||||
|
png(dateien["banner.svg"]!, 1280, "banner.png");
|
||||||
|
console.log(`${Object.keys(dateien).length} SVG und 7 PNG unter ${ZIEL}/ geschrieben`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -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");
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut das Laufzeit-Paket von ProxDeploy – dieselbe Struktur für Docker-Image und LXC-Installer:
|
||||||
|
#
|
||||||
|
# paket/
|
||||||
|
# app/ Next standalone (server.js, .next, node_modules), dazu public/, catalog/,
|
||||||
|
# prisma/migrations/ – Arbeitsverzeichnis für app und worker
|
||||||
|
# worker/worker.mjs worker als ein Bundle (esbuild); Pakete aus worker/node_modules
|
||||||
|
# worker/migrieren.mjs Migrationen ohne Prisma-CLI
|
||||||
|
# worker/node_modules/ Produktions-Abhängigkeiten, flach (ohne Symlinks)
|
||||||
|
# VERSION
|
||||||
|
#
|
||||||
|
# scripts/paket-bauen.sh [version] → paket/
|
||||||
|
# scripts/paket-bauen.sh 0.6.0 --tar → zusätzlich proxdeploy-0.6.0.tar.gz
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
version="${1:-$(node -p 'require("./package.json").version')}"
|
||||||
|
commit="$(git rev-parse --short HEAD 2>/dev/null || echo unbekannt)"
|
||||||
|
|
||||||
|
rm -rf paket
|
||||||
|
mkdir -p paket/worker
|
||||||
|
|
||||||
|
# 1. Web-App (standalone). Version und Commit landen im Build (Seitenleiste, /api/v1/ich).
|
||||||
|
NEXT_PUBLIC_APP_VERSION="$version" NEXT_PUBLIC_GIT_SHA="$commit" pnpm build >/dev/null
|
||||||
|
cp -a .next/standalone paket/app
|
||||||
|
cp -a .next/static paket/app/.next/static
|
||||||
|
cp -a public paket/app/public
|
||||||
|
cp -a catalog paket/app/catalog
|
||||||
|
mkdir -p paket/app/prisma && cp -a prisma/migrations paket/app/prisma/migrations
|
||||||
|
|
||||||
|
# 2. worker und Migrationslauf als Bundles; eigener Code (inkl. Prisma-Client aus src/generated)
|
||||||
|
# wird gebündelt, Pakete bleiben extern.
|
||||||
|
for eintrag in "src/worker/main.ts:worker.mjs" "src/betrieb/migrieren-start.ts:migrieren.mjs"; do
|
||||||
|
pnpm exec esbuild "${eintrag%%:*}" --bundle --platform=node --format=esm --target=node24 \
|
||||||
|
--packages=external --log-level=warning \
|
||||||
|
--define:process.env.NEXT_PUBLIC_APP_VERSION="\"$version\"" \
|
||||||
|
--define:process.env.NEXT_PUBLIC_GIT_SHA="\"$commit\"" \
|
||||||
|
--metafile="paket/worker/${eintrag##*:}.meta.json" \
|
||||||
|
--outfile="paket/worker/${eintrag##*:}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. Nur die Pakete, die die Bundles wirklich importieren (aus den esbuild-Metadateien), in den
|
||||||
|
# Versionen aus package.json – flach, ohne Build-Skripte. Next, React & Co. braucht der worker nicht.
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
node - "$tmp" paket/worker/*.meta.json <<'JS'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const [ziel, ...metas] = process.argv.slice(2);
|
||||||
|
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
||||||
|
const benoetigt = new Set();
|
||||||
|
for (const datei of metas) {
|
||||||
|
const meta = JSON.parse(fs.readFileSync(datei, "utf8"));
|
||||||
|
for (const eingabe of Object.values(meta.inputs))
|
||||||
|
for (const imp of eingabe.imports ?? [])
|
||||||
|
if (imp.external && !imp.path.startsWith("node:")) {
|
||||||
|
const teile = imp.path.split("/");
|
||||||
|
benoetigt.add(imp.path.startsWith("@") ? teile.slice(0, 2).join("/") : teile[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const abhaengigkeiten = {};
|
||||||
|
for (const name of [...benoetigt].sort()) {
|
||||||
|
if (!pkg.dependencies[name]) throw new Error(`${name} wird vom worker importiert, steht aber nicht in dependencies`);
|
||||||
|
// Exakt die Version, die in der Entwicklung installiert ist (aus dem Lockfile aufgelöst).
|
||||||
|
abhaengigkeiten[name] = JSON.parse(fs.readFileSync(`node_modules/${name}/package.json`, "utf8")).version;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(`${ziel}/package.json`, JSON.stringify({ name: "proxdeploy-worker", private: true, type: "module", dependencies: abhaengigkeiten }, null, 2));
|
||||||
|
console.log(`worker braucht: ${Object.entries(abhaengigkeiten).map(([n, v]) => `${n}@${v}`).join(", ")}`);
|
||||||
|
JS
|
||||||
|
rm -f paket/worker/*.meta.json
|
||||||
|
cp pnpm-workspace.yaml "$tmp/"
|
||||||
|
(cd "$tmp" && pnpm install --prod --ignore-scripts --config.node-linker=hoisted >/dev/null)
|
||||||
|
mv "$tmp/node_modules" paket/worker/node_modules
|
||||||
|
rm -rf "$tmp"
|
||||||
|
|
||||||
|
echo "$version+$commit" > paket/VERSION
|
||||||
|
echo "paket/ gebaut ($version+$commit, $(du -sh paket | cut -f1))"
|
||||||
|
|
||||||
|
if [ "${2:-}" = "--tar" ]; then
|
||||||
|
tar -czf "proxdeploy-$version.tar.gz" -C paket .
|
||||||
|
sha256sum "proxdeploy-$version.tar.gz"
|
||||||
|
fi
|
||||||