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,174 @@
|
||||
package befehle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
|
||||
)
|
||||
|
||||
func eineID(befehl string) cobra.PositionalArgs {
|
||||
return func(_ *cobra.Command, argumente []string) error {
|
||||
if len(argumente) != 1 {
|
||||
return bedienfehler("Auftrags-ID fehlt: proxdeploy %s <job-id>", befehl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (u *umgebung) statusBefehl() *cobra.Command {
|
||||
var folgen bool
|
||||
befehl := &cobra.Command{
|
||||
Use: "status <job-id>",
|
||||
Short: "Stand eines Auftrags (mit --follow live bis zum Ende)",
|
||||
Args: eineID("status"),
|
||||
RunE: func(cmd *cobra.Command, argumente []string) error {
|
||||
c, _, err := u.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if folgen {
|
||||
_, err := u.folgen(cmd.Context(), c, argumente[0])
|
||||
return err
|
||||
}
|
||||
a, err := c.Auftrag(cmd.Context(), argumente[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fertig := endzustaende[a.Status]
|
||||
if u.opt.json {
|
||||
if err := u.jsonAusgeben(a); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
d := u.neuerDarsteller()
|
||||
d.live = true // einmal vollständig zeichnen, ohne Neuzeichnen
|
||||
d.zeichnen(a, fertig)
|
||||
}
|
||||
if fertig && a.Status != api.AuftragStatusERFOLGREICH {
|
||||
return &exitFehler{code: ExitFehlschlag, gemeldet: true}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
befehl.Flags().BoolVarP(&folgen, "follow", "f", false, "live verfolgen bis zum Ende")
|
||||
return befehl
|
||||
}
|
||||
|
||||
func (u *umgebung) logsBefehl() *cobra.Command {
|
||||
var folgen bool
|
||||
befehl := &cobra.Command{
|
||||
Use: "logs <job-id>",
|
||||
Short: "Detail-Log eines Auftrags (mit --follow live)",
|
||||
Args: eineID("logs"),
|
||||
RunE: func(cmd *cobra.Command, argumente []string) error {
|
||||
c, _, err := u.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := argumente[0]
|
||||
zeilen, err := c.Log(cmd.Context(), id, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, z := range zeilen {
|
||||
u.logZeile(z)
|
||||
}
|
||||
if !folgen {
|
||||
return nil
|
||||
}
|
||||
letzte := ""
|
||||
if len(zeilen) > 0 {
|
||||
letzte = zeilen[len(zeilen)-1].Id
|
||||
}
|
||||
return u.logFolgen(cmd.Context(), c, id, letzte)
|
||||
},
|
||||
}
|
||||
befehl.Flags().BoolVarP(&folgen, "follow", "f", false, "neue Zeilen live anhängen, bis der Auftrag fertig ist")
|
||||
return befehl
|
||||
}
|
||||
|
||||
func (u *umgebung) logZeile(z api.LogZeile) {
|
||||
if u.opt.json {
|
||||
daten, _ := json.Marshal(z)
|
||||
fmt.Fprintln(u.aus, string(daten))
|
||||
return
|
||||
}
|
||||
s := u.stil()
|
||||
zeit := z.Zeit
|
||||
if t, err := time.Parse(time.RFC3339Nano, z.Zeit); err == nil {
|
||||
zeit = t.Local().Format("15:04:05")
|
||||
}
|
||||
phase := ""
|
||||
if z.Phase != nil {
|
||||
phase = *z.Phase
|
||||
}
|
||||
nachricht := z.Nachricht
|
||||
switch z.Stufe {
|
||||
case "error":
|
||||
nachricht = s.Rot(nachricht)
|
||||
case "warn":
|
||||
nachricht = s.Amber(nachricht)
|
||||
default:
|
||||
nachricht = s.Paper(nachricht)
|
||||
}
|
||||
fmt.Fprintf(u.aus, "%s %s %s\n", s.Muted(zeit), s.Muted(fmt.Sprintf("%-16s", phase)), nachricht)
|
||||
}
|
||||
|
||||
// logFolgen hängt neue Zeilen an (SSE-Ereignis `log`), bis der Auftrag fertig ist.
|
||||
func (u *umgebung) logFolgen(ctx context.Context, c *api.Client, id, ab string) error {
|
||||
for versuch := 0; versuch < 5; versuch++ {
|
||||
err := c.Ereignisse(ctx, id, ab, func(e api.Ereignis) error {
|
||||
switch e.Art {
|
||||
case "log":
|
||||
var zeilen []api.LogZeile
|
||||
if err := json.Unmarshal(e.Daten, &zeilen); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, z := range zeilen {
|
||||
u.logZeile(z)
|
||||
ab = z.Id
|
||||
}
|
||||
case "ende":
|
||||
return api.Ende
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, api.Ende) || ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
var apiFehler *api.Fehler
|
||||
if errors.As(err, &apiFehler) {
|
||||
return err
|
||||
}
|
||||
time.Sleep(time.Duration(versuch+1) * time.Second)
|
||||
}
|
||||
return &api.VerbindungsFehler{Ursache: errors.New("Ereignisstrom bricht wiederholt ab")}
|
||||
}
|
||||
|
||||
func (u *umgebung) cancelBefehl() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "cancel <job-id>",
|
||||
Short: "Auftrag abbrechen (hält an der nächsten Phasengrenze)",
|
||||
Args: eineID("cancel"),
|
||||
RunE: func(cmd *cobra.Command, argumente []string) error {
|
||||
c, _, err := u.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Abbrechen(cmd.Context(), argumente[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
if !u.opt.json {
|
||||
fmt.Fprintln(u.aus, u.stil().Amber("Abbruch angefordert – "+strings.TrimSpace(argumente[0])+" hält an der nächsten Phasengrenze."))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package befehle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var aktualisieren = flag.Bool("update", false, "Golden-Dateien neu schreiben")
|
||||
|
||||
func golden(t *testing.T, name, ist string) {
|
||||
t.Helper()
|
||||
pfad := filepath.Join("testdata", name)
|
||||
if *aktualisieren {
|
||||
if err := os.MkdirAll("testdata", 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(pfad, []byte(ist), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
soll, err := os.ReadFile(pfad)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ist != string(soll) {
|
||||
t.Errorf("%s weicht ab.\n--- soll\n%s\n--- ist\n%s", name, soll, ist)
|
||||
}
|
||||
}
|
||||
|
||||
// phase baut eine Phase der API.
|
||||
func phase(schluessel, titel, status, detail string) map[string]any {
|
||||
p := map[string]any{"schluessel": schluessel, "titel": titel, "status": status, "detail": nil, "ursache": nil, "hinweis": nil, "gestartetAm": nil, "beendetAm": nil}
|
||||
if detail != "" {
|
||||
p["detail"] = detail
|
||||
}
|
||||
if status == "LAEUFT" {
|
||||
p["gestartetAm"] = "2026-10-12T10:00:00.000Z"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func auftrag(status string, phasen []map[string]any, abschluss any) map[string]any {
|
||||
return map[string]any{
|
||||
"id": "c1", "kurzId": "a-7f3k", "art": "AUSROLLEN", "status": status,
|
||||
"kopf": "Server rz1 · Node pve01 · Debian 13 (Template 9013)",
|
||||
"server": map[string]any{"name": "rz1", "titel": "RZ 1"}, "knoten": "pve01", "gastTyp": "VM",
|
||||
"vmid": 120, "name": "web01", "distributionId": "debian-13", "profile": []string{"base", "docker"},
|
||||
"ergebnis": map[string]any{"vmid": 120, "ip": "10.0.20.15", "ssh": "ssh florian@10.0.20.15"},
|
||||
"fehler": nil, "erstelltVon": "florian", "erstelltAm": "2026-10-12T10:00:00.000Z",
|
||||
"gestartetAm": "2026-10-12T10:00:00.000Z", "beendetAm": nil, "abschluss": abschluss, "phasen": phasen,
|
||||
}
|
||||
}
|
||||
|
||||
var alle = []map[string]any{
|
||||
phase("pruefungen", "Prüfungen", "ERLEDIGT", "VMID 120 frei · local-zfs · vmbr0 VLAN 20"),
|
||||
phase("klonen", "Klonen", "ERLEDIGT", "full clone 9013 → 120 · Disk auf 40G"),
|
||||
phase("cloudinit", "cloud-init", "ERLEDIGT", "user florian · SSH-Key · Profile base, docker"),
|
||||
phase("start", "Start", "ERLEDIGT", "VM 120 läuft"),
|
||||
phase("agent", "Guest-Agent", "ERLEDIGT", "antwortet nach 18s"),
|
||||
phase("provisionierung", "Provisionierung", "ERLEDIGT", "base, docker"),
|
||||
}
|
||||
|
||||
// fakeAPI spielt die REST-API nach; `anfragen` hält die Rümpfe fest.
|
||||
type fakeAPI struct {
|
||||
mu sync.Mutex
|
||||
anfragen map[string][]map[string]any
|
||||
staende []map[string]any
|
||||
plan map[string]any
|
||||
status int
|
||||
antwort any
|
||||
}
|
||||
|
||||
func (f *fakeAPI) handler(t *testing.T) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer pd_test" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(401)
|
||||
fmt.Fprint(w, `{"fehler":{"code":"nicht_angemeldet","nachricht":"Nicht angemeldet."}}`)
|
||||
return
|
||||
}
|
||||
if r.Body != nil && r.Method == http.MethodPost {
|
||||
var rumpf map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&rumpf)
|
||||
f.mu.Lock()
|
||||
if f.anfragen == nil {
|
||||
f.anfragen = map[string][]map[string]any{}
|
||||
}
|
||||
f.anfragen[r.URL.Path] = append(f.anfragen[r.URL.Path], rumpf)
|
||||
f.mu.Unlock()
|
||||
}
|
||||
schreiben := func(status int, wert any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(wert)
|
||||
}
|
||||
if f.status != 0 {
|
||||
schreiben(f.status, f.antwort)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == "/api/v1/ich":
|
||||
schreiben(200, map[string]any{"benutzername": "florian", "anzeigename": "Florian", "rolle": "OPERATOR", "rechte": []string{}, "version": "0.4.0"})
|
||||
case r.URL.Path == "/api/v1/auftraege" && r.Method == http.MethodPost:
|
||||
schreiben(202, map[string]any{"id": "c1", "kurzId": "a-7f3k"})
|
||||
case r.URL.Path == "/api/v1/auftraege/plan":
|
||||
schreiben(200, f.plan)
|
||||
case strings.HasSuffix(r.URL.Path, "/ereignisse"):
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
for nr, s := range f.staende {
|
||||
daten, _ := json.Marshal(s)
|
||||
fmt.Fprintf(w, "id: %d\nevent: stand\ndata: %s\n\n: ping\n\n", nr+1, daten)
|
||||
}
|
||||
fmt.Fprint(w, "event: ende\ndata: {}\n\n")
|
||||
case r.URL.Path == "/api/v1/distributionen":
|
||||
schreiben(200, []map[string]any{
|
||||
{"id": "debian-13", "name": "Debian 13 „Trixie“", "familie": "debian", "aktiv": true, "vm": true, "lxc": true},
|
||||
{"id": "ubuntu-26.04", "name": "Ubuntu 26.04 LTS", "familie": "debian", "aktiv": true, "vm": true, "lxc": false},
|
||||
})
|
||||
default:
|
||||
t.Errorf("unerwartete Anfrage %s %s", r.Method, r.URL.Path)
|
||||
schreiben(404, map[string]any{"fehler": map[string]any{"code": "nicht_gefunden", "nachricht": "?"}})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func lauf(t *testing.T, f *fakeAPI, argumente ...string) (string, string, int) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(f.handler(t))
|
||||
t.Cleanup(server.Close)
|
||||
t.Setenv("PROXDEPLOY_CONFIG", filepath.Join(t.TempDir(), "config.toml"))
|
||||
t.Setenv("PROXDEPLOY_URL", server.URL)
|
||||
t.Setenv("PROXDEPLOY_TOKEN", "pd_test")
|
||||
var aus, fehler bytes.Buffer
|
||||
u := &umgebung{aus: &aus, fehler: &fehler, terminal: false, opt: &optionen{}}
|
||||
code := u.ausfuehren(context.Background(), argumente)
|
||||
return aus.String(), fehler.String(), code
|
||||
}
|
||||
|
||||
func TestVmZeilenweiseBisZurBox(t *testing.T) {
|
||||
Version = "1.0"
|
||||
laeuft := append(append([]map[string]any{}, alle[:5]...), phase("provisionierung", "Provisionierung", "LAEUFT", "cloud-init läuft"))
|
||||
f := &fakeAPI{staende: []map[string]any{
|
||||
auftrag("LAEUFT", []map[string]any{phase("pruefungen", "Prüfungen", "LAEUFT", ""), phase("klonen", "Klonen", "OFFEN", "")}, nil),
|
||||
auftrag("LAEUFT", laeuft, nil),
|
||||
auftrag("ERFOLGREICH", alle, map[string]any{"ok": true, "titel": "web01 bereit", "dauer": "1m 54s", "zeilen": [][]string{
|
||||
{"VMID", "120"}, {"IP", "10.0.20.15"}, {"Profile", "base, docker"}, {"SSH", "ssh florian@10.0.20.15"},
|
||||
}}),
|
||||
}}
|
||||
aus, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--profile", "base,docker",
|
||||
"--ip", "10.0.20.15/24", "--vlan", "20", "--disk", "40G", "--var", "zabbix-agent.server=zbx.local")
|
||||
if code != 0 {
|
||||
t.Fatalf("Exit %d, stderr: %s", code, fehler)
|
||||
}
|
||||
golden(t, "vm-zeilenweise.golden", aus)
|
||||
|
||||
// Nur gesetzte Flags gehen hinaus – Storage, Bridge, Benutzer und Schlüssel füllt der Server.
|
||||
gesendet, _ := json.MarshalIndent(f.anfragen["/api/v1/auftraege"][0], "", " ")
|
||||
golden(t, "vm-anfrage.golden", string(gesendet)+"\n")
|
||||
}
|
||||
|
||||
func TestVmFehlgeschlagenExit1UndQuiet(t *testing.T) {
|
||||
Version = "1.0"
|
||||
fehlerPhase := phase("klonen", "Klonen", "FEHLER", "")
|
||||
fehlerPhase["ursache"] = "Storage local-zfs ist voll."
|
||||
fehlerPhase["hinweis"] = "Anderen Storage wählen (--storage)."
|
||||
f := &fakeAPI{staende: []map[string]any{
|
||||
auftrag("FEHLGESCHLAGEN", []map[string]any{alle[0], fehlerPhase, phase("cloudinit", "cloud-init", "OFFEN", "")},
|
||||
map[string]any{"ok": false, "titel": "web01 fehlgeschlagen", "dauer": "12s", "zeilen": [][]string{
|
||||
{"Phase", "Klonen"}, {"Ursache", "Storage local-zfs ist voll."}, {"Hinweis", "Anderen Storage wählen (--storage)."},
|
||||
}}),
|
||||
}}
|
||||
aus, _, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--quiet", "--ascii")
|
||||
if code != ExitFehlschlag {
|
||||
t.Fatalf("Exit %d, erwartet 1", code)
|
||||
}
|
||||
golden(t, "vm-fehler-quiet-ascii.golden", aus)
|
||||
}
|
||||
|
||||
func TestDryRun(t *testing.T) {
|
||||
Version = "1.0"
|
||||
f := &fakeAPI{plan: map[string]any{
|
||||
"kopf": "Server rz1 · Node pve01 · Debian 13 (Template 9013)",
|
||||
"phasen": []map[string]any{alle[0], alle[1], phase("provisionierung", "Provisionierung", "ERLEDIGT", "base, docker")},
|
||||
"aufrufe": []map[string]any{
|
||||
{"methode": "POST", "pfad": "/nodes/pve01/qemu/9013/clone", "parameter": map[string]any{"newid": 120, "full": 1, "name": "web01"}},
|
||||
{"methode": "POST", "pfad": "/nodes/pve01/qemu/120/status/start"},
|
||||
},
|
||||
"skript": nil, "cliBefehl": "proxdeploy vm web01 --server rz1 --distro debian-13", "automatisch": []string{"base"}, "ok": true,
|
||||
}}
|
||||
aus, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--dry-run")
|
||||
if code != 0 {
|
||||
t.Fatalf("Exit %d: %s", code, fehler)
|
||||
}
|
||||
golden(t, "vm-dryrun.golden", aus)
|
||||
if len(f.anfragen["/api/v1/auftraege"]) != 0 {
|
||||
t.Error("Dry-Run hat einen Auftrag angelegt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFehlerUndExitCodes(t *testing.T) {
|
||||
f := &fakeAPI{status: 400, antwort: map[string]any{"fehler": map[string]any{
|
||||
"code": "ungueltig", "nachricht": "Die Eingaben sind nicht vollständig oder ungültig.",
|
||||
"felder": map[string]string{"netz.ip": "Format: 10.0.20.15/24", "variablen.zabbix-agent.server": "Zabbix-Server fehlt."},
|
||||
}}}
|
||||
_, fehler, code := lauf(t, f, "vm", "web01", "--server", "rz1", "--distro", "debian-13", "--ip", "x")
|
||||
if code != ExitBedienung {
|
||||
t.Errorf("Exit %d, erwartet 2", code)
|
||||
}
|
||||
golden(t, "fehler-felder.golden", fehler)
|
||||
|
||||
f = &fakeAPI{status: 401, antwort: map[string]any{"fehler": map[string]any{"code": "nicht_angemeldet", "nachricht": "Nicht angemeldet."}}}
|
||||
if _, _, code := lauf(t, f, "servers"); code != ExitVerbindung {
|
||||
t.Errorf("401: Exit %d, erwartet 3", code)
|
||||
}
|
||||
|
||||
t.Setenv("PROXDEPLOY_URL", "http://127.0.0.1:1")
|
||||
t.Setenv("PROXDEPLOY_TOKEN", "pd_test")
|
||||
var aus, fehlerAus bytes.Buffer
|
||||
u := &umgebung{aus: &aus, fehler: &fehlerAus, opt: &optionen{}}
|
||||
if code := u.ausfuehren(context.Background(), []string{"distros"}); code != ExitVerbindung {
|
||||
t.Errorf("nicht erreichbar: Exit %d, erwartet 3", code)
|
||||
}
|
||||
if code := u.ausfuehren(context.Background(), []string{"vm"}); code != ExitBedienung {
|
||||
t.Errorf("ohne Name: Exit %d, erwartet 2", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistrosTabelleUndJson(t *testing.T) {
|
||||
aus, _, code := lauf(t, &fakeAPI{}, "distros")
|
||||
if code != 0 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
golden(t, "distros.golden", aus)
|
||||
aus, _, _ = lauf(t, &fakeAPI{}, "distros", "--json")
|
||||
var liste []map[string]any
|
||||
if err := json.Unmarshal([]byte(aus), &liste); err != nil || len(liste) != 2 {
|
||||
t.Errorf("--json nicht lesbar: %v\n%s", err, aus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatch(t *testing.T) {
|
||||
Version = "1.0"
|
||||
datei := filepath.Join(t.TempDir(), "gaeste.yaml")
|
||||
if err := os.WriteFile(datei, []byte(`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
|
||||
memory: 4096
|
||||
- name: mon01
|
||||
type: lxc
|
||||
profile: [base, zabbix-agent]
|
||||
vars: { zabbix-agent: { server: zbx.example } }
|
||||
nesting: true
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeAPI{staende: []map[string]any{auftrag("ERFOLGREICH", alle, map[string]any{"ok": true, "titel": "web01 bereit", "dauer": "1m 54s", "zeilen": [][]string{}})}}
|
||||
_, fehler, code := lauf(t, f, "batch", datei, "--quiet")
|
||||
if code != 0 {
|
||||
t.Fatalf("Exit %d: %s", code, fehler)
|
||||
}
|
||||
gesendet, _ := json.MarshalIndent(f.anfragen["/api/v1/auftraege"], "", " ")
|
||||
golden(t, "batch-anfragen.golden", string(gesendet)+"\n")
|
||||
|
||||
falsch := filepath.Join(t.TempDir(), "falsch.yaml")
|
||||
_ = os.WriteFile(falsch, []byte("guests:\n - name: a\n type: vm\n server: rz1\n ram: 4096\n"), 0o644)
|
||||
_, fehler, code = lauf(t, &fakeAPI{}, "batch", falsch)
|
||||
if code != ExitBedienung || !strings.Contains(fehler, `unbekannter Schlüssel "ram"`) {
|
||||
t.Errorf("Exit %d, %s", code, fehler)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginMitToken(t *testing.T) {
|
||||
server := httptest.NewServer((&fakeAPI{}).handler(t))
|
||||
defer server.Close()
|
||||
pfad := filepath.Join(t.TempDir(), "proxdeploy", "config.toml")
|
||||
t.Setenv("PROXDEPLOY_CONFIG", pfad)
|
||||
t.Setenv("PROXDEPLOY_URL", "")
|
||||
t.Setenv("PROXDEPLOY_TOKEN", "")
|
||||
var aus, fehler bytes.Buffer
|
||||
u := &umgebung{aus: &aus, fehler: &fehler, opt: &optionen{}}
|
||||
if code := u.ausfuehren(context.Background(), []string{"login", server.URL, "--token", "pd_test", "--default-server", "rz1"}); code != 0 {
|
||||
t.Fatalf("Exit %d: %s", code, fehler.String())
|
||||
}
|
||||
info, err := os.Stat(pfad)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("Rechte %v, erwartet 0600", info.Mode().Perm())
|
||||
}
|
||||
inhalt, _ := os.ReadFile(pfad)
|
||||
if !strings.Contains(string(inhalt), `token = "pd_test"`) || !strings.Contains(string(inhalt), `server = "rz1"`) {
|
||||
t.Errorf("Konfiguration:\n%s", inhalt)
|
||||
}
|
||||
if !strings.Contains(aus.String(), "Angemeldet als florian") {
|
||||
t.Errorf("Ausgabe: %s", aus.String())
|
||||
}
|
||||
// Ein falsches Token wird nicht gespeichert.
|
||||
aus.Reset()
|
||||
code := u.ausfuehren(context.Background(), []string{"login", server.URL, "--token", "pd_falsch", "--instance", "labor"})
|
||||
if code != ExitVerbindung {
|
||||
t.Errorf("falsches Token: Exit %d", code)
|
||||
}
|
||||
inhalt2, _ := os.ReadFile(pfad)
|
||||
if string(inhalt2) != string(inhalt) {
|
||||
t.Error("falsches Token hat die Konfiguration verändert")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package befehle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
|
||||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe"
|
||||
)
|
||||
|
||||
var endzustaende = map[api.AuftragStatus]bool{
|
||||
api.AuftragStatusERFOLGREICH: true,
|
||||
api.AuftragStatusFEHLGESCHLAGEN: true,
|
||||
api.AuftragStatusABGEBROCHEN: true,
|
||||
api.AuftragStatusAUFGERAEUMT: true,
|
||||
}
|
||||
|
||||
func wert(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func zeitAus(p *string) (time.Time, bool) {
|
||||
if p == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, *p)
|
||||
return t, err == nil
|
||||
}
|
||||
|
||||
// phasenAus übernimmt die Phasen der API unverändert; nur die Laufzeit rechnet die CLI selbst.
|
||||
func phasenAus(a api.Auftrag, jetzt time.Time) []ausgabe.Phase {
|
||||
liste := make([]ausgabe.Phase, 0, len(a.Phasen))
|
||||
for _, p := range a.Phasen {
|
||||
phase := ausgabe.Phase{
|
||||
Titel: p.Titel,
|
||||
Status: string(p.Status),
|
||||
Detail: wert(p.Detail),
|
||||
Ursache: wert(p.Ursache),
|
||||
Hinweis: wert(p.Hinweis),
|
||||
}
|
||||
if seit, ok := zeitAus(p.GestartetAm); ok && p.Status == api.PhaseStatusLAEUFT {
|
||||
phase.Laufzeit = jetzt.Sub(seit)
|
||||
}
|
||||
liste = append(liste, phase)
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
func boxAus(a *api.Abschluss) ausgabe.Box {
|
||||
box := ausgabe.Box{Ok: a.Ok, Titel: a.Titel, Dauer: wert(a.Dauer)}
|
||||
for _, z := range a.Zeilen {
|
||||
if len(z) == 2 {
|
||||
box.Zeilen = append(box.Zeilen, [2]string{z[0], z[1]})
|
||||
}
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
// darsteller zeichnet einen Auftrag: live im Terminal (an Ort und Stelle neu), sonst zeilenweise.
|
||||
type darsteller struct {
|
||||
u *umgebung
|
||||
s ausgabe.Stil
|
||||
live bool
|
||||
gezeichnet int // Bildschirmzeilen der letzten Zeichnung (live)
|
||||
kopf bool
|
||||
gedruckt int // Anzahl schon gedruckter Phasen (zeilenweise)
|
||||
takt int
|
||||
}
|
||||
|
||||
func (u *umgebung) neuerDarsteller() *darsteller {
|
||||
return &darsteller{u: u, s: u.stil(), live: u.terminal && !u.opt.still && !u.opt.json}
|
||||
}
|
||||
|
||||
func (d *darsteller) bildschirmzeilen(zeilen []string) int {
|
||||
spalten := 0
|
||||
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil {
|
||||
spalten = w
|
||||
}
|
||||
n := 0
|
||||
for _, z := range zeilen {
|
||||
breite := runewidth.StringWidth(ohneFarben(z))
|
||||
if spalten > 0 && breite > spalten {
|
||||
n += (breite + spalten - 1) / spalten
|
||||
} else {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func ohneFarben(text string) string {
|
||||
var b strings.Builder
|
||||
in := false
|
||||
for _, r := range text {
|
||||
switch {
|
||||
case r == '\x1b':
|
||||
in = true
|
||||
case in && r == 'm':
|
||||
in = false
|
||||
case !in:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// zeichnen gibt den Stand aus. `fertig`: der Auftrag ist beendet, die Box kommt dazu.
|
||||
func (d *darsteller) zeichnen(a api.Auftrag, fertig bool) {
|
||||
if d.u.opt.json {
|
||||
return
|
||||
}
|
||||
phasen := phasenAus(a, time.Now())
|
||||
if d.u.opt.still {
|
||||
if fertig && a.Abschluss != nil {
|
||||
fmt.Fprintln(d.u.aus, strings.Join(d.s.BoxZeilen(boxAus(a.Abschluss)), "\n"))
|
||||
}
|
||||
return
|
||||
}
|
||||
breite := ausgabe.Spaltenbreite(phasen)
|
||||
|
||||
if d.live {
|
||||
sichtbar := make([]ausgabe.Phase, 0, len(phasen))
|
||||
for _, p := range phasen {
|
||||
if p.Status != "OFFEN" || fertig {
|
||||
sichtbar = append(sichtbar, p)
|
||||
}
|
||||
}
|
||||
zeilen := []string{d.s.Kopf(Version, a.Kopf, false), ""}
|
||||
zeilen = append(zeilen, d.s.PhasenZeilenMit(sichtbar, breite, d.takt)...)
|
||||
if fertig && a.Abschluss != nil {
|
||||
zeilen = append(zeilen, "")
|
||||
zeilen = append(zeilen, d.s.BoxZeilen(boxAus(a.Abschluss))...)
|
||||
}
|
||||
var b strings.Builder
|
||||
if d.gezeichnet > 0 {
|
||||
fmt.Fprintf(&b, "\x1b[%dF\x1b[J", d.gezeichnet)
|
||||
}
|
||||
b.WriteString(strings.Join(zeilen, "\n"))
|
||||
b.WriteString("\n")
|
||||
fmt.Fprint(d.u.aus, b.String())
|
||||
d.gezeichnet = d.bildschirmzeilen(zeilen)
|
||||
return
|
||||
}
|
||||
|
||||
// Zeilenweise (Pipe, Datei, CI): jede Phase einmal, sobald sie abgeschlossen ist. Die Kopfzeile
|
||||
// kommt nach der ersten Phase – dann stehen Node und Template fest.
|
||||
abgeschlossen := 0
|
||||
for _, p := range phasen {
|
||||
if p.Status == "OFFEN" || p.Status == "LAEUFT" {
|
||||
break
|
||||
}
|
||||
abgeschlossen++
|
||||
}
|
||||
if !d.kopf && (abgeschlossen > 0 || fertig) {
|
||||
fmt.Fprintln(d.u.aus, d.s.Kopf(Version, a.Kopf, false))
|
||||
fmt.Fprintln(d.u.aus)
|
||||
d.kopf = true
|
||||
}
|
||||
if abgeschlossen > d.gedruckt {
|
||||
for _, z := range d.s.PhasenZeilenMit(phasen[d.gedruckt:abgeschlossen], breite, 0) {
|
||||
fmt.Fprintln(d.u.aus, z)
|
||||
}
|
||||
d.gedruckt = abgeschlossen
|
||||
}
|
||||
if fertig {
|
||||
if rest := phasen[d.gedruckt:]; len(rest) > 0 {
|
||||
for _, z := range d.s.PhasenZeilenMit(rest, breite, 0) {
|
||||
fmt.Fprintln(d.u.aus, z)
|
||||
}
|
||||
}
|
||||
if a.Abschluss != nil {
|
||||
fmt.Fprintln(d.u.aus)
|
||||
fmt.Fprintln(d.u.aus, strings.Join(d.s.BoxZeilen(boxAus(a.Abschluss)), "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// folgen hängt sich an einen Auftrag, bis er fertig ist. Fehlgeschlagen → Exit 1.
|
||||
func (u *umgebung) folgen(ctx context.Context, c *api.Client, id string) (api.Auftrag, error) {
|
||||
d := u.neuerDarsteller()
|
||||
staende := make(chan api.Auftrag, 16)
|
||||
stromFehler := make(chan error, 1)
|
||||
strCtx, stopp := context.WithCancel(ctx)
|
||||
defer stopp()
|
||||
|
||||
go func() {
|
||||
defer close(staende)
|
||||
stromFehler <- u.stroemen(strCtx, c, id, staende)
|
||||
}()
|
||||
|
||||
var letzter *api.Auftrag
|
||||
uhr := time.NewTicker(150 * time.Millisecond)
|
||||
defer uhr.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if d.live && letzter != nil {
|
||||
d.zeichnen(*letzter, false)
|
||||
}
|
||||
fmt.Fprintln(u.fehler, d.s.Muted(fmt.Sprintf("\nAbgehängt – der Auftrag läuft weiter: proxdeploy status %s", id)))
|
||||
return api.Auftrag{}, &exitFehler{code: 130, gemeldet: true}
|
||||
case a, offen := <-staende:
|
||||
if !offen {
|
||||
if err := <-stromFehler; err != nil {
|
||||
return api.Auftrag{}, err
|
||||
}
|
||||
if letzter == nil {
|
||||
return api.Auftrag{}, errors.New("kein Stand erhalten")
|
||||
}
|
||||
return u.abschliessen(d, *letzter)
|
||||
}
|
||||
letzter = &a
|
||||
if endzustaende[a.Status] {
|
||||
return u.abschliessen(d, a)
|
||||
}
|
||||
d.zeichnen(a, false)
|
||||
case <-uhr.C:
|
||||
if d.live && letzter != nil {
|
||||
d.takt++
|
||||
d.zeichnen(*letzter, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *umgebung) abschliessen(d *darsteller, a api.Auftrag) (api.Auftrag, error) {
|
||||
d.zeichnen(a, true)
|
||||
if u.opt.json {
|
||||
if err := u.jsonAusgeben(a); err != nil {
|
||||
return a, err
|
||||
}
|
||||
}
|
||||
if a.Status != api.AuftragStatusERFOLGREICH {
|
||||
return a, &exitFehler{code: ExitFehlschlag, gemeldet: true}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// stroemen liefert Stände per SSE; bricht der Strom ab, wird mit der letzten Event-ID neu
|
||||
// verbunden. Klappt SSE dreimal hintereinander nicht (z. B. ein puffernder Proxy), wird alle 2 s
|
||||
// abgefragt.
|
||||
func (u *umgebung) stroemen(ctx context.Context, c *api.Client, id string, staende chan<- api.Auftrag) error {
|
||||
letzteID := ""
|
||||
fehlschlaege := 0
|
||||
for fehlschlaege < 3 {
|
||||
erhalten := false
|
||||
err := c.Ereignisse(ctx, id, letzteID, func(e api.Ereignis) error {
|
||||
if e.ID != "" {
|
||||
letzteID = e.ID
|
||||
}
|
||||
switch e.Art {
|
||||
case "stand":
|
||||
var a api.Auftrag
|
||||
if err := json.Unmarshal(e.Daten, &a); err != nil {
|
||||
return fmt.Errorf("Stand nicht lesbar: %w", err)
|
||||
}
|
||||
erhalten = true
|
||||
fehlschlaege = 0
|
||||
select {
|
||||
case staende <- a:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
case "ende":
|
||||
return api.Ende
|
||||
}
|
||||
return nil
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, api.Ende):
|
||||
return nil
|
||||
case ctx.Err() != nil:
|
||||
return nil
|
||||
case api.IstAnmeldefehler(err):
|
||||
return err
|
||||
}
|
||||
var apiFehler *api.Fehler
|
||||
if errors.As(err, &apiFehler) {
|
||||
return err
|
||||
}
|
||||
if !erhalten {
|
||||
fehlschlaege++
|
||||
}
|
||||
select {
|
||||
case <-time.After(time.Duration(fehlschlaege+1) * time.Second):
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
a, err := c.Auftrag(ctx, id)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
var verbindung *api.VerbindungsFehler
|
||||
if !errors.As(err, &verbindung) {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case staende <- a:
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
if endzustaende[a.Status] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-time.After(2 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package befehle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe"
|
||||
)
|
||||
|
||||
// tabelle setzt Spalten bündig; die Kopfzeile in Muted.
|
||||
func (u *umgebung) tabelle(s ausgabe.Stil, kopf []string, zeilen [][]string) {
|
||||
breiten := make([]int, len(kopf))
|
||||
for i, k := range kopf {
|
||||
breiten[i] = runewidth.StringWidth(k)
|
||||
}
|
||||
for _, z := range zeilen {
|
||||
for i, zelle := range z {
|
||||
if w := runewidth.StringWidth(zelle); w > breiten[i] {
|
||||
breiten[i] = w
|
||||
}
|
||||
}
|
||||
}
|
||||
setzen := func(z []string, farbe func(string) string) string {
|
||||
var b strings.Builder
|
||||
for i, zelle := range z {
|
||||
if i == len(z)-1 {
|
||||
b.WriteString(farbe(zelle))
|
||||
break
|
||||
}
|
||||
b.WriteString(farbe(zelle + strings.Repeat(" ", breiten[i]-runewidth.StringWidth(zelle)+2)))
|
||||
}
|
||||
return strings.TrimRight(b.String(), " ")
|
||||
}
|
||||
fmt.Fprintln(u.aus, setzen(kopf, s.Muted))
|
||||
for _, z := range zeilen {
|
||||
fmt.Fprintln(u.aus, setzen(z, s.Paper))
|
||||
}
|
||||
}
|
||||
|
||||
func janein(wert bool) string {
|
||||
if wert {
|
||||
return "ja"
|
||||
}
|
||||
return "–"
|
||||
}
|
||||
|
||||
func (u *umgebung) serversBefehl() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "servers",
|
||||
Aliases: []string{"server"},
|
||||
Short: "Proxmox-Server mit dem letzten Verbindungstest",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
c, _, err := u.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
liste, err := c.ServerListe(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.opt.json {
|
||||
return u.jsonAusgeben(liste)
|
||||
}
|
||||
s := u.stil()
|
||||
zeilen := [][]string{}
|
||||
for _, sv := range liste {
|
||||
status := "○ ungeprüft"
|
||||
if sv.Erreichbar != nil {
|
||||
status = map[bool]string{true: "✓ erreichbar", false: "✗ nicht erreichbar"}[*sv.Erreichbar]
|
||||
}
|
||||
if s.ASCII {
|
||||
status = strings.NewReplacer("✓", "[OK]", "✗", "[!!]", "○", "[--]").Replace(status)
|
||||
}
|
||||
version, nodes := "–", "–"
|
||||
if sv.PveVersion != nil {
|
||||
version = *sv.PveVersion
|
||||
}
|
||||
if sv.LetzterTest != nil {
|
||||
namen := []string{}
|
||||
for _, k := range sv.LetzterTest.Knoten {
|
||||
namen = append(namen, k.Name)
|
||||
}
|
||||
nodes = strings.Join(namen, ", ")
|
||||
if sv.LetzterTest.Cluster != nil {
|
||||
nodes = sv.LetzterTest.Cluster.Name + ": " + nodes
|
||||
}
|
||||
}
|
||||
zeilen = append(zeilen, []string{sv.Name, sv.Titel, status, version, nodes})
|
||||
}
|
||||
if len(zeilen) == 0 {
|
||||
fmt.Fprintln(u.aus, s.Muted("Noch keine Server – in der Web-GUI unter „Server“ anlegen."))
|
||||
return nil
|
||||
}
|
||||
u.tabelle(s, []string{"NAME", "TITEL", "STATUS", "PVE", "NODES"}, zeilen)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (u *umgebung) distrosBefehl() *cobra.Command {
|
||||
var typ string
|
||||
befehl := &cobra.Command{
|
||||
Use: "distros",
|
||||
Short: "Verfügbare Distributionen",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if typ != "" && typ != "vm" && typ != "lxc" {
|
||||
return bedienfehler("--type ist vm oder lxc")
|
||||
}
|
||||
c, _, err := u.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
liste, err := c.DistributionenListe(cmd.Context(), typ)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.opt.json {
|
||||
return u.jsonAusgeben(liste)
|
||||
}
|
||||
zeilen := [][]string{}
|
||||
for _, d := range liste {
|
||||
zeilen = append(zeilen, []string{d.Id, d.Name, d.Familie, janein(d.Vm), janein(d.Lxc)})
|
||||
}
|
||||
u.tabelle(u.stil(), []string{"ID", "NAME", "FAMILIE", "VM", "LXC"}, zeilen)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
befehl.Flags().StringVar(&typ, "type", "", "nur vm oder lxc")
|
||||
return befehl
|
||||
}
|
||||
|
||||
func (u *umgebung) profilesBefehl() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "profiles",
|
||||
Short: "Profile und ihre Variablen (für --profile und --var)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
c, _, err := u.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
liste, err := c.ProfileListe(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.opt.json {
|
||||
return u.jsonAusgeben(liste)
|
||||
}
|
||||
zeilen := [][]string{}
|
||||
for _, p := range liste {
|
||||
variablen := []string{}
|
||||
if p.Definition.Variables != nil {
|
||||
for _, v := range *p.Definition.Variables {
|
||||
name := p.Id + "." + v.Name
|
||||
if v.Required {
|
||||
name += "*"
|
||||
}
|
||||
variablen = append(variablen, name)
|
||||
}
|
||||
}
|
||||
text := strings.Join(variablen, " ")
|
||||
if text == "" {
|
||||
text = "–"
|
||||
}
|
||||
zeilen = append(zeilen, []string{p.Id, p.Name, strings.Join(p.Familien, ","), text})
|
||||
}
|
||||
u.tabelle(u.stil(), []string{"ID", "NAME", "FAMILIEN", "VARIABLEN (* = Pflicht)"}, zeilen)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (u *umgebung) presetsBefehl() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "presets",
|
||||
Aliases: []string{"vorlagen"},
|
||||
Short: "Vorlagen (für --preset)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
c, _, err := u.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
liste, err := c.VorlagenListe(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.opt.json {
|
||||
return u.jsonAusgeben(liste)
|
||||
}
|
||||
zeilen := [][]string{}
|
||||
for _, v := range liste {
|
||||
beschreibung := "–"
|
||||
if v.Beschreibung != nil && *v.Beschreibung != "" {
|
||||
beschreibung = *v.Beschreibung
|
||||
}
|
||||
zeilen = append(zeilen, []string{v.Name, v.Titel, beschreibung})
|
||||
}
|
||||
u.tabelle(u.stil(), []string{"NAME", "TITEL", "BESCHREIBUNG"}, zeilen)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package befehle
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
|
||||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/konfig"
|
||||
)
|
||||
|
||||
func (u *umgebung) loginBefehl() *cobra.Command {
|
||||
var token, benutzer, name, server string
|
||||
var tage int
|
||||
befehl := &cobra.Command{
|
||||
Use: "login <url>",
|
||||
Short: "An einer ProxDeploy-Instanz anmelden und das API-Token speichern",
|
||||
Long: "Fragt Benutzername und Passwort ab, legt ein API-Token an und speichert es in\n" +
|
||||
"~/.config/proxdeploy/config.toml (Rechte 0600). Mit --token wird ein vorhandenes\n" +
|
||||
"Token (Web-GUI → Einstellungen → API-Tokens) geprüft und gespeichert.",
|
||||
Example: " proxdeploy login https://proxdeploy.example\n proxdeploy login https://proxdeploy.labor --instance labor --token pd_…",
|
||||
Args: func(_ *cobra.Command, argumente []string) error {
|
||||
if len(argumente) != 1 {
|
||||
return bedienfehler("URL fehlt: proxdeploy login <url>")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, argumente []string) error {
|
||||
adresse := strings.TrimRight(strings.TrimSpace(argumente[0]), "/")
|
||||
if !strings.Contains(adresse, "://") {
|
||||
adresse = "https://" + adresse
|
||||
}
|
||||
geprueft, err := url.Parse(adresse)
|
||||
if err != nil || geprueft.Host == "" || (geprueft.Scheme != "https" && geprueft.Scheme != "http") {
|
||||
return bedienfehler("keine gültige URL: %s", argumente[0])
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
c := api.Neu(adresse, "", Version)
|
||||
|
||||
if token == "" {
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
return bedienfehler("ohne Terminal kein Passwort-Dialog – --token angeben")
|
||||
}
|
||||
leser := bufio.NewReader(os.Stdin)
|
||||
if benutzer == "" {
|
||||
fmt.Fprint(u.fehler, "Benutzername: ")
|
||||
zeile, err := leser.ReadString('\n')
|
||||
if err != nil {
|
||||
return bedienfehler("Eingabe abgebrochen")
|
||||
}
|
||||
benutzer = strings.TrimSpace(zeile)
|
||||
}
|
||||
fmt.Fprint(u.fehler, "Passwort: ")
|
||||
passwort, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(u.fehler)
|
||||
if err != nil {
|
||||
return bedienfehler("Eingabe abgebrochen")
|
||||
}
|
||||
if name == "" {
|
||||
rechner, _ := os.Hostname()
|
||||
name = "cli@" + rechner
|
||||
}
|
||||
anmeldung := api.TokenAnmeldung{Benutzername: benutzer, Passwort: string(passwort), Name: name}
|
||||
if tage > 0 {
|
||||
anmeldung.Tage = &tage
|
||||
}
|
||||
neu, err := c.TokenAnmelden(ctx, anmeldung)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token = neu.Token
|
||||
}
|
||||
|
||||
c.Token = token
|
||||
ich, err := c.Ich(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
datei, pfad, err := konfig.Lesen()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instanz := konfig.Instanz{URL: adresse, Token: token, Server: server}
|
||||
if alt, ok := datei.Instanzen[u.opt.instanz]; ok && server == "" {
|
||||
instanz.Server = alt.Server
|
||||
} else if u.opt.instanz == "" && server == "" {
|
||||
instanz.Server = datei.Server
|
||||
}
|
||||
datei.Setzen(u.opt.instanz, instanz)
|
||||
if err := konfig.Schreiben(pfad, datei); err != nil {
|
||||
return fmt.Errorf("Konfiguration nicht geschrieben: %w", err)
|
||||
}
|
||||
if u.opt.json {
|
||||
return u.jsonAusgeben(map[string]any{"benutzername": ich.Benutzername, "rolle": ich.Rolle, "version": ich.Version, "konfiguration": pfad})
|
||||
}
|
||||
s := u.stil()
|
||||
symbol := "✓"
|
||||
if s.ASCII {
|
||||
symbol = "[OK]"
|
||||
}
|
||||
rolle := map[api.IchRolle]string{api.ADMIN: "Admin", api.OPERATOR: "Operator"}[ich.Rolle]
|
||||
fmt.Fprintf(u.aus, "%s Angemeldet als %s %s\n", s.Teal(symbol), s.Fett(ich.Benutzername),
|
||||
s.Muted(fmt.Sprintf("(%s) · ProxDeploy v%s · Token in %s", rolle, ich.Version, pfad)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := befehl.Flags()
|
||||
f.StringVar(&token, "token", "", "vorhandenes API-Token (pd_…) statt Passwort")
|
||||
f.StringVar(&benutzer, "user", "", "Benutzername (sonst Abfrage)")
|
||||
f.StringVar(&name, "name", "", "Name des Tokens (Standard: cli@<rechner>)")
|
||||
f.IntVar(&tage, "days", 0, "Token läuft nach so vielen Tagen ab (Standard: unbefristet)")
|
||||
f.StringVar(&server, "default-server", "", "Standard für --server speichern")
|
||||
return befehl
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package befehle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func (u *umgebung) templateBefehl() *cobra.Command {
|
||||
gruppe := &cobra.Command{Use: "template", Short: "Templates ansehen und bauen"}
|
||||
|
||||
var listeServer string
|
||||
liste := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "ProxDeploy-Templates eines Servers",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
c, instanz, err := u.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if listeServer == "" {
|
||||
listeServer = instanz.Server
|
||||
}
|
||||
if listeServer == "" {
|
||||
return bedienfehler("--server fehlt")
|
||||
}
|
||||
templates, err := c.TemplatesListe(cmd.Context(), listeServer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.opt.json {
|
||||
return u.jsonAusgeben(templates)
|
||||
}
|
||||
zeilen := [][]string{}
|
||||
for _, t := range templates {
|
||||
profile := "–"
|
||||
if len(t.Profile) > 0 {
|
||||
profile = strings.Join(t.Profile, ", ")
|
||||
}
|
||||
zeilen = append(zeilen, []string{fmt.Sprint(t.Vmid), t.Name, t.Knoten, wert(t.DistributionId), profile})
|
||||
}
|
||||
u.tabelle(u.stil(), []string{"VMID", "NAME", "NODE", "DISTRIBUTION", "PROFILE"}, zeilen)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
liste.Flags().StringVar(&listeServer, "server", "", "Proxmox-Server")
|
||||
|
||||
var server, distro, knoten, storage, bridge string
|
||||
var vlan int
|
||||
var profile []string
|
||||
var nichtWarten bool
|
||||
bauen := &cobra.Command{
|
||||
Use: "build",
|
||||
Short: "Template aus dem Cloud-Image bauen",
|
||||
Example: " proxdeploy template build --server rz1 --distro debian-13\n proxdeploy template build --server rz1 --distro debian-13 --profile base,docker",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
c, instanz, err := u.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if server == "" {
|
||||
server = instanz.Server
|
||||
}
|
||||
if server == "" {
|
||||
return bedienfehler("--server fehlt")
|
||||
}
|
||||
if distro == "" {
|
||||
return bedienfehler("--distro fehlt (siehe `proxdeploy distros --type vm`)")
|
||||
}
|
||||
anfrage := map[string]any{"distribution": distro}
|
||||
gesetzt := cmd.Flags().Changed
|
||||
if gesetzt("node") {
|
||||
anfrage["knoten"] = knoten
|
||||
}
|
||||
if gesetzt("storage") {
|
||||
anfrage["storage"] = storage
|
||||
}
|
||||
if gesetzt("bridge") {
|
||||
anfrage["bridge"] = bridge
|
||||
}
|
||||
if gesetzt("vlan") {
|
||||
anfrage["vlan"] = vlan
|
||||
}
|
||||
if gesetzt("profile") {
|
||||
anfrage["profile"] = profile
|
||||
}
|
||||
angenommen, err := c.TemplateBauen(cmd.Context(), server, anfrage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.angenommen(cmd.Context(), c, angenommen, nichtWarten)
|
||||
},
|
||||
}
|
||||
f := bauen.Flags()
|
||||
f.StringVar(&server, "server", "", "Proxmox-Server")
|
||||
f.StringVar(&distro, "distro", "", "Distribution, z. B. debian-13")
|
||||
f.StringVar(&knoten, "node", "", "Node (Standard: aus den Server-Einstellungen bzw. der erste)")
|
||||
f.StringVar(&storage, "storage", "", "Storage für die Disk (Standard: aus den Server-Einstellungen)")
|
||||
f.StringVar(&bridge, "bridge", "", "Bridge der Vorbereitungs-VM (Standard: vmbr0)")
|
||||
f.IntVar(&vlan, "vlan", 0, "VLAN der Vorbereitungs-VM")
|
||||
f.StringSliceVar(&profile, "profile", nil, "Profile einbacken, z. B. base,docker")
|
||||
f.BoolVar(&nichtWarten, "no-wait", false, "nicht verfolgen, nur die Auftrags-ID ausgeben")
|
||||
|
||||
gruppe.AddCommand(liste, bauen)
|
||||
return gruppe
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
[
|
||||
{
|
||||
"distribution": "debian-13",
|
||||
"name": "web01",
|
||||
"netz": {
|
||||
"gateway": "10.0.20.1",
|
||||
"ip": "10.0.20.15/24",
|
||||
"modus": "statisch"
|
||||
},
|
||||
"profile": [
|
||||
"base",
|
||||
"docker"
|
||||
],
|
||||
"server": "rz1",
|
||||
"speicherMb": 4096,
|
||||
"typ": "vm"
|
||||
},
|
||||
{
|
||||
"distribution": "debian-13",
|
||||
"features": {
|
||||
"nesting": true
|
||||
},
|
||||
"name": "mon01",
|
||||
"profile": [
|
||||
"base",
|
||||
"zabbix-agent"
|
||||
],
|
||||
"server": "rz1",
|
||||
"typ": "lxc",
|
||||
"variablen": {
|
||||
"zabbix-agent": {
|
||||
"server": "zbx.example"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
ID NAME FAMILIE VM LXC
|
||||
debian-13 Debian 13 „Trixie“ debian ja ja
|
||||
ubuntu-26.04 Ubuntu 26.04 LTS debian ja –
|
||||
@@ -0,0 +1,3 @@
|
||||
✗ Die Eingaben sind nicht vollständig oder ungültig.
|
||||
--ip: Format: 10.0.20.15/24
|
||||
--var zabbix-agent.server: Zabbix-Server fehlt.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"diskGb": 40,
|
||||
"distribution": "debian-13",
|
||||
"name": "web01",
|
||||
"netz": {
|
||||
"ip": "10.0.20.15/24",
|
||||
"modus": "statisch",
|
||||
"vlan": 20
|
||||
},
|
||||
"profile": [
|
||||
"base",
|
||||
"docker"
|
||||
],
|
||||
"server": "rz1",
|
||||
"typ": "vm",
|
||||
"variablen": {
|
||||
"zabbix-agent": {
|
||||
"server": "zbx.local"
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013) · DRY-RUN
|
||||
|
||||
✓ Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
|
||||
✓ Klonen full clone 9013 → 120 · Disk auf 40G
|
||||
✓ Provisionierung base, docker
|
||||
|
||||
→ POST /nodes/pve01/qemu/9013/clone full=1 name=web01 newid=120
|
||||
→ POST /nodes/pve01/qemu/120/status/start
|
||||
|
||||
Automatisch dazu: base
|
||||
|
||||
Nichts angelegt. Ohne --dry-run wird ausgerollt.
|
||||
@@ -0,0 +1,6 @@
|
||||
+------------------------------------------------+
|
||||
| [!!] web01 fehlgeschlagen · 12s |
|
||||
| Phase Klonen |
|
||||
| Ursache Storage local-zfs ist voll. |
|
||||
| Hinweis Anderen Storage wählen (--storage). |
|
||||
+------------------------------------------------+
|
||||
@@ -0,0 +1,16 @@
|
||||
⬡ ProxDeploy v1.0 · Server rz1 · Node pve01 · Debian 13 (Template 9013)
|
||||
|
||||
✓ Prüfungen VMID 120 frei · local-zfs · vmbr0 VLAN 20
|
||||
✓ Klonen full clone 9013 → 120 · Disk auf 40G
|
||||
✓ cloud-init user florian · SSH-Key · Profile base, docker
|
||||
✓ Start VM 120 läuft
|
||||
✓ Guest-Agent antwortet nach 18s
|
||||
✓ Provisionierung base, docker
|
||||
|
||||
┌──────────────────────────────────────┐
|
||||
│ ● web01 bereit · 1m 54s │
|
||||
│ VMID 120 │
|
||||
│ IP 10.0.20.15 │
|
||||
│ Profile base, docker │
|
||||
│ SSH ssh florian@10.0.20.15 │
|
||||
└──────────────────────────────────────┘
|
||||
@@ -0,0 +1,314 @@
|
||||
// Package befehle enthält die Befehle der CLI `proxdeploy`.
|
||||
package befehle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
|
||||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe"
|
||||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/konfig"
|
||||
)
|
||||
|
||||
// Exit-Codes (docs/architektur.md, Abschnitt CLI).
|
||||
const (
|
||||
ExitOk = 0
|
||||
ExitFehlschlag = 1 // Auftrag fehlgeschlagen oder abgebrochen
|
||||
ExitBedienung = 2 // falscher Aufruf, ungültige Eingaben
|
||||
ExitVerbindung = 3 // nicht erreichbar, nicht angemeldet
|
||||
)
|
||||
|
||||
// Version wird beim Bauen gesetzt (-ldflags "-X …/befehle.Version=0.4.0").
|
||||
var Version = "0.0.0-dev"
|
||||
|
||||
// Optionen, die für alle Befehle gelten.
|
||||
type optionen struct {
|
||||
keineFarbe bool
|
||||
ascii bool
|
||||
still bool
|
||||
json bool
|
||||
instanz string
|
||||
}
|
||||
|
||||
// Umgebung eines Laufs – für Tests austauschbar.
|
||||
type umgebung struct {
|
||||
aus, fehler io.Writer
|
||||
terminal bool
|
||||
opt *optionen
|
||||
}
|
||||
|
||||
func (u *umgebung) stil() ausgabe.Stil {
|
||||
return ausgabe.Stil{Farbe: ausgabe.FarbmodusErmitteln(u.opt.keineFarbe, u.terminal), ASCII: u.opt.ascii}
|
||||
}
|
||||
|
||||
// exitFehler trägt den Exit-Code; die Meldung ist schon ausgegeben, wenn `gemeldet` gesetzt ist.
|
||||
type exitFehler struct {
|
||||
code int
|
||||
gemeldet bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *exitFehler) Error() string {
|
||||
if e.err != nil {
|
||||
return e.err.Error()
|
||||
}
|
||||
return fmt.Sprintf("Exit %d", e.code)
|
||||
}
|
||||
|
||||
func bedienfehler(format string, argumente ...any) error {
|
||||
return &exitFehler{code: ExitBedienung, err: fmt.Errorf(format, argumente...)}
|
||||
}
|
||||
|
||||
// Ausfuehren ist der Einstieg aus main.
|
||||
func Ausfuehren() int {
|
||||
u := &umgebung{
|
||||
aus: os.Stdout,
|
||||
fehler: os.Stderr,
|
||||
terminal: term.IsTerminal(int(os.Stdout.Fd())),
|
||||
opt: &optionen{},
|
||||
}
|
||||
ctx, stopp := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stopp()
|
||||
return u.ausfuehren(ctx, os.Args[1:])
|
||||
}
|
||||
|
||||
func (u *umgebung) ausfuehren(ctx context.Context, argumente []string) int {
|
||||
wurzel := u.wurzel()
|
||||
wurzel.SetArgs(argumente)
|
||||
wurzel.SetOut(u.aus)
|
||||
wurzel.SetErr(u.fehler)
|
||||
err := wurzel.ExecuteContext(ctx)
|
||||
if err == nil {
|
||||
return ExitOk
|
||||
}
|
||||
return u.fehlerMelden(err)
|
||||
}
|
||||
|
||||
func (u *umgebung) wurzel() *cobra.Command {
|
||||
wurzel := &cobra.Command{
|
||||
Use: "proxdeploy",
|
||||
Short: "VMs und LXC-Container auf Proxmox VE ausrollen",
|
||||
Long: "ProxDeploy – VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.\nEin unabhängiges Werkzeug, nicht von Proxmox Server Solutions.",
|
||||
Version: Version,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
wurzel.CompletionOptions.HiddenDefaultCmd = true
|
||||
wurzel.SetUsageTemplate(hilfeVorlage)
|
||||
wurzel.SetVersionTemplate("proxdeploy {{.Version}}\n")
|
||||
wurzel.InitDefaultVersionFlag()
|
||||
wurzel.Flags().Lookup("version").Usage = "Version zeigen"
|
||||
wurzel.SetHelpCommand(&cobra.Command{Use: "help [befehl]", Short: "Hilfe zu einem Befehl", Hidden: true,
|
||||
RunE: func(c *cobra.Command, argumente []string) error {
|
||||
ziel, _, err := c.Root().Find(argumente)
|
||||
if err != nil || ziel == nil {
|
||||
ziel = c.Root()
|
||||
}
|
||||
return ziel.Help()
|
||||
}})
|
||||
wurzel.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
|
||||
return &exitFehler{code: ExitBedienung, err: flagFehlerUebersetzen(err)}
|
||||
})
|
||||
f := wurzel.PersistentFlags()
|
||||
f.BoolVar(&u.opt.keineFarbe, "no-color", false, "ohne Farben (auch mit NO_COLOR)")
|
||||
f.BoolVar(&u.opt.ascii, "ascii", false, "Symbole als [OK], [..], [!!]")
|
||||
f.BoolVarP(&u.opt.still, "quiet", "q", false, "nur Abschlussbox bzw. Fehler")
|
||||
f.BoolVar(&u.opt.json, "json", false, "maschinenlesbare Ausgabe")
|
||||
f.StringVar(&u.opt.instanz, "instance", "", "Instanz aus der Konfiguration ([instances.<name>])")
|
||||
|
||||
wurzel.AddCommand(
|
||||
u.loginBefehl(),
|
||||
u.serversBefehl(),
|
||||
u.distrosBefehl(),
|
||||
u.profilesBefehl(),
|
||||
u.presetsBefehl(),
|
||||
u.templateBefehl(),
|
||||
u.ausrollBefehl("vm"),
|
||||
u.ausrollBefehl("lxc"),
|
||||
u.batchBefehl(),
|
||||
u.statusBefehl(),
|
||||
u.logsBefehl(),
|
||||
u.cancelBefehl(),
|
||||
)
|
||||
hilfeEindeutschen(wurzel)
|
||||
return wurzel
|
||||
}
|
||||
|
||||
func hilfeEindeutschen(c *cobra.Command) {
|
||||
c.InitDefaultHelpFlag()
|
||||
if f := c.Flags().Lookup("help"); f != nil {
|
||||
f.Usage = "Hilfe zu " + c.CommandPath()
|
||||
}
|
||||
for _, unter := range c.Commands() {
|
||||
hilfeEindeutschen(unter)
|
||||
}
|
||||
}
|
||||
|
||||
// client baut den API-Client aus Konfiguration und Umgebung.
|
||||
func (u *umgebung) client() (*api.Client, konfig.Instanz, error) {
|
||||
datei, pfad, err := konfig.Lesen()
|
||||
if err != nil {
|
||||
return nil, konfig.Instanz{}, &exitFehler{code: ExitBedienung, err: err}
|
||||
}
|
||||
instanz, err := datei.Waehlen(u.opt.instanz)
|
||||
if err != nil {
|
||||
return nil, konfig.Instanz{}, &exitFehler{code: ExitBedienung, err: err}
|
||||
}
|
||||
if instanz.URL == "" || instanz.Token == "" {
|
||||
return nil, instanz, &exitFehler{
|
||||
code: ExitVerbindung,
|
||||
err: fmt.Errorf("nicht angemeldet – zuerst `proxdeploy login <url>` (Konfiguration: %s)", pfad),
|
||||
}
|
||||
}
|
||||
return api.Neu(instanz.URL, instanz.Token, Version), instanz, nil
|
||||
}
|
||||
|
||||
// fehlerMelden gibt einen Fehler aus und liefert den Exit-Code.
|
||||
func (u *umgebung) fehlerMelden(err error) int {
|
||||
var ef *exitFehler
|
||||
code := ExitBedienung
|
||||
if errors.As(err, &ef) {
|
||||
code = ef.code
|
||||
if ef.gemeldet {
|
||||
return code
|
||||
}
|
||||
if ef.err != nil {
|
||||
err = ef.err
|
||||
}
|
||||
}
|
||||
var apiFehler *api.Fehler
|
||||
var verbindung *api.VerbindungsFehler
|
||||
switch {
|
||||
case errors.As(err, &apiFehler):
|
||||
if apiFehler.Status == 401 || apiFehler.Status == 403 {
|
||||
code = ExitVerbindung
|
||||
} else if ef == nil {
|
||||
code = ExitBedienung
|
||||
}
|
||||
case errors.As(err, &verbindung):
|
||||
code = ExitVerbindung
|
||||
case errors.Is(err, context.Canceled):
|
||||
return 130
|
||||
}
|
||||
|
||||
if u.opt.json {
|
||||
inhalt := map[string]any{"nachricht": err.Error()}
|
||||
if apiFehler != nil {
|
||||
inhalt = map[string]any{"code": apiFehler.Code, "nachricht": apiFehler.Nachricht}
|
||||
if apiFehler.Hinweis != "" {
|
||||
inhalt["hinweis"] = apiFehler.Hinweis
|
||||
}
|
||||
if len(apiFehler.Felder) > 0 {
|
||||
inhalt["felder"] = apiFehler.Felder
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(u.fehler).Encode(map[string]any{"fehler": inhalt})
|
||||
return code
|
||||
}
|
||||
s := ausgabe.Stil{Farbe: ausgabe.FarbmodusErmitteln(u.opt.keineFarbe, term.IsTerminal(int(os.Stderr.Fd())) && u.terminal), ASCII: u.opt.ascii}
|
||||
symbol := "✗"
|
||||
if s.ASCII {
|
||||
symbol = "[!!]"
|
||||
}
|
||||
fmt.Fprintln(u.fehler, s.Rot(symbol+" "+err.Error()))
|
||||
if apiFehler != nil {
|
||||
namen := make([]string, 0, len(apiFehler.Felder))
|
||||
for n := range apiFehler.Felder {
|
||||
namen = append(namen, n)
|
||||
}
|
||||
sort.Strings(namen)
|
||||
for _, n := range namen {
|
||||
fmt.Fprintln(u.fehler, " "+s.Paper(flagFuerFeld(n))+s.Muted(": "+apiFehler.Felder[n]))
|
||||
}
|
||||
if apiFehler.Hinweis != "" {
|
||||
fmt.Fprintln(u.fehler, s.Muted(" → "+apiFehler.Hinweis))
|
||||
}
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
var (
|
||||
ungueltigerWert = regexp.MustCompile(`^invalid argument "(.*)" for "(.*)" flag: .*$`)
|
||||
unbekannteOption = regexp.MustCompile(`^unknown (?:shorthand )?flag: (.*)$`)
|
||||
fehlenderWert = regexp.MustCompile(`^flag needs an argument: (.*)$`)
|
||||
)
|
||||
|
||||
// flagFehlerUebersetzen: die Meldungen von pflag auf Deutsch.
|
||||
func flagFehlerUebersetzen(err error) error {
|
||||
text := err.Error()
|
||||
if t := ungueltigerWert.FindStringSubmatch(text); t != nil {
|
||||
return fmt.Errorf("ungültiger Wert „%s“ für %s", t[1], t[2])
|
||||
}
|
||||
if t := unbekannteOption.FindStringSubmatch(text); t != nil {
|
||||
return fmt.Errorf("unbekannte Option %s – siehe --help", t[1])
|
||||
}
|
||||
if t := fehlenderWert.FindStringSubmatch(text); t != nil {
|
||||
return fmt.Errorf("Option %s braucht einen Wert", t[1])
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// flagFuerFeld übersetzt ein Feld der API in das passende Flag, damit Feldfehler verständlich sind.
|
||||
func flagFuerFeld(feld string) string {
|
||||
zuordnung := map[string]string{
|
||||
"server": "--server", "knoten": "--node", "distribution": "--distro", "name": "<name>",
|
||||
"vmid": "--vmid", "tags": "--tags", "cores": "--cores", "speicherMb": "--memory",
|
||||
"diskGb": "--disk", "storage": "--storage", "swapMb": "--swap", "netz.bridge": "--bridge",
|
||||
"netz.vlan": "--vlan", "netz.ip": "--ip", "netz.gateway": "--gw", "netz.dns": "--dns",
|
||||
"netz.suchdomain": "--search-domain", "zugang.benutzer": "--user",
|
||||
"zugang.sshSchluessel": "--ssh-key", "profile": "--profile", "vorlage": "--preset",
|
||||
}
|
||||
if flag, ok := zuordnung[feld]; ok {
|
||||
return flag
|
||||
}
|
||||
for praefix, flag := range zuordnung {
|
||||
if strings.HasPrefix(feld, praefix+".") {
|
||||
return flag
|
||||
}
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(feld, "variablen."); ok {
|
||||
return "--var " + rest
|
||||
}
|
||||
return feld
|
||||
}
|
||||
|
||||
// jsonAusgeben schreibt eingerücktes JSON (--json).
|
||||
func (u *umgebung) jsonAusgeben(wert any) error {
|
||||
kodierer := json.NewEncoder(u.aus)
|
||||
kodierer.SetIndent("", " ")
|
||||
kodierer.SetEscapeHTML(false)
|
||||
return kodierer.Encode(wert)
|
||||
}
|
||||
|
||||
// Deutsche Fassung der Cobra-Hilfe.
|
||||
const hilfeVorlage = `Aufruf:{{if .Runnable}}
|
||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [befehl]{{end}}{{if gt (len .Aliases) 0}}
|
||||
|
||||
Auch: {{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
|
||||
Beispiele:
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Befehle:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
|
||||
Optionen:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
Für alle Befehle:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Mehr zu einem Befehl: {{.CommandPath}} [befehl] --help{{end}}
|
||||
`
|
||||
Reference in New Issue
Block a user