Verlauf filterbar (URL, API, proxdeploy history); Wiederholen füllt den Assistenten aus einem alten Auftrag; Aufräumen löscht nach Fehlschlag oder Abbruch den halb angelegten Gast – nur, wenn dessen Beschreibung auf den Auftrag verweist. Deshalb setzt das Klonen die Beschreibung jetzt sofort. Auf pve-demo geprüft (CT 109). Closes #014 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
9.4 KiB
Go
317 lines
9.4 KiB
Go
// Package befehle enthält die Befehle der CLI `proxdeploy`.
|
||
package befehle
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"os/signal"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/spf13/cobra"
|
||
"golang.org/x/term"
|
||
|
||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
|
||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe"
|
||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/konfig"
|
||
)
|
||
|
||
// Exit-Codes (docs/architektur.md, Abschnitt CLI).
|
||
const (
|
||
ExitOk = 0
|
||
ExitFehlschlag = 1 // Auftrag fehlgeschlagen oder abgebrochen
|
||
ExitBedienung = 2 // falscher Aufruf, ungültige Eingaben
|
||
ExitVerbindung = 3 // nicht erreichbar, nicht angemeldet
|
||
)
|
||
|
||
// Version wird beim Bauen gesetzt (-ldflags "-X …/befehle.Version=0.4.0").
|
||
var Version = "0.0.0-dev"
|
||
|
||
// Optionen, die für alle Befehle gelten.
|
||
type optionen struct {
|
||
keineFarbe bool
|
||
ascii bool
|
||
still bool
|
||
json bool
|
||
instanz string
|
||
}
|
||
|
||
// Umgebung eines Laufs – für Tests austauschbar.
|
||
type umgebung struct {
|
||
aus, fehler io.Writer
|
||
terminal bool
|
||
opt *optionen
|
||
}
|
||
|
||
func (u *umgebung) stil() ausgabe.Stil {
|
||
return ausgabe.Stil{Farbe: ausgabe.FarbmodusErmitteln(u.opt.keineFarbe, u.terminal), ASCII: u.opt.ascii}
|
||
}
|
||
|
||
// exitFehler trägt den Exit-Code; die Meldung ist schon ausgegeben, wenn `gemeldet` gesetzt ist.
|
||
type exitFehler struct {
|
||
code int
|
||
gemeldet bool
|
||
err error
|
||
}
|
||
|
||
func (e *exitFehler) Error() string {
|
||
if e.err != nil {
|
||
return e.err.Error()
|
||
}
|
||
return fmt.Sprintf("Exit %d", e.code)
|
||
}
|
||
|
||
func bedienfehler(format string, argumente ...any) error {
|
||
return &exitFehler{code: ExitBedienung, err: fmt.Errorf(format, argumente...)}
|
||
}
|
||
|
||
// Ausfuehren ist der Einstieg aus main.
|
||
func Ausfuehren() int {
|
||
u := &umgebung{
|
||
aus: os.Stdout,
|
||
fehler: os.Stderr,
|
||
terminal: term.IsTerminal(int(os.Stdout.Fd())),
|
||
opt: &optionen{},
|
||
}
|
||
ctx, stopp := signal.NotifyContext(context.Background(), os.Interrupt)
|
||
defer stopp()
|
||
return u.ausfuehren(ctx, os.Args[1:])
|
||
}
|
||
|
||
func (u *umgebung) ausfuehren(ctx context.Context, argumente []string) int {
|
||
wurzel := u.wurzel()
|
||
wurzel.SetArgs(argumente)
|
||
wurzel.SetOut(u.aus)
|
||
wurzel.SetErr(u.fehler)
|
||
err := wurzel.ExecuteContext(ctx)
|
||
if err == nil {
|
||
return ExitOk
|
||
}
|
||
return u.fehlerMelden(err)
|
||
}
|
||
|
||
func (u *umgebung) wurzel() *cobra.Command {
|
||
wurzel := &cobra.Command{
|
||
Use: "proxdeploy",
|
||
Short: "VMs und LXC-Container auf Proxmox VE ausrollen",
|
||
Long: "ProxDeploy – VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.\nEin unabhängiges Werkzeug, nicht von Proxmox Server Solutions.",
|
||
Version: Version,
|
||
SilenceUsage: true,
|
||
SilenceErrors: true,
|
||
}
|
||
wurzel.CompletionOptions.HiddenDefaultCmd = true
|
||
wurzel.SetUsageTemplate(hilfeVorlage)
|
||
wurzel.SetVersionTemplate("proxdeploy {{.Version}}\n")
|
||
wurzel.InitDefaultVersionFlag()
|
||
wurzel.Flags().Lookup("version").Usage = "Version zeigen"
|
||
wurzel.SetHelpCommand(&cobra.Command{Use: "help [befehl]", Short: "Hilfe zu einem Befehl", Hidden: true,
|
||
RunE: func(c *cobra.Command, argumente []string) error {
|
||
ziel, _, err := c.Root().Find(argumente)
|
||
if err != nil || ziel == nil {
|
||
ziel = c.Root()
|
||
}
|
||
return ziel.Help()
|
||
}})
|
||
wurzel.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
|
||
return &exitFehler{code: ExitBedienung, err: flagFehlerUebersetzen(err)}
|
||
})
|
||
f := wurzel.PersistentFlags()
|
||
f.BoolVar(&u.opt.keineFarbe, "no-color", false, "ohne Farben (auch mit NO_COLOR)")
|
||
f.BoolVar(&u.opt.ascii, "ascii", false, "Symbole als [OK], [..], [!!]")
|
||
f.BoolVarP(&u.opt.still, "quiet", "q", false, "nur Abschlussbox bzw. Fehler")
|
||
f.BoolVar(&u.opt.json, "json", false, "maschinenlesbare Ausgabe")
|
||
f.StringVar(&u.opt.instanz, "instance", "", "Instanz aus der Konfiguration ([instances.<name>])")
|
||
|
||
wurzel.AddCommand(
|
||
u.loginBefehl(),
|
||
u.serversBefehl(),
|
||
u.distrosBefehl(),
|
||
u.profilesBefehl(),
|
||
u.presetsBefehl(),
|
||
u.templateBefehl(),
|
||
u.ausrollBefehl("vm"),
|
||
u.ausrollBefehl("lxc"),
|
||
u.batchBefehl(),
|
||
u.statusBefehl(),
|
||
u.logsBefehl(),
|
||
u.cancelBefehl(),
|
||
u.historyBefehl(),
|
||
u.cleanupBefehl(),
|
||
)
|
||
hilfeEindeutschen(wurzel)
|
||
return wurzel
|
||
}
|
||
|
||
func hilfeEindeutschen(c *cobra.Command) {
|
||
c.InitDefaultHelpFlag()
|
||
if f := c.Flags().Lookup("help"); f != nil {
|
||
f.Usage = "Hilfe zu " + c.CommandPath()
|
||
}
|
||
for _, unter := range c.Commands() {
|
||
hilfeEindeutschen(unter)
|
||
}
|
||
}
|
||
|
||
// client baut den API-Client aus Konfiguration und Umgebung.
|
||
func (u *umgebung) client() (*api.Client, konfig.Instanz, error) {
|
||
datei, pfad, err := konfig.Lesen()
|
||
if err != nil {
|
||
return nil, konfig.Instanz{}, &exitFehler{code: ExitBedienung, err: err}
|
||
}
|
||
instanz, err := datei.Waehlen(u.opt.instanz)
|
||
if err != nil {
|
||
return nil, konfig.Instanz{}, &exitFehler{code: ExitBedienung, err: err}
|
||
}
|
||
if instanz.URL == "" || instanz.Token == "" {
|
||
return nil, instanz, &exitFehler{
|
||
code: ExitVerbindung,
|
||
err: fmt.Errorf("nicht angemeldet – zuerst `proxdeploy login <url>` (Konfiguration: %s)", pfad),
|
||
}
|
||
}
|
||
return api.Neu(instanz.URL, instanz.Token, Version), instanz, nil
|
||
}
|
||
|
||
// fehlerMelden gibt einen Fehler aus und liefert den Exit-Code.
|
||
func (u *umgebung) fehlerMelden(err error) int {
|
||
var ef *exitFehler
|
||
code := ExitBedienung
|
||
if errors.As(err, &ef) {
|
||
code = ef.code
|
||
if ef.gemeldet {
|
||
return code
|
||
}
|
||
if ef.err != nil {
|
||
err = ef.err
|
||
}
|
||
}
|
||
var apiFehler *api.Fehler
|
||
var verbindung *api.VerbindungsFehler
|
||
switch {
|
||
case errors.As(err, &apiFehler):
|
||
if apiFehler.Status == 401 || apiFehler.Status == 403 {
|
||
code = ExitVerbindung
|
||
} else if ef == nil {
|
||
code = ExitBedienung
|
||
}
|
||
case errors.As(err, &verbindung):
|
||
code = ExitVerbindung
|
||
case errors.Is(err, context.Canceled):
|
||
return 130
|
||
}
|
||
|
||
if u.opt.json {
|
||
inhalt := map[string]any{"nachricht": err.Error()}
|
||
if apiFehler != nil {
|
||
inhalt = map[string]any{"code": apiFehler.Code, "nachricht": apiFehler.Nachricht}
|
||
if apiFehler.Hinweis != "" {
|
||
inhalt["hinweis"] = apiFehler.Hinweis
|
||
}
|
||
if len(apiFehler.Felder) > 0 {
|
||
inhalt["felder"] = apiFehler.Felder
|
||
}
|
||
}
|
||
_ = json.NewEncoder(u.fehler).Encode(map[string]any{"fehler": inhalt})
|
||
return code
|
||
}
|
||
s := ausgabe.Stil{Farbe: ausgabe.FarbmodusErmitteln(u.opt.keineFarbe, term.IsTerminal(int(os.Stderr.Fd())) && u.terminal), ASCII: u.opt.ascii}
|
||
symbol := "✗"
|
||
if s.ASCII {
|
||
symbol = "[!!]"
|
||
}
|
||
fmt.Fprintln(u.fehler, s.Rot(symbol+" "+err.Error()))
|
||
if apiFehler != nil {
|
||
namen := make([]string, 0, len(apiFehler.Felder))
|
||
for n := range apiFehler.Felder {
|
||
namen = append(namen, n)
|
||
}
|
||
sort.Strings(namen)
|
||
for _, n := range namen {
|
||
fmt.Fprintln(u.fehler, " "+s.Paper(flagFuerFeld(n))+s.Muted(": "+apiFehler.Felder[n]))
|
||
}
|
||
if apiFehler.Hinweis != "" {
|
||
fmt.Fprintln(u.fehler, s.Muted(" → "+apiFehler.Hinweis))
|
||
}
|
||
}
|
||
return code
|
||
}
|
||
|
||
var (
|
||
ungueltigerWert = regexp.MustCompile(`^invalid argument "(.*)" for "(.*)" flag: .*$`)
|
||
unbekannteOption = regexp.MustCompile(`^unknown (?:shorthand )?flag: (.*)$`)
|
||
fehlenderWert = regexp.MustCompile(`^flag needs an argument: (.*)$`)
|
||
)
|
||
|
||
// flagFehlerUebersetzen: die Meldungen von pflag auf Deutsch.
|
||
func flagFehlerUebersetzen(err error) error {
|
||
text := err.Error()
|
||
if t := ungueltigerWert.FindStringSubmatch(text); t != nil {
|
||
return fmt.Errorf("ungültiger Wert „%s“ für %s", t[1], t[2])
|
||
}
|
||
if t := unbekannteOption.FindStringSubmatch(text); t != nil {
|
||
return fmt.Errorf("unbekannte Option %s – siehe --help", t[1])
|
||
}
|
||
if t := fehlenderWert.FindStringSubmatch(text); t != nil {
|
||
return fmt.Errorf("Option %s braucht einen Wert", t[1])
|
||
}
|
||
return err
|
||
}
|
||
|
||
// flagFuerFeld übersetzt ein Feld der API in das passende Flag, damit Feldfehler verständlich sind.
|
||
func flagFuerFeld(feld string) string {
|
||
zuordnung := map[string]string{
|
||
"server": "--server", "knoten": "--node", "distribution": "--distro", "name": "<name>",
|
||
"vmid": "--vmid", "tags": "--tags", "cores": "--cores", "speicherMb": "--memory",
|
||
"diskGb": "--disk", "storage": "--storage", "swapMb": "--swap", "netz.bridge": "--bridge",
|
||
"netz.vlan": "--vlan", "netz.ip": "--ip", "netz.gateway": "--gw", "netz.dns": "--dns",
|
||
"netz.suchdomain": "--search-domain", "zugang.benutzer": "--user",
|
||
"zugang.sshSchluessel": "--ssh-key", "profile": "--profile", "vorlage": "--preset",
|
||
}
|
||
if flag, ok := zuordnung[feld]; ok {
|
||
return flag
|
||
}
|
||
for praefix, flag := range zuordnung {
|
||
if strings.HasPrefix(feld, praefix+".") {
|
||
return flag
|
||
}
|
||
}
|
||
if rest, ok := strings.CutPrefix(feld, "variablen."); ok {
|
||
return "--var " + rest
|
||
}
|
||
return feld
|
||
}
|
||
|
||
// jsonAusgeben schreibt eingerücktes JSON (--json).
|
||
func (u *umgebung) jsonAusgeben(wert any) error {
|
||
kodierer := json.NewEncoder(u.aus)
|
||
kodierer.SetIndent("", " ")
|
||
kodierer.SetEscapeHTML(false)
|
||
return kodierer.Encode(wert)
|
||
}
|
||
|
||
// Deutsche Fassung der Cobra-Hilfe.
|
||
const hilfeVorlage = `Aufruf:{{if .Runnable}}
|
||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||
{{.CommandPath}} [befehl]{{end}}{{if gt (len .Aliases) 0}}
|
||
|
||
Auch: {{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||
|
||
Beispiele:
|
||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}
|
||
|
||
Befehle:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||
|
||
Optionen:
|
||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||
|
||
Für alle Befehle:
|
||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableSubCommands}}
|
||
|
||
Mehr zu einem Befehl: {{.CommandPath}} [befehl] --help{{end}}
|
||
`
|