package befehle import ( "bytes" "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 ", 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 ") } 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 { ausgegeben := false for _, e := range eintraege { // Leerzeile nur zwischen Blöcken, die auch etwas ausgeben (mit --quiet bleiben gute stumm). puffer := &bytes.Buffer{} ziel := u.aus u.aus = puffer err := u.trockenlauf(ctx, c, e.anfrage, e.name) u.aus = ziel if puffer.Len() > 0 { if ausgegeben && !u.opt.json { fmt.Fprintln(u.aus) } _, _ = u.aus.Write(puffer.Bytes()) ausgegeben = true } if 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 }