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,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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user