package befehle import ( "context" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "github.com/spf13/cobra" "github.com/spf13/pflag" "git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api" "git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe" ) // gastFlags sind die Angaben zu einem Gast – dieselben für `vm`/`lxc` und für Einträge in `batch`. type gastFlags struct { server, knoten, distro, preset, ip, gw, bridge, storage, benutzer, suchdomain string vmid, vlan, cores, speicher, swap int disk string profile, dns, tags, schluessel, variablen []string privilegiert, nesting, templateBauen bool } func (g *gastFlags) registrieren(f *pflag.FlagSet, typ string) { f.StringVar(&g.server, "server", "", "Proxmox-Server (Kurzname, siehe „proxdeploy servers“)") f.StringVar(&g.knoten, "node", "", "Node (Standard: wo das Template liegt bzw. der mit dem meisten freien RAM)") f.StringVar(&g.distro, "distro", "", "Distribution, z. B. debian-13 (siehe „proxdeploy distros“)") f.StringSliceVar(&g.profile, "profile", nil, "Profile, z. B. base,docker (Standard: base)") f.StringVar(&g.preset, "preset", "", "Vorlage, deren Werte vorbelegt werden (Flags gehen vor)") f.StringVar(&g.ip, "ip", "", "statische Adresse mit Präfix, z. B. 10.0.20.15/24 (sonst DHCP)") f.StringVar(&g.gw, "gw", "", "Gateway") f.StringSliceVar(&g.dns, "dns", nil, "DNS-Server, kommagetrennt") f.StringVar(&g.suchdomain, "search-domain", "", "Suchdomain") f.StringVar(&g.bridge, "bridge", "", "Bridge (Standard: vmbr0)") f.IntVar(&g.vlan, "vlan", 0, "VLAN-Tag") f.IntVar(&g.vmid, "vmid", 0, "VMID/CTID (Standard: nächste freie)") f.IntVar(&g.cores, "cores", 0, "CPU-Kerne (Standard: 2)") f.IntVar(&g.speicher, "memory", 0, "RAM in MB (Standard: 2048)") f.StringVar(&g.disk, "disk", "", "Disk, z. B. 40G (Standard: 20G)") f.StringVar(&g.storage, "storage", "", "Storage (Standard: aus den Server-Einstellungen)") f.StringVar(&g.benutzer, "user", "", "Benutzer im Gast (Standard: dein Benutzername)") f.StringArrayVar(&g.schluessel, "ssh-key", nil, "öffentlicher SSH-Schlüssel oder Pfad zur .pub (Standard: deine hinterlegten)") f.StringSliceVar(&g.tags, "tags", nil, "Tags, kommagetrennt") f.StringArrayVar(&g.variablen, "var", nil, "Profil-Variable profil.name=wert (siehe „proxdeploy profiles“)") if typ == "lxc" { f.IntVar(&g.swap, "swap", 0, "Swap in MB (Standard: 512)") f.BoolVar(&g.privilegiert, "privileged", false, "privilegierter Container (Standard: unprivilegiert)") f.BoolVar(&g.nesting, "nesting", true, "Feature nesting (Standard an wie in Proxmox; --nesting=false schaltet es ab)") } else { f.BoolVar(&g.templateBauen, "build-template", false, "fehlt das Template, vorher bauen") } } var diskMuster = regexp.MustCompile(`^(\d+)\s*([gG])?$`) // anfrage baut die API-Anfrage. Nur gesetzte Werte kommen hinein; den Rest füllen Vorlage und // Server (Storage, Bridge, Benutzer, Schlüssel). func (g *gastFlags) anfrage(typ, name string, gesetzt func(string) bool) (map[string]any, error) { a := map[string]any{"typ": typ, "name": name} setze := func(flag, feld string, wert any) { if gesetzt(flag) { a[feld] = wert } } setze("server", "server", g.server) setze("node", "knoten", g.knoten) setze("distro", "distribution", g.distro) setze("profile", "profile", g.profile) setze("preset", "vorlage", g.preset) setze("vmid", "vmid", g.vmid) setze("cores", "cores", g.cores) setze("memory", "speicherMb", g.speicher) setze("storage", "storage", g.storage) setze("tags", "tags", g.tags) setze("swap", "swapMb", g.swap) setze("build-template", "templateBauen", g.templateBauen) if gesetzt("privileged") { a["unprivilegiert"] = !g.privilegiert } if gesetzt("nesting") { a["features"] = map[string]any{"nesting": g.nesting} } if gesetzt("disk") { treffer := diskMuster.FindStringSubmatch(strings.TrimSpace(g.disk)) if treffer == nil { return nil, bedienfehler("--disk: Größe in GB, z. B. 40G") } groesse, _ := strconv.Atoi(treffer[1]) a["diskGb"] = groesse } netz := map[string]any{} if gesetzt("ip") { netz["modus"] = "statisch" netz["ip"] = g.ip } if gesetzt("gw") { netz["gateway"] = g.gw } if gesetzt("dns") { netz["dns"] = g.dns } if gesetzt("search-domain") { netz["suchdomain"] = g.suchdomain } if gesetzt("bridge") { netz["bridge"] = g.bridge } if gesetzt("vlan") { netz["vlan"] = g.vlan } if len(netz) > 0 { a["netz"] = netz } zugang := map[string]any{} if gesetzt("user") { zugang["benutzer"] = g.benutzer } if gesetzt("ssh-key") { schluessel := []string{} for _, s := range g.schluessel { text, err := schluesselLesen(s) if err != nil { return nil, err } schluessel = append(schluessel, text...) } zugang["sshSchluessel"] = schluessel } if len(zugang) > 0 { a["zugang"] = zugang } if len(g.variablen) > 0 { variablen := map[string]map[string]any{} for _, v := range g.variablen { schluessel, wert, ok := strings.Cut(v, "=") profil, name, ok2 := strings.Cut(schluessel, ".") if !ok || !ok2 || profil == "" || name == "" { return nil, bedienfehler("--var %q: Format profil.name=wert", v) } if variablen[profil] == nil { variablen[profil] = map[string]any{} } variablen[profil][name] = wert } a["variablen"] = variablen } return a, nil } // schluesselLesen nimmt einen Schlüssel als Text oder liest eine .pub-Datei (eine Zeile je Schlüssel). func schluesselLesen(angabe string) ([]string, error) { if strings.HasPrefix(angabe, "ssh-") || strings.HasPrefix(angabe, "ecdsa-") || strings.HasPrefix(angabe, "sk-") { return []string{strings.TrimSpace(angabe)}, nil } pfad := angabe if rest, ok := strings.CutPrefix(pfad, "~/"); ok { if heim, err := os.UserHomeDir(); err == nil { pfad = filepath.Join(heim, rest) } } inhalt, err := os.ReadFile(pfad) if err != nil { return nil, bedienfehler("--ssh-key: %v", err) } var liste []string for _, zeile := range strings.Split(string(inhalt), "\n") { if z := strings.TrimSpace(zeile); z != "" && !strings.HasPrefix(z, "#") { if strings.Contains(z, "PRIVATE KEY") { return nil, bedienfehler("--ssh-key: %s ist ein privater Schlüssel – die .pub angeben", angabe) } liste = append(liste, z) } } return liste, nil } func (u *umgebung) ausrollBefehl(typ string) *cobra.Command { var g gastFlags var trocken, nichtWarten bool art := map[string]string{"vm": "VM", "lxc": "LXC-Container"}[typ] befehl := &cobra.Command{ Use: typ + " ", Short: art + " ausrollen", Example: map[string]string{ "vm": " proxdeploy vm web01 --server rz1 --distro debian-13 --profile base,docker --ip 10.0.20.15/24 --gw 10.0.20.1 --vlan 20", "lxc": " proxdeploy lxc dns01 --server rz1 --distro ubuntu-24.04 --profile base", }[typ], Args: func(cmd *cobra.Command, argumente []string) error { if len(argumente) != 1 { return bedienfehler("Name fehlt: proxdeploy %s …", typ) } return nil }, RunE: func(cmd *cobra.Command, argumente []string) error { c, instanz, err := u.client() if err != nil { return err } gesetzt := cmd.Flags().Changed if !gesetzt("server") && instanz.Server != "" { g.server = instanz.Server gesetzt = func(flag string) bool { return flag == "server" || cmd.Flags().Changed(flag) } } if !gesetzt("server") { return bedienfehler("--server fehlt (oder `server = \"…\"` in der Konfiguration)") } if !gesetzt("distro") && !gesetzt("preset") { return bedienfehler("--distro fehlt (siehe `proxdeploy distros`)") } anfrage, err := g.anfrage(typ, argumente[0], gesetzt) if err != nil { return err } if trocken { return u.trockenlauf(cmd.Context(), c, anfrage, argumente[0]) } angenommen, err := c.Ausrollen(cmd.Context(), anfrage) if err != nil { return err } return u.angenommen(cmd.Context(), c, angenommen, nichtWarten) }, } g.registrieren(befehl.Flags(), typ) befehl.Flags().BoolVar(&trocken, "dry-run", false, "nur planen: Phasen und geplante API-Aufrufe zeigen, nichts anlegen") befehl.Flags().BoolVar(&nichtWarten, "no-wait", false, "nicht verfolgen, nur die Auftrags-ID ausgeben") return befehl } // angenommen: Auftrag läuft – verfolgen oder nur die ID ausgeben. func (u *umgebung) angenommen(ctx context.Context, c *api.Client, a api.AuftragAngenommen, nichtWarten bool) error { if nichtWarten { if u.opt.json { return u.jsonAusgeben(a) } fmt.Fprintln(u.aus, a.KurzId) return nil } _, err := u.folgen(ctx, c, a.KurzId) return err } // trockenlauf zeigt den Plan: Kopfzeile mit DRY-RUN, Phasen, jeden geplanten Aufruf als „→ …“. func (u *umgebung) trockenlauf(ctx context.Context, c *api.Client, anfrage map[string]any, name string) error { plan, err := c.Planen(ctx, anfrage) if err != nil { return err } if u.opt.json { if err := u.jsonAusgeben(plan); err != nil { return err } if !plan.Ok { return &exitFehler{code: ExitFehlschlag, gemeldet: true} } return nil } s := u.stil() phasen := make([]ausgabe.Phase, 0, len(plan.Phasen)) for _, p := range plan.Phasen { phasen = append(phasen, ausgabe.Phase{ Titel: p.Titel, Status: string(p.Status), Detail: wert(p.Detail), Ursache: wert(p.Ursache), Hinweis: wert(p.Hinweis), }) } aufrufe := make([]ausgabe.Aufruf, 0, len(plan.Aufrufe)) for _, a := range plan.Aufrufe { aufruf := ausgabe.Aufruf{Methode: a.Methode, Pfad: a.Pfad} if a.Parameter != nil { aufruf.Parameter = *a.Parameter } aufrufe = append(aufrufe, aufruf) } var zeilen []string if !u.opt.still { zeilen = append(zeilen, s.Kopf(Version, plan.Kopf, true), "") zeilen = append(zeilen, s.PhasenZeilen(phasen, 0)...) zeilen = append(zeilen, "") zeilen = append(zeilen, s.AufrufZeilen(aufrufe)...) if len(plan.Automatisch) > 0 { zeilen = append(zeilen, "", s.Muted("Automatisch dazu: "+strings.Join(plan.Automatisch, ", "))) } } if !plan.Ok { box := ausgabe.Box{Titel: name + " würde fehlschlagen"} for _, p := range plan.Phasen { if p.Status == api.PhaseStatusFEHLER { box.Zeilen = append(box.Zeilen, [2]string{"Phase", p.Titel}) if p.Ursache != nil { box.Zeilen = append(box.Zeilen, [2]string{"Ursache", *p.Ursache}) } if p.Hinweis != nil { box.Zeilen = append(box.Zeilen, [2]string{"Hinweis", *p.Hinweis}) } } } if len(zeilen) > 0 { zeilen = append(zeilen, "") } zeilen = append(zeilen, s.BoxZeilen(box)...) } else if !u.opt.still { zeilen = append(zeilen, "", s.Muted("Nichts angelegt. Ohne --dry-run wird ausgerollt.")) } if len(zeilen) > 0 { fmt.Fprintln(u.aus, strings.Join(zeilen, "\n")) } if !plan.Ok { return &exitFehler{code: ExitFehlschlag, gemeldet: true} } return nil }