Files
proxdeploy/cli/internal/befehle/wurzel.go
T
florian.wildeandClaude Opus 5.5 bd4bca5399 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>
2026-10-12 15:08:41 +02:00

315 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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}}
`