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>
267 lines
7.3 KiB
Go
267 lines
7.3 KiB
Go
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
|
||
}
|