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,317 @@
|
||||
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", false, "Feature nesting (das Profil docker setzt es selbst)")
|
||||
} 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
|
||||
}
|
||||
Reference in New Issue
Block a user