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