Files
proxdeploy/cli/internal/befehle/ausrollen.go
T
florian.wildeandClaude Opus 5.5 bc13b94c7e fix(proxmox): Task-Status unter Last weiter abfragen, nesting an
Im Feldtest wurde ein LXC fertig angelegt, während ProxDeploy nach drei gescheiterten Statusabfragen aufgab (Verbindungsaufbau > 10 s bei 76 % I/O-Last). Statusabfragen werden jetzt bis zum Task-Zeitlimit wiederholt, der Verbindungsaufbau darf 30 s dauern. nesting ist für Container standardmäßig an wie in der Proxmox-Oberfläche – Proxmox warnt sonst bei systemd 249/252.

Refs #020

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:46:15 +02:00

318 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 + " <name>",
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 <name> …", 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
}