feat(cli): proxdeploy in Go

Befehle login, servers, distros, profiles, presets, vm, lxc, template list|build, batch, status, logs --follow, cancel. Renderer im Aufbau der Vorgabe (zeichengenau per Golden-File), 24-bit/256/ohne Farbe, --ascii, --quiet, --json, --dry-run; live im Terminal, zeilenweise ohne. SSE mit Wiederverbinden und Polling als Rückfall. Typen aus openapi.json per oapi-codegen; Release-Builds per cli/bauen.sh.

Refs #013

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:08:41 +02:00
co-authored by Claude Opus 5.5
parent 9fdced62db
commit bd4bca5399
36 changed files with 5960 additions and 0 deletions
+287
View File
@@ -0,0 +1,287 @@
package api
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Client spricht die REST-API von ProxDeploy. Die Typen der Antworten sind aus openapi.json
// erzeugt (typen.gen.go); Anfragen gehen als Map hinaus, damit nur gesetzte Flags mitkommen und
// Vorlage und Server-Standards den Rest füllen.
type Client struct {
// Basis-URL der Instanz, z. B. https://proxdeploy.example (ohne /api/v1).
URL string
Token string
Version string
HTTP *http.Client
}
func Neu(basis, token, version string) *Client {
return &Client{
URL: strings.TrimRight(basis, "/"),
Token: token,
Version: version,
HTTP: &http.Client{Timeout: 60 * time.Second},
}
}
// Fehler ist eine Fehlerantwort der API (`{ fehler: { code, nachricht, hinweis?, felder? } }`).
type Fehler struct {
Status int
Code string
Nachricht string
Hinweis string
Felder map[string]string
}
func (f *Fehler) Error() string { return f.Nachricht }
// VerbindungsFehler: die Instanz war nicht erreichbar oder hat keine API-Antwort geliefert.
type VerbindungsFehler struct{ Ursache error }
func (f *VerbindungsFehler) Error() string {
return fmt.Sprintf("ProxDeploy nicht erreichbar: %v", f.Ursache)
}
func (f *VerbindungsFehler) Unwrap() error { return f.Ursache }
// IstAnmeldefehler: Token fehlt, ist abgelaufen oder widerrufen.
func IstAnmeldefehler(err error) bool {
var f *Fehler
return errors.As(err, &f) && f.Status == http.StatusUnauthorized
}
func (c *Client) anfrage(ctx context.Context, methode, pfad string, rumpf any) (*http.Request, error) {
var koerper io.Reader
if rumpf != nil {
daten, err := json.Marshal(rumpf)
if err != nil {
return nil, err
}
koerper = bytes.NewReader(daten)
}
req, err := http.NewRequestWithContext(ctx, methode, c.URL+"/api/v1"+pfad, koerper)
if err != nil {
return nil, err
}
if rumpf != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "proxdeploy-cli/"+c.Version)
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
return req, nil
}
func fehlerLesen(antwort *http.Response) error {
daten, _ := io.ReadAll(io.LimitReader(antwort.Body, 1<<20))
var huelle struct {
Fehler *struct {
Code string `json:"code"`
Nachricht string `json:"nachricht"`
Hinweis string `json:"hinweis"`
Felder map[string]string `json:"felder"`
} `json:"fehler"`
}
if json.Unmarshal(daten, &huelle) != nil || huelle.Fehler == nil {
// Kein API-Format: falsche URL, Reverse-Proxy-Fehlerseite o. Ä.
return &VerbindungsFehler{Ursache: fmt.Errorf("HTTP %d ohne API-Antwort – stimmt die URL?", antwort.StatusCode)}
}
return &Fehler{
Status: antwort.StatusCode,
Code: huelle.Fehler.Code,
Nachricht: huelle.Fehler.Nachricht,
Hinweis: huelle.Fehler.Hinweis,
Felder: huelle.Fehler.Felder,
}
}
// Aufrufen schickt eine Anfrage und liest die JSON-Antwort nach `ziel` (nil = verwerfen).
func (c *Client) Aufrufen(ctx context.Context, methode, pfad string, rumpf, ziel any) error {
req, err := c.anfrage(ctx, methode, pfad, rumpf)
if err != nil {
return err
}
antwort, err := c.HTTP.Do(req)
if err != nil {
return &VerbindungsFehler{Ursache: err}
}
defer antwort.Body.Close()
if antwort.StatusCode >= 400 {
return fehlerLesen(antwort)
}
if ziel == nil || antwort.StatusCode == http.StatusNoContent {
return nil
}
if !strings.HasPrefix(antwort.Header.Get("Content-Type"), "application/json") {
return &VerbindungsFehler{Ursache: errors.New("Antwort ist kein JSON – stimmt die URL?")}
}
if err := json.NewDecoder(antwort.Body).Decode(ziel); err != nil {
return fmt.Errorf("Antwort nicht lesbar: %w", err)
}
return nil
}
func (c *Client) Ich(ctx context.Context) (Ich, error) {
var ich Ich
return ich, c.Aufrufen(ctx, http.MethodGet, "/ich", nil, &ich)
}
func (c *Client) TokenAnmelden(ctx context.Context, anmeldung TokenAnmeldung) (NeuesToken, error) {
var token NeuesToken
return token, c.Aufrufen(ctx, http.MethodPost, "/auth/token", anmeldung, &token)
}
func (c *Client) ServerListe(ctx context.Context) ([]Server, error) {
var liste []Server
return liste, c.Aufrufen(ctx, http.MethodGet, "/server", nil, &liste)
}
func (c *Client) DistributionenListe(ctx context.Context, typ string) ([]Distribution, error) {
pfad := "/distributionen"
if typ != "" {
pfad += "?typ=" + url.QueryEscape(typ)
}
var liste []Distribution
return liste, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &liste)
}
func (c *Client) ProfileListe(ctx context.Context) ([]Profil, error) {
var liste []Profil
return liste, c.Aufrufen(ctx, http.MethodGet, "/profile", nil, &liste)
}
func (c *Client) VorlagenListe(ctx context.Context) ([]Vorlage, error) {
var liste []Vorlage
return liste, c.Aufrufen(ctx, http.MethodGet, "/vorlagen", nil, &liste)
}
func (c *Client) TemplatesListe(ctx context.Context, server string) ([]Template, error) {
var liste []Template
return liste, c.Aufrufen(ctx, http.MethodGet, "/server/"+url.PathEscape(server)+"/templates", nil, &liste)
}
func (c *Client) Ausrollen(ctx context.Context, anfrage map[string]any) (AuftragAngenommen, error) {
var a AuftragAngenommen
return a, c.Aufrufen(ctx, http.MethodPost, "/auftraege", anfrage, &a)
}
func (c *Client) Planen(ctx context.Context, anfrage map[string]any) (Plan, error) {
var p Plan
return p, c.Aufrufen(ctx, http.MethodPost, "/auftraege/plan", anfrage, &p)
}
func (c *Client) TemplateBauen(ctx context.Context, server string, anfrage map[string]any) (AuftragAngenommen, error) {
var a AuftragAngenommen
return a, c.Aufrufen(ctx, http.MethodPost, "/server/"+url.PathEscape(server)+"/templates", anfrage, &a)
}
func (c *Client) Auftrag(ctx context.Context, id string) (Auftrag, error) {
var a Auftrag
return a, c.Aufrufen(ctx, http.MethodGet, "/auftraege/"+url.PathEscape(id), nil, &a)
}
func (c *Client) Log(ctx context.Context, id, ab string) ([]LogZeile, error) {
var zeilen []LogZeile
pfad := "/auftraege/" + url.PathEscape(id) + "/log"
if ab != "" {
pfad += "?ab=" + url.QueryEscape(ab)
}
return zeilen, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &zeilen)
}
func (c *Client) Abbrechen(ctx context.Context, id string) error {
return c.Aufrufen(ctx, http.MethodPost, "/auftraege/"+url.PathEscape(id)+"/abbrechen", nil, nil)
}
// Ereignis aus dem SSE-Strom eines Auftrags.
type Ereignis struct {
ID string
Art string // stand, log, ende
Daten []byte
}
// Ereignisse liest den SSE-Strom bis zum Ende oder Abbruch. `ab` ist die letzte gesehene Event-ID
// (Log-Zeile); ein Wiederverbinden mit ihr setzt genau dort fort.
func (c *Client) Ereignisse(ctx context.Context, id, ab string, beiEreignis func(Ereignis) error) error {
req, err := c.anfrage(ctx, http.MethodGet, "/auftraege/"+url.PathEscape(id)+"/ereignisse", nil)
if err != nil {
return err
}
req.Header.Set("Accept", "text/event-stream")
if ab != "" {
req.Header.Set("Last-Event-ID", ab)
}
// Ohne Zeitlimit: der Strom läuft, solange der Auftrag läuft (Herzschlag alle 15 s).
strom := &http.Client{Transport: c.HTTP.Transport}
antwort, err := strom.Do(req)
if err != nil {
return &VerbindungsFehler{Ursache: err}
}
defer antwort.Body.Close()
if antwort.StatusCode >= 400 {
return fehlerLesen(antwort)
}
if !strings.HasPrefix(antwort.Header.Get("Content-Type"), "text/event-stream") {
return &VerbindungsFehler{Ursache: errors.New("kein Ereignisstrom (puffert ein Proxy?)")}
}
return sseLesen(antwort.Body, beiEreignis)
}
func sseLesen(r io.Reader, beiEreignis func(Ereignis) error) error {
leser := bufio.NewReaderSize(r, 64*1024)
var e Ereignis
var daten bytes.Buffer
for {
zeile, err := leser.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) {
return io.ErrUnexpectedEOF
}
return &VerbindungsFehler{Ursache: err}
}
zeile = strings.TrimRight(zeile, "\r\n")
switch {
case zeile == "":
if e.Art != "" || daten.Len() > 0 {
if e.Art == "" {
e.Art = "message"
}
// Kopie: der Puffer wird für das nächste Ereignis wiederverwendet.
e.Daten = bytes.Clone(bytes.TrimSuffix(daten.Bytes(), []byte("\n")))
if err := beiEreignis(e); err != nil {
return err
}
}
e = Ereignis{}
daten.Reset()
case strings.HasPrefix(zeile, ":"):
// Kommentar (Herzschlag)
default:
feld, wert, _ := strings.Cut(zeile, ":")
wert = strings.TrimPrefix(wert, " ")
switch feld {
case "id":
e.ID = wert
case "event":
e.Art = wert
case "data":
daten.WriteString(wert)
daten.WriteByte('\n')
}
}
}
}
// Ende signalisiert, dass der Strom regulär geschlossen wurde (Ereignis `ende`).
var Ende = errors.New("ende")
+31
View File
@@ -0,0 +1,31 @@
package api
import (
"errors"
"io"
"strings"
"testing"
)
func TestSseLesen(t *testing.T) {
strom := ": ping\r\n\r\nid: 41\r\nevent: log\r\ndata: [1,\r\ndata: 2]\r\n\r\nevent: stand\ndata: {}\n\n: ping\n\nevent: ende\ndata: {}\n\n"
var gesehen []Ereignis
err := sseLesen(strings.NewReader(strom), func(e Ereignis) error {
gesehen = append(gesehen, e)
if e.Art == "ende" {
return Ende
}
return nil
})
if !errors.Is(err, Ende) {
t.Fatalf("Fehler %v", err)
}
if len(gesehen) != 3 || gesehen[0].ID != "41" || string(gesehen[0].Daten) != "[1,\n2]" || gesehen[1].Art != "stand" {
t.Errorf("gelesen: %+v", gesehen)
}
// Bricht der Strom vor `ende` ab, merkt der Aufrufer es (und verbindet neu).
err = sseLesen(strings.NewReader("event: stand\ndata: {}\n\n"), func(Ereignis) error { return nil })
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Errorf("Abbruch: %v", err)
}
}
+4
View File
@@ -0,0 +1,4 @@
// Package api enthält die aus cli/api/openapi.json erzeugten Typen und den HTTP-Client der CLI.
package api
//go:generate go tool oapi-codegen -config ../../api/oapi-codegen.yaml ../../api/openapi.json
+751
View File
@@ -0,0 +1,751 @@
// Package api provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT.
package api
import (
"encoding/json"
"github.com/oapi-codegen/runtime"
)
// Defines values for AuftragArt.
const (
AUFRAEUMEN AuftragArt = "AUFRAEUMEN"
AUSROLLEN AuftragArt = "AUSROLLEN"
TEMPLATEBAUEN AuftragArt = "TEMPLATE_BAUEN"
)
// Valid indicates whether the value is a known member of the AuftragArt enum.
func (e AuftragArt) Valid() bool {
switch e {
case AUFRAEUMEN:
return true
case AUSROLLEN:
return true
case TEMPLATEBAUEN:
return true
default:
return false
}
}
// Defines values for AuftragGastTyp.
const (
LXC AuftragGastTyp = "LXC"
VM AuftragGastTyp = "VM"
)
// Valid indicates whether the value is a known member of the AuftragGastTyp enum.
func (e AuftragGastTyp) Valid() bool {
switch e {
case LXC:
return true
case VM:
return true
default:
return false
}
}
// Defines values for AuftragStatus.
const (
AuftragStatusABGEBROCHEN AuftragStatus = "ABGEBROCHEN"
AuftragStatusAUFGERAEUMT AuftragStatus = "AUFGERAEUMT"
AuftragStatusERFOLGREICH AuftragStatus = "ERFOLGREICH"
AuftragStatusFEHLGESCHLAGEN AuftragStatus = "FEHLGESCHLAGEN"
AuftragStatusLAEUFT AuftragStatus = "LAEUFT"
AuftragStatusWARTEND AuftragStatus = "WARTEND"
)
// Valid indicates whether the value is a known member of the AuftragStatus enum.
func (e AuftragStatus) Valid() bool {
switch e {
case AuftragStatusABGEBROCHEN:
return true
case AuftragStatusAUFGERAEUMT:
return true
case AuftragStatusERFOLGREICH:
return true
case AuftragStatusFEHLGESCHLAGEN:
return true
case AuftragStatusLAEUFT:
return true
case AuftragStatusWARTEND:
return true
default:
return false
}
}
// Defines values for AusrollAnfrageNetzModus.
const (
Dhcp AusrollAnfrageNetzModus = "dhcp"
Statisch AusrollAnfrageNetzModus = "statisch"
)
// Valid indicates whether the value is a known member of the AusrollAnfrageNetzModus enum.
func (e AusrollAnfrageNetzModus) Valid() bool {
switch e {
case Dhcp:
return true
case Statisch:
return true
default:
return false
}
}
// Defines values for AusrollAnfrageTyp.
const (
AusrollAnfrageTypLxc AusrollAnfrageTyp = "lxc"
AusrollAnfrageTypVm AusrollAnfrageTyp = "vm"
)
// Valid indicates whether the value is a known member of the AusrollAnfrageTyp enum.
func (e AusrollAnfrageTyp) Valid() bool {
switch e {
case AusrollAnfrageTypLxc:
return true
case AusrollAnfrageTypVm:
return true
default:
return false
}
}
// Defines values for IchRolle.
const (
ADMIN IchRolle = "ADMIN"
OPERATOR IchRolle = "OPERATOR"
)
// Valid indicates whether the value is a known member of the IchRolle enum.
func (e IchRolle) Valid() bool {
switch e {
case ADMIN:
return true
case OPERATOR:
return true
default:
return false
}
}
// Defines values for PhaseStatus.
const (
PhaseStatusERLEDIGT PhaseStatus = "ERLEDIGT"
PhaseStatusFEHLER PhaseStatus = "FEHLER"
PhaseStatusLAEUFT PhaseStatus = "LAEUFT"
PhaseStatusOFFEN PhaseStatus = "OFFEN"
PhaseStatusUEBERSPRUNGEN PhaseStatus = "UEBERSPRUNGEN"
)
// Valid indicates whether the value is a known member of the PhaseStatus enum.
func (e PhaseStatus) Valid() bool {
switch e {
case PhaseStatusERLEDIGT:
return true
case PhaseStatusFEHLER:
return true
case PhaseStatusLAEUFT:
return true
case PhaseStatusOFFEN:
return true
case PhaseStatusUEBERSPRUNGEN:
return true
default:
return false
}
}
// Defines values for ProfilHerkunft.
const (
EIGEN ProfilHerkunft = "EIGEN"
KATALOG ProfilHerkunft = "KATALOG"
)
// Valid indicates whether the value is a known member of the ProfilHerkunft enum.
func (e ProfilHerkunft) Valid() bool {
switch e {
case EIGEN:
return true
case KATALOG:
return true
default:
return false
}
}
// Defines values for ProfilVariableType.
const (
Boolean ProfilVariableType = "boolean"
Number ProfilVariableType = "number"
Select ProfilVariableType = "select"
String ProfilVariableType = "string"
)
// Valid indicates whether the value is a known member of the ProfilVariableType enum.
func (e ProfilVariableType) Valid() bool {
switch e {
case Boolean:
return true
case Number:
return true
case Select:
return true
case String:
return true
default:
return false
}
}
// Defines values for DistributionenListeParamsTyp.
const (
DistributionenListeParamsTypLxc DistributionenListeParamsTyp = "lxc"
DistributionenListeParamsTypVm DistributionenListeParamsTyp = "vm"
)
// Valid indicates whether the value is a known member of the DistributionenListeParamsTyp enum.
func (e DistributionenListeParamsTyp) Valid() bool {
switch e {
case DistributionenListeParamsTypLxc:
return true
case DistributionenListeParamsTypVm:
return true
default:
return false
}
}
// Abschluss defines model for Abschluss.
type Abschluss struct {
Dauer *string `json:"dauer"`
Ok bool `json:"ok"`
Titel string `json:"titel"`
Zeilen [][]string `json:"zeilen"`
}
// Auftrag defines model for Auftrag.
type Auftrag struct {
Abschluss *Abschluss `json:"abschluss"`
Art AuftragArt `json:"art"`
// BeendetAm ISO 8601
BeendetAm *string `json:"beendetAm"`
DistributionId *string `json:"distributionId"`
Ergebnis *AuftragErgebnis `json:"ergebnis"`
// ErstelltAm ISO 8601
ErstelltAm string `json:"erstelltAm"`
ErstelltVon string `json:"erstelltVon"`
Fehler *struct {
Hinweis *string `json:"hinweis,omitempty"`
Phase *string `json:"phase,omitempty"`
Ursache string `json:"ursache"`
} `json:"fehler"`
GastTyp *AuftragGastTyp `json:"gastTyp"`
// GestartetAm ISO 8601
GestartetAm *string `json:"gestartetAm"`
Id string `json:"id"`
Knoten *string `json:"knoten"`
Kopf string `json:"kopf"`
KurzId string `json:"kurzId"`
Name *string `json:"name"`
Phasen []Phase `json:"phasen"`
Profile []string `json:"profile"`
Server struct {
Name string `json:"name"`
Titel string `json:"titel"`
} `json:"server"`
Status AuftragStatus `json:"status"`
Vmid *int `json:"vmid"`
}
// AuftragArt defines model for Auftrag.Art.
type AuftragArt string
// AuftragGastTyp defines model for Auftrag.GastTyp.
type AuftragGastTyp string
// AuftragStatus defines model for Auftrag.Status.
type AuftragStatus string
// AuftragAngenommen defines model for AuftragAngenommen.
type AuftragAngenommen struct {
Id string `json:"id"`
KurzId string `json:"kurzId"`
}
// AuftragErgebnis defines model for AuftragErgebnis.
type AuftragErgebnis struct {
Ip *string `json:"ip,omitempty"`
Knoten *string `json:"knoten,omitempty"`
Ssh *string `json:"ssh,omitempty"`
Template *string `json:"template,omitempty"`
Vmid *int `json:"vmid,omitempty"`
}
// AusrollAnfrage defines model for AusrollAnfrage.
type AusrollAnfrage struct {
Cores *int `json:"cores,omitempty"`
DiskGb *int `json:"diskGb,omitempty"`
Distribution string `json:"distribution"`
Features *struct {
Keyctl *bool `json:"keyctl,omitempty"`
Nesting *bool `json:"nesting,omitempty"`
} `json:"features,omitempty"`
Knoten *string `json:"knoten,omitempty"`
Name string `json:"name"`
Netz struct {
Bridge string `json:"bridge"`
Dns *[]string `json:"dns,omitempty"`
Gateway *string `json:"gateway,omitempty"`
Ip *string `json:"ip,omitempty"`
Modus *AusrollAnfrageNetzModus `json:"modus,omitempty"`
Suchdomain *string `json:"suchdomain,omitempty"`
Vlan *int `json:"vlan,omitempty"`
} `json:"netz"`
Profile *[]string `json:"profile,omitempty"`
Server string `json:"server"`
SpeicherMb *int `json:"speicherMb,omitempty"`
StartBeiBoot *bool `json:"startBeiBoot,omitempty"`
Storage string `json:"storage"`
SwapMb *int `json:"swapMb,omitempty"`
Tags *[]string `json:"tags,omitempty"`
TemplateBauen *bool `json:"templateBauen,omitempty"`
Typ AusrollAnfrageTyp `json:"typ"`
Unprivilegiert *bool `json:"unprivilegiert,omitempty"`
Variablen *map[string]map[string]AusrollAnfrage_Variablen_AdditionalProperties `json:"variablen,omitempty"`
Vmid *int `json:"vmid,omitempty"`
Vorlage *string `json:"vorlage,omitempty"`
Zugang struct {
Benutzer string `json:"benutzer"`
SshSchluessel []string `json:"sshSchluessel"`
} `json:"zugang"`
}
// AusrollAnfrageNetzModus defines model for AusrollAnfrage.Netz.Modus.
type AusrollAnfrageNetzModus string
// AusrollAnfrageTyp defines model for AusrollAnfrage.Typ.
type AusrollAnfrageTyp string
// AusrollAnfrageVariablen0 defines model for AusrollAnfrage.Variablen.0.
type AusrollAnfrageVariablen0 = string
// AusrollAnfrageVariablen1 defines model for AusrollAnfrage.Variablen.1.
type AusrollAnfrageVariablen1 = float32
// AusrollAnfrageVariablen2 defines model for AusrollAnfrage.Variablen.2.
type AusrollAnfrageVariablen2 = bool
// AusrollAnfrage_Variablen_AdditionalProperties defines model for AusrollAnfrage.variablen.AdditionalProperties.
type AusrollAnfrage_Variablen_AdditionalProperties struct {
union json.RawMessage
}
// Distribution defines model for Distribution.
type Distribution struct {
Aktiv bool `json:"aktiv"`
Familie string `json:"familie"`
Id string `json:"id"`
Lxc bool `json:"lxc"`
Name string `json:"name"`
Vm bool `json:"vm"`
}
// FehlerAntwort defines model for FehlerAntwort.
type FehlerAntwort struct {
Fehler struct {
Code string `json:"code"`
Felder *map[string]string `json:"felder,omitempty"`
Hinweis *string `json:"hinweis,omitempty"`
Nachricht string `json:"nachricht"`
} `json:"fehler"`
}
// GeplanterAufruf defines model for GeplanterAufruf.
type GeplanterAufruf struct {
Methode string `json:"methode"`
Parameter *map[string]interface{} `json:"parameter,omitempty"`
Pfad string `json:"pfad"`
}
// Ich defines model for Ich.
type Ich struct {
Anzeigename string `json:"anzeigename"`
Benutzername string `json:"benutzername"`
Rechte []string `json:"rechte"`
Rolle IchRolle `json:"rolle"`
Version string `json:"version"`
}
// IchRolle defines model for Ich.Rolle.
type IchRolle string
// LogZeile defines model for LogZeile.
type LogZeile struct {
Id string `json:"id"`
Nachricht string `json:"nachricht"`
Phase *string `json:"phase"`
Stufe string `json:"stufe"`
// Zeit ISO 8601
Zeit string `json:"zeit"`
}
// NeuesToken defines model for NeuesToken.
type NeuesToken struct {
// AblaufAm ISO 8601
AblaufAm *string `json:"ablaufAm"`
// ErstelltAm ISO 8601
ErstelltAm string `json:"erstelltAm"`
Id string `json:"id"`
Name string `json:"name"`
Praefix string `json:"praefix"`
Token string `json:"token"`
}
// Phase defines model for Phase.
type Phase struct {
// BeendetAm ISO 8601
BeendetAm *string `json:"beendetAm,omitempty"`
Detail *string `json:"detail"`
// GestartetAm ISO 8601
GestartetAm *string `json:"gestartetAm,omitempty"`
Hinweis *string `json:"hinweis"`
Schluessel string `json:"schluessel"`
Status PhaseStatus `json:"status"`
Titel string `json:"titel"`
Ursache *string `json:"ursache"`
}
// PhaseStatus defines model for Phase.Status.
type PhaseStatus string
// Plan defines model for Plan.
type Plan struct {
Aufrufe []GeplanterAufruf `json:"aufrufe"`
Automatisch []string `json:"automatisch"`
CliBefehl string `json:"cliBefehl"`
Kopf string `json:"kopf"`
Ok bool `json:"ok"`
Phasen []Phase `json:"phasen"`
Skript *string `json:"skript"`
}
// Profil defines model for Profil.
type Profil struct {
Aktiv bool `json:"aktiv"`
Beschreibung *string `json:"beschreibung"`
Definition struct {
Variables *[]ProfilVariable `json:"variables,omitempty"`
} `json:"definition"`
Familien []string `json:"familien"`
Herkunft ProfilHerkunft `json:"herkunft"`
Id string `json:"id"`
Name string `json:"name"`
}
// ProfilHerkunft defines model for Profil.Herkunft.
type ProfilHerkunft string
// ProfilVariable defines model for ProfilVariable.
type ProfilVariable struct {
Default *ProfilVariable_Default `json:"default,omitempty"`
Help *string `json:"help,omitempty"`
Label string `json:"label"`
Name string `json:"name"`
Options *[]string `json:"options,omitempty"`
Required bool `json:"required"`
Type ProfilVariableType `json:"type"`
}
// ProfilVariableDefault0 defines model for ProfilVariable.Default.0.
type ProfilVariableDefault0 = string
// ProfilVariableDefault1 defines model for ProfilVariable.Default.1.
type ProfilVariableDefault1 = float32
// ProfilVariableDefault2 defines model for ProfilVariable.Default.2.
type ProfilVariableDefault2 = bool
// ProfilVariable_Default defines model for ProfilVariable.Default.
type ProfilVariable_Default struct {
union json.RawMessage
}
// ProfilVariableType defines model for ProfilVariable.Type.
type ProfilVariableType string
// Server defines model for Server.
type Server struct {
Erreichbar *bool `json:"erreichbar"`
// GeprueftAm ISO 8601
GeprueftAm *string `json:"geprueftAm"`
LetzterTest *struct {
Cluster *struct {
Name string `json:"name"`
} `json:"cluster"`
Knoten []struct {
Name string `json:"name"`
Online bool `json:"online"`
SpeicherBelegt *float32 `json:"speicherBelegt"`
SpeicherGesamt *float32 `json:"speicherGesamt"`
} `json:"knoten"`
} `json:"letzterTest"`
Name string `json:"name"`
PveVersion *string `json:"pveVersion"`
SshAktiv bool `json:"sshAktiv"`
Titel string `json:"titel"`
Url string `json:"url"`
}
// Template defines model for Template.
type Template struct {
DistributionId *string `json:"distributionId"`
// GebautAm ISO 8601
GebautAm *string `json:"gebautAm"`
Knoten string `json:"knoten"`
Name string `json:"name"`
Profile []string `json:"profile"`
Vmid int `json:"vmid"`
}
// TemplateBauAnfrage defines model for TemplateBauAnfrage.
type TemplateBauAnfrage struct {
Bridge *string `json:"bridge,omitempty"`
Distribution string `json:"distribution"`
Knoten string `json:"knoten"`
Profile *[]string `json:"profile,omitempty"`
Storage string `json:"storage"`
Vlan *int `json:"vlan,omitempty"`
}
// TokenAnmeldung defines model for TokenAnmeldung.
type TokenAnmeldung struct {
Benutzername string `json:"benutzername"`
Name string `json:"name"`
Passwort string `json:"passwort"`
Tage *int `json:"tage,omitempty"`
}
// Vorlage defines model for Vorlage.
type Vorlage struct {
Beschreibung *string `json:"beschreibung"`
Name string `json:"name"`
Titel string `json:"titel"`
}
// AuftragEreignisseParams defines parameters for AuftragEreignisse.
type AuftragEreignisseParams struct {
LastEventID *string `json:"Last-Event-ID,omitempty"`
}
// AuftragLogParams defines parameters for AuftragLog.
type AuftragLogParams struct {
Ab *string `form:"ab,omitempty" json:"ab,omitempty"`
}
// DistributionenListeParams defines parameters for DistributionenListe.
type DistributionenListeParams struct {
Typ *DistributionenListeParamsTyp `form:"typ,omitempty" json:"typ,omitempty"`
}
// DistributionenListeParamsTyp defines parameters for DistributionenListe.
type DistributionenListeParamsTyp string
// AusrollenJSONRequestBody defines body for Ausrollen for application/json ContentType.
type AusrollenJSONRequestBody = AusrollAnfrage
// AusrollenPlanenJSONRequestBody defines body for AusrollenPlanen for application/json ContentType.
type AusrollenPlanenJSONRequestBody = AusrollAnfrage
// TokenAnmeldenJSONRequestBody defines body for TokenAnmelden for application/json ContentType.
type TokenAnmeldenJSONRequestBody = TokenAnmeldung
// TemplateBauenJSONRequestBody defines body for TemplateBauen for application/json ContentType.
type TemplateBauenJSONRequestBody = TemplateBauAnfrage
// AsAusrollAnfrageVariablen0 returns the union data inside the AusrollAnfrage_Variablen_AdditionalProperties as a AusrollAnfrageVariablen0
func (t AusrollAnfrage_Variablen_AdditionalProperties) AsAusrollAnfrageVariablen0() (AusrollAnfrageVariablen0, error) {
var body AusrollAnfrageVariablen0
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromAusrollAnfrageVariablen0 overwrites any union data inside the AusrollAnfrage_Variablen_AdditionalProperties as the provided AusrollAnfrageVariablen0
func (t *AusrollAnfrage_Variablen_AdditionalProperties) FromAusrollAnfrageVariablen0(v AusrollAnfrageVariablen0) error {
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeAusrollAnfrageVariablen0 performs a merge with any union data inside the AusrollAnfrage_Variablen_AdditionalProperties, using the provided AusrollAnfrageVariablen0
func (t *AusrollAnfrage_Variablen_AdditionalProperties) MergeAusrollAnfrageVariablen0(v AusrollAnfrageVariablen0) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsAusrollAnfrageVariablen1 returns the union data inside the AusrollAnfrage_Variablen_AdditionalProperties as a AusrollAnfrageVariablen1
func (t AusrollAnfrage_Variablen_AdditionalProperties) AsAusrollAnfrageVariablen1() (AusrollAnfrageVariablen1, error) {
var body AusrollAnfrageVariablen1
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromAusrollAnfrageVariablen1 overwrites any union data inside the AusrollAnfrage_Variablen_AdditionalProperties as the provided AusrollAnfrageVariablen1
func (t *AusrollAnfrage_Variablen_AdditionalProperties) FromAusrollAnfrageVariablen1(v AusrollAnfrageVariablen1) error {
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeAusrollAnfrageVariablen1 performs a merge with any union data inside the AusrollAnfrage_Variablen_AdditionalProperties, using the provided AusrollAnfrageVariablen1
func (t *AusrollAnfrage_Variablen_AdditionalProperties) MergeAusrollAnfrageVariablen1(v AusrollAnfrageVariablen1) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsAusrollAnfrageVariablen2 returns the union data inside the AusrollAnfrage_Variablen_AdditionalProperties as a AusrollAnfrageVariablen2
func (t AusrollAnfrage_Variablen_AdditionalProperties) AsAusrollAnfrageVariablen2() (AusrollAnfrageVariablen2, error) {
var body AusrollAnfrageVariablen2
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromAusrollAnfrageVariablen2 overwrites any union data inside the AusrollAnfrage_Variablen_AdditionalProperties as the provided AusrollAnfrageVariablen2
func (t *AusrollAnfrage_Variablen_AdditionalProperties) FromAusrollAnfrageVariablen2(v AusrollAnfrageVariablen2) error {
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeAusrollAnfrageVariablen2 performs a merge with any union data inside the AusrollAnfrage_Variablen_AdditionalProperties, using the provided AusrollAnfrageVariablen2
func (t *AusrollAnfrage_Variablen_AdditionalProperties) MergeAusrollAnfrageVariablen2(v AusrollAnfrageVariablen2) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
func (t AusrollAnfrage_Variablen_AdditionalProperties) MarshalJSON() ([]byte, error) {
b, err := t.union.MarshalJSON()
return b, err
}
func (t *AusrollAnfrage_Variablen_AdditionalProperties) UnmarshalJSON(b []byte) error {
err := t.union.UnmarshalJSON(b)
return err
}
// AsProfilVariableDefault0 returns the union data inside the ProfilVariable_Default as a ProfilVariableDefault0
func (t ProfilVariable_Default) AsProfilVariableDefault0() (ProfilVariableDefault0, error) {
var body ProfilVariableDefault0
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromProfilVariableDefault0 overwrites any union data inside the ProfilVariable_Default as the provided ProfilVariableDefault0
func (t *ProfilVariable_Default) FromProfilVariableDefault0(v ProfilVariableDefault0) error {
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeProfilVariableDefault0 performs a merge with any union data inside the ProfilVariable_Default, using the provided ProfilVariableDefault0
func (t *ProfilVariable_Default) MergeProfilVariableDefault0(v ProfilVariableDefault0) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsProfilVariableDefault1 returns the union data inside the ProfilVariable_Default as a ProfilVariableDefault1
func (t ProfilVariable_Default) AsProfilVariableDefault1() (ProfilVariableDefault1, error) {
var body ProfilVariableDefault1
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromProfilVariableDefault1 overwrites any union data inside the ProfilVariable_Default as the provided ProfilVariableDefault1
func (t *ProfilVariable_Default) FromProfilVariableDefault1(v ProfilVariableDefault1) error {
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeProfilVariableDefault1 performs a merge with any union data inside the ProfilVariable_Default, using the provided ProfilVariableDefault1
func (t *ProfilVariable_Default) MergeProfilVariableDefault1(v ProfilVariableDefault1) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsProfilVariableDefault2 returns the union data inside the ProfilVariable_Default as a ProfilVariableDefault2
func (t ProfilVariable_Default) AsProfilVariableDefault2() (ProfilVariableDefault2, error) {
var body ProfilVariableDefault2
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromProfilVariableDefault2 overwrites any union data inside the ProfilVariable_Default as the provided ProfilVariableDefault2
func (t *ProfilVariable_Default) FromProfilVariableDefault2(v ProfilVariableDefault2) error {
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeProfilVariableDefault2 performs a merge with any union data inside the ProfilVariable_Default, using the provided ProfilVariableDefault2
func (t *ProfilVariable_Default) MergeProfilVariableDefault2(v ProfilVariableDefault2) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
func (t ProfilVariable_Default) MarshalJSON() ([]byte, error) {
b, err := t.union.MarshalJSON()
return b, err
}
func (t *ProfilVariable_Default) UnmarshalJSON(b []byte) error {
err := t.union.UnmarshalJSON(b)
return err
}
+231
View File
@@ -0,0 +1,231 @@
package ausgabe
import (
"fmt"
"sort"
"strings"
"time"
"github.com/mattn/go-runewidth"
)
// Phase, wie sie gezeigt wird – Felder kommen unverändert aus der API.
type Phase struct {
Titel string
Status string // OFFEN, LAEUFT, ERLEDIGT, FEHLER, UEBERSPRUNGEN
Detail string
Ursache string
Hinweis string
// Laufzeit der laufenden Phase (nur bei LAEUFT).
Laufzeit time.Duration
}
// Box ist die Abschlussbox; Zeilen sind [Bezeichnung, Wert].
type Box struct {
Ok bool
Titel string
Dauer string
Zeilen [][2]string
}
// Aufruf ist ein geplanter Proxmox-Aufruf im Dry-Run.
type Aufruf struct {
Methode string
Pfad string
Parameter map[string]any
}
// Dauer wie in Web-GUI und Engine: „18s“, „1m 12s“, „1h 03m“.
func Dauer(d time.Duration) string {
s := int((d + 500*time.Millisecond) / time.Second)
if s < 0 {
s = 0
}
switch {
case s < 60:
return fmt.Sprintf("%ds", s)
case s < 3600:
return fmt.Sprintf("%dm %02ds", s/60, s%60)
default:
return fmt.Sprintf("%dh %02dm", s/3600, (s%3600)/60)
}
}
// Kopf: „⬡ ProxDeploy v1.0 · Server rz1 · …“ – Prox in Paper, Deploy fett in Teal, Rest Muted.
func (s Stil) Kopf(version, kopf string, trocken bool) string {
var b strings.Builder
if !s.ASCII {
b.WriteString(s.Muted("⬡ "))
}
b.WriteString(s.Paper("Prox"))
b.WriteString(s.TealFett("Deploy"))
rest := " v" + version
if kopf != "" {
rest += " · " + kopf
}
b.WriteString(s.Muted(rest))
if trocken {
b.WriteString(s.AmberFett(" · DRY-RUN"))
}
return b.String()
}
// Spaltenbreite der Phasennamen: längster Name + 2, mindestens 14 (wie die Web-GUI).
func Spaltenbreite(phasen []Phase) int {
breite := 12
for _, p := range phasen {
if w := runewidth.StringWidth(p.Titel); w > breite {
breite = w
}
}
return breite + 2
}
// PhasenZeilen setzt jede Phase als Zeile: Symbol, Name in fester Breite, Detail in Muted. Bei
// Fehler Ursache in Rot und darunter der Hinweis. `takt` dreht den Spinner der laufenden Phase.
func (s Stil) PhasenZeilen(phasen []Phase, takt int) []string {
return s.PhasenZeilenMit(phasen, Spaltenbreite(phasen), takt)
}
// PhasenZeilenMit setzt mit fester Spaltenbreite – live bleibt sie so stabil, auch wenn noch nicht
// alle Phasen sichtbar sind.
func (s Stil) PhasenZeilenMit(phasen []Phase, breite, takt int) []string {
einzug := strings.Repeat(" ", runewidth.StringWidth(s.symbol("OFFEN"))+1+breite)
var zeilen []string
for _, p := range phasen {
symbol := s.symbol(p.Status)
if p.Status == "LAEUFT" && !s.ASCII {
symbol = spinner[takt%len(spinner)]
}
titel := p.Titel + strings.Repeat(" ", breite-runewidth.StringWidth(p.Titel))
var z strings.Builder
switch p.Status {
case "ERLEDIGT":
z.WriteString(s.Teal(symbol) + " " + s.Paper(titel))
case "LAEUFT":
z.WriteString(s.Amber(symbol) + " " + s.Paper(titel))
case "FEHLER":
z.WriteString(s.Rot(symbol) + " " + s.Paper(titel))
default:
z.WriteString(s.Muted(symbol) + " " + s.Muted(titel))
}
switch p.Status {
case "FEHLER":
z.WriteString(s.Rot(erste(p.Ursache, p.Detail)))
zeilen = append(zeilen, strings.TrimRight(z.String(), " "))
if p.Hinweis != "" {
zeilen = append(zeilen, einzug+s.Muted("→ "+p.Hinweis))
}
continue
case "LAEUFT":
text := Dauer(p.Laufzeit)
if p.Detail != "" {
text = p.Detail + " … " + text
}
z.WriteString(s.Muted(text))
default:
z.WriteString(s.Muted(p.Detail))
}
zeilen = append(zeilen, strings.TrimRight(z.String(), " "))
}
return zeilen
}
func erste(werte ...string) string {
for _, w := range werte {
if w != "" {
return w
}
}
return ""
}
// Mindestbreite der Box innen (zwischen den Rahmenstrichen) – wie im Mockup.
const boxMindestbreite = 38
// BoxZeilen setzt die Abschlussbox: Rahmen Teal (Erfolg) oder Rot, Breite aus dem Inhalt.
func (s Stil) BoxZeilen(b Box) []string {
rahmen := s.Teal
kopfSymbol := "●"
if !b.Ok {
rahmen = s.Rot
kopfSymbol = "✗"
}
if s.ASCII {
kopfSymbol = map[bool]string{true: "[OK]", false: "[!!]"}[b.Ok]
}
type zeile struct{ roh, farbig string }
kopf := kopfSymbol + " " + b.Titel
kopfFarbig := rahmen(kopfSymbol) + " " + s.Fett(b.Titel)
if b.Dauer != "" {
kopf += " · " + b.Dauer
kopfFarbig += s.Muted(" · " + b.Dauer)
}
inhalt := []zeile{{kopf, kopfFarbig}}
label := 7
for _, z := range b.Zeilen {
if w := runewidth.StringWidth(z[0]); w > label {
label = w
}
}
label += 2
for _, z := range b.Zeilen {
name := z[0] + strings.Repeat(" ", label-runewidth.StringWidth(z[0]))
inhalt = append(inhalt, zeile{name + z[1], s.Muted(name) + s.Paper(z[1])})
}
breite := boxMindestbreite
for _, z := range inhalt {
if w := runewidth.StringWidth(z.roh) + 4; w > breite {
breite = w
}
}
ol, or, ul, ur, h, v := "┌", "┐", "└", "┘", "─", "│"
if s.ASCII {
ol, or, ul, ur, h, v = "+", "+", "+", "+", "-", "|"
}
zeilen := []string{rahmen(ol + strings.Repeat(h, breite) + or)}
for _, z := range inhalt {
luecke := breite - 1 - runewidth.StringWidth(z.roh)
zeilen = append(zeilen, rahmen(v)+" "+z.farbig+strings.Repeat(" ", luecke)+rahmen(v))
}
zeilen = append(zeilen, rahmen(ul+strings.Repeat(h, breite)+ur))
return zeilen
}
// AufrufZeilen: jeder geplante Aufruf als „→ POST /pfad k=v …“, lange Werte gekürzt wie im Web.
func (s Stil) AufrufZeilen(aufrufe []Aufruf) []string {
pfeil := "→ "
if s.ASCII {
pfeil = "-> "
}
var zeilen []string
for _, a := range aufrufe {
z := s.Amber(pfeil) + s.Paper(a.Methode+" "+a.Pfad)
if len(a.Parameter) > 0 {
namen := make([]string, 0, len(a.Parameter))
for n := range a.Parameter {
namen = append(namen, n)
}
sort.Strings(namen)
teile := make([]string, 0, len(namen))
for _, n := range namen {
teile = append(teile, n+"="+kuerzen(fmt.Sprint(a.Parameter[n]), 60))
}
z += " " + s.Muted(strings.Join(teile, " "))
}
zeilen = append(zeilen, z)
}
return zeilen
}
func kuerzen(text string, hoechstens int) string {
// Eine Zeile je Aufruf: Umbrüche (z. B. in der Beschreibung) sichtbar, aber einzeilig.
text = strings.NewReplacer("\r\n", "⏎", "\n", "⏎").Replace(text)
r := []rune(text)
if len(r) <= hoechstens {
return text
}
return string(r[:hoechstens-3]) + "…"
}
+118
View File
@@ -0,0 +1,118 @@
package ausgabe
import (
"flag"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
var aktualisieren = flag.Bool("update", false, "Golden-Dateien neu schreiben")
// Die Beispielausgabe aus der Vorgabe.
var mockPhasen = []Phase{
{Titel: "Prüfungen", Status: "ERLEDIGT", Detail: "VMID 120 frei · local-zfs · vmbr0 VLAN 20"},
{Titel: "Klonen", Status: "ERLEDIGT", Detail: "full clone 9013 → 120 · Disk auf 40G"},
{Titel: "cloud-init", Status: "ERLEDIGT", Detail: "user florian · SSH-Key · Profile base, docker"},
{Titel: "Start", Status: "ERLEDIGT", Detail: "VM 120 läuft"},
{Titel: "Guest-Agent", Status: "ERLEDIGT", Detail: "antwortet nach 18s"},
{Titel: "Provisionierung", Status: "LAEUFT", Detail: "cloud-init läuft", Laufzeit: 72 * time.Second},
}
var mockBox = Box{
Ok: true,
Titel: "web01 bereit",
Dauer: "1m 54s",
Zeilen: [][2]string{
{"VMID", "120"},
{"IP", "10.0.20.15"},
{"Profile", "base, docker"},
{"SSH", "ssh florian@10.0.20.15"},
},
}
func mockAusgabe(s Stil) string {
zeilen := []string{s.Kopf("1.0", "Server rz1 · Node pve01 · Debian 13 (Template 9013)", false), ""}
zeilen = append(zeilen, s.PhasenZeilen(mockPhasen, 0)...)
zeilen = append(zeilen, "")
zeilen = append(zeilen, s.BoxZeilen(mockBox)...)
return strings.Join(zeilen, "\n") + "\n"
}
func golden(t *testing.T, name, ist string) {
t.Helper()
pfad := filepath.Join("testdata", name)
if *aktualisieren {
if err := os.WriteFile(pfad, []byte(ist), 0o644); err != nil {
t.Fatal(err)
}
}
soll, err := os.ReadFile(pfad)
if err != nil {
t.Fatal(err)
}
if ist != string(soll) {
t.Errorf("%s weicht ab.\n--- soll\n%s\n--- ist\n%s", name, soll, ist)
}
}
// Ohne Farbe muss die Ausgabe zeichengenau dem Mockup der Vorgabe entsprechen.
// mockup.txt ist von Hand aus der Vorgabe übernommen und wird nie mit -update überschrieben.
func TestMockupZeichengenau(t *testing.T) {
soll, err := os.ReadFile(filepath.Join("testdata", "mockup.txt"))
if err != nil {
t.Fatal(err)
}
if ist := mockAusgabe(Stil{}); ist != string(soll) {
t.Errorf("weicht vom Mockup ab.\n--- soll\n%s\n--- ist\n%s", soll, ist)
}
}
func TestFarbenUndAscii(t *testing.T) {
golden(t, "mockup-24bit.golden", mockAusgabe(Stil{Farbe: Echtfarben}))
golden(t, "mockup-256.golden", mockAusgabe(Stil{Farbe: Farben256}))
golden(t, "mockup-ascii.golden", mockAusgabe(Stil{ASCII: true}))
}
func TestFehlerUndDryRun(t *testing.T) {
s := Stil{}
zeilen := []string{s.Kopf("1.0", "Server rz1 · Node pve01 · Debian 13", true), ""}
zeilen = append(zeilen, s.PhasenZeilen([]Phase{
{Titel: "Prüfungen", Status: "ERLEDIGT", Detail: "VMID 120 frei"},
{Titel: "Template", Status: "UEBERSPRUNGEN", Detail: "vorhanden (9013)"},
{Titel: "Klonen", Status: "FEHLER", Ursache: "Storage local-zfs ist voll.", Hinweis: "Anderen Storage wählen."},
}, 0)...)
zeilen = append(zeilen, "")
zeilen = append(zeilen, s.AufrufZeilen([]Aufruf{
{Methode: "POST", Pfad: "/nodes/pve01/qemu/9013/clone", Parameter: map[string]any{"newid": 120, "full": 1, "name": "web01"}},
{Methode: "PUT", Pfad: "/nodes/pve01/qemu/120/config", Parameter: map[string]any{"sshkeys": strings.Repeat("A", 80), "description": "Zeile 1\nZeile 2"}},
})...)
zeilen = append(zeilen, "")
zeilen = append(zeilen, s.BoxZeilen(Box{
Titel: "web01 fehlgeschlagen", Dauer: "12s",
Zeilen: [][2]string{{"Phase", "Klonen"}, {"Ursache", "Storage local-zfs ist voll."}, {"Hinweis", "Anderen Storage wählen und die VM mit einem sehr langen Hinweis neu ausrollen."}},
})...)
golden(t, "fehler-dryrun.golden", strings.Join(zeilen, "\n")+"\n")
}
func TestDauer(t *testing.T) {
for d, soll := range map[time.Duration]string{
0: "0s", 18 * time.Second: "18s", 72 * time.Second: "1m 12s", 65 * time.Second: "1m 05s",
3780 * time.Second: "1h 03m", 1499 * time.Millisecond: "1s",
} {
if ist := Dauer(d); ist != soll {
t.Errorf("Dauer(%v) = %q, soll %q", d, ist, soll)
}
}
}
func TestXterm256(t *testing.T) {
// Teal #2DD4BF → 43 (#00d7af), Paper #E6EDF3 → 254 (Graustufe #e4e4e4), Rot #F87171 → 203 (#ff5f5f).
for f, soll := range map[rgb]int{teal: 43, paper: 254, rot: 203} {
if ist := xterm256(f); ist != soll {
t.Errorf("xterm256(%v) = %d, soll %d", f, ist, soll)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
// Package ausgabe rendert die CLI-Ausgabe im Aufbau der Vorgabe: Kopfzeile, Phasen, Abschlussbox.
// Alle Texte kommen aus der Engine; hier wird nur gesetzt und gefärbt.
package ausgabe
import (
"fmt"
"os"
"strings"
)
// Farbmodus: 24-bit, 256 Farben oder keine.
type Farbmodus int
const (
Aus Farbmodus = iota
Farben256
Echtfarben
)
// Die Markenfarben (siehe README, Abschnitt Branding).
type rgb struct{ r, g, b uint8 }
var (
paper = rgb{0xE6, 0xED, 0xF3}
muted = rgb{0x8B, 0x98, 0xA5}
teal = rgb{0x2D, 0xD4, 0xBF}
amber = rgb{0xF5, 0xB8, 0x41}
rot = rgb{0xF8, 0x71, 0x71}
)
// Stil legt fest, wie gesetzt wird: Farben und ob nur ASCII-Symbole erlaubt sind.
type Stil struct {
Farbe Farbmodus
ASCII bool
}
// FarbmodusErmitteln: aus bei NO_COLOR, --no-color oder ohne Terminal; sonst 24-bit, wenn das
// Terminal es meldet (COLORTERM), sonst 256 Farben.
func FarbmodusErmitteln(keineFarbe, istTerminal bool) Farbmodus {
if keineFarbe || !istTerminal {
return Aus
}
if _, gesetzt := os.LookupEnv("NO_COLOR"); gesetzt {
return Aus
}
if os.Getenv("TERM") == "dumb" {
return Aus
}
switch strings.ToLower(os.Getenv("COLORTERM")) {
case "truecolor", "24bit":
return Echtfarben
}
return Farben256
}
func (s Stil) faerben(f rgb, fett bool, text string) string {
if s.Farbe == Aus || text == "" {
return text
}
var code string
switch s.Farbe {
case Echtfarben:
code = fmt.Sprintf("38;2;%d;%d;%d", f.r, f.g, f.b)
default:
code = fmt.Sprintf("38;5;%d", xterm256(f))
}
if fett {
code = "1;" + code
}
return "\x1b[" + code + "m" + text + "\x1b[0m"
}
func (s Stil) Paper(t string) string { return s.faerben(paper, false, t) }
func (s Stil) Fett(t string) string { return s.faerben(paper, true, t) }
func (s Stil) Muted(t string) string { return s.faerben(muted, false, t) }
func (s Stil) Teal(t string) string { return s.faerben(teal, false, t) }
func (s Stil) TealFett(t string) string { return s.faerben(teal, true, t) }
func (s Stil) Amber(t string) string { return s.faerben(amber, false, t) }
func (s Stil) AmberFett(t string) string {
return s.faerben(amber, true, t)
}
func (s Stil) Rot(t string) string { return s.faerben(rot, false, t) }
// xterm256 wählt die nächste Farbe aus Würfel (16–231) oder Graustufen (232–255).
func xterm256(f rgb) int {
stufen := []int{0, 95, 135, 175, 215, 255}
naechste := func(wert uint8) int {
beste, abstand := 0, 1<<30
for i, s := range stufen {
d := int(wert) - s
if d*d < abstand {
beste, abstand = i, d*d
}
}
return beste
}
r, g, b := naechste(f.r), naechste(f.g), naechste(f.b)
wuerfel := 16 + 36*r + 6*g + b
dw := quadAbstand(f, stufen[r], stufen[g], stufen[b])
grau := (int(f.r) + int(f.g) + int(f.b)) / 3
gi := (grau - 8) / 10
if gi < 0 {
gi = 0
} else if gi > 23 {
gi = 23
}
gw := 8 + gi*10
if quadAbstand(f, gw, gw, gw) < dw {
return 232 + gi
}
return wuerfel
}
func quadAbstand(f rgb, r, g, b int) int {
dr, dg, db := int(f.r)-r, int(f.g)-g, int(f.b)-b
return dr*dr + dg*dg + db*db
}
// Symbole je Status, mit ASCII-Ersatz (--ascii).
func (s Stil) symbol(status string) string {
if s.ASCII {
switch status {
case "ERLEDIGT":
return "[OK]"
case "LAEUFT":
return "[..]"
case "FEHLER":
return "[!!]"
default:
return "[--]"
}
}
switch status {
case "ERLEDIGT":
return "✓"
case "LAEUFT":
return "◐"
case "FEHLER":
return "✗"
default:
return "○"
}
}
// Spinner für die laufende Phase: ◐◓◑◒.
var spinner = []string{"◐", "◓", "◑", "◒"}
+16
View File
@@ -0,0 +1,16 @@
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 · DRY-RUN
✓ Prüfungen VMID 120 frei
○ Template vorhanden (9013)
✗ Klonen Storage local-zfs ist voll.
→ Anderen Storage wählen.
→ POST /nodes/pve01/qemu/9013/clone full=1 name=web01 newid=120
→ PUT /nodes/pve01/qemu/120/config description=Zeile 1⏎Zeile 2 sshkeys=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA…
┌───────────────────────────────────────────────────────────────────────────────────────────┐
│ ✗ web01 fehlgeschlagen · 12s │
│ Phase Klonen │
│ Ursache Storage local-zfs ist voll. │
│ Hinweis Anderen Storage wählen und die VM mit einem sehr langen Hinweis neu ausrollen. │
└───────────────────────────────────────────────────────────────────────────────────────────┘
+16
View File
@@ -0,0 +1,16 @@
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013)
✓ Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
✓ Klonen full clone 9013 → 120 · Disk auf 40G
✓ cloud-init user florian · SSH-Key · Profile base, docker
✓ Start VM 120 läuft
✓ Guest-Agent antwortet nach 18s
◐ Provisionierung cloud-init läuft … 1m 12s
┌──────────────────────────────────────┐
│ ● web01 bereit · 1m 54s │
│ VMID 120 │
│ IP 10.0.20.15 │
│ Profile base, docker │
│ SSH ssh florian@10.0.20.15 │
└──────────────────────────────────────┘
+16
View File
@@ -0,0 +1,16 @@
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013)
✓ Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
✓ Klonen full clone 9013 → 120 · Disk auf 40G
✓ cloud-init user florian · SSH-Key · Profile base, docker
✓ Start VM 120 läuft
✓ Guest-Agent antwortet nach 18s
◐ Provisionierung cloud-init läuft … 1m 12s
┌──────────────────────────────────────┐
│ ● web01 bereit · 1m 54s │
│ VMID 120 │
│ IP 10.0.20.15 │
│ Profile base, docker │
│ SSH ssh florian@10.0.20.15 │
└──────────────────────────────────────┘
+16
View File
@@ -0,0 +1,16 @@
ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013)
[OK] Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
[OK] Klonen full clone 9013 → 120 · Disk auf 40G
[OK] cloud-init user florian · SSH-Key · Profile base, docker
[OK] Start VM 120 läuft
[OK] Guest-Agent antwortet nach 18s
[..] Provisionierung cloud-init läuft … 1m 12s
+--------------------------------------+
| [OK] web01 bereit · 1m 54s |
| VMID 120 |
| IP 10.0.20.15 |
| Profile base, docker |
| SSH ssh florian@10.0.20.15 |
+--------------------------------------+
+16
View File
@@ -0,0 +1,16 @@
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013)
✓ Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
✓ Klonen full clone 9013 → 120 · Disk auf 40G
✓ cloud-init user florian · SSH-Key · Profile base, docker
✓ Start VM 120 läuft
✓ Guest-Agent antwortet nach 18s
◐ Provisionierung cloud-init läuft … 1m 12s
┌──────────────────────────────────────┐
│ ● web01 bereit · 1m 54s │
│ VMID 120 │
│ IP 10.0.20.15 │
│ Profile base, docker │
│ SSH ssh florian@10.0.20.15 │
└──────────────────────────────────────┘
+174
View File
@@ -0,0 +1,174 @@
package befehle
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
)
func eineID(befehl string) cobra.PositionalArgs {
return func(_ *cobra.Command, argumente []string) error {
if len(argumente) != 1 {
return bedienfehler("Auftrags-ID fehlt: proxdeploy %s <job-id>", befehl)
}
return nil
}
}
func (u *umgebung) statusBefehl() *cobra.Command {
var folgen bool
befehl := &cobra.Command{
Use: "status <job-id>",
Short: "Stand eines Auftrags (mit --follow live bis zum Ende)",
Args: eineID("status"),
RunE: func(cmd *cobra.Command, argumente []string) error {
c, _, err := u.client()
if err != nil {
return err
}
if folgen {
_, err := u.folgen(cmd.Context(), c, argumente[0])
return err
}
a, err := c.Auftrag(cmd.Context(), argumente[0])
if err != nil {
return err
}
fertig := endzustaende[a.Status]
if u.opt.json {
if err := u.jsonAusgeben(a); err != nil {
return err
}
} else {
d := u.neuerDarsteller()
d.live = true // einmal vollständig zeichnen, ohne Neuzeichnen
d.zeichnen(a, fertig)
}
if fertig && a.Status != api.AuftragStatusERFOLGREICH {
return &exitFehler{code: ExitFehlschlag, gemeldet: true}
}
return nil
},
}
befehl.Flags().BoolVarP(&folgen, "follow", "f", false, "live verfolgen bis zum Ende")
return befehl
}
func (u *umgebung) logsBefehl() *cobra.Command {
var folgen bool
befehl := &cobra.Command{
Use: "logs <job-id>",
Short: "Detail-Log eines Auftrags (mit --follow live)",
Args: eineID("logs"),
RunE: func(cmd *cobra.Command, argumente []string) error {
c, _, err := u.client()
if err != nil {
return err
}
id := argumente[0]
zeilen, err := c.Log(cmd.Context(), id, "")
if err != nil {
return err
}
for _, z := range zeilen {
u.logZeile(z)
}
if !folgen {
return nil
}
letzte := ""
if len(zeilen) > 0 {
letzte = zeilen[len(zeilen)-1].Id
}
return u.logFolgen(cmd.Context(), c, id, letzte)
},
}
befehl.Flags().BoolVarP(&folgen, "follow", "f", false, "neue Zeilen live anhängen, bis der Auftrag fertig ist")
return befehl
}
func (u *umgebung) logZeile(z api.LogZeile) {
if u.opt.json {
daten, _ := json.Marshal(z)
fmt.Fprintln(u.aus, string(daten))
return
}
s := u.stil()
zeit := z.Zeit
if t, err := time.Parse(time.RFC3339Nano, z.Zeit); err == nil {
zeit = t.Local().Format("15:04:05")
}
phase := ""
if z.Phase != nil {
phase = *z.Phase
}
nachricht := z.Nachricht
switch z.Stufe {
case "error":
nachricht = s.Rot(nachricht)
case "warn":
nachricht = s.Amber(nachricht)
default:
nachricht = s.Paper(nachricht)
}
fmt.Fprintf(u.aus, "%s %s %s\n", s.Muted(zeit), s.Muted(fmt.Sprintf("%-16s", phase)), nachricht)
}
// logFolgen hängt neue Zeilen an (SSE-Ereignis `log`), bis der Auftrag fertig ist.
func (u *umgebung) logFolgen(ctx context.Context, c *api.Client, id, ab string) error {
for versuch := 0; versuch < 5; versuch++ {
err := c.Ereignisse(ctx, id, ab, func(e api.Ereignis) error {
switch e.Art {
case "log":
var zeilen []api.LogZeile
if err := json.Unmarshal(e.Daten, &zeilen); err != nil {
return err
}
for _, z := range zeilen {
u.logZeile(z)
ab = z.Id
}
case "ende":
return api.Ende
}
return nil
})
if errors.Is(err, api.Ende) || ctx.Err() != nil {
return nil
}
var apiFehler *api.Fehler
if errors.As(err, &apiFehler) {
return err
}
time.Sleep(time.Duration(versuch+1) * time.Second)
}
return &api.VerbindungsFehler{Ursache: errors.New("Ereignisstrom bricht wiederholt ab")}
}
func (u *umgebung) cancelBefehl() *cobra.Command {
return &cobra.Command{
Use: "cancel <job-id>",
Short: "Auftrag abbrechen (hält an der nächsten Phasengrenze)",
Args: eineID("cancel"),
RunE: func(cmd *cobra.Command, argumente []string) error {
c, _, err := u.client()
if err != nil {
return err
}
if err := c.Abbrechen(cmd.Context(), argumente[0]); err != nil {
return err
}
if !u.opt.json {
fmt.Fprintln(u.aus, u.stil().Amber("Abbruch angefordert – "+strings.TrimSpace(argumente[0])+" hält an der nächsten Phasengrenze."))
}
return nil
},
}
}
+317
View File
@@ -0,0 +1,317 @@
package befehle
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe"
)
// gastFlags sind die Angaben zu einem Gast – dieselben für `vm`/`lxc` und für Einträge in `batch`.
type gastFlags struct {
server, knoten, distro, preset, ip, gw, bridge, storage, benutzer, suchdomain string
vmid, vlan, cores, speicher, swap int
disk string
profile, dns, tags, schluessel, variablen []string
privilegiert, nesting, templateBauen bool
}
func (g *gastFlags) registrieren(f *pflag.FlagSet, typ string) {
f.StringVar(&g.server, "server", "", "Proxmox-Server (Kurzname, siehe „proxdeploy servers“)")
f.StringVar(&g.knoten, "node", "", "Node (Standard: wo das Template liegt bzw. der mit dem meisten freien RAM)")
f.StringVar(&g.distro, "distro", "", "Distribution, z. B. debian-13 (siehe „proxdeploy distros“)")
f.StringSliceVar(&g.profile, "profile", nil, "Profile, z. B. base,docker (Standard: base)")
f.StringVar(&g.preset, "preset", "", "Vorlage, deren Werte vorbelegt werden (Flags gehen vor)")
f.StringVar(&g.ip, "ip", "", "statische Adresse mit Präfix, z. B. 10.0.20.15/24 (sonst DHCP)")
f.StringVar(&g.gw, "gw", "", "Gateway")
f.StringSliceVar(&g.dns, "dns", nil, "DNS-Server, kommagetrennt")
f.StringVar(&g.suchdomain, "search-domain", "", "Suchdomain")
f.StringVar(&g.bridge, "bridge", "", "Bridge (Standard: vmbr0)")
f.IntVar(&g.vlan, "vlan", 0, "VLAN-Tag")
f.IntVar(&g.vmid, "vmid", 0, "VMID/CTID (Standard: nächste freie)")
f.IntVar(&g.cores, "cores", 0, "CPU-Kerne (Standard: 2)")
f.IntVar(&g.speicher, "memory", 0, "RAM in MB (Standard: 2048)")
f.StringVar(&g.disk, "disk", "", "Disk, z. B. 40G (Standard: 20G)")
f.StringVar(&g.storage, "storage", "", "Storage (Standard: aus den Server-Einstellungen)")
f.StringVar(&g.benutzer, "user", "", "Benutzer im Gast (Standard: dein Benutzername)")
f.StringArrayVar(&g.schluessel, "ssh-key", nil, "öffentlicher SSH-Schlüssel oder Pfad zur .pub (Standard: deine hinterlegten)")
f.StringSliceVar(&g.tags, "tags", nil, "Tags, kommagetrennt")
f.StringArrayVar(&g.variablen, "var", nil, "Profil-Variable profil.name=wert (siehe „proxdeploy profiles“)")
if typ == "lxc" {
f.IntVar(&g.swap, "swap", 0, "Swap in MB (Standard: 512)")
f.BoolVar(&g.privilegiert, "privileged", false, "privilegierter Container (Standard: unprivilegiert)")
f.BoolVar(&g.nesting, "nesting", false, "Feature nesting (das Profil docker setzt es selbst)")
} else {
f.BoolVar(&g.templateBauen, "build-template", false, "fehlt das Template, vorher bauen")
}
}
var diskMuster = regexp.MustCompile(`^(\d+)\s*([gG])?$`)
// anfrage baut die API-Anfrage. Nur gesetzte Werte kommen hinein; den Rest füllen Vorlage und
// Server (Storage, Bridge, Benutzer, Schlüssel).
func (g *gastFlags) anfrage(typ, name string, gesetzt func(string) bool) (map[string]any, error) {
a := map[string]any{"typ": typ, "name": name}
setze := func(flag, feld string, wert any) {
if gesetzt(flag) {
a[feld] = wert
}
}
setze("server", "server", g.server)
setze("node", "knoten", g.knoten)
setze("distro", "distribution", g.distro)
setze("profile", "profile", g.profile)
setze("preset", "vorlage", g.preset)
setze("vmid", "vmid", g.vmid)
setze("cores", "cores", g.cores)
setze("memory", "speicherMb", g.speicher)
setze("storage", "storage", g.storage)
setze("tags", "tags", g.tags)
setze("swap", "swapMb", g.swap)
setze("build-template", "templateBauen", g.templateBauen)
if gesetzt("privileged") {
a["unprivilegiert"] = !g.privilegiert
}
if gesetzt("nesting") {
a["features"] = map[string]any{"nesting": g.nesting}
}
if gesetzt("disk") {
treffer := diskMuster.FindStringSubmatch(strings.TrimSpace(g.disk))
if treffer == nil {
return nil, bedienfehler("--disk: Größe in GB, z. B. 40G")
}
groesse, _ := strconv.Atoi(treffer[1])
a["diskGb"] = groesse
}
netz := map[string]any{}
if gesetzt("ip") {
netz["modus"] = "statisch"
netz["ip"] = g.ip
}
if gesetzt("gw") {
netz["gateway"] = g.gw
}
if gesetzt("dns") {
netz["dns"] = g.dns
}
if gesetzt("search-domain") {
netz["suchdomain"] = g.suchdomain
}
if gesetzt("bridge") {
netz["bridge"] = g.bridge
}
if gesetzt("vlan") {
netz["vlan"] = g.vlan
}
if len(netz) > 0 {
a["netz"] = netz
}
zugang := map[string]any{}
if gesetzt("user") {
zugang["benutzer"] = g.benutzer
}
if gesetzt("ssh-key") {
schluessel := []string{}
for _, s := range g.schluessel {
text, err := schluesselLesen(s)
if err != nil {
return nil, err
}
schluessel = append(schluessel, text...)
}
zugang["sshSchluessel"] = schluessel
}
if len(zugang) > 0 {
a["zugang"] = zugang
}
if len(g.variablen) > 0 {
variablen := map[string]map[string]any{}
for _, v := range g.variablen {
schluessel, wert, ok := strings.Cut(v, "=")
profil, name, ok2 := strings.Cut(schluessel, ".")
if !ok || !ok2 || profil == "" || name == "" {
return nil, bedienfehler("--var %q: Format profil.name=wert", v)
}
if variablen[profil] == nil {
variablen[profil] = map[string]any{}
}
variablen[profil][name] = wert
}
a["variablen"] = variablen
}
return a, nil
}
// schluesselLesen nimmt einen Schlüssel als Text oder liest eine .pub-Datei (eine Zeile je Schlüssel).
func schluesselLesen(angabe string) ([]string, error) {
if strings.HasPrefix(angabe, "ssh-") || strings.HasPrefix(angabe, "ecdsa-") || strings.HasPrefix(angabe, "sk-") {
return []string{strings.TrimSpace(angabe)}, nil
}
pfad := angabe
if rest, ok := strings.CutPrefix(pfad, "~/"); ok {
if heim, err := os.UserHomeDir(); err == nil {
pfad = filepath.Join(heim, rest)
}
}
inhalt, err := os.ReadFile(pfad)
if err != nil {
return nil, bedienfehler("--ssh-key: %v", err)
}
var liste []string
for _, zeile := range strings.Split(string(inhalt), "\n") {
if z := strings.TrimSpace(zeile); z != "" && !strings.HasPrefix(z, "#") {
if strings.Contains(z, "PRIVATE KEY") {
return nil, bedienfehler("--ssh-key: %s ist ein privater Schlüssel – die .pub angeben", angabe)
}
liste = append(liste, z)
}
}
return liste, nil
}
func (u *umgebung) ausrollBefehl(typ string) *cobra.Command {
var g gastFlags
var trocken, nichtWarten bool
art := map[string]string{"vm": "VM", "lxc": "LXC-Container"}[typ]
befehl := &cobra.Command{
Use: typ + " <name>",
Short: art + " ausrollen",
Example: map[string]string{
"vm": " proxdeploy vm web01 --server rz1 --distro debian-13 --profile base,docker --ip 10.0.20.15/24 --gw 10.0.20.1 --vlan 20",
"lxc": " proxdeploy lxc dns01 --server rz1 --distro ubuntu-24.04 --profile base",
}[typ],
Args: func(cmd *cobra.Command, argumente []string) error {
if len(argumente) != 1 {
return bedienfehler("Name fehlt: proxdeploy %s <name> …", typ)
}
return nil
},
RunE: func(cmd *cobra.Command, argumente []string) error {
c, instanz, err := u.client()
if err != nil {
return err
}
gesetzt := cmd.Flags().Changed
if !gesetzt("server") && instanz.Server != "" {
g.server = instanz.Server
gesetzt = func(flag string) bool { return flag == "server" || cmd.Flags().Changed(flag) }
}
if !gesetzt("server") {
return bedienfehler("--server fehlt (oder `server = \"…\"` in der Konfiguration)")
}
if !gesetzt("distro") && !gesetzt("preset") {
return bedienfehler("--distro fehlt (siehe `proxdeploy distros`)")
}
anfrage, err := g.anfrage(typ, argumente[0], gesetzt)
if err != nil {
return err
}
if trocken {
return u.trockenlauf(cmd.Context(), c, anfrage, argumente[0])
}
angenommen, err := c.Ausrollen(cmd.Context(), anfrage)
if err != nil {
return err
}
return u.angenommen(cmd.Context(), c, angenommen, nichtWarten)
},
}
g.registrieren(befehl.Flags(), typ)
befehl.Flags().BoolVar(&trocken, "dry-run", false, "nur planen: Phasen und geplante API-Aufrufe zeigen, nichts anlegen")
befehl.Flags().BoolVar(&nichtWarten, "no-wait", false, "nicht verfolgen, nur die Auftrags-ID ausgeben")
return befehl
}
// angenommen: Auftrag läuft – verfolgen oder nur die ID ausgeben.
func (u *umgebung) angenommen(ctx context.Context, c *api.Client, a api.AuftragAngenommen, nichtWarten bool) error {
if nichtWarten {
if u.opt.json {
return u.jsonAusgeben(a)
}
fmt.Fprintln(u.aus, a.KurzId)
return nil
}
_, err := u.folgen(ctx, c, a.KurzId)
return err
}
// trockenlauf zeigt den Plan: Kopfzeile mit DRY-RUN, Phasen, jeden geplanten Aufruf als „→ …“.
func (u *umgebung) trockenlauf(ctx context.Context, c *api.Client, anfrage map[string]any, name string) error {
plan, err := c.Planen(ctx, anfrage)
if err != nil {
return err
}
if u.opt.json {
if err := u.jsonAusgeben(plan); err != nil {
return err
}
if !plan.Ok {
return &exitFehler{code: ExitFehlschlag, gemeldet: true}
}
return nil
}
s := u.stil()
phasen := make([]ausgabe.Phase, 0, len(plan.Phasen))
for _, p := range plan.Phasen {
phasen = append(phasen, ausgabe.Phase{
Titel: p.Titel, Status: string(p.Status), Detail: wert(p.Detail), Ursache: wert(p.Ursache), Hinweis: wert(p.Hinweis),
})
}
aufrufe := make([]ausgabe.Aufruf, 0, len(plan.Aufrufe))
for _, a := range plan.Aufrufe {
aufruf := ausgabe.Aufruf{Methode: a.Methode, Pfad: a.Pfad}
if a.Parameter != nil {
aufruf.Parameter = *a.Parameter
}
aufrufe = append(aufrufe, aufruf)
}
var zeilen []string
if !u.opt.still {
zeilen = append(zeilen, s.Kopf(Version, plan.Kopf, true), "")
zeilen = append(zeilen, s.PhasenZeilen(phasen, 0)...)
zeilen = append(zeilen, "")
zeilen = append(zeilen, s.AufrufZeilen(aufrufe)...)
if len(plan.Automatisch) > 0 {
zeilen = append(zeilen, "", s.Muted("Automatisch dazu: "+strings.Join(plan.Automatisch, ", ")))
}
}
if !plan.Ok {
box := ausgabe.Box{Titel: name + " würde fehlschlagen"}
for _, p := range plan.Phasen {
if p.Status == api.PhaseStatusFEHLER {
box.Zeilen = append(box.Zeilen, [2]string{"Phase", p.Titel})
if p.Ursache != nil {
box.Zeilen = append(box.Zeilen, [2]string{"Ursache", *p.Ursache})
}
if p.Hinweis != nil {
box.Zeilen = append(box.Zeilen, [2]string{"Hinweis", *p.Hinweis})
}
}
}
if len(zeilen) > 0 {
zeilen = append(zeilen, "")
}
zeilen = append(zeilen, s.BoxZeilen(box)...)
} else if !u.opt.still {
zeilen = append(zeilen, "", s.Muted("Nichts angelegt. Ohne --dry-run wird ausgerollt."))
}
if len(zeilen) > 0 {
fmt.Fprintln(u.aus, strings.Join(zeilen, "\n"))
}
if !plan.Ok {
return &exitFehler{code: ExitFehlschlag, gemeldet: true}
}
return nil
}
+266
View File
@@ -0,0 +1,266 @@
package befehle
import (
"errors"
"fmt"
"os"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"go.yaml.in/yaml/v3"
)
// Batch-Datei: Schlüssel heißen wie die Flags von `proxdeploy vm`/`lxc`.
//
// defaults:
// server: rz1
// distro: debian-13
// profile: [base]
// guests:
// - name: web01
// type: vm
// profile: [base, docker]
// ip: 10.0.20.15/24
// gw: 10.0.20.1
// - name: dns01
// type: lxc
// vars: { zabbix-agent: { server: zbx.example } }
type batchDatei struct {
Defaults map[string]any `yaml:"defaults"`
Guests []map[string]any `yaml:"guests"`
}
type batchEintrag struct {
name, typ string
anfrage map[string]any
}
// batchLesen prüft die ganze Datei, bevor irgendetwas ausgerollt wird.
func batchLesen(pfad string, standardServer string) ([]batchEintrag, error) {
inhalt, err := os.ReadFile(pfad)
if err != nil {
return nil, bedienfehler("%v", err)
}
var datei batchDatei
dekoder := yaml.NewDecoder(strings.NewReader(string(inhalt)))
dekoder.KnownFields(true)
if err := dekoder.Decode(&datei); err != nil {
return nil, bedienfehler("%s: %v", pfad, err)
}
if len(datei.Guests) == 0 {
return nil, bedienfehler("%s: keine Einträge unter guests", pfad)
}
var eintraege []batchEintrag
namen := map[string]bool{}
for nr, gast := range datei.Guests {
werte := map[string]any{}
for k, v := range datei.Defaults {
werte[k] = v
}
for k, v := range gast {
werte[k] = v
}
ort := fmt.Sprintf("%s, Eintrag %d", pfad, nr+1)
name, _ := werte["name"].(string)
typ, _ := werte["type"].(string)
if name == "" {
return nil, bedienfehler("%s: name fehlt", ort)
}
ort = fmt.Sprintf("%s (%s)", ort, name)
if typ != "vm" && typ != "lxc" {
return nil, bedienfehler("%s: type ist vm oder lxc", ort)
}
if namen[name] {
return nil, bedienfehler("%s: Name doppelt", ort)
}
namen[name] = true
delete(werte, "name")
delete(werte, "type")
if _, ok := werte["server"]; !ok && standardServer != "" {
werte["server"] = standardServer
}
var g gastFlags
flags := pflag.NewFlagSet(name, pflag.ContinueOnError)
g.registrieren(flags, typ)
if err := flagsSetzen(flags, werte); err != nil {
return nil, bedienfehler("%s: %v", ort, err)
}
if !flags.Changed("server") {
return nil, bedienfehler("%s: server fehlt", ort)
}
anfrage, err := g.anfrage(typ, name, flags.Changed)
if err != nil {
var ef *exitFehler
if errors.As(err, &ef) {
return nil, bedienfehler("%s: %v", ort, ef.err)
}
return nil, err
}
eintraege = append(eintraege, batchEintrag{name: name, typ: typ, anfrage: anfrage})
}
return eintraege, nil
}
// flagsSetzen überträgt YAML-Werte auf die Flags – so gelten dieselben Regeln wie auf der Kommandozeile.
func flagsSetzen(flags *pflag.FlagSet, werte map[string]any) error {
schluessel := make([]string, 0, len(werte))
for k := range werte {
schluessel = append(schluessel, k)
}
sort.Strings(schluessel)
for _, k := range schluessel {
w := werte[k]
if k == "vars" {
profile, ok := w.(map[string]any)
if !ok {
return errors.New("vars: profil: { name: wert }")
}
for profil, inhalt := range profile {
variablen, ok := inhalt.(map[string]any)
if !ok {
return fmt.Errorf("vars.%s: { name: wert }", profil)
}
for name, v := range variablen {
if err := flags.Set("var", fmt.Sprintf("%s.%s=%v", profil, name, v)); err != nil {
return err
}
}
}
continue
}
if flags.Lookup(k) == nil {
return fmt.Errorf("unbekannter Schlüssel %q", k)
}
switch liste := w.(type) {
case []any:
teile := make([]string, 0, len(liste))
for _, e := range liste {
teile = append(teile, fmt.Sprint(e))
}
if k == "ssh-key" {
for _, t := range teile {
if err := flags.Set(k, t); err != nil {
return fmt.Errorf("%s: %v", k, err)
}
}
continue
}
if err := flags.Set(k, strings.Join(teile, ",")); err != nil {
return fmt.Errorf("%s: %v", k, err)
}
default:
if err := flags.Set(k, fmt.Sprint(w)); err != nil {
return fmt.Errorf("%s: %v", k, err)
}
}
}
return nil
}
func (u *umgebung) batchBefehl() *cobra.Command {
var trocken bool
befehl := &cobra.Command{
Use: "batch <datei.yaml>",
Short: "Mehrere Gäste aus einer YAML-Datei ausrollen",
Long: "Rollt alle Gäste unter `guests` aus; `defaults` gilt für jeden Eintrag. Schlüssel heißen\n" +
"wie die Flags von `proxdeploy vm`/`lxc` (server, distro, profile, ip, gw, vlan, cores, …),\n" +
"dazu name, type (vm|lxc) und vars: { profil: { name: wert } }.\n" +
"Die Datei wird vollständig geprüft, bevor der erste Auftrag startet.",
Args: func(_ *cobra.Command, argumente []string) error {
if len(argumente) != 1 {
return bedienfehler("Datei fehlt: proxdeploy batch <datei.yaml>")
}
return nil
},
RunE: func(cmd *cobra.Command, argumente []string) error {
c, instanz, err := u.client()
if err != nil {
return err
}
eintraege, err := batchLesen(argumente[0], instanz.Server)
if err != nil {
return err
}
ctx := cmd.Context()
s := u.stil()
fehlgeschlagen := 0
if trocken {
for nr, e := range eintraege {
if nr > 0 && !u.opt.json {
fmt.Fprintln(u.aus)
}
if err := u.trockenlauf(ctx, c, e.anfrage, e.name); err != nil {
var ef *exitFehler
if !errors.As(err, &ef) || !ef.gemeldet {
u.fehlerMelden(err)
}
fehlgeschlagen++
}
}
} else {
type gestartet struct {
eintrag batchEintrag
id string
fehler error
}
var auftraege []gestartet
// Erst alle anlegen – der worker arbeitet sie parallel ab –, dann der Reihe nach verfolgen.
for _, e := range eintraege {
a, err := c.Ausrollen(ctx, e.anfrage)
auftraege = append(auftraege, gestartet{eintrag: e, id: a.KurzId, fehler: err})
}
zusammenfassung := [][]string{}
for nr, g := range auftraege {
if g.fehler != nil {
fmt.Fprintf(u.fehler, "%s\n", s.Rot(g.eintrag.name+": nicht gestartet"))
u.fehlerMelden(g.fehler)
fehlgeschlagen++
zusammenfassung = append(zusammenfassung, []string{g.eintrag.name, "–", "✗ nicht gestartet", ""})
continue
}
if nr > 0 && !u.opt.json && !u.opt.still {
fmt.Fprintln(u.aus)
}
a, err := u.folgen(ctx, c, g.id)
var ef *exitFehler
if err != nil && errors.As(err, &ef) && ef.code == 130 {
return err
}
status := "✓ bereit"
if err != nil {
fehlgeschlagen++
status = "✗ " + strings.ToLower(string(a.Status))
if a.Status == "" {
u.fehlerMelden(err)
status = "✗ Fehler"
}
}
ip := ""
if a.Ergebnis != nil {
ip = wert(a.Ergebnis.Ip)
}
zusammenfassung = append(zusammenfassung, []string{g.eintrag.name, g.id, status, ip})
}
if !u.opt.json && len(auftraege) > 1 {
fmt.Fprintln(u.aus)
if s.ASCII {
for _, z := range zusammenfassung {
z[2] = strings.NewReplacer("✓", "[OK]", "✗", "[!!]").Replace(z[2])
}
}
u.tabelle(s, []string{"NAME", "AUFTRAG", "STATUS", "IP"}, zusammenfassung)
}
}
if fehlgeschlagen > 0 {
return &exitFehler{code: ExitFehlschlag, gemeldet: true}
}
return nil
},
}
befehl.Flags().BoolVar(&trocken, "dry-run", false, "nur planen")
return befehl
}
+325
View File
@@ -0,0 +1,325 @@
package befehle
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
var aktualisieren = flag.Bool("update", false, "Golden-Dateien neu schreiben")
func golden(t *testing.T, name, ist string) {
t.Helper()
pfad := filepath.Join("testdata", name)
if *aktualisieren {
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(pfad, []byte(ist), 0o644); err != nil {
t.Fatal(err)
}
}
soll, err := os.ReadFile(pfad)
if err != nil {
t.Fatal(err)
}
if ist != string(soll) {
t.Errorf("%s weicht ab.\n--- soll\n%s\n--- ist\n%s", name, soll, ist)
}
}
// phase baut eine Phase der API.
func phase(schluessel, titel, status, detail string) map[string]any {
p := map[string]any{"schluessel": schluessel, "titel": titel, "status": status, "detail": nil, "ursache": nil, "hinweis": nil, "gestartetAm": nil, "beendetAm": nil}
if detail != "" {
p["detail"] = detail
}
if status == "LAEUFT" {
p["gestartetAm"] = "2026-10-12T10:00:00.000Z"
}
return p
}
func auftrag(status string, phasen []map[string]any, abschluss any) map[string]any {
return map[string]any{
"id": "c1", "kurzId": "a-7f3k", "art": "AUSROLLEN", "status": status,
"kopf": "Server rz1 · Node pve01 · Debian 13 (Template 9013)",
"server": map[string]any{"name": "rz1", "titel": "RZ 1"}, "knoten": "pve01", "gastTyp": "VM",
"vmid": 120, "name": "web01", "distributionId": "debian-13", "profile": []string{"base", "docker"},
"ergebnis": map[string]any{"vmid": 120, "ip": "10.0.20.15", "ssh": "ssh florian@10.0.20.15"},
"fehler": nil, "erstelltVon": "florian", "erstelltAm": "2026-10-12T10:00:00.000Z",
"gestartetAm": "2026-10-12T10:00:00.000Z", "beendetAm": nil, "abschluss": abschluss, "phasen": phasen,
}
}
var alle = []map[string]any{
phase("pruefungen", "Prüfungen", "ERLEDIGT", "VMID 120 frei · local-zfs · vmbr0 VLAN 20"),
phase("klonen", "Klonen", "ERLEDIGT", "full clone 9013 → 120 · Disk auf 40G"),
phase("cloudinit", "cloud-init", "ERLEDIGT", "user florian · SSH-Key · Profile base, docker"),
phase("start", "Start", "ERLEDIGT", "VM 120 läuft"),
phase("agent", "Guest-Agent", "ERLEDIGT", "antwortet nach 18s"),
phase("provisionierung", "Provisionierung", "ERLEDIGT", "base, docker"),
}
// fakeAPI spielt die REST-API nach; `anfragen` hält die Rümpfe fest.
type fakeAPI struct {
mu sync.Mutex
anfragen map[string][]map[string]any
staende []map[string]any
plan map[string]any
status int
antwort any
}
func (f *fakeAPI) handler(t *testing.T) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer pd_test" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
fmt.Fprint(w, `{"fehler":{"code":"nicht_angemeldet","nachricht":"Nicht angemeldet."}}`)
return
}
if r.Body != nil && r.Method == http.MethodPost {
var rumpf map[string]any
_ = json.NewDecoder(r.Body).Decode(&rumpf)
f.mu.Lock()
if f.anfragen == nil {
f.anfragen = map[string][]map[string]any{}
}
f.anfragen[r.URL.Path] = append(f.anfragen[r.URL.Path], rumpf)
f.mu.Unlock()
}
schreiben := func(status int, wert any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(wert)
}
if f.status != 0 {
schreiben(f.status, f.antwort)
return
}
switch {
case r.URL.Path == "/api/v1/ich":
schreiben(200, map[string]any{"benutzername": "florian", "anzeigename": "Florian", "rolle": "OPERATOR", "rechte": []string{}, "version": "0.4.0"})
case r.URL.Path == "/api/v1/auftraege" && r.Method == http.MethodPost:
schreiben(202, map[string]any{"id": "c1", "kurzId": "a-7f3k"})
case r.URL.Path == "/api/v1/auftraege/plan":
schreiben(200, f.plan)
case strings.HasSuffix(r.URL.Path, "/ereignisse"):
w.Header().Set("Content-Type", "text/event-stream")
for nr, s := range f.staende {
daten, _ := json.Marshal(s)
fmt.Fprintf(w, "id: %d\nevent: stand\ndata: %s\n\n: ping\n\n", nr+1, daten)
}
fmt.Fprint(w, "event: ende\ndata: {}\n\n")
case r.URL.Path == "/api/v1/distributionen":
schreiben(200, []map[string]any{
{"id": "debian-13", "name": "Debian 13 „Trixie“", "familie": "debian", "aktiv": true, "vm": true, "lxc": true},
{"id": "ubuntu-26.04", "name": "Ubuntu 26.04 LTS", "familie": "debian", "aktiv": true, "vm": true, "lxc": false},
})
default:
t.Errorf("unerwartete Anfrage %s %s", r.Method, r.URL.Path)
schreiben(404, map[string]any{"fehler": map[string]any{"code": "nicht_gefunden", "nachricht": "?"}})
}
})
}
func lauf(t *testing.T, f *fakeAPI, argumente ...string) (string, string, int) {
t.Helper()
server := httptest.NewServer(f.handler(t))
t.Cleanup(server.Close)
t.Setenv("PROXDEPLOY_CONFIG", filepath.Join(t.TempDir(), "config.toml"))
t.Setenv("PROXDEPLOY_URL", server.URL)
t.Setenv("PROXDEPLOY_TOKEN", "pd_test")
var aus, fehler bytes.Buffer
u := &umgebung{aus: &aus, fehler: &fehler, terminal: false, opt: &optionen{}}
code := u.ausfuehren(context.Background(), argumente)
return aus.String(), fehler.String(), code
}
func TestVmZeilenweiseBisZurBox(t *testing.T) {
Version = "1.0"
laeuft := append(append([]map[string]any{}, alle[:5]...), phase("provisionierung", "Provisionierung", "LAEUFT", "cloud-init läuft"))
f := &fakeAPI{staende: []map[string]any{
auftrag("LAEUFT", []map[string]any{phase("pruefungen", "Prüfungen", "LAEUFT", ""), phase("klonen", "Klonen", "OFFEN", "")}, nil),
auftrag("LAEUFT", laeuft, nil),
auftrag("ERFOLGREICH", alle, map[string]any{"ok": true, "titel": "web01 bereit", "dauer": "1m 54s", "zeilen": [][]string{
{"VMID", "120"}, {"IP", "10.0.20.15"}, {"Profile", "base, docker"}, {"SSH", "ssh florian@10.0.20.15"},
}}),
}}
aus, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--profile", "base,docker",
"--ip", "10.0.20.15/24", "--vlan", "20", "--disk", "40G", "--var", "zabbix-agent.server=zbx.local")
if code != 0 {
t.Fatalf("Exit %d, stderr: %s", code, fehler)
}
golden(t, "vm-zeilenweise.golden", aus)
// Nur gesetzte Flags gehen hinaus – Storage, Bridge, Benutzer und Schlüssel füllt der Server.
gesendet, _ := json.MarshalIndent(f.anfragen["/api/v1/auftraege"][0], "", " ")
golden(t, "vm-anfrage.golden", string(gesendet)+"\n")
}
func TestVmFehlgeschlagenExit1UndQuiet(t *testing.T) {
Version = "1.0"
fehlerPhase := phase("klonen", "Klonen", "FEHLER", "")
fehlerPhase["ursache"] = "Storage local-zfs ist voll."
fehlerPhase["hinweis"] = "Anderen Storage wählen (--storage)."
f := &fakeAPI{staende: []map[string]any{
auftrag("FEHLGESCHLAGEN", []map[string]any{alle[0], fehlerPhase, phase("cloudinit", "cloud-init", "OFFEN", "")},
map[string]any{"ok": false, "titel": "web01 fehlgeschlagen", "dauer": "12s", "zeilen": [][]string{
{"Phase", "Klonen"}, {"Ursache", "Storage local-zfs ist voll."}, {"Hinweis", "Anderen Storage wählen (--storage)."},
}}),
}}
aus, _, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--quiet", "--ascii")
if code != ExitFehlschlag {
t.Fatalf("Exit %d, erwartet 1", code)
}
golden(t, "vm-fehler-quiet-ascii.golden", aus)
}
func TestDryRun(t *testing.T) {
Version = "1.0"
f := &fakeAPI{plan: map[string]any{
"kopf": "Server rz1 · Node pve01 · Debian 13 (Template 9013)",
"phasen": []map[string]any{alle[0], alle[1], phase("provisionierung", "Provisionierung", "ERLEDIGT", "base, docker")},
"aufrufe": []map[string]any{
{"methode": "POST", "pfad": "/nodes/pve01/qemu/9013/clone", "parameter": map[string]any{"newid": 120, "full": 1, "name": "web01"}},
{"methode": "POST", "pfad": "/nodes/pve01/qemu/120/status/start"},
},
"skript": nil, "cliBefehl": "proxdeploy vm web01 --server rz1 --distro debian-13", "automatisch": []string{"base"}, "ok": true,
}}
aus, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--dry-run")
if code != 0 {
t.Fatalf("Exit %d: %s", code, fehler)
}
golden(t, "vm-dryrun.golden", aus)
if len(f.anfragen["/api/v1/auftraege"]) != 0 {
t.Error("Dry-Run hat einen Auftrag angelegt")
}
}
func TestFehlerUndExitCodes(t *testing.T) {
f := &fakeAPI{status: 400, antwort: map[string]any{"fehler": map[string]any{
"code": "ungueltig", "nachricht": "Die Eingaben sind nicht vollständig oder ungültig.",
"felder": map[string]string{"netz.ip": "Format: 10.0.20.15/24", "variablen.zabbix-agent.server": "Zabbix-Server fehlt."},
}}}
_, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--ip", "x")
if code != ExitBedienung {
t.Errorf("Exit %d, erwartet 2", code)
}
golden(t, "fehler-felder.golden", fehler)
f = &fakeAPI{status: 401, antwort: map[string]any{"fehler": map[string]any{"code": "nicht_angemeldet", "nachricht": "Nicht angemeldet."}}}
if _, _, code := lauf(t, f, "servers"); code != ExitVerbindung {
t.Errorf("401: Exit %d, erwartet 3", code)
}
t.Setenv("PROXDEPLOY_URL", "http://127.0.0.1:1")
t.Setenv("PROXDEPLOY_TOKEN", "pd_test")
var aus, fehlerAus bytes.Buffer
u := &umgebung{aus: &aus, fehler: &fehlerAus, opt: &optionen{}}
if code := u.ausfuehren(context.Background(), []string{"distros"}); code != ExitVerbindung {
t.Errorf("nicht erreichbar: Exit %d, erwartet 3", code)
}
if code := u.ausfuehren(context.Background(), []string{"vm"}); code != ExitBedienung {
t.Errorf("ohne Name: Exit %d, erwartet 2", code)
}
}
func TestDistrosTabelleUndJson(t *testing.T) {
aus, _, code := lauf(t, &fakeAPI{}, "distros")
if code != 0 {
t.Fatal(code)
}
golden(t, "distros.golden", aus)
aus, _, _ = lauf(t, &fakeAPI{}, "distros", "--json")
var liste []map[string]any
if err := json.Unmarshal([]byte(aus), &liste); err != nil || len(liste) != 2 {
t.Errorf("--json nicht lesbar: %v\n%s", err, aus)
}
}
func TestBatch(t *testing.T) {
Version = "1.0"
datei := filepath.Join(t.TempDir(), "gaeste.yaml")
if err := os.WriteFile(datei, []byte(`defaults:
server: rz1
distro: debian-13
profile: [base]
guests:
- name: web01
type: vm
profile: [base, docker]
ip: 10.0.20.15/24
gw: 10.0.20.1
memory: 4096
- name: mon01
type: lxc
profile: [base, zabbix-agent]
vars: { zabbix-agent: { server: zbx.example } }
nesting: true
`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeAPI{staende: []map[string]any{auftrag("ERFOLGREICH", alle, map[string]any{"ok": true, "titel": "web01 bereit", "dauer": "1m 54s", "zeilen": [][]string{}})}}
_, fehler, code := lauf(t, f, "batch", datei, "--quiet")
if code != 0 {
t.Fatalf("Exit %d: %s", code, fehler)
}
gesendet, _ := json.MarshalIndent(f.anfragen["/api/v1/auftraege"], "", " ")
golden(t, "batch-anfragen.golden", string(gesendet)+"\n")
falsch := filepath.Join(t.TempDir(), "falsch.yaml")
_ = os.WriteFile(falsch, []byte("guests:\n - name: a\n type: vm\n server: rz1\n ram: 4096\n"), 0o644)
_, fehler, code = lauf(t, &fakeAPI{}, "batch", falsch)
if code != ExitBedienung || !strings.Contains(fehler, `unbekannter Schlüssel "ram"`) {
t.Errorf("Exit %d, %s", code, fehler)
}
}
func TestLoginMitToken(t *testing.T) {
server := httptest.NewServer((&fakeAPI{}).handler(t))
defer server.Close()
pfad := filepath.Join(t.TempDir(), "proxdeploy", "config.toml")
t.Setenv("PROXDEPLOY_CONFIG", pfad)
t.Setenv("PROXDEPLOY_URL", "")
t.Setenv("PROXDEPLOY_TOKEN", "")
var aus, fehler bytes.Buffer
u := &umgebung{aus: &aus, fehler: &fehler, opt: &optionen{}}
if code := u.ausfuehren(context.Background(), []string{"login", server.URL, "--token", "pd_test", "--default-server", "rz1"}); code != 0 {
t.Fatalf("Exit %d: %s", code, fehler.String())
}
info, err := os.Stat(pfad)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("Rechte %v, erwartet 0600", info.Mode().Perm())
}
inhalt, _ := os.ReadFile(pfad)
if !strings.Contains(string(inhalt), `token = "pd_test"`) || !strings.Contains(string(inhalt), `server = "rz1"`) {
t.Errorf("Konfiguration:\n%s", inhalt)
}
if !strings.Contains(aus.String(), "Angemeldet als florian") {
t.Errorf("Ausgabe: %s", aus.String())
}
// Ein falsches Token wird nicht gespeichert.
aus.Reset()
code := u.ausfuehren(context.Background(), []string{"login", server.URL, "--token", "pd_falsch", "--instance", "labor"})
if code != ExitVerbindung {
t.Errorf("falsches Token: Exit %d", code)
}
inhalt2, _ := os.ReadFile(pfad)
if string(inhalt2) != string(inhalt) {
t.Error("falsches Token hat die Konfiguration verändert")
}
}
+328
View File
@@ -0,0 +1,328 @@
package befehle
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/mattn/go-runewidth"
"golang.org/x/term"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe"
)
var endzustaende = map[api.AuftragStatus]bool{
api.AuftragStatusERFOLGREICH: true,
api.AuftragStatusFEHLGESCHLAGEN: true,
api.AuftragStatusABGEBROCHEN: true,
api.AuftragStatusAUFGERAEUMT: true,
}
func wert(p *string) string {
if p == nil {
return ""
}
return *p
}
func zeitAus(p *string) (time.Time, bool) {
if p == nil {
return time.Time{}, false
}
t, err := time.Parse(time.RFC3339Nano, *p)
return t, err == nil
}
// phasenAus übernimmt die Phasen der API unverändert; nur die Laufzeit rechnet die CLI selbst.
func phasenAus(a api.Auftrag, jetzt time.Time) []ausgabe.Phase {
liste := make([]ausgabe.Phase, 0, len(a.Phasen))
for _, p := range a.Phasen {
phase := ausgabe.Phase{
Titel: p.Titel,
Status: string(p.Status),
Detail: wert(p.Detail),
Ursache: wert(p.Ursache),
Hinweis: wert(p.Hinweis),
}
if seit, ok := zeitAus(p.GestartetAm); ok && p.Status == api.PhaseStatusLAEUFT {
phase.Laufzeit = jetzt.Sub(seit)
}
liste = append(liste, phase)
}
return liste
}
func boxAus(a *api.Abschluss) ausgabe.Box {
box := ausgabe.Box{Ok: a.Ok, Titel: a.Titel, Dauer: wert(a.Dauer)}
for _, z := range a.Zeilen {
if len(z) == 2 {
box.Zeilen = append(box.Zeilen, [2]string{z[0], z[1]})
}
}
return box
}
// darsteller zeichnet einen Auftrag: live im Terminal (an Ort und Stelle neu), sonst zeilenweise.
type darsteller struct {
u *umgebung
s ausgabe.Stil
live bool
gezeichnet int // Bildschirmzeilen der letzten Zeichnung (live)
kopf bool
gedruckt int // Anzahl schon gedruckter Phasen (zeilenweise)
takt int
}
func (u *umgebung) neuerDarsteller() *darsteller {
return &darsteller{u: u, s: u.stil(), live: u.terminal && !u.opt.still && !u.opt.json}
}
func (d *darsteller) bildschirmzeilen(zeilen []string) int {
spalten := 0
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil {
spalten = w
}
n := 0
for _, z := range zeilen {
breite := runewidth.StringWidth(ohneFarben(z))
if spalten > 0 && breite > spalten {
n += (breite + spalten - 1) / spalten
} else {
n++
}
}
return n
}
func ohneFarben(text string) string {
var b strings.Builder
in := false
for _, r := range text {
switch {
case r == '\x1b':
in = true
case in && r == 'm':
in = false
case !in:
b.WriteRune(r)
}
}
return b.String()
}
// zeichnen gibt den Stand aus. `fertig`: der Auftrag ist beendet, die Box kommt dazu.
func (d *darsteller) zeichnen(a api.Auftrag, fertig bool) {
if d.u.opt.json {
return
}
phasen := phasenAus(a, time.Now())
if d.u.opt.still {
if fertig && a.Abschluss != nil {
fmt.Fprintln(d.u.aus, strings.Join(d.s.BoxZeilen(boxAus(a.Abschluss)), "\n"))
}
return
}
breite := ausgabe.Spaltenbreite(phasen)
if d.live {
sichtbar := make([]ausgabe.Phase, 0, len(phasen))
for _, p := range phasen {
if p.Status != "OFFEN" || fertig {
sichtbar = append(sichtbar, p)
}
}
zeilen := []string{d.s.Kopf(Version, a.Kopf, false), ""}
zeilen = append(zeilen, d.s.PhasenZeilenMit(sichtbar, breite, d.takt)...)
if fertig && a.Abschluss != nil {
zeilen = append(zeilen, "")
zeilen = append(zeilen, d.s.BoxZeilen(boxAus(a.Abschluss))...)
}
var b strings.Builder
if d.gezeichnet > 0 {
fmt.Fprintf(&b, "\x1b[%dF\x1b[J", d.gezeichnet)
}
b.WriteString(strings.Join(zeilen, "\n"))
b.WriteString("\n")
fmt.Fprint(d.u.aus, b.String())
d.gezeichnet = d.bildschirmzeilen(zeilen)
return
}
// Zeilenweise (Pipe, Datei, CI): jede Phase einmal, sobald sie abgeschlossen ist. Die Kopfzeile
// kommt nach der ersten Phase – dann stehen Node und Template fest.
abgeschlossen := 0
for _, p := range phasen {
if p.Status == "OFFEN" || p.Status == "LAEUFT" {
break
}
abgeschlossen++
}
if !d.kopf && (abgeschlossen > 0 || fertig) {
fmt.Fprintln(d.u.aus, d.s.Kopf(Version, a.Kopf, false))
fmt.Fprintln(d.u.aus)
d.kopf = true
}
if abgeschlossen > d.gedruckt {
for _, z := range d.s.PhasenZeilenMit(phasen[d.gedruckt:abgeschlossen], breite, 0) {
fmt.Fprintln(d.u.aus, z)
}
d.gedruckt = abgeschlossen
}
if fertig {
if rest := phasen[d.gedruckt:]; len(rest) > 0 {
for _, z := range d.s.PhasenZeilenMit(rest, breite, 0) {
fmt.Fprintln(d.u.aus, z)
}
}
if a.Abschluss != nil {
fmt.Fprintln(d.u.aus)
fmt.Fprintln(d.u.aus, strings.Join(d.s.BoxZeilen(boxAus(a.Abschluss)), "\n"))
}
}
}
// folgen hängt sich an einen Auftrag, bis er fertig ist. Fehlgeschlagen → Exit 1.
func (u *umgebung) folgen(ctx context.Context, c *api.Client, id string) (api.Auftrag, error) {
d := u.neuerDarsteller()
staende := make(chan api.Auftrag, 16)
stromFehler := make(chan error, 1)
strCtx, stopp := context.WithCancel(ctx)
defer stopp()
go func() {
defer close(staende)
stromFehler <- u.stroemen(strCtx, c, id, staende)
}()
var letzter *api.Auftrag
uhr := time.NewTicker(150 * time.Millisecond)
defer uhr.Stop()
for {
select {
case <-ctx.Done():
if d.live && letzter != nil {
d.zeichnen(*letzter, false)
}
fmt.Fprintln(u.fehler, d.s.Muted(fmt.Sprintf("\nAbgehängt – der Auftrag läuft weiter: proxdeploy status %s", id)))
return api.Auftrag{}, &exitFehler{code: 130, gemeldet: true}
case a, offen := <-staende:
if !offen {
if err := <-stromFehler; err != nil {
return api.Auftrag{}, err
}
if letzter == nil {
return api.Auftrag{}, errors.New("kein Stand erhalten")
}
return u.abschliessen(d, *letzter)
}
letzter = &a
if endzustaende[a.Status] {
return u.abschliessen(d, a)
}
d.zeichnen(a, false)
case <-uhr.C:
if d.live && letzter != nil {
d.takt++
d.zeichnen(*letzter, false)
}
}
}
}
func (u *umgebung) abschliessen(d *darsteller, a api.Auftrag) (api.Auftrag, error) {
d.zeichnen(a, true)
if u.opt.json {
if err := u.jsonAusgeben(a); err != nil {
return a, err
}
}
if a.Status != api.AuftragStatusERFOLGREICH {
return a, &exitFehler{code: ExitFehlschlag, gemeldet: true}
}
return a, nil
}
// stroemen liefert Stände per SSE; bricht der Strom ab, wird mit der letzten Event-ID neu
// verbunden. Klappt SSE dreimal hintereinander nicht (z. B. ein puffernder Proxy), wird alle 2 s
// abgefragt.
func (u *umgebung) stroemen(ctx context.Context, c *api.Client, id string, staende chan<- api.Auftrag) error {
letzteID := ""
fehlschlaege := 0
for fehlschlaege < 3 {
erhalten := false
err := c.Ereignisse(ctx, id, letzteID, func(e api.Ereignis) error {
if e.ID != "" {
letzteID = e.ID
}
switch e.Art {
case "stand":
var a api.Auftrag
if err := json.Unmarshal(e.Daten, &a); err != nil {
return fmt.Errorf("Stand nicht lesbar: %w", err)
}
erhalten = true
fehlschlaege = 0
select {
case staende <- a:
case <-ctx.Done():
return ctx.Err()
}
case "ende":
return api.Ende
}
return nil
})
switch {
case errors.Is(err, api.Ende):
return nil
case ctx.Err() != nil:
return nil
case api.IstAnmeldefehler(err):
return err
}
var apiFehler *api.Fehler
if errors.As(err, &apiFehler) {
return err
}
if !erhalten {
fehlschlaege++
}
select {
case <-time.After(time.Duration(fehlschlaege+1) * time.Second):
case <-ctx.Done():
return nil
}
}
for {
a, err := c.Auftrag(ctx, id)
if err != nil {
if ctx.Err() != nil {
return nil
}
var verbindung *api.VerbindungsFehler
if !errors.As(err, &verbindung) {
return err
}
} else {
select {
case staende <- a:
case <-ctx.Done():
return nil
}
if endzustaende[a.Status] {
return nil
}
}
select {
case <-time.After(2 * time.Second):
case <-ctx.Done():
return nil
}
}
}
+208
View File
@@ -0,0 +1,208 @@
package befehle
import (
"fmt"
"strings"
"github.com/mattn/go-runewidth"
"github.com/spf13/cobra"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe"
)
// tabelle setzt Spalten bündig; die Kopfzeile in Muted.
func (u *umgebung) tabelle(s ausgabe.Stil, kopf []string, zeilen [][]string) {
breiten := make([]int, len(kopf))
for i, k := range kopf {
breiten[i] = runewidth.StringWidth(k)
}
for _, z := range zeilen {
for i, zelle := range z {
if w := runewidth.StringWidth(zelle); w > breiten[i] {
breiten[i] = w
}
}
}
setzen := func(z []string, farbe func(string) string) string {
var b strings.Builder
for i, zelle := range z {
if i == len(z)-1 {
b.WriteString(farbe(zelle))
break
}
b.WriteString(farbe(zelle + strings.Repeat(" ", breiten[i]-runewidth.StringWidth(zelle)+2)))
}
return strings.TrimRight(b.String(), " ")
}
fmt.Fprintln(u.aus, setzen(kopf, s.Muted))
for _, z := range zeilen {
fmt.Fprintln(u.aus, setzen(z, s.Paper))
}
}
func janein(wert bool) string {
if wert {
return "ja"
}
return "–"
}
func (u *umgebung) serversBefehl() *cobra.Command {
return &cobra.Command{
Use: "servers",
Aliases: []string{"server"},
Short: "Proxmox-Server mit dem letzten Verbindungstest",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
c, _, err := u.client()
if err != nil {
return err
}
liste, err := c.ServerListe(cmd.Context())
if err != nil {
return err
}
if u.opt.json {
return u.jsonAusgeben(liste)
}
s := u.stil()
zeilen := [][]string{}
for _, sv := range liste {
status := "○ ungeprüft"
if sv.Erreichbar != nil {
status = map[bool]string{true: "✓ erreichbar", false: "✗ nicht erreichbar"}[*sv.Erreichbar]
}
if s.ASCII {
status = strings.NewReplacer("✓", "[OK]", "✗", "[!!]", "○", "[--]").Replace(status)
}
version, nodes := "–", "–"
if sv.PveVersion != nil {
version = *sv.PveVersion
}
if sv.LetzterTest != nil {
namen := []string{}
for _, k := range sv.LetzterTest.Knoten {
namen = append(namen, k.Name)
}
nodes = strings.Join(namen, ", ")
if sv.LetzterTest.Cluster != nil {
nodes = sv.LetzterTest.Cluster.Name + ": " + nodes
}
}
zeilen = append(zeilen, []string{sv.Name, sv.Titel, status, version, nodes})
}
if len(zeilen) == 0 {
fmt.Fprintln(u.aus, s.Muted("Noch keine Server – in der Web-GUI unter „Server“ anlegen."))
return nil
}
u.tabelle(s, []string{"NAME", "TITEL", "STATUS", "PVE", "NODES"}, zeilen)
return nil
},
}
}
func (u *umgebung) distrosBefehl() *cobra.Command {
var typ string
befehl := &cobra.Command{
Use: "distros",
Short: "Verfügbare Distributionen",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if typ != "" && typ != "vm" && typ != "lxc" {
return bedienfehler("--type ist vm oder lxc")
}
c, _, err := u.client()
if err != nil {
return err
}
liste, err := c.DistributionenListe(cmd.Context(), typ)
if err != nil {
return err
}
if u.opt.json {
return u.jsonAusgeben(liste)
}
zeilen := [][]string{}
for _, d := range liste {
zeilen = append(zeilen, []string{d.Id, d.Name, d.Familie, janein(d.Vm), janein(d.Lxc)})
}
u.tabelle(u.stil(), []string{"ID", "NAME", "FAMILIE", "VM", "LXC"}, zeilen)
return nil
},
}
befehl.Flags().StringVar(&typ, "type", "", "nur vm oder lxc")
return befehl
}
func (u *umgebung) profilesBefehl() *cobra.Command {
return &cobra.Command{
Use: "profiles",
Short: "Profile und ihre Variablen (für --profile und --var)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
c, _, err := u.client()
if err != nil {
return err
}
liste, err := c.ProfileListe(cmd.Context())
if err != nil {
return err
}
if u.opt.json {
return u.jsonAusgeben(liste)
}
zeilen := [][]string{}
for _, p := range liste {
variablen := []string{}
if p.Definition.Variables != nil {
for _, v := range *p.Definition.Variables {
name := p.Id + "." + v.Name
if v.Required {
name += "*"
}
variablen = append(variablen, name)
}
}
text := strings.Join(variablen, " ")
if text == "" {
text = "–"
}
zeilen = append(zeilen, []string{p.Id, p.Name, strings.Join(p.Familien, ","), text})
}
u.tabelle(u.stil(), []string{"ID", "NAME", "FAMILIEN", "VARIABLEN (* = Pflicht)"}, zeilen)
return nil
},
}
}
func (u *umgebung) presetsBefehl() *cobra.Command {
return &cobra.Command{
Use: "presets",
Aliases: []string{"vorlagen"},
Short: "Vorlagen (für --preset)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
c, _, err := u.client()
if err != nil {
return err
}
liste, err := c.VorlagenListe(cmd.Context())
if err != nil {
return err
}
if u.opt.json {
return u.jsonAusgeben(liste)
}
zeilen := [][]string{}
for _, v := range liste {
beschreibung := "–"
if v.Beschreibung != nil && *v.Beschreibung != "" {
beschreibung = *v.Beschreibung
}
zeilen = append(zeilen, []string{v.Name, v.Titel, beschreibung})
}
u.tabelle(u.stil(), []string{"NAME", "TITEL", "BESCHREIBUNG"}, zeilen)
return nil
},
}
}
+119
View File
@@ -0,0 +1,119 @@
package befehle
import (
"bufio"
"fmt"
"net/url"
"os"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/konfig"
)
func (u *umgebung) loginBefehl() *cobra.Command {
var token, benutzer, name, server string
var tage int
befehl := &cobra.Command{
Use: "login <url>",
Short: "An einer ProxDeploy-Instanz anmelden und das API-Token speichern",
Long: "Fragt Benutzername und Passwort ab, legt ein API-Token an und speichert es in\n" +
"~/.config/proxdeploy/config.toml (Rechte 0600). Mit --token wird ein vorhandenes\n" +
"Token (Web-GUI → Einstellungen → API-Tokens) geprüft und gespeichert.",
Example: " proxdeploy login https://proxdeploy.example\n proxdeploy login https://proxdeploy.labor --instance labor --token pd_…",
Args: func(_ *cobra.Command, argumente []string) error {
if len(argumente) != 1 {
return bedienfehler("URL fehlt: proxdeploy login <url>")
}
return nil
},
RunE: func(cmd *cobra.Command, argumente []string) error {
adresse := strings.TrimRight(strings.TrimSpace(argumente[0]), "/")
if !strings.Contains(adresse, "://") {
adresse = "https://" + adresse
}
geprueft, err := url.Parse(adresse)
if err != nil || geprueft.Host == "" || (geprueft.Scheme != "https" && geprueft.Scheme != "http") {
return bedienfehler("keine gültige URL: %s", argumente[0])
}
ctx := cmd.Context()
c := api.Neu(adresse, "", Version)
if token == "" {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return bedienfehler("ohne Terminal kein Passwort-Dialog – --token angeben")
}
leser := bufio.NewReader(os.Stdin)
if benutzer == "" {
fmt.Fprint(u.fehler, "Benutzername: ")
zeile, err := leser.ReadString('\n')
if err != nil {
return bedienfehler("Eingabe abgebrochen")
}
benutzer = strings.TrimSpace(zeile)
}
fmt.Fprint(u.fehler, "Passwort: ")
passwort, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(u.fehler)
if err != nil {
return bedienfehler("Eingabe abgebrochen")
}
if name == "" {
rechner, _ := os.Hostname()
name = "cli@" + rechner
}
anmeldung := api.TokenAnmeldung{Benutzername: benutzer, Passwort: string(passwort), Name: name}
if tage > 0 {
anmeldung.Tage = &tage
}
neu, err := c.TokenAnmelden(ctx, anmeldung)
if err != nil {
return err
}
token = neu.Token
}
c.Token = token
ich, err := c.Ich(ctx)
if err != nil {
return err
}
datei, pfad, err := konfig.Lesen()
if err != nil {
return err
}
instanz := konfig.Instanz{URL: adresse, Token: token, Server: server}
if alt, ok := datei.Instanzen[u.opt.instanz]; ok && server == "" {
instanz.Server = alt.Server
} else if u.opt.instanz == "" && server == "" {
instanz.Server = datei.Server
}
datei.Setzen(u.opt.instanz, instanz)
if err := konfig.Schreiben(pfad, datei); err != nil {
return fmt.Errorf("Konfiguration nicht geschrieben: %w", err)
}
if u.opt.json {
return u.jsonAusgeben(map[string]any{"benutzername": ich.Benutzername, "rolle": ich.Rolle, "version": ich.Version, "konfiguration": pfad})
}
s := u.stil()
symbol := "✓"
if s.ASCII {
symbol = "[OK]"
}
rolle := map[api.IchRolle]string{api.ADMIN: "Admin", api.OPERATOR: "Operator"}[ich.Rolle]
fmt.Fprintf(u.aus, "%s Angemeldet als %s %s\n", s.Teal(symbol), s.Fett(ich.Benutzername),
s.Muted(fmt.Sprintf("(%s) · ProxDeploy v%s · Token in %s", rolle, ich.Version, pfad)))
return nil
},
}
f := befehl.Flags()
f.StringVar(&token, "token", "", "vorhandenes API-Token (pd_…) statt Passwort")
f.StringVar(&benutzer, "user", "", "Benutzername (sonst Abfrage)")
f.StringVar(&name, "name", "", "Name des Tokens (Standard: cli@<rechner>)")
f.IntVar(&tage, "days", 0, "Token läuft nach so vielen Tagen ab (Standard: unbefristet)")
f.StringVar(&server, "default-server", "", "Standard für --server speichern")
return befehl
}
+109
View File
@@ -0,0 +1,109 @@
package befehle
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
func (u *umgebung) templateBefehl() *cobra.Command {
gruppe := &cobra.Command{Use: "template", Short: "Templates ansehen und bauen"}
var listeServer string
liste := &cobra.Command{
Use: "list",
Short: "ProxDeploy-Templates eines Servers",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
c, instanz, err := u.client()
if err != nil {
return err
}
if listeServer == "" {
listeServer = instanz.Server
}
if listeServer == "" {
return bedienfehler("--server fehlt")
}
templates, err := c.TemplatesListe(cmd.Context(), listeServer)
if err != nil {
return err
}
if u.opt.json {
return u.jsonAusgeben(templates)
}
zeilen := [][]string{}
for _, t := range templates {
profile := "–"
if len(t.Profile) > 0 {
profile = strings.Join(t.Profile, ", ")
}
zeilen = append(zeilen, []string{fmt.Sprint(t.Vmid), t.Name, t.Knoten, wert(t.DistributionId), profile})
}
u.tabelle(u.stil(), []string{"VMID", "NAME", "NODE", "DISTRIBUTION", "PROFILE"}, zeilen)
return nil
},
}
liste.Flags().StringVar(&listeServer, "server", "", "Proxmox-Server")
var server, distro, knoten, storage, bridge string
var vlan int
var profile []string
var nichtWarten bool
bauen := &cobra.Command{
Use: "build",
Short: "Template aus dem Cloud-Image bauen",
Example: " proxdeploy template build --server rz1 --distro debian-13\n proxdeploy template build --server rz1 --distro debian-13 --profile base,docker",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
c, instanz, err := u.client()
if err != nil {
return err
}
if server == "" {
server = instanz.Server
}
if server == "" {
return bedienfehler("--server fehlt")
}
if distro == "" {
return bedienfehler("--distro fehlt (siehe `proxdeploy distros --type vm`)")
}
anfrage := map[string]any{"distribution": distro}
gesetzt := cmd.Flags().Changed
if gesetzt("node") {
anfrage["knoten"] = knoten
}
if gesetzt("storage") {
anfrage["storage"] = storage
}
if gesetzt("bridge") {
anfrage["bridge"] = bridge
}
if gesetzt("vlan") {
anfrage["vlan"] = vlan
}
if gesetzt("profile") {
anfrage["profile"] = profile
}
angenommen, err := c.TemplateBauen(cmd.Context(), server, anfrage)
if err != nil {
return err
}
return u.angenommen(cmd.Context(), c, angenommen, nichtWarten)
},
}
f := bauen.Flags()
f.StringVar(&server, "server", "", "Proxmox-Server")
f.StringVar(&distro, "distro", "", "Distribution, z. B. debian-13")
f.StringVar(&knoten, "node", "", "Node (Standard: aus den Server-Einstellungen bzw. der erste)")
f.StringVar(&storage, "storage", "", "Storage für die Disk (Standard: aus den Server-Einstellungen)")
f.StringVar(&bridge, "bridge", "", "Bridge der Vorbereitungs-VM (Standard: vmbr0)")
f.IntVar(&vlan, "vlan", 0, "VLAN der Vorbereitungs-VM")
f.StringSliceVar(&profile, "profile", nil, "Profile einbacken, z. B. base,docker")
f.BoolVar(&nichtWarten, "no-wait", false, "nicht verfolgen, nur die Auftrags-ID ausgeben")
gruppe.AddCommand(liste, bauen)
return gruppe
}
+36
View File
@@ -0,0 +1,36 @@
[
{
"distribution": "debian-13",
"name": "web01",
"netz": {
"gateway": "10.0.20.1",
"ip": "10.0.20.15/24",
"modus": "statisch"
},
"profile": [
"base",
"docker"
],
"server": "rz1",
"speicherMb": 4096,
"typ": "vm"
},
{
"distribution": "debian-13",
"features": {
"nesting": true
},
"name": "mon01",
"profile": [
"base",
"zabbix-agent"
],
"server": "rz1",
"typ": "lxc",
"variablen": {
"zabbix-agent": {
"server": "zbx.example"
}
}
}
]
+3
View File
@@ -0,0 +1,3 @@
ID NAME FAMILIE VM LXC
debian-13 Debian 13 „Trixie“ debian ja ja
ubuntu-26.04 Ubuntu 26.04 LTS debian ja –
+3
View File
@@ -0,0 +1,3 @@
✗ Die Eingaben sind nicht vollständig oder ungültig.
--ip: Format: 10.0.20.15/24
--var zabbix-agent.server: Zabbix-Server fehlt.
+21
View File
@@ -0,0 +1,21 @@
{
"diskGb": 40,
"distribution": "debian-13",
"name": "web01",
"netz": {
"ip": "10.0.20.15/24",
"modus": "statisch",
"vlan": 20
},
"profile": [
"base",
"docker"
],
"server": "rz1",
"typ": "vm",
"variablen": {
"zabbix-agent": {
"server": "zbx.local"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013) · DRY-RUN
✓ Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
✓ Klonen full clone 9013 → 120 · Disk auf 40G
✓ Provisionierung base, docker
→ POST /nodes/pve01/qemu/9013/clone full=1 name=web01 newid=120
→ POST /nodes/pve01/qemu/120/status/start
Automatisch dazu: base
Nichts angelegt. Ohne --dry-run wird ausgerollt.
@@ -0,0 +1,6 @@
+------------------------------------------------+
| [!!] web01 fehlgeschlagen · 12s |
| Phase Klonen |
| Ursache Storage local-zfs ist voll. |
| Hinweis Anderen Storage wählen (--storage). |
+------------------------------------------------+
+16
View File
@@ -0,0 +1,16 @@
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013)
✓ Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
✓ Klonen full clone 9013 → 120 · Disk auf 40G
✓ cloud-init user florian · SSH-Key · Profile base, docker
✓ Start VM 120 läuft
✓ Guest-Agent antwortet nach 18s
✓ Provisionierung base, docker
┌──────────────────────────────────────┐
│ ● web01 bereit · 1m 54s │
│ VMID 120 │
│ IP 10.0.20.15 │
│ Profile base, docker │
│ SSH ssh florian@10.0.20.15 │
└──────────────────────────────────────┘
+314
View File
@@ -0,0 +1,314 @@
// Package befehle enthält die Befehle der CLI `proxdeploy`.
package befehle
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/signal"
"regexp"
"sort"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe"
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/konfig"
)
// Exit-Codes (docs/architektur.md, Abschnitt CLI).
const (
ExitOk = 0
ExitFehlschlag = 1 // Auftrag fehlgeschlagen oder abgebrochen
ExitBedienung = 2 // falscher Aufruf, ungültige Eingaben
ExitVerbindung = 3 // nicht erreichbar, nicht angemeldet
)
// Version wird beim Bauen gesetzt (-ldflags "-X …/befehle.Version=0.4.0").
var Version = "0.0.0-dev"
// Optionen, die für alle Befehle gelten.
type optionen struct {
keineFarbe bool
ascii bool
still bool
json bool
instanz string
}
// Umgebung eines Laufs – für Tests austauschbar.
type umgebung struct {
aus, fehler io.Writer
terminal bool
opt *optionen
}
func (u *umgebung) stil() ausgabe.Stil {
return ausgabe.Stil{Farbe: ausgabe.FarbmodusErmitteln(u.opt.keineFarbe, u.terminal), ASCII: u.opt.ascii}
}
// exitFehler trägt den Exit-Code; die Meldung ist schon ausgegeben, wenn `gemeldet` gesetzt ist.
type exitFehler struct {
code int
gemeldet bool
err error
}
func (e *exitFehler) Error() string {
if e.err != nil {
return e.err.Error()
}
return fmt.Sprintf("Exit %d", e.code)
}
func bedienfehler(format string, argumente ...any) error {
return &exitFehler{code: ExitBedienung, err: fmt.Errorf(format, argumente...)}
}
// Ausfuehren ist der Einstieg aus main.
func Ausfuehren() int {
u := &umgebung{
aus: os.Stdout,
fehler: os.Stderr,
terminal: term.IsTerminal(int(os.Stdout.Fd())),
opt: &optionen{},
}
ctx, stopp := signal.NotifyContext(context.Background(), os.Interrupt)
defer stopp()
return u.ausfuehren(ctx, os.Args[1:])
}
func (u *umgebung) ausfuehren(ctx context.Context, argumente []string) int {
wurzel := u.wurzel()
wurzel.SetArgs(argumente)
wurzel.SetOut(u.aus)
wurzel.SetErr(u.fehler)
err := wurzel.ExecuteContext(ctx)
if err == nil {
return ExitOk
}
return u.fehlerMelden(err)
}
func (u *umgebung) wurzel() *cobra.Command {
wurzel := &cobra.Command{
Use: "proxdeploy",
Short: "VMs und LXC-Container auf Proxmox VE ausrollen",
Long: "ProxDeploy – VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.\nEin unabhängiges Werkzeug, nicht von Proxmox Server Solutions.",
Version: Version,
SilenceUsage: true,
SilenceErrors: true,
}
wurzel.CompletionOptions.HiddenDefaultCmd = true
wurzel.SetUsageTemplate(hilfeVorlage)
wurzel.SetVersionTemplate("proxdeploy {{.Version}}\n")
wurzel.InitDefaultVersionFlag()
wurzel.Flags().Lookup("version").Usage = "Version zeigen"
wurzel.SetHelpCommand(&cobra.Command{Use: "help [befehl]", Short: "Hilfe zu einem Befehl", Hidden: true,
RunE: func(c *cobra.Command, argumente []string) error {
ziel, _, err := c.Root().Find(argumente)
if err != nil || ziel == nil {
ziel = c.Root()
}
return ziel.Help()
}})
wurzel.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
return &exitFehler{code: ExitBedienung, err: flagFehlerUebersetzen(err)}
})
f := wurzel.PersistentFlags()
f.BoolVar(&u.opt.keineFarbe, "no-color", false, "ohne Farben (auch mit NO_COLOR)")
f.BoolVar(&u.opt.ascii, "ascii", false, "Symbole als [OK], [..], [!!]")
f.BoolVarP(&u.opt.still, "quiet", "q", false, "nur Abschlussbox bzw. Fehler")
f.BoolVar(&u.opt.json, "json", false, "maschinenlesbare Ausgabe")
f.StringVar(&u.opt.instanz, "instance", "", "Instanz aus der Konfiguration ([instances.<name>])")
wurzel.AddCommand(
u.loginBefehl(),
u.serversBefehl(),
u.distrosBefehl(),
u.profilesBefehl(),
u.presetsBefehl(),
u.templateBefehl(),
u.ausrollBefehl("vm"),
u.ausrollBefehl("lxc"),
u.batchBefehl(),
u.statusBefehl(),
u.logsBefehl(),
u.cancelBefehl(),
)
hilfeEindeutschen(wurzel)
return wurzel
}
func hilfeEindeutschen(c *cobra.Command) {
c.InitDefaultHelpFlag()
if f := c.Flags().Lookup("help"); f != nil {
f.Usage = "Hilfe zu " + c.CommandPath()
}
for _, unter := range c.Commands() {
hilfeEindeutschen(unter)
}
}
// client baut den API-Client aus Konfiguration und Umgebung.
func (u *umgebung) client() (*api.Client, konfig.Instanz, error) {
datei, pfad, err := konfig.Lesen()
if err != nil {
return nil, konfig.Instanz{}, &exitFehler{code: ExitBedienung, err: err}
}
instanz, err := datei.Waehlen(u.opt.instanz)
if err != nil {
return nil, konfig.Instanz{}, &exitFehler{code: ExitBedienung, err: err}
}
if instanz.URL == "" || instanz.Token == "" {
return nil, instanz, &exitFehler{
code: ExitVerbindung,
err: fmt.Errorf("nicht angemeldet – zuerst `proxdeploy login <url>` (Konfiguration: %s)", pfad),
}
}
return api.Neu(instanz.URL, instanz.Token, Version), instanz, nil
}
// fehlerMelden gibt einen Fehler aus und liefert den Exit-Code.
func (u *umgebung) fehlerMelden(err error) int {
var ef *exitFehler
code := ExitBedienung
if errors.As(err, &ef) {
code = ef.code
if ef.gemeldet {
return code
}
if ef.err != nil {
err = ef.err
}
}
var apiFehler *api.Fehler
var verbindung *api.VerbindungsFehler
switch {
case errors.As(err, &apiFehler):
if apiFehler.Status == 401 || apiFehler.Status == 403 {
code = ExitVerbindung
} else if ef == nil {
code = ExitBedienung
}
case errors.As(err, &verbindung):
code = ExitVerbindung
case errors.Is(err, context.Canceled):
return 130
}
if u.opt.json {
inhalt := map[string]any{"nachricht": err.Error()}
if apiFehler != nil {
inhalt = map[string]any{"code": apiFehler.Code, "nachricht": apiFehler.Nachricht}
if apiFehler.Hinweis != "" {
inhalt["hinweis"] = apiFehler.Hinweis
}
if len(apiFehler.Felder) > 0 {
inhalt["felder"] = apiFehler.Felder
}
}
_ = json.NewEncoder(u.fehler).Encode(map[string]any{"fehler": inhalt})
return code
}
s := ausgabe.Stil{Farbe: ausgabe.FarbmodusErmitteln(u.opt.keineFarbe, term.IsTerminal(int(os.Stderr.Fd())) && u.terminal), ASCII: u.opt.ascii}
symbol := "✗"
if s.ASCII {
symbol = "[!!]"
}
fmt.Fprintln(u.fehler, s.Rot(symbol+" "+err.Error()))
if apiFehler != nil {
namen := make([]string, 0, len(apiFehler.Felder))
for n := range apiFehler.Felder {
namen = append(namen, n)
}
sort.Strings(namen)
for _, n := range namen {
fmt.Fprintln(u.fehler, " "+s.Paper(flagFuerFeld(n))+s.Muted(": "+apiFehler.Felder[n]))
}
if apiFehler.Hinweis != "" {
fmt.Fprintln(u.fehler, s.Muted(" → "+apiFehler.Hinweis))
}
}
return code
}
var (
ungueltigerWert = regexp.MustCompile(`^invalid argument "(.*)" for "(.*)" flag: .*$`)
unbekannteOption = regexp.MustCompile(`^unknown (?:shorthand )?flag: (.*)$`)
fehlenderWert = regexp.MustCompile(`^flag needs an argument: (.*)$`)
)
// flagFehlerUebersetzen: die Meldungen von pflag auf Deutsch.
func flagFehlerUebersetzen(err error) error {
text := err.Error()
if t := ungueltigerWert.FindStringSubmatch(text); t != nil {
return fmt.Errorf("ungültiger Wert „%s“ für %s", t[1], t[2])
}
if t := unbekannteOption.FindStringSubmatch(text); t != nil {
return fmt.Errorf("unbekannte Option %s – siehe --help", t[1])
}
if t := fehlenderWert.FindStringSubmatch(text); t != nil {
return fmt.Errorf("Option %s braucht einen Wert", t[1])
}
return err
}
// flagFuerFeld übersetzt ein Feld der API in das passende Flag, damit Feldfehler verständlich sind.
func flagFuerFeld(feld string) string {
zuordnung := map[string]string{
"server": "--server", "knoten": "--node", "distribution": "--distro", "name": "<name>",
"vmid": "--vmid", "tags": "--tags", "cores": "--cores", "speicherMb": "--memory",
"diskGb": "--disk", "storage": "--storage", "swapMb": "--swap", "netz.bridge": "--bridge",
"netz.vlan": "--vlan", "netz.ip": "--ip", "netz.gateway": "--gw", "netz.dns": "--dns",
"netz.suchdomain": "--search-domain", "zugang.benutzer": "--user",
"zugang.sshSchluessel": "--ssh-key", "profile": "--profile", "vorlage": "--preset",
}
if flag, ok := zuordnung[feld]; ok {
return flag
}
for praefix, flag := range zuordnung {
if strings.HasPrefix(feld, praefix+".") {
return flag
}
}
if rest, ok := strings.CutPrefix(feld, "variablen."); ok {
return "--var " + rest
}
return feld
}
// jsonAusgeben schreibt eingerücktes JSON (--json).
func (u *umgebung) jsonAusgeben(wert any) error {
kodierer := json.NewEncoder(u.aus)
kodierer.SetIndent("", " ")
kodierer.SetEscapeHTML(false)
return kodierer.Encode(wert)
}
// Deutsche Fassung der Cobra-Hilfe.
const hilfeVorlage = `Aufruf:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [befehl]{{end}}{{if gt (len .Aliases) 0}}
Auch: {{.NameAndAliases}}{{end}}{{if .HasExample}}
Beispiele:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}
Befehle:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Optionen:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Für alle Befehle:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableSubCommands}}
Mehr zu einem Befehl: {{.CommandPath}} [befehl] --help{{end}}
`
+131
View File
@@ -0,0 +1,131 @@
// Package konfig liest und schreibt ~/.config/proxdeploy/config.toml.
//
// url = "https://proxdeploy.example"
// token = "pd_…"
// server = "rz1" # optional: Standard für --server
//
// [instances.labor] # weitere Instanzen, Auswahl mit --instance labor
// url = "https://proxdeploy.labor"
// token = "pd_…"
//
// PROXDEPLOY_URL, PROXDEPLOY_TOKEN und PROXDEPLOY_INSTANCE gehen der Datei vor.
package konfig
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"github.com/BurntSushi/toml"
)
type Instanz struct {
URL string `toml:"url"`
Token string `toml:"token"`
Server string `toml:"server,omitempty"`
}
type Datei struct {
Instanz
Instanzen map[string]Instanz `toml:"instances,omitempty"`
}
// Pfad: $XDG_CONFIG_HOME/proxdeploy/config.toml, sonst ~/.config/…; unter Windows %AppData%.
func Pfad() (string, error) {
if eigener := os.Getenv("PROXDEPLOY_CONFIG"); eigener != "" {
return eigener, nil
}
basis := os.Getenv("XDG_CONFIG_HOME")
if basis == "" {
if runtime.GOOS == "windows" {
var err error
if basis, err = os.UserConfigDir(); err != nil {
return "", err
}
} else {
heim, err := os.UserHomeDir()
if err != nil {
return "", err
}
basis = filepath.Join(heim, ".config")
}
}
return filepath.Join(basis, "proxdeploy", "config.toml"), nil
}
// Lesen liefert die Datei oder eine leere, wenn es sie noch nicht gibt.
func Lesen() (Datei, string, error) {
pfad, err := Pfad()
if err != nil {
return Datei{}, "", err
}
var d Datei
if _, err := toml.DecodeFile(pfad, &d); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return Datei{}, pfad, nil
}
return Datei{}, pfad, fmt.Errorf("%s: %w", pfad, err)
}
return d, pfad, nil
}
// Waehlen: die gewünschte Instanz (leer = Hauptabschnitt), überlagert von der Umgebung.
func (d Datei) Waehlen(name string) (Instanz, error) {
if name == "" {
name = os.Getenv("PROXDEPLOY_INSTANCE")
}
i := d.Instanz
if name != "" {
gefunden, ok := d.Instanzen[name]
if !ok {
return Instanz{}, fmt.Errorf("Instanz %q steht nicht in der Konfiguration", name)
}
i = gefunden
}
if url := os.Getenv("PROXDEPLOY_URL"); url != "" {
i.URL = url
}
if token := os.Getenv("PROXDEPLOY_TOKEN"); token != "" {
i.Token = token
}
return i, nil
}
// Setzen trägt eine Instanz ein (leer = Hauptabschnitt).
func (d *Datei) Setzen(name string, i Instanz) {
if name == "" {
d.Instanz = i
return
}
if d.Instanzen == nil {
d.Instanzen = map[string]Instanz{}
}
d.Instanzen[name] = i
}
// Schreiben legt die Datei mit Rechten 0600 an – sie enthält das Token.
func Schreiben(pfad string, d Datei) error {
if err := os.MkdirAll(filepath.Dir(pfad), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(pfad), ".config-*.toml")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return err
}
if err := toml.NewEncoder(tmp).Encode(d); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), pfad)
}
+51
View File
@@ -0,0 +1,51 @@
package konfig
import (
"os"
"path/filepath"
"testing"
)
func TestWaehlenUndUmgebung(t *testing.T) {
pfad := filepath.Join(t.TempDir(), "proxdeploy", "config.toml")
t.Setenv("PROXDEPLOY_CONFIG", pfad)
t.Setenv("PROXDEPLOY_URL", "")
t.Setenv("PROXDEPLOY_TOKEN", "")
t.Setenv("PROXDEPLOY_INSTANCE", "")
var d Datei
d.Setzen("", Instanz{URL: "https://a", Token: "pd_a", Server: "rz1"})
d.Setzen("labor", Instanz{URL: "https://b", Token: "pd_b"})
if err := Schreiben(pfad, d); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(pfad); info.Mode().Perm() != 0o600 {
t.Errorf("Rechte %v", info.Mode().Perm())
}
gelesen, _, err := Lesen()
if err != nil {
t.Fatal(err)
}
if i, _ := gelesen.Waehlen(""); i.URL != "https://a" || i.Server != "rz1" {
t.Errorf("Hauptabschnitt: %+v", i)
}
if i, _ := gelesen.Waehlen("labor"); i.Token != "pd_b" {
t.Errorf("labor: %+v", i)
}
if _, err := gelesen.Waehlen("gibtsnicht"); err == nil {
t.Error("unbekannte Instanz ohne Fehler")
}
t.Setenv("PROXDEPLOY_INSTANCE", "labor")
t.Setenv("PROXDEPLOY_TOKEN", "pd_env")
if i, _ := gelesen.Waehlen(""); i.URL != "https://b" || i.Token != "pd_env" {
t.Errorf("Umgebung: %+v", i)
}
}
func TestLesenOhneDatei(t *testing.T) {
t.Setenv("PROXDEPLOY_CONFIG", filepath.Join(t.TempDir(), "fehlt.toml"))
d, _, err := Lesen()
if err != nil || d.URL != "" {
t.Errorf("%v %+v", err, d)
}
}