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:
2026-10-12 15:08:41 +02:00
co-authored by Claude Opus 5.5
parent 9fdced62db
commit bd4bca5399
36 changed files with 5960 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
package ausgabe
import (
"flag"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
var aktualisieren = flag.Bool("update", false, "Golden-Dateien neu schreiben")
// Die Beispielausgabe aus der Vorgabe.
var mockPhasen = []Phase{
{Titel: "Prüfungen", Status: "ERLEDIGT", Detail: "VMID 120 frei · local-zfs · vmbr0 VLAN 20"},
{Titel: "Klonen", Status: "ERLEDIGT", Detail: "full clone 9013 → 120 · Disk auf 40G"},
{Titel: "cloud-init", Status: "ERLEDIGT", Detail: "user florian · SSH-Key · Profile base, docker"},
{Titel: "Start", Status: "ERLEDIGT", Detail: "VM 120 läuft"},
{Titel: "Guest-Agent", Status: "ERLEDIGT", Detail: "antwortet nach 18s"},
{Titel: "Provisionierung", Status: "LAEUFT", Detail: "cloud-init läuft", Laufzeit: 72 * time.Second},
}
var mockBox = Box{
Ok: true,
Titel: "web01 bereit",
Dauer: "1m 54s",
Zeilen: [][2]string{
{"VMID", "120"},
{"IP", "10.0.20.15"},
{"Profile", "base, docker"},
{"SSH", "ssh florian@10.0.20.15"},
},
}
func mockAusgabe(s Stil) string {
zeilen := []string{s.Kopf("1.0", "Server rz1 · Node pve01 · Debian 13 (Template 9013)", false), ""}
zeilen = append(zeilen, s.PhasenZeilen(mockPhasen, 0)...)
zeilen = append(zeilen, "")
zeilen = append(zeilen, s.BoxZeilen(mockBox)...)
return strings.Join(zeilen, "\n") + "\n"
}
func golden(t *testing.T, name, ist string) {
t.Helper()
pfad := filepath.Join("testdata", name)
if *aktualisieren {
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)
}
}
// Ohne Farbe muss die Ausgabe zeichengenau dem Mockup der Vorgabe entsprechen.
// mockup.txt ist von Hand aus der Vorgabe übernommen und wird nie mit -update überschrieben.
func TestMockupZeichengenau(t *testing.T) {
soll, err := os.ReadFile(filepath.Join("testdata", "mockup.txt"))
if err != nil {
t.Fatal(err)
}
if ist := mockAusgabe(Stil{}); ist != string(soll) {
t.Errorf("weicht vom Mockup ab.\n--- soll\n%s\n--- ist\n%s", soll, ist)
}
}
func TestFarbenUndAscii(t *testing.T) {
golden(t, "mockup-24bit.golden", mockAusgabe(Stil{Farbe: Echtfarben}))
golden(t, "mockup-256.golden", mockAusgabe(Stil{Farbe: Farben256}))
golden(t, "mockup-ascii.golden", mockAusgabe(Stil{ASCII: true}))
}
func TestFehlerUndDryRun(t *testing.T) {
s := Stil{}
zeilen := []string{s.Kopf("1.0", "Server rz1 · Node pve01 · Debian 13", true), ""}
zeilen = append(zeilen, s.PhasenZeilen([]Phase{
{Titel: "Prüfungen", Status: "ERLEDIGT", Detail: "VMID 120 frei"},
{Titel: "Template", Status: "UEBERSPRUNGEN", Detail: "vorhanden (9013)"},
{Titel: "Klonen", Status: "FEHLER", Ursache: "Storage local-zfs ist voll.", Hinweis: "Anderen Storage wählen."},
}, 0)...)
zeilen = append(zeilen, "")
zeilen = append(zeilen, s.AufrufZeilen([]Aufruf{
{Methode: "POST", Pfad: "/nodes/pve01/qemu/9013/clone", Parameter: map[string]any{"newid": 120, "full": 1, "name": "web01"}},
{Methode: "PUT", Pfad: "/nodes/pve01/qemu/120/config", Parameter: map[string]any{"sshkeys": strings.Repeat("A", 80), "description": "Zeile 1\nZeile 2"}},
})...)
zeilen = append(zeilen, "")
zeilen = append(zeilen, s.BoxZeilen(Box{
Titel: "web01 fehlgeschlagen", Dauer: "12s",
Zeilen: [][2]string{{"Phase", "Klonen"}, {"Ursache", "Storage local-zfs ist voll."}, {"Hinweis", "Anderen Storage wählen und die VM mit einem sehr langen Hinweis neu ausrollen."}},
})...)
golden(t, "fehler-dryrun.golden", strings.Join(zeilen, "\n")+"\n")
}
func TestDauer(t *testing.T) {
for d, soll := range map[time.Duration]string{
0: "0s", 18 * time.Second: "18s", 72 * time.Second: "1m 12s", 65 * time.Second: "1m 05s",
3780 * time.Second: "1h 03m", 1499 * time.Millisecond: "1s",
} {
if ist := Dauer(d); ist != soll {
t.Errorf("Dauer(%v) = %q, soll %q", d, ist, soll)
}
}
}
func TestXterm256(t *testing.T) {
// Teal #2DD4BF → 43 (#00d7af), Paper #E6EDF3 → 254 (Graustufe #e4e4e4), Rot #F87171 → 203 (#ff5f5f).
for f, soll := range map[rgb]int{teal: 43, paper: 254, rot: 203} {
if ist := xterm256(f); ist != soll {
t.Errorf("xterm256(%v) = %d, soll %d", f, ist, soll)
}
}
}