From bd4bca53997a6a799bf55d35fce1485a6bd4eb1c Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 15:08:41 +0200 Subject: [PATCH] feat(cli): proxdeploy in Go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Befehle login, servers, distros, profiles, presets, vm, lxc, template list|build, batch, status, logs --follow, cancel. Renderer im Aufbau der Vorgabe (zeichengenau per Golden-File), 24-bit/256/ohne Farbe, --ascii, --quiet, --json, --dry-run; live im Terminal, zeilenweise ohne. SSE mit Wiederverbinden und Polling als Rückfall. Typen aus openapi.json per oapi-codegen; Release-Builds per cli/bauen.sh. Refs #013 Co-Authored-By: Claude Opus 5.5 --- cli/api/oapi-codegen.yaml | 7 + cli/api/openapi.json | 1594 +++++++++++++++++ cli/bauen.sh | 19 + cli/go.mod | 39 + cli/go.sum | 201 +++ cli/internal/api/client.go | 287 +++ cli/internal/api/client_test.go | 31 + cli/internal/api/generieren.go | 4 + cli/internal/api/typen.gen.go | 751 ++++++++ cli/internal/ausgabe/darstellung.go | 231 +++ cli/internal/ausgabe/darstellung_test.go | 118 ++ cli/internal/ausgabe/stil.go | 147 ++ .../ausgabe/testdata/fehler-dryrun.golden | 16 + .../ausgabe/testdata/mockup-24bit.golden | 16 + .../ausgabe/testdata/mockup-256.golden | 16 + .../ausgabe/testdata/mockup-ascii.golden | 16 + cli/internal/ausgabe/testdata/mockup.txt | 16 + cli/internal/befehle/auftraege.go | 174 ++ cli/internal/befehle/ausrollen.go | 317 ++++ cli/internal/befehle/batch.go | 266 +++ cli/internal/befehle/befehle_test.go | 325 ++++ cli/internal/befehle/folgen.go | 328 ++++ cli/internal/befehle/listen.go | 208 +++ cli/internal/befehle/login.go | 119 ++ cli/internal/befehle/template.go | 109 ++ .../befehle/testdata/batch-anfragen.golden | 36 + cli/internal/befehle/testdata/distros.golden | 3 + .../befehle/testdata/fehler-felder.golden | 3 + .../befehle/testdata/vm-anfrage.golden | 21 + .../befehle/testdata/vm-dryrun.golden | 12 + .../testdata/vm-fehler-quiet-ascii.golden | 6 + .../befehle/testdata/vm-zeilenweise.golden | 16 + cli/internal/befehle/wurzel.go | 314 ++++ cli/internal/konfig/konfig.go | 131 ++ cli/internal/konfig/konfig_test.go | 51 + cli/main.go | 12 + 36 files changed, 5960 insertions(+) create mode 100644 cli/api/oapi-codegen.yaml create mode 100644 cli/api/openapi.json create mode 100755 cli/bauen.sh create mode 100644 cli/go.mod create mode 100644 cli/go.sum create mode 100644 cli/internal/api/client.go create mode 100644 cli/internal/api/client_test.go create mode 100644 cli/internal/api/generieren.go create mode 100644 cli/internal/api/typen.gen.go create mode 100644 cli/internal/ausgabe/darstellung.go create mode 100644 cli/internal/ausgabe/darstellung_test.go create mode 100644 cli/internal/ausgabe/stil.go create mode 100644 cli/internal/ausgabe/testdata/fehler-dryrun.golden create mode 100644 cli/internal/ausgabe/testdata/mockup-24bit.golden create mode 100644 cli/internal/ausgabe/testdata/mockup-256.golden create mode 100644 cli/internal/ausgabe/testdata/mockup-ascii.golden create mode 100644 cli/internal/ausgabe/testdata/mockup.txt create mode 100644 cli/internal/befehle/auftraege.go create mode 100644 cli/internal/befehle/ausrollen.go create mode 100644 cli/internal/befehle/batch.go create mode 100644 cli/internal/befehle/befehle_test.go create mode 100644 cli/internal/befehle/folgen.go create mode 100644 cli/internal/befehle/listen.go create mode 100644 cli/internal/befehle/login.go create mode 100644 cli/internal/befehle/template.go create mode 100644 cli/internal/befehle/testdata/batch-anfragen.golden create mode 100644 cli/internal/befehle/testdata/distros.golden create mode 100644 cli/internal/befehle/testdata/fehler-felder.golden create mode 100644 cli/internal/befehle/testdata/vm-anfrage.golden create mode 100644 cli/internal/befehle/testdata/vm-dryrun.golden create mode 100644 cli/internal/befehle/testdata/vm-fehler-quiet-ascii.golden create mode 100644 cli/internal/befehle/testdata/vm-zeilenweise.golden create mode 100644 cli/internal/befehle/wurzel.go create mode 100644 cli/internal/konfig/konfig.go create mode 100644 cli/internal/konfig/konfig_test.go create mode 100644 cli/main.go diff --git a/cli/api/oapi-codegen.yaml b/cli/api/oapi-codegen.yaml new file mode 100644 index 0000000..b3f2e6d --- /dev/null +++ b/cli/api/oapi-codegen.yaml @@ -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 diff --git a/cli/api/openapi.json b/cli/api/openapi.json new file mode 100644 index 0000000..1e08b3f --- /dev/null +++ b/cli/api/openapi.json @@ -0,0 +1,1594 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "ProxDeploy API", + "version": "0.3.0", + "description": "REST-API von ProxDeploy – dieselbe für Web-GUI und CLI. Fehler: `{ fehler: { code, nachricht, hinweis?, felder? } }`." + }, + "servers": [ + { + "url": "/api/v1" + } + ], + "security": [ + { + "token": [] + } + ], + "components": { + "securitySchemes": { + "token": { + "type": "http", + "scheme": "bearer", + "description": "API-Token (pd_…)" + } + }, + "schemas": { + "FehlerAntwort": { + "type": "object", + "properties": { + "fehler": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "nachricht": { + "type": "string" + }, + "hinweis": { + "type": "string" + }, + "felder": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "code", + "nachricht" + ] + } + }, + "required": [ + "fehler" + ] + }, + "Ich": { + "type": "object", + "properties": { + "benutzername": { + "type": "string" + }, + "anzeigename": { + "type": "string" + }, + "rolle": { + "type": "string", + "enum": [ + "ADMIN", + "OPERATOR" + ] + }, + "rechte": { + "type": "array", + "items": { + "type": "string" + } + }, + "version": { + "type": "string" + } + }, + "required": [ + "benutzername", + "anzeigename", + "rolle", + "rechte", + "version" + ] + }, + "TokenAnmeldung": { + "type": "object", + "properties": { + "benutzername": { + "type": "string" + }, + "passwort": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tage": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "benutzername", + "passwort", + "name" + ] + }, + "NeuesToken": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "praefix": { + "type": "string" + }, + "token": { + "type": "string" + }, + "erstelltAm": { + "type": "string", + "description": "ISO 8601" + }, + "ablaufAm": { + "nullable": true, + "type": "string", + "description": "ISO 8601" + } + }, + "required": [ + "id", + "name", + "praefix", + "token", + "erstelltAm", + "ablaufAm" + ] + }, + "Server": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "titel": { + "type": "string" + }, + "url": { + "type": "string" + }, + "erreichbar": { + "nullable": true, + "type": "boolean" + }, + "pveVersion": { + "nullable": true, + "type": "string" + }, + "geprueftAm": { + "nullable": true, + "type": "string", + "description": "ISO 8601" + }, + "sshAktiv": { + "type": "boolean" + }, + "letzterTest": { + "nullable": true, + "type": "object", + "properties": { + "cluster": { + "nullable": true, + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "knoten": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "online": { + "type": "boolean" + }, + "speicherBelegt": { + "nullable": true, + "type": "number" + }, + "speicherGesamt": { + "nullable": true, + "type": "number" + } + }, + "required": [ + "name", + "online", + "speicherBelegt", + "speicherGesamt" + ] + } + } + }, + "required": [ + "cluster", + "knoten" + ] + } + }, + "required": [ + "name", + "titel", + "url", + "erreichbar", + "pveVersion", + "geprueftAm", + "sshAktiv", + "letzterTest" + ] + }, + "Distribution": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "familie": { + "type": "string" + }, + "aktiv": { + "type": "boolean" + }, + "vm": { + "type": "boolean" + }, + "lxc": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "familie", + "aktiv", + "vm", + "lxc" + ] + }, + "ProfilVariable": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "select" + ] + }, + "required": { + "type": "boolean" + }, + "default": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "help": { + "type": "string" + } + }, + "required": [ + "name", + "label", + "type", + "required" + ] + }, + "Profil": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "beschreibung": { + "nullable": true, + "type": "string" + }, + "familien": { + "type": "array", + "items": { + "type": "string" + } + }, + "herkunft": { + "type": "string", + "enum": [ + "KATALOG", + "EIGEN" + ] + }, + "aktiv": { + "type": "boolean" + }, + "definition": { + "type": "object", + "properties": { + "variables": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProfilVariable" + } + } + } + } + }, + "required": [ + "id", + "name", + "beschreibung", + "familien", + "herkunft", + "aktiv", + "definition" + ] + }, + "Vorlage": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "titel": { + "type": "string" + }, + "beschreibung": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "name", + "titel", + "beschreibung" + ] + }, + "Template": { + "type": "object", + "properties": { + "vmid": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "knoten": { + "type": "string" + }, + "distributionId": { + "nullable": true, + "type": "string" + }, + "gebautAm": { + "nullable": true, + "type": "string", + "description": "ISO 8601" + }, + "profile": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "vmid", + "name", + "knoten", + "distributionId", + "gebautAm", + "profile" + ] + }, + "AusrollAnfrage": { + "type": "object", + "properties": { + "typ": { + "type": "string", + "enum": [ + "vm", + "lxc" + ] + }, + "server": { + "type": "string", + "minLength": 1 + }, + "knoten": { + "type": "string" + }, + "distribution": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$" + }, + "vmid": { + "type": "integer", + "minimum": 100, + "maximum": 999999999 + }, + "tags": { + "default": [], + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9_+.-]+$" + } + }, + "cores": { + "default": 2, + "type": "integer", + "minimum": 1, + "maximum": 128 + }, + "speicherMb": { + "default": 2048, + "type": "integer", + "minimum": 256, + "maximum": 1048576 + }, + "diskGb": { + "default": 20, + "type": "integer", + "minimum": 1, + "maximum": 65536 + }, + "storage": { + "type": "string", + "minLength": 1 + }, + "swapMb": { + "default": 512, + "type": "integer", + "minimum": 0, + "maximum": 1048576 + }, + "unprivilegiert": { + "default": true, + "type": "boolean" + }, + "features": { + "default": { + "nesting": false, + "keyctl": false + }, + "type": "object", + "properties": { + "nesting": { + "default": false, + "type": "boolean" + }, + "keyctl": { + "default": false, + "type": "boolean" + } + } + }, + "netz": { + "type": "object", + "properties": { + "bridge": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9_.-]+$" + }, + "vlan": { + "type": "integer", + "minimum": 1, + "maximum": 4094 + }, + "modus": { + "default": "dhcp", + "type": "string", + "enum": [ + "dhcp", + "statisch" + ] + }, + "ip": { + "type": "string", + "pattern": "^(\\d{1,3}\\.){3}\\d{1,3}\\/\\d{1,2}$" + }, + "gateway": { + "type": "string", + "pattern": "^(\\d{1,3}\\.){3}\\d{1,3}$" + }, + "dns": { + "default": [], + "maxItems": 3, + "type": "array", + "items": { + "type": "string", + "pattern": "^(\\d{1,3}\\.){3}\\d{1,3}$" + } + }, + "suchdomain": { + "type": "string", + "pattern": "^[a-z0-9.-]*$" + } + }, + "required": [ + "bridge" + ] + }, + "zugang": { + "type": "object", + "properties": { + "benutzer": { + "type": "string", + "pattern": "^[a-z_][a-z0-9_-]{0,31}$" + }, + "sshSchluessel": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\\.com) [A-Za-z0-9+/=]+( [^\\r\\n]*)?$" + } + } + }, + "required": [ + "benutzer", + "sshSchluessel" + ] + }, + "profile": { + "default": [ + "base" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "variablen": { + "default": {}, + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + } + }, + "templateBauen": { + "default": false, + "type": "boolean" + }, + "startBeiBoot": { + "default": true, + "type": "boolean" + }, + "vorlage": { + "type": "string" + } + }, + "required": [ + "typ", + "server", + "distribution", + "name", + "storage", + "netz", + "zugang" + ] + }, + "TemplateBauAnfrage": { + "type": "object", + "properties": { + "distribution": { + "type": "string", + "minLength": 1 + }, + "knoten": { + "type": "string", + "minLength": 1 + }, + "storage": { + "type": "string", + "minLength": 1 + }, + "bridge": { + "default": "vmbr0", + "type": "string", + "minLength": 1 + }, + "vlan": { + "type": "integer", + "minimum": 1, + "maximum": 4094 + }, + "profile": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "distribution", + "knoten", + "storage" + ] + }, + "AuftragAngenommen": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kurzId": { + "type": "string" + } + }, + "required": [ + "id", + "kurzId" + ] + }, + "Phase": { + "type": "object", + "properties": { + "schluessel": { + "type": "string" + }, + "titel": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "OFFEN", + "LAEUFT", + "ERLEDIGT", + "FEHLER", + "UEBERSPRUNGEN" + ] + }, + "detail": { + "nullable": true, + "type": "string" + }, + "ursache": { + "nullable": true, + "type": "string" + }, + "hinweis": { + "nullable": true, + "type": "string" + }, + "gestartetAm": { + "nullable": true, + "type": "string", + "description": "ISO 8601" + }, + "beendetAm": { + "nullable": true, + "type": "string", + "description": "ISO 8601" + } + }, + "required": [ + "schluessel", + "titel", + "status", + "detail", + "ursache", + "hinweis" + ] + }, + "AuftragErgebnis": { + "type": "object", + "properties": { + "vmid": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "knoten": { + "type": "string" + }, + "ip": { + "nullable": true, + "type": "string" + }, + "ssh": { + "nullable": true, + "type": "string" + }, + "template": { + "type": "string" + } + } + }, + "Abschluss": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "titel": { + "type": "string" + }, + "dauer": { + "nullable": true, + "type": "string" + }, + "zeilen": { + "type": "array", + "items": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "ok", + "titel", + "dauer", + "zeilen" + ] + }, + "Auftrag": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kurzId": { + "type": "string" + }, + "art": { + "type": "string", + "enum": [ + "AUSROLLEN", + "TEMPLATE_BAUEN", + "AUFRAEUMEN" + ] + }, + "status": { + "type": "string", + "enum": [ + "WARTEND", + "LAEUFT", + "ERFOLGREICH", + "FEHLGESCHLAGEN", + "ABGEBROCHEN", + "AUFGERAEUMT" + ] + }, + "kopf": { + "type": "string" + }, + "server": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "titel": { + "type": "string" + } + }, + "required": [ + "name", + "titel" + ] + }, + "knoten": { + "nullable": true, + "type": "string" + }, + "gastTyp": { + "nullable": true, + "type": "string", + "enum": [ + "VM", + "LXC" + ] + }, + "vmid": { + "nullable": true, + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "nullable": true, + "type": "string" + }, + "distributionId": { + "nullable": true, + "type": "string" + }, + "profile": { + "type": "array", + "items": { + "type": "string" + } + }, + "ergebnis": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/AuftragErgebnis" + } + ] + }, + "fehler": { + "nullable": true, + "type": "object", + "properties": { + "phase": { + "type": "string" + }, + "ursache": { + "type": "string" + }, + "hinweis": { + "type": "string" + } + }, + "required": [ + "ursache" + ] + }, + "erstelltVon": { + "type": "string" + }, + "erstelltAm": { + "type": "string", + "description": "ISO 8601" + }, + "gestartetAm": { + "nullable": true, + "type": "string", + "description": "ISO 8601" + }, + "beendetAm": { + "nullable": true, + "type": "string", + "description": "ISO 8601" + }, + "abschluss": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Abschluss" + } + ] + }, + "phasen": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Phase" + } + } + }, + "required": [ + "id", + "kurzId", + "art", + "status", + "kopf", + "server", + "knoten", + "gastTyp", + "vmid", + "name", + "distributionId", + "profile", + "ergebnis", + "fehler", + "erstelltVon", + "erstelltAm", + "gestartetAm", + "beendetAm", + "abschluss", + "phasen" + ] + }, + "LogZeile": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "zeit": { + "type": "string", + "description": "ISO 8601" + }, + "stufe": { + "type": "string" + }, + "phase": { + "nullable": true, + "type": "string" + }, + "nachricht": { + "type": "string" + } + }, + "required": [ + "id", + "zeit", + "stufe", + "phase", + "nachricht" + ] + }, + "GeplanterAufruf": { + "type": "object", + "properties": { + "methode": { + "type": "string" + }, + "pfad": { + "type": "string" + }, + "parameter": { + "type": "object" + } + }, + "required": [ + "methode", + "pfad" + ] + }, + "Plan": { + "type": "object", + "properties": { + "kopf": { + "type": "string" + }, + "phasen": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Phase" + } + }, + "aufrufe": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GeplanterAufruf" + } + }, + "skript": { + "nullable": true, + "type": "string" + }, + "cliBefehl": { + "type": "string" + }, + "automatisch": { + "type": "array", + "items": { + "type": "string" + } + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "kopf", + "phasen", + "aufrufe", + "skript", + "cliBefehl", + "automatisch", + "ok" + ] + } + } + }, + "paths": { + "/auth/token": { + "post": { + "operationId": "tokenAnmelden", + "summary": "Benutzername und Passwort gegen ein API-Token tauschen (proxdeploy login)", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenAnmeldung" + } + } + } + }, + "responses": { + "201": { + "description": "Token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NeuesToken" + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + }, + "/ich": { + "get": { + "operationId": "ich", + "summary": "Angemeldeter Benutzer, Rechte und Version", + "responses": { + "200": { + "description": "Benutzer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ich" + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + }, + "/server": { + "get": { + "operationId": "serverListe", + "summary": "Alle Proxmox-Server mit dem letzten Verbindungstest", + "responses": { + "200": { + "description": "Server", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Server" + } + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + }, + "/server/{name}/templates": { + "get": { + "operationId": "templatesListe", + "summary": "ProxDeploy-Templates eines Servers", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Kurzname des Servers", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Templates", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Template" + } + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + }, + "post": { + "operationId": "templateBauen", + "summary": "Template bauen (Auftrag)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Kurzname des Servers", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateBauAnfrage" + } + } + } + }, + "responses": { + "202": { + "description": "Auftrag angenommen", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuftragAngenommen" + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + }, + "/distributionen": { + "get": { + "operationId": "distributionenListe", + "summary": "Aktive Distributionen", + "parameters": [ + { + "name": "typ", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "vm", + "lxc" + ] + } + } + ], + "responses": { + "200": { + "description": "Distributionen", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Distribution" + } + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + }, + "/profile": { + "get": { + "operationId": "profileListe", + "summary": "Aktive Profile mit ihren Variablen", + "responses": { + "200": { + "description": "Profile", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Profil" + } + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + }, + "/vorlagen": { + "get": { + "operationId": "vorlagenListe", + "summary": "Vorlagen (Presets)", + "responses": { + "200": { + "description": "Vorlagen", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Vorlage" + } + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + }, + "/auftraege": { + "post": { + "operationId": "ausrollen", + "summary": "VM oder LXC ausrollen (Auftrag)", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AusrollAnfrage" + } + } + } + }, + "responses": { + "202": { + "description": "Auftrag angenommen", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuftragAngenommen" + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + }, + "/auftraege/plan": { + "post": { + "operationId": "ausrollenPlanen", + "summary": "Dry-Run: Phasen und geplante Proxmox-Aufrufe, ohne etwas anzulegen", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AusrollAnfrage" + } + } + } + }, + "responses": { + "200": { + "description": "Plan", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Plan" + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + }, + "/auftraege/{id}": { + "get": { + "operationId": "auftragHolen", + "summary": "Stand eines Auftrags mit allen Phasen", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "ID oder Kurz-ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Auftrag", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Auftrag" + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + }, + "/auftraege/{id}/log": { + "get": { + "operationId": "auftragLog", + "summary": "Detail-Log ab einer Zeilen-ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "ID oder Kurz-ID", + "schema": { + "type": "string" + } + }, + { + "name": "ab", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Zeilen", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LogZeile" + } + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + }, + "/auftraege/{id}/ereignisse": { + "get": { + "operationId": "auftragEreignisse", + "summary": "Server-Sent Events: `stand` (Auftrag), `log` (LogZeile[], Event-ID = letzte Zeilen-ID), `ende`", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "ID oder Kurz-ID", + "schema": { + "type": "string" + } + }, + { + "name": "Last-Event-ID", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Ereignisstrom", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + }, + "/auftraege/{id}/abbrechen": { + "post": { + "operationId": "auftragAbbrechen", + "summary": "Abbruch anfordern; der worker hält an der nächsten Phasengrenze an", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "ID oder Kurz-ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Abbruch angefordert" + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + } + } +} diff --git a/cli/bauen.sh b/cli/bauen.sh new file mode 100755 index 0000000..1fc4422 --- /dev/null +++ b/cli/bauen.sh @@ -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) diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..aaca9d9 --- /dev/null +++ b/cli/go.mod @@ -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 +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..12f2180 --- /dev/null +++ b/cli/go.sum @@ -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= diff --git a/cli/internal/api/client.go b/cli/internal/api/client.go new file mode 100644 index 0000000..f372506 --- /dev/null +++ b/cli/internal/api/client.go @@ -0,0 +1,287 @@ +package api + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client spricht die REST-API von ProxDeploy. Die Typen der Antworten sind aus openapi.json +// erzeugt (typen.gen.go); Anfragen gehen als Map hinaus, damit nur gesetzte Flags mitkommen und +// Vorlage und Server-Standards den Rest füllen. +type Client struct { + // Basis-URL der Instanz, z. B. https://proxdeploy.example (ohne /api/v1). + URL string + Token string + Version string + HTTP *http.Client +} + +func Neu(basis, token, version string) *Client { + return &Client{ + URL: strings.TrimRight(basis, "/"), + Token: token, + Version: version, + HTTP: &http.Client{Timeout: 60 * time.Second}, + } +} + +// Fehler ist eine Fehlerantwort der API (`{ fehler: { code, nachricht, hinweis?, felder? } }`). +type Fehler struct { + Status int + Code string + Nachricht string + Hinweis string + Felder map[string]string +} + +func (f *Fehler) Error() string { return f.Nachricht } + +// VerbindungsFehler: die Instanz war nicht erreichbar oder hat keine API-Antwort geliefert. +type VerbindungsFehler struct{ Ursache error } + +func (f *VerbindungsFehler) Error() string { + return fmt.Sprintf("ProxDeploy nicht erreichbar: %v", f.Ursache) +} +func (f *VerbindungsFehler) Unwrap() error { return f.Ursache } + +// IstAnmeldefehler: Token fehlt, ist abgelaufen oder widerrufen. +func IstAnmeldefehler(err error) bool { + var f *Fehler + return errors.As(err, &f) && f.Status == http.StatusUnauthorized +} + +func (c *Client) anfrage(ctx context.Context, methode, pfad string, rumpf any) (*http.Request, error) { + var koerper io.Reader + if rumpf != nil { + daten, err := json.Marshal(rumpf) + if err != nil { + return nil, err + } + koerper = bytes.NewReader(daten) + } + req, err := http.NewRequestWithContext(ctx, methode, c.URL+"/api/v1"+pfad, koerper) + if err != nil { + return nil, err + } + if rumpf != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "proxdeploy-cli/"+c.Version) + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + return req, nil +} + +func fehlerLesen(antwort *http.Response) error { + daten, _ := io.ReadAll(io.LimitReader(antwort.Body, 1<<20)) + var huelle struct { + Fehler *struct { + Code string `json:"code"` + Nachricht string `json:"nachricht"` + Hinweis string `json:"hinweis"` + Felder map[string]string `json:"felder"` + } `json:"fehler"` + } + if json.Unmarshal(daten, &huelle) != nil || huelle.Fehler == nil { + // Kein API-Format: falsche URL, Reverse-Proxy-Fehlerseite o. Ä. + return &VerbindungsFehler{Ursache: fmt.Errorf("HTTP %d ohne API-Antwort – stimmt die URL?", antwort.StatusCode)} + } + return &Fehler{ + Status: antwort.StatusCode, + Code: huelle.Fehler.Code, + Nachricht: huelle.Fehler.Nachricht, + Hinweis: huelle.Fehler.Hinweis, + Felder: huelle.Fehler.Felder, + } +} + +// Aufrufen schickt eine Anfrage und liest die JSON-Antwort nach `ziel` (nil = verwerfen). +func (c *Client) Aufrufen(ctx context.Context, methode, pfad string, rumpf, ziel any) error { + req, err := c.anfrage(ctx, methode, pfad, rumpf) + if err != nil { + return err + } + antwort, err := c.HTTP.Do(req) + if err != nil { + return &VerbindungsFehler{Ursache: err} + } + defer antwort.Body.Close() + if antwort.StatusCode >= 400 { + return fehlerLesen(antwort) + } + if ziel == nil || antwort.StatusCode == http.StatusNoContent { + return nil + } + if !strings.HasPrefix(antwort.Header.Get("Content-Type"), "application/json") { + return &VerbindungsFehler{Ursache: errors.New("Antwort ist kein JSON – stimmt die URL?")} + } + if err := json.NewDecoder(antwort.Body).Decode(ziel); err != nil { + return fmt.Errorf("Antwort nicht lesbar: %w", err) + } + return nil +} + +func (c *Client) Ich(ctx context.Context) (Ich, error) { + var ich Ich + return ich, c.Aufrufen(ctx, http.MethodGet, "/ich", nil, &ich) +} + +func (c *Client) TokenAnmelden(ctx context.Context, anmeldung TokenAnmeldung) (NeuesToken, error) { + var token NeuesToken + return token, c.Aufrufen(ctx, http.MethodPost, "/auth/token", anmeldung, &token) +} + +func (c *Client) ServerListe(ctx context.Context) ([]Server, error) { + var liste []Server + return liste, c.Aufrufen(ctx, http.MethodGet, "/server", nil, &liste) +} + +func (c *Client) DistributionenListe(ctx context.Context, typ string) ([]Distribution, error) { + pfad := "/distributionen" + if typ != "" { + pfad += "?typ=" + url.QueryEscape(typ) + } + var liste []Distribution + return liste, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &liste) +} + +func (c *Client) ProfileListe(ctx context.Context) ([]Profil, error) { + var liste []Profil + return liste, c.Aufrufen(ctx, http.MethodGet, "/profile", nil, &liste) +} + +func (c *Client) VorlagenListe(ctx context.Context) ([]Vorlage, error) { + var liste []Vorlage + return liste, c.Aufrufen(ctx, http.MethodGet, "/vorlagen", nil, &liste) +} + +func (c *Client) TemplatesListe(ctx context.Context, server string) ([]Template, error) { + var liste []Template + return liste, c.Aufrufen(ctx, http.MethodGet, "/server/"+url.PathEscape(server)+"/templates", nil, &liste) +} + +func (c *Client) Ausrollen(ctx context.Context, anfrage map[string]any) (AuftragAngenommen, error) { + var a AuftragAngenommen + return a, c.Aufrufen(ctx, http.MethodPost, "/auftraege", anfrage, &a) +} + +func (c *Client) Planen(ctx context.Context, anfrage map[string]any) (Plan, error) { + var p Plan + return p, c.Aufrufen(ctx, http.MethodPost, "/auftraege/plan", anfrage, &p) +} + +func (c *Client) TemplateBauen(ctx context.Context, server string, anfrage map[string]any) (AuftragAngenommen, error) { + var a AuftragAngenommen + return a, c.Aufrufen(ctx, http.MethodPost, "/server/"+url.PathEscape(server)+"/templates", anfrage, &a) +} + +func (c *Client) Auftrag(ctx context.Context, id string) (Auftrag, error) { + var a Auftrag + return a, c.Aufrufen(ctx, http.MethodGet, "/auftraege/"+url.PathEscape(id), nil, &a) +} + +func (c *Client) Log(ctx context.Context, id, ab string) ([]LogZeile, error) { + var zeilen []LogZeile + pfad := "/auftraege/" + url.PathEscape(id) + "/log" + if ab != "" { + pfad += "?ab=" + url.QueryEscape(ab) + } + return zeilen, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &zeilen) +} + +func (c *Client) Abbrechen(ctx context.Context, id string) error { + return c.Aufrufen(ctx, http.MethodPost, "/auftraege/"+url.PathEscape(id)+"/abbrechen", nil, nil) +} + +// Ereignis aus dem SSE-Strom eines Auftrags. +type Ereignis struct { + ID string + Art string // stand, log, ende + Daten []byte +} + +// Ereignisse liest den SSE-Strom bis zum Ende oder Abbruch. `ab` ist die letzte gesehene Event-ID +// (Log-Zeile); ein Wiederverbinden mit ihr setzt genau dort fort. +func (c *Client) Ereignisse(ctx context.Context, id, ab string, beiEreignis func(Ereignis) error) error { + req, err := c.anfrage(ctx, http.MethodGet, "/auftraege/"+url.PathEscape(id)+"/ereignisse", nil) + if err != nil { + return err + } + req.Header.Set("Accept", "text/event-stream") + if ab != "" { + req.Header.Set("Last-Event-ID", ab) + } + // Ohne Zeitlimit: der Strom läuft, solange der Auftrag läuft (Herzschlag alle 15 s). + strom := &http.Client{Transport: c.HTTP.Transport} + antwort, err := strom.Do(req) + if err != nil { + return &VerbindungsFehler{Ursache: err} + } + defer antwort.Body.Close() + if antwort.StatusCode >= 400 { + return fehlerLesen(antwort) + } + if !strings.HasPrefix(antwort.Header.Get("Content-Type"), "text/event-stream") { + return &VerbindungsFehler{Ursache: errors.New("kein Ereignisstrom (puffert ein Proxy?)")} + } + return sseLesen(antwort.Body, beiEreignis) +} + +func sseLesen(r io.Reader, beiEreignis func(Ereignis) error) error { + leser := bufio.NewReaderSize(r, 64*1024) + var e Ereignis + var daten bytes.Buffer + for { + zeile, err := leser.ReadString('\n') + if err != nil { + if errors.Is(err, io.EOF) { + return io.ErrUnexpectedEOF + } + return &VerbindungsFehler{Ursache: err} + } + zeile = strings.TrimRight(zeile, "\r\n") + switch { + case zeile == "": + if e.Art != "" || daten.Len() > 0 { + if e.Art == "" { + e.Art = "message" + } + // Kopie: der Puffer wird für das nächste Ereignis wiederverwendet. + e.Daten = bytes.Clone(bytes.TrimSuffix(daten.Bytes(), []byte("\n"))) + if err := beiEreignis(e); err != nil { + return err + } + } + e = Ereignis{} + daten.Reset() + case strings.HasPrefix(zeile, ":"): + // Kommentar (Herzschlag) + default: + feld, wert, _ := strings.Cut(zeile, ":") + wert = strings.TrimPrefix(wert, " ") + switch feld { + case "id": + e.ID = wert + case "event": + e.Art = wert + case "data": + daten.WriteString(wert) + daten.WriteByte('\n') + } + } + } +} + +// Ende signalisiert, dass der Strom regulär geschlossen wurde (Ereignis `ende`). +var Ende = errors.New("ende") diff --git a/cli/internal/api/client_test.go b/cli/internal/api/client_test.go new file mode 100644 index 0000000..27b912d --- /dev/null +++ b/cli/internal/api/client_test.go @@ -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) + } +} diff --git a/cli/internal/api/generieren.go b/cli/internal/api/generieren.go new file mode 100644 index 0000000..2a038ea --- /dev/null +++ b/cli/internal/api/generieren.go @@ -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 diff --git a/cli/internal/api/typen.gen.go b/cli/internal/api/typen.gen.go new file mode 100644 index 0000000..daa7076 --- /dev/null +++ b/cli/internal/api/typen.gen.go @@ -0,0 +1,751 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT. +package api + +import ( + "encoding/json" + + "github.com/oapi-codegen/runtime" +) + +// Defines values for AuftragArt. +const ( + AUFRAEUMEN AuftragArt = "AUFRAEUMEN" + AUSROLLEN AuftragArt = "AUSROLLEN" + TEMPLATEBAUEN AuftragArt = "TEMPLATE_BAUEN" +) + +// Valid indicates whether the value is a known member of the AuftragArt enum. +func (e AuftragArt) Valid() bool { + switch e { + case AUFRAEUMEN: + return true + case AUSROLLEN: + return true + case TEMPLATEBAUEN: + return true + default: + return false + } +} + +// Defines values for AuftragGastTyp. +const ( + LXC AuftragGastTyp = "LXC" + VM AuftragGastTyp = "VM" +) + +// Valid indicates whether the value is a known member of the AuftragGastTyp enum. +func (e AuftragGastTyp) Valid() bool { + switch e { + case LXC: + return true + case VM: + return true + default: + return false + } +} + +// Defines values for AuftragStatus. +const ( + AuftragStatusABGEBROCHEN AuftragStatus = "ABGEBROCHEN" + AuftragStatusAUFGERAEUMT AuftragStatus = "AUFGERAEUMT" + AuftragStatusERFOLGREICH AuftragStatus = "ERFOLGREICH" + AuftragStatusFEHLGESCHLAGEN AuftragStatus = "FEHLGESCHLAGEN" + AuftragStatusLAEUFT AuftragStatus = "LAEUFT" + AuftragStatusWARTEND AuftragStatus = "WARTEND" +) + +// Valid indicates whether the value is a known member of the AuftragStatus enum. +func (e AuftragStatus) Valid() bool { + switch e { + case AuftragStatusABGEBROCHEN: + return true + case AuftragStatusAUFGERAEUMT: + return true + case AuftragStatusERFOLGREICH: + return true + case AuftragStatusFEHLGESCHLAGEN: + return true + case AuftragStatusLAEUFT: + return true + case AuftragStatusWARTEND: + return true + default: + return false + } +} + +// Defines values for AusrollAnfrageNetzModus. +const ( + Dhcp AusrollAnfrageNetzModus = "dhcp" + Statisch AusrollAnfrageNetzModus = "statisch" +) + +// Valid indicates whether the value is a known member of the AusrollAnfrageNetzModus enum. +func (e AusrollAnfrageNetzModus) Valid() bool { + switch e { + case Dhcp: + return true + case Statisch: + return true + default: + return false + } +} + +// Defines values for AusrollAnfrageTyp. +const ( + AusrollAnfrageTypLxc AusrollAnfrageTyp = "lxc" + AusrollAnfrageTypVm AusrollAnfrageTyp = "vm" +) + +// Valid indicates whether the value is a known member of the AusrollAnfrageTyp enum. +func (e AusrollAnfrageTyp) Valid() bool { + switch e { + case AusrollAnfrageTypLxc: + return true + case AusrollAnfrageTypVm: + return true + default: + return false + } +} + +// Defines values for IchRolle. +const ( + ADMIN IchRolle = "ADMIN" + OPERATOR IchRolle = "OPERATOR" +) + +// Valid indicates whether the value is a known member of the IchRolle enum. +func (e IchRolle) Valid() bool { + switch e { + case ADMIN: + return true + case OPERATOR: + return true + default: + return false + } +} + +// Defines values for PhaseStatus. +const ( + PhaseStatusERLEDIGT PhaseStatus = "ERLEDIGT" + PhaseStatusFEHLER PhaseStatus = "FEHLER" + PhaseStatusLAEUFT PhaseStatus = "LAEUFT" + PhaseStatusOFFEN PhaseStatus = "OFFEN" + PhaseStatusUEBERSPRUNGEN PhaseStatus = "UEBERSPRUNGEN" +) + +// Valid indicates whether the value is a known member of the PhaseStatus enum. +func (e PhaseStatus) Valid() bool { + switch e { + case PhaseStatusERLEDIGT: + return true + case PhaseStatusFEHLER: + return true + case PhaseStatusLAEUFT: + return true + case PhaseStatusOFFEN: + return true + case PhaseStatusUEBERSPRUNGEN: + return true + default: + return false + } +} + +// Defines values for ProfilHerkunft. +const ( + EIGEN ProfilHerkunft = "EIGEN" + KATALOG ProfilHerkunft = "KATALOG" +) + +// Valid indicates whether the value is a known member of the ProfilHerkunft enum. +func (e ProfilHerkunft) Valid() bool { + switch e { + case EIGEN: + return true + case KATALOG: + return true + default: + return false + } +} + +// Defines values for ProfilVariableType. +const ( + Boolean ProfilVariableType = "boolean" + Number ProfilVariableType = "number" + Select ProfilVariableType = "select" + String ProfilVariableType = "string" +) + +// Valid indicates whether the value is a known member of the ProfilVariableType enum. +func (e ProfilVariableType) Valid() bool { + switch e { + case Boolean: + return true + case Number: + return true + case Select: + return true + case String: + return true + default: + return false + } +} + +// Defines values for DistributionenListeParamsTyp. +const ( + DistributionenListeParamsTypLxc DistributionenListeParamsTyp = "lxc" + DistributionenListeParamsTypVm DistributionenListeParamsTyp = "vm" +) + +// Valid indicates whether the value is a known member of the DistributionenListeParamsTyp enum. +func (e DistributionenListeParamsTyp) Valid() bool { + switch e { + case DistributionenListeParamsTypLxc: + return true + case DistributionenListeParamsTypVm: + return true + default: + return false + } +} + +// Abschluss defines model for Abschluss. +type Abschluss struct { + Dauer *string `json:"dauer"` + Ok bool `json:"ok"` + Titel string `json:"titel"` + Zeilen [][]string `json:"zeilen"` +} + +// Auftrag defines model for Auftrag. +type Auftrag struct { + Abschluss *Abschluss `json:"abschluss"` + Art AuftragArt `json:"art"` + + // BeendetAm ISO 8601 + BeendetAm *string `json:"beendetAm"` + DistributionId *string `json:"distributionId"` + Ergebnis *AuftragErgebnis `json:"ergebnis"` + + // ErstelltAm ISO 8601 + ErstelltAm string `json:"erstelltAm"` + ErstelltVon string `json:"erstelltVon"` + Fehler *struct { + Hinweis *string `json:"hinweis,omitempty"` + Phase *string `json:"phase,omitempty"` + Ursache string `json:"ursache"` + } `json:"fehler"` + GastTyp *AuftragGastTyp `json:"gastTyp"` + + // GestartetAm ISO 8601 + GestartetAm *string `json:"gestartetAm"` + Id string `json:"id"` + Knoten *string `json:"knoten"` + Kopf string `json:"kopf"` + KurzId string `json:"kurzId"` + Name *string `json:"name"` + Phasen []Phase `json:"phasen"` + Profile []string `json:"profile"` + Server struct { + Name string `json:"name"` + Titel string `json:"titel"` + } `json:"server"` + Status AuftragStatus `json:"status"` + Vmid *int `json:"vmid"` +} + +// AuftragArt defines model for Auftrag.Art. +type AuftragArt string + +// AuftragGastTyp defines model for Auftrag.GastTyp. +type AuftragGastTyp string + +// AuftragStatus defines model for Auftrag.Status. +type AuftragStatus string + +// AuftragAngenommen defines model for AuftragAngenommen. +type AuftragAngenommen struct { + Id string `json:"id"` + KurzId string `json:"kurzId"` +} + +// AuftragErgebnis defines model for AuftragErgebnis. +type AuftragErgebnis struct { + Ip *string `json:"ip,omitempty"` + Knoten *string `json:"knoten,omitempty"` + Ssh *string `json:"ssh,omitempty"` + Template *string `json:"template,omitempty"` + Vmid *int `json:"vmid,omitempty"` +} + +// AusrollAnfrage defines model for AusrollAnfrage. +type AusrollAnfrage struct { + Cores *int `json:"cores,omitempty"` + DiskGb *int `json:"diskGb,omitempty"` + Distribution string `json:"distribution"` + Features *struct { + Keyctl *bool `json:"keyctl,omitempty"` + Nesting *bool `json:"nesting,omitempty"` + } `json:"features,omitempty"` + Knoten *string `json:"knoten,omitempty"` + Name string `json:"name"` + Netz struct { + Bridge string `json:"bridge"` + Dns *[]string `json:"dns,omitempty"` + Gateway *string `json:"gateway,omitempty"` + Ip *string `json:"ip,omitempty"` + Modus *AusrollAnfrageNetzModus `json:"modus,omitempty"` + Suchdomain *string `json:"suchdomain,omitempty"` + Vlan *int `json:"vlan,omitempty"` + } `json:"netz"` + Profile *[]string `json:"profile,omitempty"` + Server string `json:"server"` + SpeicherMb *int `json:"speicherMb,omitempty"` + StartBeiBoot *bool `json:"startBeiBoot,omitempty"` + Storage string `json:"storage"` + SwapMb *int `json:"swapMb,omitempty"` + Tags *[]string `json:"tags,omitempty"` + TemplateBauen *bool `json:"templateBauen,omitempty"` + Typ AusrollAnfrageTyp `json:"typ"` + Unprivilegiert *bool `json:"unprivilegiert,omitempty"` + Variablen *map[string]map[string]AusrollAnfrage_Variablen_AdditionalProperties `json:"variablen,omitempty"` + Vmid *int `json:"vmid,omitempty"` + Vorlage *string `json:"vorlage,omitempty"` + Zugang struct { + Benutzer string `json:"benutzer"` + SshSchluessel []string `json:"sshSchluessel"` + } `json:"zugang"` +} + +// AusrollAnfrageNetzModus defines model for AusrollAnfrage.Netz.Modus. +type AusrollAnfrageNetzModus string + +// AusrollAnfrageTyp defines model for AusrollAnfrage.Typ. +type AusrollAnfrageTyp string + +// AusrollAnfrageVariablen0 defines model for AusrollAnfrage.Variablen.0. +type AusrollAnfrageVariablen0 = string + +// AusrollAnfrageVariablen1 defines model for AusrollAnfrage.Variablen.1. +type AusrollAnfrageVariablen1 = float32 + +// AusrollAnfrageVariablen2 defines model for AusrollAnfrage.Variablen.2. +type AusrollAnfrageVariablen2 = bool + +// AusrollAnfrage_Variablen_AdditionalProperties defines model for AusrollAnfrage.variablen.AdditionalProperties. +type AusrollAnfrage_Variablen_AdditionalProperties struct { + union json.RawMessage +} + +// Distribution defines model for Distribution. +type Distribution struct { + Aktiv bool `json:"aktiv"` + Familie string `json:"familie"` + Id string `json:"id"` + Lxc bool `json:"lxc"` + Name string `json:"name"` + Vm bool `json:"vm"` +} + +// FehlerAntwort defines model for FehlerAntwort. +type FehlerAntwort struct { + Fehler struct { + Code string `json:"code"` + Felder *map[string]string `json:"felder,omitempty"` + Hinweis *string `json:"hinweis,omitempty"` + Nachricht string `json:"nachricht"` + } `json:"fehler"` +} + +// GeplanterAufruf defines model for GeplanterAufruf. +type GeplanterAufruf struct { + Methode string `json:"methode"` + Parameter *map[string]interface{} `json:"parameter,omitempty"` + Pfad string `json:"pfad"` +} + +// Ich defines model for Ich. +type Ich struct { + Anzeigename string `json:"anzeigename"` + Benutzername string `json:"benutzername"` + Rechte []string `json:"rechte"` + Rolle IchRolle `json:"rolle"` + Version string `json:"version"` +} + +// IchRolle defines model for Ich.Rolle. +type IchRolle string + +// LogZeile defines model for LogZeile. +type LogZeile struct { + Id string `json:"id"` + Nachricht string `json:"nachricht"` + Phase *string `json:"phase"` + Stufe string `json:"stufe"` + + // Zeit ISO 8601 + Zeit string `json:"zeit"` +} + +// NeuesToken defines model for NeuesToken. +type NeuesToken struct { + // AblaufAm ISO 8601 + AblaufAm *string `json:"ablaufAm"` + + // ErstelltAm ISO 8601 + ErstelltAm string `json:"erstelltAm"` + Id string `json:"id"` + Name string `json:"name"` + Praefix string `json:"praefix"` + Token string `json:"token"` +} + +// Phase defines model for Phase. +type Phase struct { + // BeendetAm ISO 8601 + BeendetAm *string `json:"beendetAm,omitempty"` + Detail *string `json:"detail"` + + // GestartetAm ISO 8601 + GestartetAm *string `json:"gestartetAm,omitempty"` + Hinweis *string `json:"hinweis"` + Schluessel string `json:"schluessel"` + Status PhaseStatus `json:"status"` + Titel string `json:"titel"` + Ursache *string `json:"ursache"` +} + +// PhaseStatus defines model for Phase.Status. +type PhaseStatus string + +// Plan defines model for Plan. +type Plan struct { + Aufrufe []GeplanterAufruf `json:"aufrufe"` + Automatisch []string `json:"automatisch"` + CliBefehl string `json:"cliBefehl"` + Kopf string `json:"kopf"` + Ok bool `json:"ok"` + Phasen []Phase `json:"phasen"` + Skript *string `json:"skript"` +} + +// Profil defines model for Profil. +type Profil struct { + Aktiv bool `json:"aktiv"` + Beschreibung *string `json:"beschreibung"` + Definition struct { + Variables *[]ProfilVariable `json:"variables,omitempty"` + } `json:"definition"` + Familien []string `json:"familien"` + Herkunft ProfilHerkunft `json:"herkunft"` + Id string `json:"id"` + Name string `json:"name"` +} + +// ProfilHerkunft defines model for Profil.Herkunft. +type ProfilHerkunft string + +// ProfilVariable defines model for ProfilVariable. +type ProfilVariable struct { + Default *ProfilVariable_Default `json:"default,omitempty"` + Help *string `json:"help,omitempty"` + Label string `json:"label"` + Name string `json:"name"` + Options *[]string `json:"options,omitempty"` + Required bool `json:"required"` + Type ProfilVariableType `json:"type"` +} + +// ProfilVariableDefault0 defines model for ProfilVariable.Default.0. +type ProfilVariableDefault0 = string + +// ProfilVariableDefault1 defines model for ProfilVariable.Default.1. +type ProfilVariableDefault1 = float32 + +// ProfilVariableDefault2 defines model for ProfilVariable.Default.2. +type ProfilVariableDefault2 = bool + +// ProfilVariable_Default defines model for ProfilVariable.Default. +type ProfilVariable_Default struct { + union json.RawMessage +} + +// ProfilVariableType defines model for ProfilVariable.Type. +type ProfilVariableType string + +// Server defines model for Server. +type Server struct { + Erreichbar *bool `json:"erreichbar"` + + // GeprueftAm ISO 8601 + GeprueftAm *string `json:"geprueftAm"` + LetzterTest *struct { + Cluster *struct { + Name string `json:"name"` + } `json:"cluster"` + Knoten []struct { + Name string `json:"name"` + Online bool `json:"online"` + SpeicherBelegt *float32 `json:"speicherBelegt"` + SpeicherGesamt *float32 `json:"speicherGesamt"` + } `json:"knoten"` + } `json:"letzterTest"` + Name string `json:"name"` + PveVersion *string `json:"pveVersion"` + SshAktiv bool `json:"sshAktiv"` + Titel string `json:"titel"` + Url string `json:"url"` +} + +// Template defines model for Template. +type Template struct { + DistributionId *string `json:"distributionId"` + + // GebautAm ISO 8601 + GebautAm *string `json:"gebautAm"` + Knoten string `json:"knoten"` + Name string `json:"name"` + Profile []string `json:"profile"` + Vmid int `json:"vmid"` +} + +// TemplateBauAnfrage defines model for TemplateBauAnfrage. +type TemplateBauAnfrage struct { + Bridge *string `json:"bridge,omitempty"` + Distribution string `json:"distribution"` + Knoten string `json:"knoten"` + Profile *[]string `json:"profile,omitempty"` + Storage string `json:"storage"` + Vlan *int `json:"vlan,omitempty"` +} + +// TokenAnmeldung defines model for TokenAnmeldung. +type TokenAnmeldung struct { + Benutzername string `json:"benutzername"` + Name string `json:"name"` + Passwort string `json:"passwort"` + Tage *int `json:"tage,omitempty"` +} + +// Vorlage defines model for Vorlage. +type Vorlage struct { + Beschreibung *string `json:"beschreibung"` + Name string `json:"name"` + Titel string `json:"titel"` +} + +// AuftragEreignisseParams defines parameters for AuftragEreignisse. +type AuftragEreignisseParams struct { + LastEventID *string `json:"Last-Event-ID,omitempty"` +} + +// AuftragLogParams defines parameters for AuftragLog. +type AuftragLogParams struct { + Ab *string `form:"ab,omitempty" json:"ab,omitempty"` +} + +// DistributionenListeParams defines parameters for DistributionenListe. +type DistributionenListeParams struct { + Typ *DistributionenListeParamsTyp `form:"typ,omitempty" json:"typ,omitempty"` +} + +// DistributionenListeParamsTyp defines parameters for DistributionenListe. +type DistributionenListeParamsTyp string + +// AusrollenJSONRequestBody defines body for Ausrollen for application/json ContentType. +type AusrollenJSONRequestBody = AusrollAnfrage + +// AusrollenPlanenJSONRequestBody defines body for AusrollenPlanen for application/json ContentType. +type AusrollenPlanenJSONRequestBody = AusrollAnfrage + +// TokenAnmeldenJSONRequestBody defines body for TokenAnmelden for application/json ContentType. +type TokenAnmeldenJSONRequestBody = TokenAnmeldung + +// TemplateBauenJSONRequestBody defines body for TemplateBauen for application/json ContentType. +type TemplateBauenJSONRequestBody = TemplateBauAnfrage + +// AsAusrollAnfrageVariablen0 returns the union data inside the AusrollAnfrage_Variablen_AdditionalProperties as a AusrollAnfrageVariablen0 +func (t AusrollAnfrage_Variablen_AdditionalProperties) AsAusrollAnfrageVariablen0() (AusrollAnfrageVariablen0, error) { + var body AusrollAnfrageVariablen0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromAusrollAnfrageVariablen0 overwrites any union data inside the AusrollAnfrage_Variablen_AdditionalProperties as the provided AusrollAnfrageVariablen0 +func (t *AusrollAnfrage_Variablen_AdditionalProperties) FromAusrollAnfrageVariablen0(v AusrollAnfrageVariablen0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeAusrollAnfrageVariablen0 performs a merge with any union data inside the AusrollAnfrage_Variablen_AdditionalProperties, using the provided AusrollAnfrageVariablen0 +func (t *AusrollAnfrage_Variablen_AdditionalProperties) MergeAusrollAnfrageVariablen0(v AusrollAnfrageVariablen0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsAusrollAnfrageVariablen1 returns the union data inside the AusrollAnfrage_Variablen_AdditionalProperties as a AusrollAnfrageVariablen1 +func (t AusrollAnfrage_Variablen_AdditionalProperties) AsAusrollAnfrageVariablen1() (AusrollAnfrageVariablen1, error) { + var body AusrollAnfrageVariablen1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromAusrollAnfrageVariablen1 overwrites any union data inside the AusrollAnfrage_Variablen_AdditionalProperties as the provided AusrollAnfrageVariablen1 +func (t *AusrollAnfrage_Variablen_AdditionalProperties) FromAusrollAnfrageVariablen1(v AusrollAnfrageVariablen1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeAusrollAnfrageVariablen1 performs a merge with any union data inside the AusrollAnfrage_Variablen_AdditionalProperties, using the provided AusrollAnfrageVariablen1 +func (t *AusrollAnfrage_Variablen_AdditionalProperties) MergeAusrollAnfrageVariablen1(v AusrollAnfrageVariablen1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsAusrollAnfrageVariablen2 returns the union data inside the AusrollAnfrage_Variablen_AdditionalProperties as a AusrollAnfrageVariablen2 +func (t AusrollAnfrage_Variablen_AdditionalProperties) AsAusrollAnfrageVariablen2() (AusrollAnfrageVariablen2, error) { + var body AusrollAnfrageVariablen2 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromAusrollAnfrageVariablen2 overwrites any union data inside the AusrollAnfrage_Variablen_AdditionalProperties as the provided AusrollAnfrageVariablen2 +func (t *AusrollAnfrage_Variablen_AdditionalProperties) FromAusrollAnfrageVariablen2(v AusrollAnfrageVariablen2) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeAusrollAnfrageVariablen2 performs a merge with any union data inside the AusrollAnfrage_Variablen_AdditionalProperties, using the provided AusrollAnfrageVariablen2 +func (t *AusrollAnfrage_Variablen_AdditionalProperties) MergeAusrollAnfrageVariablen2(v AusrollAnfrageVariablen2) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t AusrollAnfrage_Variablen_AdditionalProperties) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *AusrollAnfrage_Variablen_AdditionalProperties) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsProfilVariableDefault0 returns the union data inside the ProfilVariable_Default as a ProfilVariableDefault0 +func (t ProfilVariable_Default) AsProfilVariableDefault0() (ProfilVariableDefault0, error) { + var body ProfilVariableDefault0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromProfilVariableDefault0 overwrites any union data inside the ProfilVariable_Default as the provided ProfilVariableDefault0 +func (t *ProfilVariable_Default) FromProfilVariableDefault0(v ProfilVariableDefault0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeProfilVariableDefault0 performs a merge with any union data inside the ProfilVariable_Default, using the provided ProfilVariableDefault0 +func (t *ProfilVariable_Default) MergeProfilVariableDefault0(v ProfilVariableDefault0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsProfilVariableDefault1 returns the union data inside the ProfilVariable_Default as a ProfilVariableDefault1 +func (t ProfilVariable_Default) AsProfilVariableDefault1() (ProfilVariableDefault1, error) { + var body ProfilVariableDefault1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromProfilVariableDefault1 overwrites any union data inside the ProfilVariable_Default as the provided ProfilVariableDefault1 +func (t *ProfilVariable_Default) FromProfilVariableDefault1(v ProfilVariableDefault1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeProfilVariableDefault1 performs a merge with any union data inside the ProfilVariable_Default, using the provided ProfilVariableDefault1 +func (t *ProfilVariable_Default) MergeProfilVariableDefault1(v ProfilVariableDefault1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsProfilVariableDefault2 returns the union data inside the ProfilVariable_Default as a ProfilVariableDefault2 +func (t ProfilVariable_Default) AsProfilVariableDefault2() (ProfilVariableDefault2, error) { + var body ProfilVariableDefault2 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromProfilVariableDefault2 overwrites any union data inside the ProfilVariable_Default as the provided ProfilVariableDefault2 +func (t *ProfilVariable_Default) FromProfilVariableDefault2(v ProfilVariableDefault2) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeProfilVariableDefault2 performs a merge with any union data inside the ProfilVariable_Default, using the provided ProfilVariableDefault2 +func (t *ProfilVariable_Default) MergeProfilVariableDefault2(v ProfilVariableDefault2) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t ProfilVariable_Default) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *ProfilVariable_Default) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} diff --git a/cli/internal/ausgabe/darstellung.go b/cli/internal/ausgabe/darstellung.go new file mode 100644 index 0000000..bd29989 --- /dev/null +++ b/cli/internal/ausgabe/darstellung.go @@ -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]) + "…" +} diff --git a/cli/internal/ausgabe/darstellung_test.go b/cli/internal/ausgabe/darstellung_test.go new file mode 100644 index 0000000..b14676e --- /dev/null +++ b/cli/internal/ausgabe/darstellung_test.go @@ -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) + } + } +} diff --git a/cli/internal/ausgabe/stil.go b/cli/internal/ausgabe/stil.go new file mode 100644 index 0000000..1cbf01c --- /dev/null +++ b/cli/internal/ausgabe/stil.go @@ -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{"◐", "◓", "◑", "◒"} diff --git a/cli/internal/ausgabe/testdata/fehler-dryrun.golden b/cli/internal/ausgabe/testdata/fehler-dryrun.golden new file mode 100644 index 0000000..74d01d8 --- /dev/null +++ b/cli/internal/ausgabe/testdata/fehler-dryrun.golden @@ -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. │ +└───────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/cli/internal/ausgabe/testdata/mockup-24bit.golden b/cli/internal/ausgabe/testdata/mockup-24bit.golden new file mode 100644 index 0000000..ac28e2e --- /dev/null +++ b/cli/internal/ausgabe/testdata/mockup-24bit.golden @@ -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 │ +└──────────────────────────────────────┘ diff --git a/cli/internal/ausgabe/testdata/mockup-256.golden b/cli/internal/ausgabe/testdata/mockup-256.golden new file mode 100644 index 0000000..1f43aed --- /dev/null +++ b/cli/internal/ausgabe/testdata/mockup-256.golden @@ -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 │ +└──────────────────────────────────────┘ diff --git a/cli/internal/ausgabe/testdata/mockup-ascii.golden b/cli/internal/ausgabe/testdata/mockup-ascii.golden new file mode 100644 index 0000000..a77dbed --- /dev/null +++ b/cli/internal/ausgabe/testdata/mockup-ascii.golden @@ -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 | ++--------------------------------------+ diff --git a/cli/internal/ausgabe/testdata/mockup.txt b/cli/internal/ausgabe/testdata/mockup.txt new file mode 100644 index 0000000..21938c8 --- /dev/null +++ b/cli/internal/ausgabe/testdata/mockup.txt @@ -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 │ +└──────────────────────────────────────┘ diff --git a/cli/internal/befehle/auftraege.go b/cli/internal/befehle/auftraege.go new file mode 100644 index 0000000..6d687ae --- /dev/null +++ b/cli/internal/befehle/auftraege.go @@ -0,0 +1,174 @@ +package befehle + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api" +) + +func eineID(befehl string) cobra.PositionalArgs { + return func(_ *cobra.Command, argumente []string) error { + if len(argumente) != 1 { + return bedienfehler("Auftrags-ID fehlt: proxdeploy %s ", befehl) + } + return nil + } +} + +func (u *umgebung) statusBefehl() *cobra.Command { + var folgen bool + befehl := &cobra.Command{ + Use: "status ", + 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 ", + 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 ", + 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 + }, + } +} diff --git a/cli/internal/befehle/ausrollen.go b/cli/internal/befehle/ausrollen.go new file mode 100644 index 0000000..7d0638d --- /dev/null +++ b/cli/internal/befehle/ausrollen.go @@ -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 + " ", + 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 …", 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 +} diff --git a/cli/internal/befehle/batch.go b/cli/internal/befehle/batch.go new file mode 100644 index 0000000..acf8020 --- /dev/null +++ b/cli/internal/befehle/batch.go @@ -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 ", + 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 ") + } + 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 +} diff --git a/cli/internal/befehle/befehle_test.go b/cli/internal/befehle/befehle_test.go new file mode 100644 index 0000000..36a5c60 --- /dev/null +++ b/cli/internal/befehle/befehle_test.go @@ -0,0 +1,325 @@ +package befehle + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +var aktualisieren = flag.Bool("update", false, "Golden-Dateien neu schreiben") + +func golden(t *testing.T, name, ist string) { + t.Helper() + pfad := filepath.Join("testdata", name) + if *aktualisieren { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pfad, []byte(ist), 0o644); err != nil { + t.Fatal(err) + } + } + soll, err := os.ReadFile(pfad) + if err != nil { + t.Fatal(err) + } + if ist != string(soll) { + t.Errorf("%s weicht ab.\n--- soll\n%s\n--- ist\n%s", name, soll, ist) + } +} + +// phase baut eine Phase der API. +func phase(schluessel, titel, status, detail string) map[string]any { + p := map[string]any{"schluessel": schluessel, "titel": titel, "status": status, "detail": nil, "ursache": nil, "hinweis": nil, "gestartetAm": nil, "beendetAm": nil} + if detail != "" { + p["detail"] = detail + } + if status == "LAEUFT" { + p["gestartetAm"] = "2026-10-12T10:00:00.000Z" + } + return p +} + +func auftrag(status string, phasen []map[string]any, abschluss any) map[string]any { + return map[string]any{ + "id": "c1", "kurzId": "a-7f3k", "art": "AUSROLLEN", "status": status, + "kopf": "Server rz1 · Node pve01 · Debian 13 (Template 9013)", + "server": map[string]any{"name": "rz1", "titel": "RZ 1"}, "knoten": "pve01", "gastTyp": "VM", + "vmid": 120, "name": "web01", "distributionId": "debian-13", "profile": []string{"base", "docker"}, + "ergebnis": map[string]any{"vmid": 120, "ip": "10.0.20.15", "ssh": "ssh florian@10.0.20.15"}, + "fehler": nil, "erstelltVon": "florian", "erstelltAm": "2026-10-12T10:00:00.000Z", + "gestartetAm": "2026-10-12T10:00:00.000Z", "beendetAm": nil, "abschluss": abschluss, "phasen": phasen, + } +} + +var alle = []map[string]any{ + phase("pruefungen", "Prüfungen", "ERLEDIGT", "VMID 120 frei · local-zfs · vmbr0 VLAN 20"), + phase("klonen", "Klonen", "ERLEDIGT", "full clone 9013 → 120 · Disk auf 40G"), + phase("cloudinit", "cloud-init", "ERLEDIGT", "user florian · SSH-Key · Profile base, docker"), + phase("start", "Start", "ERLEDIGT", "VM 120 läuft"), + phase("agent", "Guest-Agent", "ERLEDIGT", "antwortet nach 18s"), + phase("provisionierung", "Provisionierung", "ERLEDIGT", "base, docker"), +} + +// fakeAPI spielt die REST-API nach; `anfragen` hält die Rümpfe fest. +type fakeAPI struct { + mu sync.Mutex + anfragen map[string][]map[string]any + staende []map[string]any + plan map[string]any + status int + antwort any +} + +func (f *fakeAPI) handler(t *testing.T) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer pd_test" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + fmt.Fprint(w, `{"fehler":{"code":"nicht_angemeldet","nachricht":"Nicht angemeldet."}}`) + return + } + if r.Body != nil && r.Method == http.MethodPost { + var rumpf map[string]any + _ = json.NewDecoder(r.Body).Decode(&rumpf) + f.mu.Lock() + if f.anfragen == nil { + f.anfragen = map[string][]map[string]any{} + } + f.anfragen[r.URL.Path] = append(f.anfragen[r.URL.Path], rumpf) + f.mu.Unlock() + } + schreiben := func(status int, wert any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(wert) + } + if f.status != 0 { + schreiben(f.status, f.antwort) + return + } + switch { + case r.URL.Path == "/api/v1/ich": + schreiben(200, map[string]any{"benutzername": "florian", "anzeigename": "Florian", "rolle": "OPERATOR", "rechte": []string{}, "version": "0.4.0"}) + case r.URL.Path == "/api/v1/auftraege" && r.Method == http.MethodPost: + schreiben(202, map[string]any{"id": "c1", "kurzId": "a-7f3k"}) + case r.URL.Path == "/api/v1/auftraege/plan": + schreiben(200, f.plan) + case strings.HasSuffix(r.URL.Path, "/ereignisse"): + w.Header().Set("Content-Type", "text/event-stream") + for nr, s := range f.staende { + daten, _ := json.Marshal(s) + fmt.Fprintf(w, "id: %d\nevent: stand\ndata: %s\n\n: ping\n\n", nr+1, daten) + } + fmt.Fprint(w, "event: ende\ndata: {}\n\n") + case r.URL.Path == "/api/v1/distributionen": + schreiben(200, []map[string]any{ + {"id": "debian-13", "name": "Debian 13 „Trixie“", "familie": "debian", "aktiv": true, "vm": true, "lxc": true}, + {"id": "ubuntu-26.04", "name": "Ubuntu 26.04 LTS", "familie": "debian", "aktiv": true, "vm": true, "lxc": false}, + }) + default: + t.Errorf("unerwartete Anfrage %s %s", r.Method, r.URL.Path) + schreiben(404, map[string]any{"fehler": map[string]any{"code": "nicht_gefunden", "nachricht": "?"}}) + } + }) +} + +func lauf(t *testing.T, f *fakeAPI, argumente ...string) (string, string, int) { + t.Helper() + server := httptest.NewServer(f.handler(t)) + t.Cleanup(server.Close) + t.Setenv("PROXDEPLOY_CONFIG", filepath.Join(t.TempDir(), "config.toml")) + t.Setenv("PROXDEPLOY_URL", server.URL) + t.Setenv("PROXDEPLOY_TOKEN", "pd_test") + var aus, fehler bytes.Buffer + u := &umgebung{aus: &aus, fehler: &fehler, terminal: false, opt: &optionen{}} + code := u.ausfuehren(context.Background(), argumente) + return aus.String(), fehler.String(), code +} + +func TestVmZeilenweiseBisZurBox(t *testing.T) { + Version = "1.0" + laeuft := append(append([]map[string]any{}, alle[:5]...), phase("provisionierung", "Provisionierung", "LAEUFT", "cloud-init läuft")) + f := &fakeAPI{staende: []map[string]any{ + auftrag("LAEUFT", []map[string]any{phase("pruefungen", "Prüfungen", "LAEUFT", ""), phase("klonen", "Klonen", "OFFEN", "")}, nil), + auftrag("LAEUFT", laeuft, nil), + auftrag("ERFOLGREICH", alle, map[string]any{"ok": true, "titel": "web01 bereit", "dauer": "1m 54s", "zeilen": [][]string{ + {"VMID", "120"}, {"IP", "10.0.20.15"}, {"Profile", "base, docker"}, {"SSH", "ssh florian@10.0.20.15"}, + }}), + }} + aus, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--profile", "base,docker", + "--ip", "10.0.20.15/24", "--vlan", "20", "--disk", "40G", "--var", "zabbix-agent.server=zbx.local") + if code != 0 { + t.Fatalf("Exit %d, stderr: %s", code, fehler) + } + golden(t, "vm-zeilenweise.golden", aus) + + // Nur gesetzte Flags gehen hinaus – Storage, Bridge, Benutzer und Schlüssel füllt der Server. + gesendet, _ := json.MarshalIndent(f.anfragen["/api/v1/auftraege"][0], "", " ") + golden(t, "vm-anfrage.golden", string(gesendet)+"\n") +} + +func TestVmFehlgeschlagenExit1UndQuiet(t *testing.T) { + Version = "1.0" + fehlerPhase := phase("klonen", "Klonen", "FEHLER", "") + fehlerPhase["ursache"] = "Storage local-zfs ist voll." + fehlerPhase["hinweis"] = "Anderen Storage wählen (--storage)." + f := &fakeAPI{staende: []map[string]any{ + auftrag("FEHLGESCHLAGEN", []map[string]any{alle[0], fehlerPhase, phase("cloudinit", "cloud-init", "OFFEN", "")}, + map[string]any{"ok": false, "titel": "web01 fehlgeschlagen", "dauer": "12s", "zeilen": [][]string{ + {"Phase", "Klonen"}, {"Ursache", "Storage local-zfs ist voll."}, {"Hinweis", "Anderen Storage wählen (--storage)."}, + }}), + }} + aus, _, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--quiet", "--ascii") + if code != ExitFehlschlag { + t.Fatalf("Exit %d, erwartet 1", code) + } + golden(t, "vm-fehler-quiet-ascii.golden", aus) +} + +func TestDryRun(t *testing.T) { + Version = "1.0" + f := &fakeAPI{plan: map[string]any{ + "kopf": "Server rz1 · Node pve01 · Debian 13 (Template 9013)", + "phasen": []map[string]any{alle[0], alle[1], phase("provisionierung", "Provisionierung", "ERLEDIGT", "base, docker")}, + "aufrufe": []map[string]any{ + {"methode": "POST", "pfad": "/nodes/pve01/qemu/9013/clone", "parameter": map[string]any{"newid": 120, "full": 1, "name": "web01"}}, + {"methode": "POST", "pfad": "/nodes/pve01/qemu/120/status/start"}, + }, + "skript": nil, "cliBefehl": "proxdeploy vm web01 --server rz1 --distro debian-13", "automatisch": []string{"base"}, "ok": true, + }} + aus, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--dry-run") + if code != 0 { + t.Fatalf("Exit %d: %s", code, fehler) + } + golden(t, "vm-dryrun.golden", aus) + if len(f.anfragen["/api/v1/auftraege"]) != 0 { + t.Error("Dry-Run hat einen Auftrag angelegt") + } +} + +func TestFehlerUndExitCodes(t *testing.T) { + f := &fakeAPI{status: 400, antwort: map[string]any{"fehler": map[string]any{ + "code": "ungueltig", "nachricht": "Die Eingaben sind nicht vollständig oder ungültig.", + "felder": map[string]string{"netz.ip": "Format: 10.0.20.15/24", "variablen.zabbix-agent.server": "Zabbix-Server fehlt."}, + }}} + _, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--ip", "x") + if code != ExitBedienung { + t.Errorf("Exit %d, erwartet 2", code) + } + golden(t, "fehler-felder.golden", fehler) + + f = &fakeAPI{status: 401, antwort: map[string]any{"fehler": map[string]any{"code": "nicht_angemeldet", "nachricht": "Nicht angemeldet."}}} + if _, _, code := lauf(t, f, "servers"); code != ExitVerbindung { + t.Errorf("401: Exit %d, erwartet 3", code) + } + + t.Setenv("PROXDEPLOY_URL", "http://127.0.0.1:1") + t.Setenv("PROXDEPLOY_TOKEN", "pd_test") + var aus, fehlerAus bytes.Buffer + u := &umgebung{aus: &aus, fehler: &fehlerAus, opt: &optionen{}} + if code := u.ausfuehren(context.Background(), []string{"distros"}); code != ExitVerbindung { + t.Errorf("nicht erreichbar: Exit %d, erwartet 3", code) + } + if code := u.ausfuehren(context.Background(), []string{"vm"}); code != ExitBedienung { + t.Errorf("ohne Name: Exit %d, erwartet 2", code) + } +} + +func TestDistrosTabelleUndJson(t *testing.T) { + aus, _, code := lauf(t, &fakeAPI{}, "distros") + if code != 0 { + t.Fatal(code) + } + golden(t, "distros.golden", aus) + aus, _, _ = lauf(t, &fakeAPI{}, "distros", "--json") + var liste []map[string]any + if err := json.Unmarshal([]byte(aus), &liste); err != nil || len(liste) != 2 { + t.Errorf("--json nicht lesbar: %v\n%s", err, aus) + } +} + +func TestBatch(t *testing.T) { + Version = "1.0" + datei := filepath.Join(t.TempDir(), "gaeste.yaml") + if err := os.WriteFile(datei, []byte(`defaults: + server: rz1 + distro: debian-13 + profile: [base] +guests: + - name: web01 + type: vm + profile: [base, docker] + ip: 10.0.20.15/24 + gw: 10.0.20.1 + memory: 4096 + - name: mon01 + type: lxc + profile: [base, zabbix-agent] + vars: { zabbix-agent: { server: zbx.example } } + nesting: true +`), 0o644); err != nil { + t.Fatal(err) + } + f := &fakeAPI{staende: []map[string]any{auftrag("ERFOLGREICH", alle, map[string]any{"ok": true, "titel": "web01 bereit", "dauer": "1m 54s", "zeilen": [][]string{}})}} + _, fehler, code := lauf(t, f, "batch", datei, "--quiet") + if code != 0 { + t.Fatalf("Exit %d: %s", code, fehler) + } + gesendet, _ := json.MarshalIndent(f.anfragen["/api/v1/auftraege"], "", " ") + golden(t, "batch-anfragen.golden", string(gesendet)+"\n") + + falsch := filepath.Join(t.TempDir(), "falsch.yaml") + _ = os.WriteFile(falsch, []byte("guests:\n - name: a\n type: vm\n server: rz1\n ram: 4096\n"), 0o644) + _, fehler, code = lauf(t, &fakeAPI{}, "batch", falsch) + if code != ExitBedienung || !strings.Contains(fehler, `unbekannter Schlüssel "ram"`) { + t.Errorf("Exit %d, %s", code, fehler) + } +} + +func TestLoginMitToken(t *testing.T) { + server := httptest.NewServer((&fakeAPI{}).handler(t)) + defer server.Close() + pfad := filepath.Join(t.TempDir(), "proxdeploy", "config.toml") + t.Setenv("PROXDEPLOY_CONFIG", pfad) + t.Setenv("PROXDEPLOY_URL", "") + t.Setenv("PROXDEPLOY_TOKEN", "") + var aus, fehler bytes.Buffer + u := &umgebung{aus: &aus, fehler: &fehler, opt: &optionen{}} + if code := u.ausfuehren(context.Background(), []string{"login", server.URL, "--token", "pd_test", "--default-server", "rz1"}); code != 0 { + t.Fatalf("Exit %d: %s", code, fehler.String()) + } + info, err := os.Stat(pfad) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("Rechte %v, erwartet 0600", info.Mode().Perm()) + } + inhalt, _ := os.ReadFile(pfad) + if !strings.Contains(string(inhalt), `token = "pd_test"`) || !strings.Contains(string(inhalt), `server = "rz1"`) { + t.Errorf("Konfiguration:\n%s", inhalt) + } + if !strings.Contains(aus.String(), "Angemeldet als florian") { + t.Errorf("Ausgabe: %s", aus.String()) + } + // Ein falsches Token wird nicht gespeichert. + aus.Reset() + code := u.ausfuehren(context.Background(), []string{"login", server.URL, "--token", "pd_falsch", "--instance", "labor"}) + if code != ExitVerbindung { + t.Errorf("falsches Token: Exit %d", code) + } + inhalt2, _ := os.ReadFile(pfad) + if string(inhalt2) != string(inhalt) { + t.Error("falsches Token hat die Konfiguration verändert") + } +} diff --git a/cli/internal/befehle/folgen.go b/cli/internal/befehle/folgen.go new file mode 100644 index 0000000..d9124a1 --- /dev/null +++ b/cli/internal/befehle/folgen.go @@ -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 + } + } +} diff --git a/cli/internal/befehle/listen.go b/cli/internal/befehle/listen.go new file mode 100644 index 0000000..8af1e02 --- /dev/null +++ b/cli/internal/befehle/listen.go @@ -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 + }, + } +} diff --git a/cli/internal/befehle/login.go b/cli/internal/befehle/login.go new file mode 100644 index 0000000..3f2ac0c --- /dev/null +++ b/cli/internal/befehle/login.go @@ -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 ", + 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 ") + } + 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@)") + 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 +} diff --git a/cli/internal/befehle/template.go b/cli/internal/befehle/template.go new file mode 100644 index 0000000..8a4f056 --- /dev/null +++ b/cli/internal/befehle/template.go @@ -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 +} diff --git a/cli/internal/befehle/testdata/batch-anfragen.golden b/cli/internal/befehle/testdata/batch-anfragen.golden new file mode 100644 index 0000000..fec76d5 --- /dev/null +++ b/cli/internal/befehle/testdata/batch-anfragen.golden @@ -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" + } + } + } +] diff --git a/cli/internal/befehle/testdata/distros.golden b/cli/internal/befehle/testdata/distros.golden new file mode 100644 index 0000000..cec4740 --- /dev/null +++ b/cli/internal/befehle/testdata/distros.golden @@ -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 – diff --git a/cli/internal/befehle/testdata/fehler-felder.golden b/cli/internal/befehle/testdata/fehler-felder.golden new file mode 100644 index 0000000..52a2dc8 --- /dev/null +++ b/cli/internal/befehle/testdata/fehler-felder.golden @@ -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. diff --git a/cli/internal/befehle/testdata/vm-anfrage.golden b/cli/internal/befehle/testdata/vm-anfrage.golden new file mode 100644 index 0000000..5926376 --- /dev/null +++ b/cli/internal/befehle/testdata/vm-anfrage.golden @@ -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" + } + } +} diff --git a/cli/internal/befehle/testdata/vm-dryrun.golden b/cli/internal/befehle/testdata/vm-dryrun.golden new file mode 100644 index 0000000..7823678 --- /dev/null +++ b/cli/internal/befehle/testdata/vm-dryrun.golden @@ -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. diff --git a/cli/internal/befehle/testdata/vm-fehler-quiet-ascii.golden b/cli/internal/befehle/testdata/vm-fehler-quiet-ascii.golden new file mode 100644 index 0000000..eb3350f --- /dev/null +++ b/cli/internal/befehle/testdata/vm-fehler-quiet-ascii.golden @@ -0,0 +1,6 @@ ++------------------------------------------------+ +| [!!] web01 fehlgeschlagen · 12s | +| Phase Klonen | +| Ursache Storage local-zfs ist voll. | +| Hinweis Anderen Storage wählen (--storage). | ++------------------------------------------------+ diff --git a/cli/internal/befehle/testdata/vm-zeilenweise.golden b/cli/internal/befehle/testdata/vm-zeilenweise.golden new file mode 100644 index 0000000..3f4a81d --- /dev/null +++ b/cli/internal/befehle/testdata/vm-zeilenweise.golden @@ -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 │ +└──────────────────────────────────────┘ diff --git a/cli/internal/befehle/wurzel.go b/cli/internal/befehle/wurzel.go new file mode 100644 index 0000000..6595691 --- /dev/null +++ b/cli/internal/befehle/wurzel.go @@ -0,0 +1,314 @@ +// Package befehle enthält die Befehle der CLI `proxdeploy`. +package befehle + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/signal" + "regexp" + "sort" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api" + "git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe" + "git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/konfig" +) + +// Exit-Codes (docs/architektur.md, Abschnitt CLI). +const ( + ExitOk = 0 + ExitFehlschlag = 1 // Auftrag fehlgeschlagen oder abgebrochen + ExitBedienung = 2 // falscher Aufruf, ungültige Eingaben + ExitVerbindung = 3 // nicht erreichbar, nicht angemeldet +) + +// Version wird beim Bauen gesetzt (-ldflags "-X …/befehle.Version=0.4.0"). +var Version = "0.0.0-dev" + +// Optionen, die für alle Befehle gelten. +type optionen struct { + keineFarbe bool + ascii bool + still bool + json bool + instanz string +} + +// Umgebung eines Laufs – für Tests austauschbar. +type umgebung struct { + aus, fehler io.Writer + terminal bool + opt *optionen +} + +func (u *umgebung) stil() ausgabe.Stil { + return ausgabe.Stil{Farbe: ausgabe.FarbmodusErmitteln(u.opt.keineFarbe, u.terminal), ASCII: u.opt.ascii} +} + +// exitFehler trägt den Exit-Code; die Meldung ist schon ausgegeben, wenn `gemeldet` gesetzt ist. +type exitFehler struct { + code int + gemeldet bool + err error +} + +func (e *exitFehler) Error() string { + if e.err != nil { + return e.err.Error() + } + return fmt.Sprintf("Exit %d", e.code) +} + +func bedienfehler(format string, argumente ...any) error { + return &exitFehler{code: ExitBedienung, err: fmt.Errorf(format, argumente...)} +} + +// Ausfuehren ist der Einstieg aus main. +func Ausfuehren() int { + u := &umgebung{ + aus: os.Stdout, + fehler: os.Stderr, + terminal: term.IsTerminal(int(os.Stdout.Fd())), + opt: &optionen{}, + } + ctx, stopp := signal.NotifyContext(context.Background(), os.Interrupt) + defer stopp() + return u.ausfuehren(ctx, os.Args[1:]) +} + +func (u *umgebung) ausfuehren(ctx context.Context, argumente []string) int { + wurzel := u.wurzel() + wurzel.SetArgs(argumente) + wurzel.SetOut(u.aus) + wurzel.SetErr(u.fehler) + err := wurzel.ExecuteContext(ctx) + if err == nil { + return ExitOk + } + return u.fehlerMelden(err) +} + +func (u *umgebung) wurzel() *cobra.Command { + wurzel := &cobra.Command{ + Use: "proxdeploy", + Short: "VMs und LXC-Container auf Proxmox VE ausrollen", + Long: "ProxDeploy – VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.\nEin unabhängiges Werkzeug, nicht von Proxmox Server Solutions.", + Version: Version, + SilenceUsage: true, + SilenceErrors: true, + } + wurzel.CompletionOptions.HiddenDefaultCmd = true + wurzel.SetUsageTemplate(hilfeVorlage) + wurzel.SetVersionTemplate("proxdeploy {{.Version}}\n") + wurzel.InitDefaultVersionFlag() + wurzel.Flags().Lookup("version").Usage = "Version zeigen" + wurzel.SetHelpCommand(&cobra.Command{Use: "help [befehl]", Short: "Hilfe zu einem Befehl", Hidden: true, + RunE: func(c *cobra.Command, argumente []string) error { + ziel, _, err := c.Root().Find(argumente) + if err != nil || ziel == nil { + ziel = c.Root() + } + return ziel.Help() + }}) + wurzel.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return &exitFehler{code: ExitBedienung, err: flagFehlerUebersetzen(err)} + }) + f := wurzel.PersistentFlags() + f.BoolVar(&u.opt.keineFarbe, "no-color", false, "ohne Farben (auch mit NO_COLOR)") + f.BoolVar(&u.opt.ascii, "ascii", false, "Symbole als [OK], [..], [!!]") + f.BoolVarP(&u.opt.still, "quiet", "q", false, "nur Abschlussbox bzw. Fehler") + f.BoolVar(&u.opt.json, "json", false, "maschinenlesbare Ausgabe") + f.StringVar(&u.opt.instanz, "instance", "", "Instanz aus der Konfiguration ([instances.])") + + wurzel.AddCommand( + u.loginBefehl(), + u.serversBefehl(), + u.distrosBefehl(), + u.profilesBefehl(), + u.presetsBefehl(), + u.templateBefehl(), + u.ausrollBefehl("vm"), + u.ausrollBefehl("lxc"), + u.batchBefehl(), + u.statusBefehl(), + u.logsBefehl(), + u.cancelBefehl(), + ) + hilfeEindeutschen(wurzel) + return wurzel +} + +func hilfeEindeutschen(c *cobra.Command) { + c.InitDefaultHelpFlag() + if f := c.Flags().Lookup("help"); f != nil { + f.Usage = "Hilfe zu " + c.CommandPath() + } + for _, unter := range c.Commands() { + hilfeEindeutschen(unter) + } +} + +// client baut den API-Client aus Konfiguration und Umgebung. +func (u *umgebung) client() (*api.Client, konfig.Instanz, error) { + datei, pfad, err := konfig.Lesen() + if err != nil { + return nil, konfig.Instanz{}, &exitFehler{code: ExitBedienung, err: err} + } + instanz, err := datei.Waehlen(u.opt.instanz) + if err != nil { + return nil, konfig.Instanz{}, &exitFehler{code: ExitBedienung, err: err} + } + if instanz.URL == "" || instanz.Token == "" { + return nil, instanz, &exitFehler{ + code: ExitVerbindung, + err: fmt.Errorf("nicht angemeldet – zuerst `proxdeploy login ` (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": "", + "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}} +` diff --git a/cli/internal/konfig/konfig.go b/cli/internal/konfig/konfig.go new file mode 100644 index 0000000..022add5 --- /dev/null +++ b/cli/internal/konfig/konfig.go @@ -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) +} diff --git a/cli/internal/konfig/konfig_test.go b/cli/internal/konfig/konfig_test.go new file mode 100644 index 0000000..549d965 --- /dev/null +++ b/cli/internal/konfig/konfig_test.go @@ -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) + } +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..1b6be5e --- /dev/null +++ b/cli/main.go @@ -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()) +}