Files
proxdeploy/cli/internal/befehle/befehle_test.go
T
florian.wildeandClaude Opus 5.5 7f6cf00a0e feat(verlauf): Filter, Wiederholen und Aufräumen
Verlauf filterbar (URL, API, proxdeploy history); Wiederholen füllt den Assistenten aus einem alten Auftrag; Aufräumen löscht nach Fehlschlag oder Abbruch den halb angelegten Gast – nur, wenn dessen Beschreibung auf den Auftrag verweist. Deshalb setzt das Klonen die Beschreibung jetzt sofort. Auf pve-demo geprüft (CT 109).

Closes #014

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:39:58 +02:00

388 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package befehle
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
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
verlauf map[string]any
einzeln map[string]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
}
f.mu.Lock()
if f.anfragen == nil {
f.anfragen = map[string][]map[string]any{}
}
f.mu.Unlock()
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" && r.Method == http.MethodGet:
f.mu.Lock()
f.anfragen["GET "+r.URL.RawQuery] = nil
f.mu.Unlock()
schreiben(200, f.verlauf)
case strings.HasSuffix(r.URL.Path, "/aufraeumen"):
schreiben(202, map[string]any{"id": "c2", "kurzId": "c-9x9x9"})
case r.URL.Path == "/api/v1/auftraege/d-kaputt":
schreiben(200, f.einzeln)
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")
}
}
func TestHistory(t *testing.T) {
ok := auftrag("ERFOLGREICH", alle, nil)
ok["erstelltAm"] = "2026-10-12T08:05:00.000Z"
ct := auftrag("FEHLGESCHLAGEN", alle, nil)
ct["kurzId"], ct["name"], ct["gastTyp"], ct["vmid"], ct["distributionId"] = "d-ct001", "dns01", "LXC", 121, "ubuntu-24.04"
ct["erstelltAm"] = "2026-10-12T08:00:00.000Z"
f := &fakeAPI{verlauf: map[string]any{"eintraege": []any{ok, ct}, "gesamt": 30, "seite": 1, "seiten": 2}}
alt := time.Local
time.Local = time.UTC
t.Cleanup(func() { time.Local = alt })
aus, fehler, code := lauf(t, f, "history", "--type", "lxc", "--status", "fehlgeschlagen")
if code != 0 {
t.Fatalf("Exit %d: %s", code, fehler)
}
golden(t, "history.golden", aus)
if _, gesehen := f.anfragen["GET status=FEHLGESCHLAGEN&typ=lxc"]; !gesehen {
t.Errorf("Filter nicht gesendet: %v", f.anfragen)
}
}
func TestCleanup(t *testing.T) {
kaputt := auftrag("FEHLGESCHLAGEN", alle, nil)
kaputt["kurzId"] = "d-kaputt"
kaputt["aufraeumbar"] = map[string]any{"typ": "VM", "vmid": 120, "knoten": "pve01"}
f := &fakeAPI{einzeln: kaputt}
// Ohne Terminal keine Rückfrage möglich: ohne --yes wird nichts gelöscht.
_, fehler, code := lauf(t, f, "cleanup", "d-kaputt")
if code != ExitBedienung || !strings.Contains(fehler, "VM 120 auf pve01 löschen?") {
t.Errorf("ohne --yes: Exit %d, %s", code, fehler)
}
if len(f.anfragen["/api/v1/auftraege/d-kaputt/aufraeumen"]) != 0 {
t.Error("ohne Bestätigung aufgeräumt")
}
aus, _, code := lauf(t, f, "cleanup", "d-kaputt", "--yes", "--no-wait")
if code != 0 || strings.TrimSpace(aus) != "c-9x9x9" || len(f.anfragen["/api/v1/auftraege/d-kaputt/aufraeumen"]) != 1 {
t.Errorf("mit --yes: Exit %d, %q", code, aus)
}
kaputt["aufraeumbar"] = nil
_, fehler, code = lauf(t, f, "cleanup", "d-kaputt", "--yes")
if code != ExitBedienung || !strings.Contains(fehler, "nichts aufzuräumen") {
t.Errorf("nicht aufräumbar: Exit %d, %s", code, fehler)
}
}