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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user