From 7f6cf00a0e687b8f641a982cc698c6e06e7233bd Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 16:39:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(verlauf):=20Filter,=20Wiederholen=20und=20?= =?UTF-8?q?Aufr=C3=A4umen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 4 + README.md | 2 + cli/api/openapi.json | 174 ++++++++++++++++++ cli/internal/api/client.go | 21 +++ cli/internal/api/typen.gen.go | 82 ++++++++- cli/internal/befehle/auftraege.go | 146 +++++++++++++++ cli/internal/befehle/befehle_test.go | 62 +++++++ cli/internal/befehle/testdata/history.golden | 4 + cli/internal/befehle/wurzel.go | 2 + docs/issues/014-verlauf.md | 17 +- docs/issues/README.md | 2 +- .../(app)/auftraege/[kurzId]/live-ansicht.tsx | 48 ++++- src/app/(app)/auftraege/[kurzId]/page.tsx | 2 +- src/app/(app)/auftraege/filter-leiste.tsx | 81 ++++++++ src/app/(app)/auftraege/page.tsx | 78 +++++++- src/app/(app)/ausrollen/assistent.tsx | 67 ++++++- src/app/(app)/ausrollen/page.tsx | 26 ++- .../api/v1/auftraege/[id]/aufraeumen/route.ts | 14 ++ src/app/api/v1/auftraege/route.ts | 7 +- src/engine/ablaeufe/aufraeumen.ts | 111 +++++++++++ src/engine/ablaeufe/vm-ausrollen.ts | 6 +- src/lib/auftrag-text.ts | 9 +- src/lib/schemas/api.ts | 10 + src/lib/schemas/auftrag.ts | 2 + src/server/openapi.ts | 29 +++ src/server/services/auftraege.ts | 133 +++++++++++-- src/worker/ablaeufe.ts | 3 +- src/worker/ablaeufe/aufraeumen.ts | 59 ++++++ tests/e2e/verlauf.spec.ts | 31 ++++ tests/engine/aufraeumen.test.ts | 162 ++++++++++++++++ tests/engine/vm-ausrollen.test.ts | 10 +- tests/verlauf.test.ts | 137 ++++++++++++++ 32 files changed, 1488 insertions(+), 53 deletions(-) create mode 100644 cli/internal/befehle/testdata/history.golden create mode 100644 src/app/(app)/auftraege/filter-leiste.tsx create mode 100644 src/app/api/v1/auftraege/[id]/aufraeumen/route.ts create mode 100644 src/engine/ablaeufe/aufraeumen.ts create mode 100644 src/worker/ablaeufe/aufraeumen.ts create mode 100644 tests/e2e/verlauf.spec.ts create mode 100644 tests/engine/aufraeumen.test.ts create mode 100644 tests/verlauf.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b2c317..4628e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] ### Hinzugefügt +- Verlauf filterbar nach Server, Typ, Distribution und Status, mit Seitenblättern; die Filter stehen in der URL. Auch per API und `proxdeploy history` (#014) +- „Wiederholen“: der Assistent übernimmt alle Werte eines früheren Auftrags, Name, VMID und feste IP gibt man neu an (#014) +- „Aufräumen“ nach Fehlschlag oder Abbruch: löscht nach Bestätigung den halb angelegten Gast – nur, wenn seine Beschreibung auf den Auftrag verweist; Templates nie. Web, API und `proxdeploy cleanup` (#014) ### Geändert ### Behoben +- Erzeugte SSH-Schlüssel (je Server und Bereitstellungsschlüssel) waren in etwa 4 von 1000 Fällen für ssh2 selbst nicht lesbar; jetzt wird geprüft und neu erzeugt (#014) ### Entfernt diff --git a/README.md b/README.md index 9a0641b..c8deaf5 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ proxdeploy template build --server rz1 --distro debian-13 --profile base,docker proxdeploy batch gaeste.yaml proxdeploy status a-7f3k proxdeploy logs a-7f3k --follow +proxdeploy history --type lxc --status fehlgeschlagen +proxdeploy cleanup a-7f3k # halb angelegten Gast nach Fehlschlag löschen (mit Rückfrage) ``` ``` diff --git a/cli/api/openapi.json b/cli/api/openapi.json index 85cb21d..588eb66 100644 --- a/cli/api/openapi.json +++ b/cli/api/openapi.json @@ -927,6 +927,33 @@ } ] }, + "aufraeumbar": { + "nullable": true, + "type": "object", + "properties": { + "typ": { + "type": "string", + "enum": [ + "VM", + "LXC" + ] + }, + "vmid": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "knoten": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "typ", + "vmid", + "knoten" + ] + }, "phasen": { "type": "array", "items": { @@ -954,9 +981,42 @@ "gestartetAm", "beendetAm", "abschluss", + "aufraeumbar", "phasen" ] }, + "Verlauf": { + "type": "object", + "properties": { + "eintraege": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Auftrag" + } + }, + "gesamt": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "seite": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "seiten": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "eintraege", + "gesamt", + "seite", + "seiten" + ] + }, "LogZeile": { "type": "object", "properties": { @@ -1347,6 +1407,81 @@ } }, "/auftraege": { + "get": { + "operationId": "verlauf", + "summary": "Verlauf, neueste zuerst, 25 je Seite", + "parameters": [ + { + "name": "server", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "typ", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "vm", + "lxc", + "template", + "aufraeumen" + ] + } + }, + { + "name": "distribution", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "seite", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "Seite des Verlaufs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Verlauf" + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + }, "post": { "operationId": "ausrollen", "summary": "VM oder LXC ausrollen (Auftrag)", @@ -1558,6 +1693,45 @@ } } }, + "/auftraege/{id}/aufraeumen": { + "post": { + "operationId": "auftragAufraeumen", + "summary": "Halb angelegten Gast eines fehlgeschlagenen/abgebrochenen Auftrags löschen (Auftrag)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "ID oder Kurz-ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Auftrag angenommen", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuftragAngenommen" + } + } + } + }, + "default": { + "description": "Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FehlerAntwort" + } + } + } + } + } + } + }, "/auftraege/{id}/abbrechen": { "post": { "operationId": "auftragAbbrechen", diff --git a/cli/internal/api/client.go b/cli/internal/api/client.go index f372506..f4003f4 100644 --- a/cli/internal/api/client.go +++ b/cli/internal/api/client.go @@ -200,6 +200,27 @@ func (c *Client) Log(ctx context.Context, id, ab string) ([]LogZeile, error) { return zeilen, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &zeilen) } +// Verlauf: eine Seite, gefiltert. Leere Filter fallen weg. +func (c *Client) Verlauf(ctx context.Context, filter map[string]string) (Verlauf, error) { + werte := url.Values{} + for k, v := range filter { + if v != "" { + werte.Set(k, v) + } + } + pfad := "/auftraege" + if len(werte) > 0 { + pfad += "?" + werte.Encode() + } + var v Verlauf + return v, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &v) +} + +func (c *Client) Aufraeumen(ctx context.Context, id string) (AuftragAngenommen, error) { + var a AuftragAngenommen + return a, c.Aufrufen(ctx, http.MethodPost, "/auftraege/"+url.PathEscape(id)+"/aufraeumen", nil, &a) +} + func (c *Client) Abbrechen(ctx context.Context, id string) error { return c.Aufrufen(ctx, http.MethodPost, "/auftraege/"+url.PathEscape(id)+"/abbrechen", nil, nil) } diff --git a/cli/internal/api/typen.gen.go b/cli/internal/api/typen.gen.go index daa7076..30c42cd 100644 --- a/cli/internal/api/typen.gen.go +++ b/cli/internal/api/typen.gen.go @@ -30,18 +30,36 @@ func (e AuftragArt) Valid() bool { } } +// Defines values for AuftragAufraeumbarTyp. +const ( + AuftragAufraeumbarTypLXC AuftragAufraeumbarTyp = "LXC" + AuftragAufraeumbarTypVM AuftragAufraeumbarTyp = "VM" +) + +// Valid indicates whether the value is a known member of the AuftragAufraeumbarTyp enum. +func (e AuftragAufraeumbarTyp) Valid() bool { + switch e { + case AuftragAufraeumbarTypLXC: + return true + case AuftragAufraeumbarTypVM: + return true + default: + return false + } +} + // Defines values for AuftragGastTyp. const ( - LXC AuftragGastTyp = "LXC" - VM AuftragGastTyp = "VM" + AuftragGastTypLXC AuftragGastTyp = "LXC" + AuftragGastTypVM AuftragGastTyp = "VM" ) // Valid indicates whether the value is a known member of the AuftragGastTyp enum. func (e AuftragGastTyp) Valid() bool { switch e { - case LXC: + case AuftragGastTypLXC: return true - case VM: + case AuftragGastTypVM: return true default: return false @@ -201,6 +219,30 @@ func (e ProfilVariableType) Valid() bool { } } +// Defines values for VerlaufParamsTyp. +const ( + VerlaufParamsTypAufraeumen VerlaufParamsTyp = "aufraeumen" + VerlaufParamsTypLxc VerlaufParamsTyp = "lxc" + VerlaufParamsTypTemplate VerlaufParamsTyp = "template" + VerlaufParamsTypVm VerlaufParamsTyp = "vm" +) + +// Valid indicates whether the value is a known member of the VerlaufParamsTyp enum. +func (e VerlaufParamsTyp) Valid() bool { + switch e { + case VerlaufParamsTypAufraeumen: + return true + case VerlaufParamsTypLxc: + return true + case VerlaufParamsTypTemplate: + return true + case VerlaufParamsTypVm: + return true + default: + return false + } +} + // Defines values for DistributionenListeParamsTyp. const ( DistributionenListeParamsTypLxc DistributionenListeParamsTyp = "lxc" @@ -229,8 +271,13 @@ type Abschluss struct { // Auftrag defines model for Auftrag. type Auftrag struct { - Abschluss *Abschluss `json:"abschluss"` - Art AuftragArt `json:"art"` + Abschluss *Abschluss `json:"abschluss"` + Art AuftragArt `json:"art"` + Aufraeumbar *struct { + Knoten *string `json:"knoten"` + Typ AuftragAufraeumbarTyp `json:"typ"` + Vmid int `json:"vmid"` + } `json:"aufraeumbar"` // BeendetAm ISO 8601 BeendetAm *string `json:"beendetAm"` @@ -267,6 +314,9 @@ type Auftrag struct { // AuftragArt defines model for Auftrag.Art. type AuftragArt string +// AuftragAufraeumbarTyp defines model for Auftrag.Aufraeumbar.Typ. +type AuftragAufraeumbarTyp string + // AuftragGastTyp defines model for Auftrag.GastTyp. type AuftragGastTyp string @@ -537,6 +587,14 @@ type TokenAnmeldung struct { Tage *int `json:"tage,omitempty"` } +// Verlauf defines model for Verlauf. +type Verlauf struct { + Eintraege []Auftrag `json:"eintraege"` + Gesamt int `json:"gesamt"` + Seite int `json:"seite"` + Seiten int `json:"seiten"` +} + // Vorlage defines model for Vorlage. type Vorlage struct { Beschreibung *string `json:"beschreibung"` @@ -544,6 +602,18 @@ type Vorlage struct { Titel string `json:"titel"` } +// VerlaufParams defines parameters for Verlauf. +type VerlaufParams struct { + Server *string `form:"server,omitempty" json:"server,omitempty"` + Typ *VerlaufParamsTyp `form:"typ,omitempty" json:"typ,omitempty"` + Distribution *string `form:"distribution,omitempty" json:"distribution,omitempty"` + Status *string `form:"status,omitempty" json:"status,omitempty"` + Seite *int `form:"seite,omitempty" json:"seite,omitempty"` +} + +// VerlaufParamsTyp defines parameters for Verlauf. +type VerlaufParamsTyp string + // AuftragEreignisseParams defines parameters for AuftragEreignisse. type AuftragEreignisseParams struct { LastEventID *string `json:"Last-Event-ID,omitempty"` diff --git a/cli/internal/befehle/auftraege.go b/cli/internal/befehle/auftraege.go index 6d687ae..d6edf66 100644 --- a/cli/internal/befehle/auftraege.go +++ b/cli/internal/befehle/auftraege.go @@ -1,14 +1,17 @@ package befehle import ( + "bufio" "context" "encoding/json" "errors" "fmt" + "os" "strings" "time" "github.com/spf13/cobra" + "golang.org/x/term" "git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api" ) @@ -172,3 +175,146 @@ func (u *umgebung) cancelBefehl() *cobra.Command { }, } } + +// Statustexte wie in der Web-GUI. +var statusText = map[api.AuftragStatus]string{ + api.AuftragStatusWARTEND: "wartet", api.AuftragStatusLAEUFT: "läuft", api.AuftragStatusERFOLGREICH: "erfolgreich", + api.AuftragStatusFEHLGESCHLAGEN: "fehlgeschlagen", api.AuftragStatusABGEBROCHEN: "abgebrochen", + api.AuftragStatusAUFGERAEUMT: "aufgeräumt", +} + +// statusAusText nimmt „fehlgeschlagen“, „läuft“, „aufgeräumt“ oder den API-Wert. +func statusAusText(text string) string { + for wert, t := range statusText { + if strings.EqualFold(t, text) || strings.EqualFold(string(wert), text) { + return string(wert) + } + } + return strings.ToUpper(text) +} + +func (u *umgebung) historyBefehl() *cobra.Command { + var server, typ, distro, status string + var seite int + befehl := &cobra.Command{ + Use: "history", + Aliases: []string{"verlauf"}, + Short: "Verlauf der Aufträge, neueste zuerst", + Example: " proxdeploy history --type lxc --status FEHLGESCHLAGEN\n proxdeploy history --server rz1 --page 2", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + c, _, err := u.client() + if err != nil { + return err + } + filter := map[string]string{"server": server, "typ": typ, "distribution": distro, "status": ""} + if status != "" { + filter["status"] = statusAusText(status) + } + if seite > 1 { + filter["seite"] = fmt.Sprint(seite) + } + v, err := c.Verlauf(cmd.Context(), filter) + if err != nil { + return err + } + if u.opt.json { + return u.jsonAusgeben(v) + } + s := u.stil() + if len(v.Eintraege) == 0 { + fmt.Fprintln(u.aus, s.Muted("Keine Aufträge.")) + return nil + } + symbole := map[api.AuftragStatus]string{ + api.AuftragStatusERFOLGREICH: "✓", api.AuftragStatusFEHLGESCHLAGEN: "✗", api.AuftragStatusABGEBROCHEN: "✗", + api.AuftragStatusLAEUFT: "◐", api.AuftragStatusWARTEND: "○", api.AuftragStatusAUFGERAEUMT: "○", + } + zeilen := [][]string{} + for _, a := range v.Eintraege { + art := map[api.AuftragArt]string{api.AUSROLLEN: "VM", api.TEMPLATEBAUEN: "Template", api.AUFRAEUMEN: "Aufräumen"}[a.Art] + if a.Art == api.AUSROLLEN && a.GastTyp != nil && *a.GastTyp == api.AuftragGastTypLXC { + art = "LXC" + } + id := "–" + if a.Vmid != nil { + id = fmt.Sprint(*a.Vmid) + } + zustand := symbole[a.Status] + " " + statusText[a.Status] + if s.ASCII { + zustand = strings.NewReplacer("✓", "[OK]", "✗", "[!!]", "◐", "[..]", "○", "[--]").Replace(zustand) + } + zeit := a.ErstelltAm + if t, ok := zeitAus(&a.ErstelltAm); ok { + zeit = t.Local().Format("02.01. 15:04") + } + name := wert(a.Name) + if name == "" { + name = "–" + } + zeilen = append(zeilen, []string{a.KurzId, name, art, a.Server.Name, wert(a.DistributionId), id, zustand, zeit}) + } + u.tabelle(s, []string{"AUFTRAG", "NAME", "TYP", "SERVER", "DISTRIBUTION", "ID", "STATUS", "ZEIT"}, zeilen) + if v.Seiten > 1 { + fmt.Fprintln(u.aus, s.Muted(fmt.Sprintf("Seite %d von %d · %d Aufträge · weiter mit --page %d", v.Seite, v.Seiten, v.Gesamt, min(v.Seite+1, v.Seiten)))) + } + return nil + }, + } + f := befehl.Flags() + f.StringVar(&server, "server", "", "nur dieser Server") + f.StringVar(&typ, "type", "", "vm, lxc, template oder aufraeumen") + f.StringVar(&distro, "distro", "", "nur diese Distribution") + f.StringVar(&status, "status", "", "z. B. fehlgeschlagen, erfolgreich, laeuft") + f.IntVar(&seite, "page", 1, "Seite (25 je Seite)") + return befehl +} + +func (u *umgebung) cleanupBefehl() *cobra.Command { + var ja, nichtWarten bool + befehl := &cobra.Command{ + Use: "cleanup ", + Short: "Halb angelegten Gast eines fehlgeschlagenen Auftrags löschen", + Long: "Stoppt und löscht die VM bzw. den Container, den ein fehlgeschlagener oder abgebrochener\n" + + "Auftrag angelegt hat. Gelöscht wird nur, was laut Beschreibung zu diesem Auftrag gehört.", + Args: eineID("cleanup"), + RunE: func(cmd *cobra.Command, argumente []string) error { + c, _, err := u.client() + if err != nil { + return err + } + a, err := c.Auftrag(cmd.Context(), argumente[0]) + if err != nil { + return err + } + if a.Aufraeumbar == nil { + return bedienfehler("bei %s gibt es nichts aufzuräumen (Status %s)", a.KurzId, statusText[a.Status]) + } + z := a.Aufraeumbar + wort := map[api.AuftragAufraeumbarTyp]string{api.AuftragAufraeumbarTypVM: "VM", api.AuftragAufraeumbarTypLXC: "Container"}[z.Typ] + ziel := fmt.Sprintf("%s %d", wort, z.Vmid) + if z.Knoten != nil { + ziel += " auf " + *z.Knoten + } + if !ja { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return bedienfehler("%s löschen? Ohne Terminal mit --yes bestätigen", ziel) + } + fmt.Fprintf(u.fehler, "%s stoppen und mit allen Disks löschen? [j/N] ", ziel) + antwort, _ := bufio.NewReader(os.Stdin).ReadString('\n') + if a := strings.ToLower(strings.TrimSpace(antwort)); a != "j" && a != "ja" && a != "y" && a != "yes" { + fmt.Fprintln(u.fehler, u.stil().Muted("Nichts gelöscht.")) + return nil + } + } + angenommen, err := c.Aufraeumen(cmd.Context(), a.KurzId) + if err != nil { + return err + } + return u.angenommen(cmd.Context(), c, angenommen, nichtWarten) + }, + } + befehl.Flags().BoolVarP(&ja, "yes", "y", false, "ohne Rückfrage") + befehl.Flags().BoolVar(&nichtWarten, "no-wait", false, "nicht verfolgen, nur die Auftrags-ID ausgeben") + return befehl +} diff --git a/cli/internal/befehle/befehle_test.go b/cli/internal/befehle/befehle_test.go index 36a5c60..5ed7a52 100644 --- a/cli/internal/befehle/befehle_test.go +++ b/cli/internal/befehle/befehle_test.go @@ -13,6 +13,7 @@ import ( "strings" "sync" "testing" + "time" ) var aktualisieren = flag.Bool("update", false, "Golden-Dateien neu schreiben") @@ -78,6 +79,8 @@ type fakeAPI struct { 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 { @@ -88,6 +91,11 @@ func (f *fakeAPI) handler(t *testing.T) http.Handler { 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) @@ -112,6 +120,15 @@ func (f *fakeAPI) handler(t *testing.T) http.Handler { 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"): @@ -323,3 +340,48 @@ func TestLoginMitToken(t *testing.T) { 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) + } +} diff --git a/cli/internal/befehle/testdata/history.golden b/cli/internal/befehle/testdata/history.golden new file mode 100644 index 0000000..088f909 --- /dev/null +++ b/cli/internal/befehle/testdata/history.golden @@ -0,0 +1,4 @@ +AUFTRAG NAME TYP SERVER DISTRIBUTION ID STATUS ZEIT +a-7f3k web01 VM rz1 debian-13 120 ✓ erfolgreich 12.10. 08:05 +d-ct001 dns01 LXC rz1 ubuntu-24.04 121 ✗ fehlgeschlagen 12.10. 08:00 +Seite 1 von 2 · 30 Aufträge · weiter mit --page 2 diff --git a/cli/internal/befehle/wurzel.go b/cli/internal/befehle/wurzel.go index 6595691..c6742bb 100644 --- a/cli/internal/befehle/wurzel.go +++ b/cli/internal/befehle/wurzel.go @@ -140,6 +140,8 @@ func (u *umgebung) wurzel() *cobra.Command { u.statusBefehl(), u.logsBefehl(), u.cancelBefehl(), + u.historyBefehl(), + u.cleanupBefehl(), ) hilfeEindeutschen(wurzel) return wurzel diff --git a/docs/issues/014-verlauf.md b/docs/issues/014-verlauf.md index 69bcd88..e081768 100644 --- a/docs/issues/014-verlauf.md +++ b/docs/issues/014-verlauf.md @@ -1,5 +1,5 @@ # #014 Verlauf und Wiederholen -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.5.0 **Labels:** feature **Geschätzt:** 4 h **Tatsächlich:** – @@ -8,14 +8,21 @@ Alle Aufträge filterbar, mit gleichen Parametern wiederholen. ## Aufgaben -- [ ] Filter Server/Typ/Distribution/Status, Seitenblättern -- [ ] Wiederholen (neue VMID/Name abfragen) +- [x] Filter Server/Typ/Distribution/Status, Seitenblättern (Web, `GET /api/v1/auftraege?…`, `proxdeploy history`) +- [x] Wiederholen (neue VMID/Name abfragen): `/ausrollen?wiederholen=`, Name, VMID und feste IP leer +- [x] Aufräumen nach Fehlschlag/Abbruch mit Bestätigung (Vorgabe „Allgemeine Anforderungen“): Web-Knopf, `POST …/aufraeumen`, `proxdeploy cleanup` ## Akzeptanzkriterien -- [ ] Filter bleiben in der URL +- [x] Filter bleiben in der URL (Playwright) +- [x] Aufräumen auf pve-demo: CT 109 nach Abbruch erkannt, gestoppt, gelöscht; Original danach „aufgeräumt“ ## Abhängigkeiten #008 ## Notizen / Entscheidungen -– +- **Gelöscht wird nur, was nachweislich zum Auftrag gehört:** die Beschreibung des Gastes enthält + „(Auftrag )“. Dafür setzt das Klonen die Beschreibung jetzt sofort (vorher erst in der + Hardware-Phase). Templates, gesperrte Gäste und fremde Beschreibungen: Abbruch, nichts angefasst. +- Wiederholen leert auch eine feste IP – dieselbe Adresse zweimal ist fast nie gewollt. +- Nebenbei gefunden: ssh2 erzeugt in ~0,4 % der Fälle ed25519-Schlüssel, die es selbst nicht + lesen kann (Ursache des „wackligen“ SSH-Tests). Behoben mit `ed25519Erzeugen`. diff --git a/docs/issues/README.md b/docs/issues/README.md index b760b6c..c91ff30 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -17,7 +17,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | erledigt | v0.3.0 | 6 h | 0 h 10 min | | [#012](012-vorlagen.md) | Vorlagen (Presets) | erledigt | v0.3.0 | 3 h | 0 h 11 min | | [#013](013-cli.md) | CLI proxdeploy (Go) | erledigt | v0.4.0 | 12 h | 0 h 42 min | -| [#014](014-verlauf.md) | Verlauf und Wiederholen | offen | v0.5.0 | 4 h | – | +| [#014](014-verlauf.md) | Verlauf und Wiederholen | erledigt | v0.5.0 | 4 h | – | | [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | offen | v0.5.0 | 6 h | – | | [#016](016-oidc.md) | Anmeldung per OIDC | offen | v0.5.0 | 4 h | – | | [#017](017-branding.md) | Branding-Assets | offen | v0.6.0 | 4 h | – | diff --git a/src/app/(app)/auftraege/[kurzId]/live-ansicht.tsx b/src/app/(app)/auftraege/[kurzId]/live-ansicht.tsx index b750d33..8b56c1d 100644 --- a/src/app/(app)/auftraege/[kurzId]/live-ansicht.tsx +++ b/src/app/(app)/auftraege/[kurzId]/live-ansicht.tsx @@ -1,12 +1,14 @@ "use client"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { Code } from "@/components/formular"; import { Knopf } from "@/components/knopf"; import { Status } from "@/components/status"; import { STATUS_ANZEIGE } from "@/lib/auftrag-text"; import { TerminalKarte } from "@/components/terminal-karte"; -import { apiAufruf } from "@/lib/api-client"; +import { apiAufruf, ApiFehler } from "@/lib/api-client"; import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag"; const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]); @@ -19,13 +21,15 @@ export function LiveAnsicht({ anfang, anfangsLog, version, - abbrechenErlaubt, + darfAusrollen, }: { anfang: AuftragSicht; anfangsLog: LogZeile[]; version: string; - abbrechenErlaubt: boolean; + darfAusrollen: boolean; }) { + const router = useRouter(); + const [aktionFehler, setAktionFehler] = useState(null); const [auftrag, setAuftrag] = useState(anfang); const [log, setLog] = useState(anfangsLog); const [verbindung, setVerbindung] = useState<"live" | "abfrage" | "beendet">( @@ -90,6 +94,25 @@ export function LiveAnsicht({ const ergebnis = auftrag.abschluss; const ssh = (auftrag.ergebnis as Record | null)?.["ssh"]; + async function aufraeumen() { + const ziel = auftrag.aufraeumbar; + if (!ziel) return; + const wort = ziel.typ === "LXC" ? "Container" : "VM"; + const frage = + `${wort} ${ziel.vmid}${ziel.knoten ? ` auf ${ziel.knoten}` : ""} stoppen und mit allen Disks löschen?\n\n` + + `ProxDeploy löscht nur, wenn die Beschreibung des Gastes auf ${auftrag.kurzId} verweist.`; + if (!window.confirm(frage)) return; + try { + const neu = await apiAufruf<{ kurzId: string }>( + "POST", + `/api/v1/auftraege/${auftrag.kurzId}/aufraeumen`, + ); + router.push(`/auftraege/${neu.kurzId}`); + } catch (fehler) { + setAktionFehler(fehler instanceof ApiFehler ? fehler.message : "Aufräumen ließ sich nicht starten."); + } + } + return (
@@ -105,9 +128,22 @@ export function LiveAnsicht({ : (auftrag.name ?? "Ausrollen")}
-
+
{text} - {abbrechenErlaubt && !ENDE.has(auftrag.status) ? ( + {darfAusrollen && auftrag.aufraeumbar ? ( + void aufraeumen()}> + Aufräumen + + ) : null} + {darfAusrollen && auftrag.art === "AUSROLLEN" && ENDE.has(auftrag.status) ? ( + + Wiederholen + + ) : null} + {darfAusrollen && !ENDE.has(auftrag.status) ? ( void apiAufruf("POST", `/api/v1/auftraege/${auftrag.kurzId}/abbrechen`)} @@ -118,6 +154,8 @@ export function LiveAnsicht({
+ {aktionFehler ? {aktionFehler} : null} + {typeof ssh === "string" ? ( diff --git a/src/app/(app)/auftraege/[kurzId]/page.tsx b/src/app/(app)/auftraege/[kurzId]/page.tsx index b812257..0a6b94b 100644 --- a/src/app/(app)/auftraege/[kurzId]/page.tsx +++ b/src/app/(app)/auftraege/[kurzId]/page.tsx @@ -28,7 +28,7 @@ export default async function AuftragSeite({ params }: { params: Promise<{ kurzI anfang={auftrag} anfangsLog={log} version={versionText()} - abbrechenErlaubt={darf(benutzer.rolle, "ausrollen")} + darfAusrollen={darf(benutzer.rolle, "ausrollen")} /> ); } diff --git a/src/app/(app)/auftraege/filter-leiste.tsx b/src/app/(app)/auftraege/filter-leiste.tsx new file mode 100644 index 0000000..9354f96 --- /dev/null +++ b/src/app/(app)/auftraege/filter-leiste.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { EINGABE_KLASSE, Feld } from "@/components/formular"; +import { Knopf } from "@/components/knopf"; + +type Auswahl = Array<{ wert: string; text: string }>; + +/** + * Filter des Verlaufs. Der Stand steht in der URL – Neuladen und Weitergeben behalten ihn; jede + * Änderung springt auf Seite 1. + */ +export function FilterLeiste({ server, distributionen }: { server: Auswahl; distributionen: Auswahl }) { + const router = useRouter(); + const pfad = usePathname(); + const parameter = useSearchParams(); + + const setze = (name: string, wert: string) => { + const neu = new URLSearchParams(parameter); + if (wert) neu.set(name, wert); + else neu.delete(name); + neu.delete("seite"); + const text = neu.toString(); + router.push(text ? `${pfad}?${text}` : pfad); + }; + + const felder: Array<{ name: string; label: string; optionen: Auswahl }> = [ + { name: "server", label: "Server", optionen: server }, + { + name: "typ", + label: "Typ", + optionen: [ + { wert: "vm", text: "VM" }, + { wert: "lxc", text: "LXC" }, + { wert: "template", text: "Template" }, + { wert: "aufraeumen", text: "Aufräumen" }, + ], + }, + { name: "distribution", label: "Distribution", optionen: distributionen }, + { + name: "status", + label: "Status", + optionen: [ + { wert: "LAEUFT", text: "läuft" }, + { wert: "WARTEND", text: "wartet" }, + { wert: "ERFOLGREICH", text: "erfolgreich" }, + { wert: "FEHLGESCHLAGEN", text: "fehlgeschlagen" }, + { wert: "ABGEBROCHEN", text: "abgebrochen" }, + { wert: "AUFGERAEUMT", text: "aufgeräumt" }, + ], + }, + ]; + const aktiv = felder.some((f) => parameter.get(f.name)); + + return ( +
+ {felder.map((f) => ( + + + + ))} + {aktiv ? ( + router.push(pfad)}> + Filter zurücksetzen + + ) : null} +
+ ); +} diff --git a/src/app/(app)/auftraege/page.tsx b/src/app/(app)/auftraege/page.tsx index 9df2e7b..d8e3dc3 100644 --- a/src/app/(app)/auftraege/page.tsx +++ b/src/app/(app)/auftraege/page.tsx @@ -3,25 +3,56 @@ import Link from "next/link"; import { Karte } from "@/components/karte"; import { Status } from "@/components/status"; import { ART_TEXT, STATUS_ANZEIGE } from "@/lib/auftrag-text"; +import { prisma } from "@/lib/db"; import { zeitpunkt } from "@/lib/formate"; import { seiteSchuetzen } from "@/server/auth/zugang"; -import { auftraegeListe } from "@/server/services/auftraege"; +import { auftraegeSuchen, verlaufFilterAus } from "@/server/services/auftraege"; +import { distributionenListe } from "@/server/services/katalog"; +import { FilterLeiste } from "./filter-leiste"; export const metadata: Metadata = { title: "Verlauf" }; -/** Verlauf aller Aufträge. Filter und Wiederholen kommen mit #014. */ -export default async function Verlauf() { +type Suche = Record; + +/** Verlauf aller Aufträge, filterbar nach Server, Typ, Distribution und Status (Stand in der URL). */ +export default async function Verlauf({ searchParams }: { searchParams: Promise }) { await seiteSchuetzen("lesen", "/auftraege"); - const auftraege = await auftraegeListe({ grenze: 100 }); + const suche = await searchParams; + const filter = verlaufFilterAus(suche); + const [{ eintraege, gesamt, seite, seiten }, server, distributionen] = await Promise.all([ + auftraegeSuchen(filter), + prisma.server.findMany({ select: { name: true, titel: true }, orderBy: { name: "asc" } }), + distributionenListe(), + ]); + + const seitenLink = (ziel: number) => { + const p = new URLSearchParams(); + for (const [k, v] of Object.entries(filter)) if (v !== undefined && k !== "seite") p.set(k, String(v)); + if (ziel > 1) p.set("seite", String(ziel)); + const text = p.toString(); + return text ? `/auftraege?${text}` : "/auftraege"; + }; + return (
-

Verlauf

+
+

Verlauf

+

+ {gesamt} {gesamt === 1 ? "Auftrag" : "Aufträge"} +

+
+ ({ wert: s.name, text: s.titel }))} + distributionen={distributionen.map((d) => ({ wert: d.id, text: d.name }))} + /> - {auftraege.length === 0 ? ( - Noch keine Aufträge. + {eintraege.length === 0 ? ( + + {gesamt === 0 && seite === 1 ? "Keine Aufträge." : "Keine Aufträge auf dieser Seite."} + ) : (
    - {auftraege.map((a) => { + {eintraege.map((a) => { const [art, text] = STATUS_ANZEIGE[a.status]; return (
  • @@ -34,9 +65,15 @@ export default async function Verlauf() { {a.name ?? ART_TEXT[a.art]} {" "} - · {ART_TEXT[a.art]} · {a.server.name} + ·{" "} + {a.art === "AUSROLLEN" && a.gastTyp + ? a.gastTyp === "LXC" + ? "LXC" + : "VM" + : ART_TEXT[a.art]}{" "} + · {a.server.name} {a.distributionId ? ` · ${a.distributionId}` : ""} - {a.vmid ? ` · ${a.vmid}` : ""} + {a.vmid ? ` · ${a.vmid}` : ""} · {a.erstelltVon} {zeitpunkt(a.erstelltAm)} @@ -50,6 +87,27 @@ export default async function Verlauf() {
)}
+ {seiten > 1 ? ( + + ) : null}
); } diff --git a/src/app/(app)/ausrollen/assistent.tsx b/src/app/(app)/ausrollen/assistent.tsx index f6d3d21..8a22190 100644 --- a/src/app/(app)/ausrollen/assistent.tsx +++ b/src/app/(app)/ausrollen/assistent.tsx @@ -10,7 +10,7 @@ import { Knopf } from "@/components/knopf"; import { Status } from "@/components/status"; import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte"; import { apiAufruf, ApiFehler } from "@/lib/api-client"; -import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen"; +import { GAST_NAME, SSH_SCHLUESSEL, type AusrollAnfrage, type AusrollEingabe } from "@/lib/schemas/ausrollen"; import type { VorlagenParameter } from "@/lib/schemas/vorlage"; // ── Daten vom Server ──────────────────────────────────────────────────────────────────────────── @@ -204,6 +204,53 @@ function vorlageUebernehmen(w: Werte, name: string, p: VorlagenParameter, daten: }; } +/** + * Einen früheren Auftrag in die Formularwerte übernehmen („Wiederholen“). Name, VMID und eine + * statische IP gehören zum alten Gast und werden geleert – sie muss man neu angeben. + */ +function wiederholungUebernehmen(w: Werte, a: AusrollAnfrage, daten: AssistentDaten): Werte { + const hinterlegt = new Set(daten.schluessel.map((x) => x.oeffentlich)); + const gewaehlt = new Set(a.zugang.sshSchluessel); + const variablen: Werte["variablen"] = {}; + for (const [profil, werte] of Object.entries(a.variablen)) { + variablen[profil] = Object.fromEntries( + Object.entries(werte).map(([k, v]) => [k, typeof v === "boolean" ? v : String(v)]), + ); + } + const server = daten.server.some((x) => x.name === a.server) ? a.server : w.server; + return { + ...w, + server, + knoten: a.knoten ?? "", + typ: a.typ, + distribution: a.distribution, + name: "", + vmid: "", + tags: a.tags.join(","), + cores: String(a.cores), + speicherMb: String(a.speicherMb), + diskGb: String(a.diskGb), + storage: a.storage, + swapMb: String(a.swapMb), + unprivilegiert: a.unprivilegiert, + nesting: a.features.nesting, + bridge: a.netz.bridge, + vlan: a.netz.vlan ? String(a.netz.vlan) : "", + modus: a.netz.modus, + ip: "", + gateway: a.netz.gateway ?? "", + dns: a.netz.dns.join(","), + suchdomain: a.netz.suchdomain ?? "", + benutzer: a.zugang.benutzer, + schluessel: a.zugang.sshSchluessel.filter((k) => !hinterlegt.has(k)).join("\n"), + ohneSchluessel: daten.schluessel.filter((x) => !gewaehlt.has(x.oeffentlich)).map((x) => x.fingerabdruck), + profile: a.profile, + variablen, + templateBauen: a.templateBauen, + vorlage: a.vorlage ?? "", + }; +} + function werteZuVorlage(w: Werte): VorlagenParameter { const a = anfrageAus(w, []); return { @@ -231,7 +278,16 @@ function werteZuVorlage(w: Werte): VorlagenParameter { * Neuladen und Weitergeben funktionieren damit ohne Server-Zwischenspeicher. Geprüft wird * endgültig auf dem Server – hier nur so viel, dass niemand ins Leere klickt. */ -export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; vorauswahl: string | null }) { +export function Assistent({ + daten, + vorauswahl, + wiederholung = null, +}: { + daten: AssistentDaten; + vorauswahl: string | null; + /** `?wiederholen=`: Anfrage eines früheren Auftrags als Vorbelegung. */ + wiederholung?: { kurzId: string; anfrage: AusrollAnfrage } | null; +}) { const router = useRouter(); const pfad = usePathname(); const parameter = useSearchParams(); @@ -279,6 +335,8 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus const vorlage = !parameter.get("d") ? daten.vorlagen.find((v) => v.name === parameter.get("vorlage")) : undefined; + if (!parameter.get("d") && wiederholung) + return wiederholungUebernehmen(ausUrl, wiederholung.anfrage, daten); return vorlage ? vorlageUebernehmen(ausUrl, vorlage.name, vorlage.parameter, daten) : ausUrl; }); const [vorlageSpeichern, setVorlageSpeichern] = useState<{ @@ -421,6 +479,11 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus

Schritt {schritt + 1} von {SCHRITTE.length} · {SCHRITTE[schritt]}

+ {wiederholung ? ( + + Wiederholung von {wiederholung.kurzId} – Name, VMID und eine feste IP neu angeben. + + ) : null}
diff --git a/src/app/(app)/ausrollen/page.tsx b/src/app/(app)/ausrollen/page.tsx index 948cd24..cc5c190 100644 --- a/src/app/(app)/ausrollen/page.tsx +++ b/src/app/(app)/ausrollen/page.tsx @@ -4,6 +4,7 @@ import { Karte } from "@/components/karte"; import { Status } from "@/components/status"; import type { VerbindungsBericht } from "@/engine/proxmox/erkundung"; import { prisma } from "@/lib/db"; +import { AusrollAnfrage } from "@/lib/schemas/ausrollen"; import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl"; import { versionText } from "@/lib/version"; import { seiteSchuetzen } from "@/server/auth/zugang"; @@ -15,7 +16,11 @@ import { Assistent, type AssistentDaten } from "./assistent"; export const metadata: Metadata = { title: "Ausrollen" }; /** Der Deploy-Assistent. Alle Daten für die Auswahlfelder kommen hier einmal vom Server. */ -export default async function Ausrollen() { +export default async function Ausrollen({ + searchParams, +}: { + searchParams: Promise<{ wiederholen?: string }>; +}) { const benutzer = await seiteSchuetzen("ausrollen", "/ausrollen"); const server = await prisma.server.findMany({ orderBy: { name: "asc" } }); const mitTest = server.filter((s) => s.letzterTest); @@ -87,5 +92,22 @@ export default async function Ausrollen() { daten.server.map((s) => s.name), (await cookies()).get(SERVER_COOKIE)?.value, ); - return ; + return ( + + ); +} + +/** Die Anfrage eines früheren Ausroll-Auftrags – nur, wenn sie noch zum heutigen Schema passt. */ +async function wiederholungLaden(kennung: string | undefined) { + if (!kennung) return null; + const auftrag = await prisma.auftrag.findFirst({ + where: { OR: [{ kurzId: kennung }, { id: kennung }], art: "AUSROLLEN" }, + select: { kurzId: true, anfrage: true }, + }); + const anfrage = AusrollAnfrage.safeParse(auftrag?.anfrage); + return auftrag && anfrage.success ? { kurzId: auftrag.kurzId, anfrage: anfrage.data } : null; } diff --git a/src/app/api/v1/auftraege/[id]/aufraeumen/route.ts b/src/app/api/v1/auftraege/[id]/aufraeumen/route.ts new file mode 100644 index 0000000..7576431 --- /dev/null +++ b/src/app/api/v1/auftraege/[id]/aufraeumen/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { aufruferAdresse } from "@/lib/adressen"; +import { api } from "@/server/api"; +import { rechtErforderlich } from "@/server/auth/zugang"; +import { aufraeumenStarten } from "@/server/services/auftraege"; + +type Kontext = { params: Promise<{ id: string }> }; + +/** Halb angelegten Gast eines fehlgeschlagenen Auftrags löschen → neuer Auftrag (202). */ +export const POST = api(async (anfrage: Request, { params }: Kontext) => { + const aufrufer = await rechtErforderlich(anfrage, "ausrollen"); + const auftrag = await aufraeumenStarten((await params).id, aufrufer, aufruferAdresse(anfrage.headers)); + return NextResponse.json({ id: auftrag.id, kurzId: auftrag.kurzId }, { status: 202 }); +}); diff --git a/src/app/api/v1/auftraege/route.ts b/src/app/api/v1/auftraege/route.ts index cdb0f31..8e2ac61 100644 --- a/src/app/api/v1/auftraege/route.ts +++ b/src/app/api/v1/auftraege/route.ts @@ -3,12 +3,13 @@ import { aufruferAdresse } from "@/lib/adressen"; import { api, jsonLesen } from "@/server/api"; import { ausrollenStarten } from "@/server/services/ausrollen"; import { rechtErforderlich } from "@/server/auth/zugang"; -import { auftraegeListe } from "@/server/services/auftraege"; +import { auftraegeSuchen, verlaufFilterAus } from "@/server/services/auftraege"; -/** Verlauf. Filter und Seitenblättern folgen mit #014. */ +/** Verlauf: `?server=&typ=vm|lxc|template|aufraeumen&distribution=&status=&seite=`. */ export const GET = api(async (anfrage: Request) => { await rechtErforderlich(anfrage, "lesen"); - return NextResponse.json(await auftraegeListe()); + const filter = verlaufFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams)); + return NextResponse.json(await auftraegeSuchen(filter)); }); /** Ausrollen → 202 mit Kurz-ID; Fortschritt unter /api/v1/auftraege//ereignisse. */ diff --git a/src/engine/ablaeufe/aufraeumen.ts b/src/engine/ablaeufe/aufraeumen.ts new file mode 100644 index 0000000..bc787ad --- /dev/null +++ b/src/engine/ablaeufe/aufraeumen.ts @@ -0,0 +1,111 @@ +import { AblaufFehler } from "@/engine/ablauf/fehler"; +import type { PhasenSchritt } from "@/engine/ablauf/ablauf"; +import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben"; +import { pfad, type ProxmoxClient } from "@/engine/proxmox/client"; + +/** + * Aufräumen nach einem fehlgeschlagenen oder abgebrochenen Auftrag: den halb angelegten Gast + * stoppen und löschen. Gelöscht wird nur, was **nachweislich zu diesem Auftrag gehört** – die + * Beschreibung trägt „(Auftrag )“, gesetzt schon beim Anlegen bzw. Klonen. Templates + * werden nie gelöscht. Passt etwas nicht, bricht der Ablauf ab, ohne etwas anzufassen. + */ +export type AufraeumEingabe = { + client: ProxmoxClient; + typ: "VM" | "LXC"; + vmid: number; + /** Node laut Auftrag; liegt der Gast inzwischen woanders (Migration), gilt der tatsächliche. */ + knoten: string | null; + /** Kurz-ID des Auftrags, der den Gast angelegt hat. */ + auftragKurzId: string; +}; + +export type AufraeumStand = { knoten?: string; vorhanden?: boolean; laeuft?: boolean; name?: string }; + +type Ressource = { + vmid?: number; + node?: string; + type?: string; + status?: string; + name?: string; + template?: number; +}; + +export function aufraeumSchritte(e: AufraeumEingabe, stand: AufraeumStand = {}): PhasenSchritt[] { + const { client } = e; + const art = e.typ === "LXC" ? "lxc" : "qemu"; + const wort = e.typ === "LXC" ? "Container" : "VM"; + const gastPfad = () => pfad`/nodes/${stand.knoten!}/${art}/${e.vmid}`; + + return [ + { + schluessel: "pruefungen", + titel: "Prüfungen", + ausfuehren: async () => { + const ressourcen = await client.get("/cluster/resources", { type: "vm" }); + const gast = ressourcen.find((r) => r.vmid === e.vmid); + if (!gast) { + stand.vorhanden = false; + return { detail: `${wort} ${e.vmid} gibt es nicht mehr – nichts zu löschen` }; + } + if (gast.type !== art) { + throw new AblaufFehler( + `Unter ${e.vmid} liegt kein ${wort}, sondern ${gast.type === "lxc" ? "ein Container" : "eine VM"}.`, + "Nichts gelöscht. Die ID wurde inzwischen anders vergeben – von Hand prüfen.", + ); + } + stand.knoten = gast.node!; + const konfig = await client.get>(`${gastPfad()}/config`); + if (gast.template === 1 || konfig["template"] === 1) { + throw new AblaufFehler( + `${e.vmid} ist ein Template.`, + "Templates räumt ProxDeploy nie auf. Unter „Templates“ bzw. in Proxmox entfernen.", + ); + } + const beschreibung = String(konfig["description"] ?? ""); + if (!beschreibung.includes(`(Auftrag ${e.auftragKurzId})`)) { + throw new AblaufFehler( + `${wort} ${e.vmid} gehört nicht erkennbar zu Auftrag ${e.auftragKurzId}.`, + "Nichts gelöscht. In Proxmox prüfen und von Hand entfernen, falls es der halb angelegte Gast ist.", + ); + } + if (konfig["lock"]) { + throw new AblaufFehler( + `${wort} ${e.vmid} ist gesperrt (${String(konfig["lock"])}).`, + `Läuft dort noch eine Aufgabe? Sonst auf dem Node „${e.typ === "LXC" ? "pct" : "qm"} unlock ${e.vmid}“ und erneut aufräumen.`, + ); + } + stand.vorhanden = true; + stand.laeuft = gast.status === "running"; + stand.name = gast.name ?? String(konfig["name"] ?? konfig["hostname"] ?? ""); + return { + detail: `${wort} ${e.vmid}${stand.name ? ` (${stand.name})` : ""} auf ${stand.knoten} gehört zu ${e.auftragKurzId}`, + }; + }, + }, + { + schluessel: "stoppen", + titel: "Stoppen", + ausfuehren: async () => { + if (!stand.vorhanden) return { uebersprungen: true, detail: "nichts zu tun" }; + if (!stand.laeuft) return { uebersprungen: true, detail: "läuft nicht" }; + await aufgabeAbwarten(client, await client.post(`${gastPfad()}/status/stop`), { + zeitlimitMs: 5 * 60 * 1000, + }); + return { detail: `${wort} ${e.vmid} gestoppt` }; + }, + }, + { + schluessel: "loeschen", + titel: "Löschen", + ausfuehren: async () => { + if (!stand.vorhanden) return { uebersprungen: true, detail: "nichts zu tun" }; + await aufgabeAbwarten( + client, + await client.delete(gastPfad(), { "destroy-unreferenced-disks": true }), + { zeitlimitMs: 15 * 60 * 1000 }, + ); + return { detail: `${wort} ${e.vmid} mit Disks gelöscht` }; + }, + }, + ]; +} diff --git a/src/engine/ablaeufe/vm-ausrollen.ts b/src/engine/ablaeufe/vm-ausrollen.ts index 346eb13..ccec6c0 100644 --- a/src/engine/ablaeufe/vm-ausrollen.ts +++ b/src/engine/ablaeufe/vm-ausrollen.ts @@ -92,6 +92,8 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = { const { client, anfrage: a, distribution: d } = e; const warten = e.warten ?? schlafen; const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`; + const beschreibung = () => + `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`; const templateStand: TemplateBauStand = {}; const schritte: PhasenSchritt[] = [ @@ -238,6 +240,8 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = { await client.post(pfad`/nodes/${tpl.knoten}/qemu/${tpl.vmid}/clone`, { newid: vmid, name: a.name, + // Schon beim Klonen: daran erkennt „Aufräumen“, dass die VM zu diesem Auftrag gehört. + description: beschreibung(), full: true, target: stand.knoten!, storage: a.storage, @@ -284,7 +288,7 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = { net0: `virtio,bridge=${a.netz.bridge}${a.netz.vlan ? `,tag=${a.netz.vlan}` : ""}`, onboot: a.startBeiBoot, tags: ["proxdeploy", d.id, ...a.tags].join(";"), - description: `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`, + description: beschreibung(), }); // Vergrößern nur, wenn größer: Proxmox kann Disks nicht verkleinern. const konfig = client.trocken ? {} : await client.get>(`${vmPfad()}/config`); diff --git a/src/lib/auftrag-text.ts b/src/lib/auftrag-text.ts index e593f84..f5a31a6 100644 --- a/src/lib/auftrag-text.ts +++ b/src/lib/auftrag-text.ts @@ -24,7 +24,7 @@ const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", " * Inhalt der Abschlussbox – auf dem Server berechnet und mit dem Auftrag ausgeliefert, damit Web * und CLI dieselben Zeilen zeigen. */ -export function abschlussAus(a: Omit): Abschluss | null { +export function abschlussAus(a: Omit): Abschluss | null { if (!ENDE.has(a.status)) return null; const e = (a.ergebnis ?? {}) as Record; const gesamt = @@ -53,5 +53,10 @@ export function abschlussAus(a: Omit): Abschluss | nu if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]); if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]); if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]); - return { ok: true, titel: `${name} bereit`, dauer: gesamt, zeilen }; + return { + ok: true, + titel: `${name} ${a.art === "AUFRAEUMEN" ? "gelöscht" : "bereit"}`, + dauer: gesamt, + zeilen, + }; } diff --git a/src/lib/schemas/api.ts b/src/lib/schemas/api.ts index 18aca1d..8ab41db 100644 --- a/src/lib/schemas/api.ts +++ b/src/lib/schemas/api.ts @@ -164,6 +164,9 @@ export const AuftragSicht = z.looseObject({ gestartetAm: Zeit.nullable(), beendetAm: Zeit.nullable(), abschluss: Abschluss.nullable(), + aufraeumbar: z + .looseObject({ typ: z.enum(["VM", "LXC"]), vmid: z.number().int(), knoten: z.string().nullable() }) + .nullable(), phasen: z.array(PhaseSicht), }); @@ -190,3 +193,10 @@ export const Plan = z.looseObject({ automatisch: z.array(z.string()), ok: z.boolean(), }); + +export const Verlauf = z.looseObject({ + eintraege: z.array(AuftragSicht), + gesamt: z.number().int(), + seite: z.number().int(), + seiten: z.number().int(), +}); diff --git a/src/lib/schemas/auftrag.ts b/src/lib/schemas/auftrag.ts index db6b72a..eacbba3 100644 --- a/src/lib/schemas/auftrag.ts +++ b/src/lib/schemas/auftrag.ts @@ -22,6 +22,8 @@ export type AuftragSicht = { beendetAm: string | null; /** Abschlussbox, sobald der Auftrag fertig ist. */ abschluss: Abschluss | null; + /** Bei Fehlschlag/Abbruch: was „Aufräumen“ löschen würde. */ + aufraeumbar: { typ: "VM" | "LXC"; vmid: number; knoten: string | null } | null; phasen: Array<{ schluessel: string; titel: string; diff --git a/src/server/openapi.ts b/src/server/openapi.ts index a60c0fa..69f47e1 100644 --- a/src/server/openapi.ts +++ b/src/server/openapi.ts @@ -29,6 +29,7 @@ const KOMPONENTEN: Record = { AuftragErgebnis: A.AuftragErgebnis, Abschluss: A.Abschluss, Auftrag: A.AuftragSicht, + Verlauf: A.Verlauf, LogZeile: A.LogZeile, GeplanterAufruf: A.GeplanterAufruf, Plan: A.Plan, @@ -126,6 +127,23 @@ const PFADE: Record> = { }, }, "/auftraege": { + get: { + operationId: "verlauf", + summary: "Verlauf, neueste zuerst, 25 je Seite", + parameters: [ + { name: "server", in: "query", required: false, schema: { type: "string" } }, + { + name: "typ", + in: "query", + required: false, + schema: { type: "string", enum: ["vm", "lxc", "template", "aufraeumen"] }, + }, + { name: "distribution", in: "query", required: false, schema: { type: "string" } }, + { name: "status", in: "query", required: false, schema: { type: "string" } }, + { name: "seite", in: "query", required: false, schema: { type: "integer", minimum: 1 } }, + ], + responses: { "200": { description: "Seite des Verlaufs", ...json(ref("Verlauf")) }, default: FEHLER }, + }, post: { operationId: "ausrollen", summary: "VM oder LXC ausrollen (Auftrag)", @@ -181,6 +199,17 @@ const PFADE: Record> = { }, }, }, + "/auftraege/{id}/aufraeumen": { + post: { + operationId: "auftragAufraeumen", + summary: "Halb angelegten Gast eines fehlgeschlagenen/abgebrochenen Auftrags löschen (Auftrag)", + parameters: [pfadParameter("id", "ID oder Kurz-ID")], + responses: { + "202": { description: "Auftrag angenommen", ...json(ref("AuftragAngenommen")) }, + default: FEHLER, + }, + }, + }, "/auftraege/{id}/abbrechen": { post: { operationId: "auftragAbbrechen", diff --git a/src/server/services/auftraege.ts b/src/server/services/auftraege.ts index cf2ea7e..60846f4 100644 --- a/src/server/services/auftraege.ts +++ b/src/server/services/auftraege.ts @@ -1,8 +1,9 @@ -import { abschlussAus } from "@/lib/auftrag-text"; import { randomInt } from "node:crypto"; import type { Auftrag, Prisma } from "@/generated/prisma/client"; import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums"; +import { abschlussAus } from "@/lib/auftrag-text"; import { prisma } from "@/lib/db"; +import { z } from "@/lib/validierung"; import type { Aufrufer } from "@/server/auth/zugang"; import { AnwendungsFehler, nichtGefunden } from "@/server/fehler"; import { melden } from "@/server/ereignisse/senke"; @@ -91,9 +92,21 @@ const MIT = { type MitBeziehungen = Prisma.AuftragGetPayload<{ include: typeof MIT }>; +/** Was „Aufräumen“ löschen würde – nur bei Fehlschlag oder Abbruch und nur, wenn etwas angelegt wurde. */ +function aufraeumbarAus( + a: Pick, +): AuftragSicht["aufraeumbar"] { + if (a.art === "AUFRAEUMEN" || (a.status !== "FEHLGESCHLAGEN" && a.status !== "ABGEBROCHEN")) return null; + const angelegt = (a.angelegt ?? {}) as { vmid?: unknown; knoten?: unknown; typ?: unknown }; + if (typeof angelegt.vmid !== "number") return null; + const typ = angelegt.typ === "LXC" || a.gastTyp === "LXC" ? "LXC" : "VM"; + const knoten = typeof angelegt.knoten === "string" ? angelegt.knoten : a.knoten; + return { typ, vmid: angelegt.vmid, knoten }; +} + export function auftragSicht(a: MitBeziehungen): AuftragSicht { const iso = (d: Date | null) => d?.toISOString() ?? null; - const sicht: Omit = { + const sicht: Omit = { id: a.id, kurzId: a.kurzId, art: a.art, @@ -124,7 +137,7 @@ export function auftragSicht(a: MitBeziehungen): AuftragSicht { beendetAm: iso(p.beendetAm), })), }; - return { ...sicht, abschluss: abschlussAus(sicht) }; + return { ...sicht, abschluss: abschlussAus(sicht), aufraeumbar: aufraeumbarAus(a) }; } /** Sucht per Kurz-ID oder interner ID – die CLI nimmt, was der Benutzer tippt. */ @@ -137,16 +150,66 @@ export async function auftragHolen(kennung: string): Promise { return auftragSicht(auftrag); } -export async function auftraegeListe( - filter: { serverId?: string; grenze?: number } = {}, -): Promise { - const zeilen = await prisma.auftrag.findMany({ - where: filter.serverId ? { serverId: filter.serverId } : {}, - include: MIT, - orderBy: { erstelltAm: "desc" }, - take: filter.grenze ?? 50, - }); - return zeilen.map(auftragSicht); +/** Filter des Verlaufs – dieselben in Web (`/auftraege?…`) und API (`GET /api/v1/auftraege?…`). */ +export const VerlaufFilter = z.object({ + server: z.string().trim().optional(), + typ: z.enum(["vm", "lxc", "template", "aufraeumen"]).optional(), + distribution: z.string().trim().optional(), + status: z + .enum(["WARTEND", "LAEUFT", "ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]) + .optional(), + seite: z.coerce.number().int().min(1).max(10_000).default(1), +}); +export type VerlaufFilter = z.infer; + +export const VERLAUF_PRO_SEITE = 25; + +/** Liest Filter aus Suchparametern; Unbekanntes und Leeres fällt still weg statt zu stören. */ +export function verlaufFilterAus(parameter: Record): VerlaufFilter { + const roh: Record = {}; + for (const [k, v] of Object.entries(parameter)) { + const wert = Array.isArray(v) ? v[0] : v; + if (wert) roh[k] = wert; + } + const geprueft: Record = {}; + for (const schluessel of Object.keys(VerlaufFilter.shape) as Array) { + const feld = VerlaufFilter.shape[schluessel].safeParse(roh[schluessel]); + if (feld.success && feld.data !== undefined) geprueft[schluessel] = feld.data; + } + return VerlaufFilter.parse(geprueft); +} + +export async function auftraegeSuchen(filter: VerlaufFilter): Promise<{ + eintraege: AuftragSicht[]; + gesamt: number; + seite: number; + seiten: number; +}> { + const where: Prisma.AuftragWhereInput = { + ...(filter.server ? { server: { name: filter.server } } : {}), + ...(filter.distribution ? { distributionId: filter.distribution } : {}), + ...(filter.status ? { status: filter.status } : {}), + ...(filter.typ === "vm" ? { art: "AUSROLLEN", gastTyp: "VM" } : {}), + ...(filter.typ === "lxc" ? { art: "AUSROLLEN", gastTyp: "LXC" } : {}), + ...(filter.typ === "template" ? { art: "TEMPLATE_BAUEN" } : {}), + ...(filter.typ === "aufraeumen" ? { art: "AUFRAEUMEN" } : {}), + }; + const [gesamt, zeilen] = await prisma.$transaction([ + prisma.auftrag.count({ where }), + prisma.auftrag.findMany({ + where, + include: MIT, + orderBy: { erstelltAm: "desc" }, + skip: (filter.seite - 1) * VERLAUF_PRO_SEITE, + take: VERLAUF_PRO_SEITE, + }), + ]); + return { + eintraege: zeilen.map(auftragSicht), + gesamt, + seite: filter.seite, + seiten: Math.max(1, Math.ceil(gesamt / VERLAUF_PRO_SEITE)), + }; } export async function logAb(auftragId: string, abId: bigint, grenze = 500): Promise { @@ -167,6 +230,50 @@ export async function logAb(auftragId: string, abId: bigint, grenze = 500): Prom export const ENDZUSTAENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]); /** Bittet den worker um Abbruch; er prüft das zwischen den Phasen. */ +/** + * Aufräumen starten: eigener Auftrag, der den halb angelegten Gast eines fehlgeschlagenen bzw. + * abgebrochenen Auftrags löscht. Was genau gelöscht wird, prüft der worker gegen Proxmox. + */ +export async function aufraeumenStarten( + kennung: string, + aufrufer: Aufrufer, + ip: string | null, +): Promise { + const sicht = await auftragHolen(kennung); + if (!sicht.aufraeumbar) { + throw new AnwendungsFehler( + 409, + "nicht_aufraeumbar", + sicht.status === "AUFGERAEUMT" + ? "Dieser Auftrag ist schon aufgeräumt." + : "Hier gibt es nichts aufzuräumen – nur fehlgeschlagene oder abgebrochene Aufträge, die etwas angelegt haben.", + ); + } + const laufend = await prisma.auftrag.findFirst({ + where: { art: "AUFRAEUMEN", bezugId: sicht.id, status: { in: ["WARTEND", "LAEUFT"] } }, + select: { kurzId: true }, + }); + if (laufend) { + throw new AnwendungsFehler(409, "laeuft_schon", `Das Aufräumen läuft schon (${laufend.kurzId}).`); + } + const original = await prisma.auftrag.findUniqueOrThrow({ where: { id: sicht.id } }); + const { typ, vmid, knoten } = sicht.aufraeumbar; + return await auftragAnlegen( + { + art: "AUFRAEUMEN", + serverId: original.serverId, + ...(knoten ? { knoten } : {}), + gastTyp: typ, + ...(original.name ? { name: original.name } : {}), + ...(original.distributionId ? { distributionId: original.distributionId } : {}), + bezugId: original.id, + anfrage: { auftrag: original.kurzId, typ, vmid, knoten }, + }, + aufrufer, + ip, + ); +} + export async function auftragAbbrechen( kennung: string, aufrufer: Aufrufer, diff --git a/src/worker/ablaeufe.ts b/src/worker/ablaeufe.ts index 6c953bb..67a5a4c 100644 --- a/src/worker/ablaeufe.ts +++ b/src/worker/ablaeufe.ts @@ -1,3 +1,4 @@ -/** Meldet alle Abläufe beim worker an. Aufräumen folgt. */ +/** Meldet alle Abläufe beim worker an. */ import "./ablaeufe/ausrollen"; import "./ablaeufe/template"; +import "./ablaeufe/aufraeumen"; diff --git a/src/worker/ablaeufe/aufraeumen.ts b/src/worker/ablaeufe/aufraeumen.ts new file mode 100644 index 0000000..fedfcd5 --- /dev/null +++ b/src/worker/ablaeufe/aufraeumen.ts @@ -0,0 +1,59 @@ +import { phasenAusfuehren } from "@/engine/ablauf/ablauf"; +import { aufraeumSchritte, type AufraeumStand } from "@/engine/ablaeufe/aufraeumen"; +import { ProxmoxClient } from "@/engine/proxmox/client"; +import { prisma } from "@/lib/db"; +import { z } from "@/lib/validierung"; +import { melden } from "@/server/ereignisse/senke"; +import { verbindungAus } from "@/server/services/server"; +import { ablaufRegistrieren } from "../ausfuehren"; +import { aufrufLogger } from "../aufruf-log"; + +const Anfrage = z.object({ + auftrag: z.string(), + typ: z.enum(["VM", "LXC"]), + vmid: z.number().int(), + knoten: z.string().nullable(), +}); + +ablaufRegistrieren("AUFRAEUMEN", async ({ auftrag, senke, abbruchGewuenscht }) => { + const anfrage = Anfrage.parse(auftrag.anfrage); + const server = await prisma.server.findUniqueOrThrow({ where: { id: auftrag.serverId } }); + await prisma.auftrag.update({ + where: { id: auftrag.id }, + data: { + vmid: anfrage.vmid, + kopf: `Server ${server.name}${anfrage.knoten ? ` · Node ${anfrage.knoten}` : ""} · ${ + anfrage.typ === "LXC" ? "CT" : "VM" + } ${anfrage.vmid} aus ${anfrage.auftrag}`, + }, + }); + await melden(auftrag.id); + + const client = new ProxmoxClient(verbindungAus(server), { beobachter: aufrufLogger(senke) }); + const stand: AufraeumStand = {}; + try { + const ergebnis = await phasenAusfuehren( + aufraeumSchritte( + { + client, + typ: anfrage.typ, + vmid: anfrage.vmid, + knoten: anfrage.knoten, + auftragKurzId: anfrage.auftrag, + }, + stand, + ), + senke, + { abbruchGewuenscht }, + ); + if (!ergebnis.ok) return ergebnis; + // Der ursprüngliche Auftrag ist damit erledigt – im Verlauf als „aufgeräumt“ sichtbar. + if (auftrag.bezugId) { + await prisma.auftrag.update({ where: { id: auftrag.bezugId }, data: { status: "AUFGERAEUMT" } }); + await melden(auftrag.bezugId); + } + return { ok: true, ergebnis: { vmid: anfrage.vmid, ...(stand.knoten ? { knoten: stand.knoten } : {}) } }; + } finally { + await client.schliessen(); + } +}); diff --git a/tests/e2e/verlauf.spec.ts b/tests/e2e/verlauf.spec.ts new file mode 100644 index 0000000..74435d5 --- /dev/null +++ b/tests/e2e/verlauf.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; +import { anmelden, keinQuerscrollen } from "./helfer"; + +test("Verlauf: Filter stehen in der URL und überleben das Neuladen", async ({ page }) => { + await anmelden(page, "/auftraege"); + await expect(page.getByRole("heading", { name: "Verlauf" })).toBeVisible(); + await page.getByLabel("Typ").selectOption("vm"); + await expect(page).toHaveURL(/typ=vm/); + await page.getByLabel("Status").selectOption("ERFOLGREICH"); + await expect(page).toHaveURL(/typ=vm.*status=ERFOLGREICH|status=ERFOLGREICH.*typ=vm/); + await page.reload(); + await expect(page.getByLabel("Typ")).toHaveValue("vm"); + await expect(page.getByLabel("Status")).toHaveValue("ERFOLGREICH"); + await keinQuerscrollen(page); + await page.getByRole("button", { name: "Filter zurücksetzen" }).click(); + await expect(page).toHaveURL(/\/auftraege$/); +}); + +test("Wiederholen füllt den Assistenten mit den alten Werten, Name leer", async ({ page }) => { + await anmelden(page, "/auftraege?typ=vm&status=ERFOLGREICH"); + const erster = page.locator("main ul li a").first(); + if ((await erster.count()) === 0) test.skip(true, "Kein erfolgreicher VM-Auftrag vorhanden"); + await erster.click(); + await page.getByRole("link", { name: "Wiederholen" }).click(); + await expect(page).toHaveURL(/\/ausrollen\?wiederholen=/); + await expect(page.getByText(/Wiederholung von d-/)).toBeVisible(); + await keinQuerscrollen(page); + for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Weiter" }).click(); + await expect(page.getByLabel("Name (Hostname)")).toHaveValue(""); + await expect(page.getByRole("button", { name: "Weiter" })).toBeDisabled(); +}); diff --git a/tests/engine/aufraeumen.test.ts b/tests/engine/aufraeumen.test.ts new file mode 100644 index 0000000..3ae1a84 --- /dev/null +++ b/tests/engine/aufraeumen.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf"; +import { aufraeumSchritte } from "@/engine/ablaeufe/aufraeumen"; +import { ProxmoxClient } from "@/engine/proxmox/client"; +import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock"; +import { szenarioEinrichten } from "../proxmox-mock/szenario"; + +const UPID = (art: string) => `UPID:pve02:00001234:00ABCDEF:6710F00D:${art}:130:proxdeploy@pve!test:`; + +const aufraeumen: Array<() => Promise> = []; +afterEach(async () => { + while (aufraeumen.length) await aufraeumen.pop()!(); +}); + +type Gast = { + type: "qemu" | "lxc"; + node: string; + status: string; + template?: number; + config: Record; +}; + +async function aufbauen(gast: Gast | null) { + const mock = await new ProxmoxMock().starten(); + szenarioEinrichten(mock, { version: "9.2.1" }); + mock + .route("GET", "/cluster/resources", () => ({ + data: [ + { vmid: 100, type: "qemu", node: "pve01", status: "running", name: "anderer" }, + ...(gast + ? [ + { + vmid: 120, + type: gast.type, + node: gast.node, + status: gast.status, + name: "web01", + template: gast.template ?? 0, + }, + ] + : []), + ], + })) + .route("GET", /^\/nodes\/pve02\/(qemu|lxc)\/120\/config$/, () => ({ data: gast?.config ?? {} })) + .route("POST", /^\/nodes\/pve02\/(qemu|lxc)\/120\/status\/stop$/, () => ({ data: UPID("qmstop") })) + .route("DELETE", /^\/nodes\/pve02\/(qemu|lxc)\/120$/, () => ({ data: UPID("qmdestroy") })) + .route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } })); + const client = new ProxmoxClient({ + url: mock.url, + tokenId: MOCK_TOKEN_ID, + tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS, + tlsPruefen: false, + fingerabdruck: mock.fingerabdruck, + }); + aufraeumen.push(async () => { + await client.schliessen(); + await mock.stoppen(); + }); + const phasen: Record = {}; + const senke: EreignisSenke = { + phasenAnlegen: async () => {}, + phaseGestartet: async () => {}, + phaseDetail: async () => {}, + phaseBeendet: async (s, status, a) => { + phasen[s] = { status, detail: a.detail, ursache: a.fehler?.ursache }; + }, + log: async () => {}, + }; + const ausfuehren = (typ: "VM" | "LXC" = "VM") => + phasenAusfuehren( + aufraeumSchritte({ client, typ, vmid: 120, knoten: "pve01", auftragKurzId: "d-abcde" }), + senke, + ); + const schreibend = () => + mock.anfragen.filter((a) => a.methode !== "GET").map((a) => `${a.methode} ${a.pfad}`); + return { mock, ausfuehren, phasen, schreibend }; +} + +const EIGEN = "Ausgerollt mit ProxDeploy (Auftrag d-abcde)\nDistribution: Debian 13"; + +describe("Aufräumen", () => { + it("stoppt und löscht den eigenen Gast – auch wenn er inzwischen auf einem anderen Node liegt", async () => { + const t = await aufbauen({ + type: "qemu", + node: "pve02", + status: "running", + config: { name: "web01", description: EIGEN }, + }); + expect((await t.ausfuehren()).ok).toBe(true); + expect(t.schreibend()).toEqual([ + "POST /nodes/pve02/qemu/120/status/stop", + "DELETE /nodes/pve02/qemu/120", + ]); + const loeschen = t.mock.anfragen.find((a) => a.methode === "DELETE")!; + expect(loeschen.abfrage.get("destroy-unreferenced-disks")).toBe("1"); + expect(t.phasen["loeschen"]!.detail).toBe("VM 120 mit Disks gelöscht"); + }); + + it("Container, der nicht läuft: Stoppen übersprungen", async () => { + const t = await aufbauen({ + type: "lxc", + node: "pve02", + status: "stopped", + config: { hostname: "ct01", description: EIGEN }, + }); + expect((await t.ausfuehren("LXC")).ok).toBe(true); + expect(t.phasen["stoppen"]!.status).toBe("UEBERSPRUNGEN"); + expect(t.schreibend()).toEqual(["DELETE /nodes/pve02/lxc/120"]); + }); + + it("schon weg: Erfolg ohne Löschen", async () => { + const t = await aufbauen(null); + expect((await t.ausfuehren()).ok).toBe(true); + expect(t.phasen["pruefungen"]!.detail).toMatch(/gibt es nicht mehr/); + expect(t.schreibend()).toEqual([]); + }); + + it.each([ + [ + "fremde Beschreibung", + { type: "qemu", node: "pve02", status: "running", config: { description: "Produktiv-DB" } }, + /gehört nicht erkennbar/, + ], + [ + "anderer Auftrag", + { + type: "qemu", + node: "pve02", + status: "running", + config: { description: "Ausgerollt mit ProxDeploy (Auftrag d-zzzzz)" }, + }, + /gehört nicht erkennbar/, + ], + [ + "Template", + { + type: "qemu", + node: "pve02", + status: "stopped", + template: 1, + config: { description: EIGEN, template: 1 }, + }, + /ist ein Template/, + ], + [ + "gesperrt", + { type: "qemu", node: "pve02", status: "stopped", config: { description: EIGEN, lock: "clone" } }, + /gesperrt \(clone\)/, + ], + [ + "falscher Typ", + { type: "lxc", node: "pve02", status: "stopped", config: { description: EIGEN } }, + /kein VM/, + ], + ] as Array<[string, Gast, RegExp]>)("%s: bricht ab und fasst nichts an", async (_name, gast, meldung) => { + const t = await aufbauen(gast); + const ergebnis = await t.ausfuehren("VM"); + expect(ergebnis.ok).toBe(false); + expect(t.phasen["pruefungen"]!.ursache).toMatch(meldung); + expect(t.schreibend()).toEqual([]); + }); +}); diff --git a/tests/engine/vm-ausrollen.test.ts b/tests/engine/vm-ausrollen.test.ts index 0d2dcf3..d5687a5 100644 --- a/tests/engine/vm-ausrollen.test.ts +++ b/tests/engine/vm-ausrollen.test.ts @@ -183,7 +183,15 @@ describe("VM ausrollen", () => { expect(angelegt).toContainEqual({ vmid: 101 }); const klon = Object.fromEntries(mock.anfragen.find((a) => a.pfad.endsWith("/clone"))!.rumpf); - expect(klon).toEqual({ newid: "101", name: "web01", full: "1", target: "pve01", storage: "local-zfs" }); + expect(klon).toMatchObject({ + newid: "101", + name: "web01", + full: "1", + target: "pve01", + storage: "local-zfs", + }); + // Schon beim Klonen erkennbar als Gast dieses Auftrags – davon hängt „Aufräumen“ ab. + expect(klon["description"]).toMatch(/^Ausgerollt mit ProxDeploy \(Auftrag d-test1\)/); const configs = mock.anfragen .filter((a) => a.methode === "PUT" && a.pfad.endsWith("/config")) diff --git a/tests/verlauf.test.ts b/tests/verlauf.test.ts new file mode 100644 index 0000000..f769fa5 --- /dev/null +++ b/tests/verlauf.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import * as liste from "@/app/api/v1/auftraege/route"; +import * as aufraeumenRoute from "@/app/api/v1/auftraege/[id]/aufraeumen/route"; +import { verschluesseln } from "@/lib/geheimnisse"; +import { auftragHolen, verlaufFilterAus, VERLAUF_PRO_SEITE } from "@/server/services/auftraege"; +import { katalogSynchronisieren } from "@/server/services/katalog"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; +import { angemeldet, anfrage, kontext } from "./helpers/anfragen"; + +describe("Verlaufsfilter aus der URL", () => { + it("übernimmt Gültiges und lässt Unsinn still fallen", () => { + expect(verlaufFilterAus({ typ: "lxc", status: "FEHLGESCHLAGEN", seite: "3", server: "rz1" })).toEqual({ + typ: "lxc", + status: "FEHLGESCHLAGEN", + seite: 3, + server: "rz1", + }); + expect(verlaufFilterAus({ typ: "quatsch", status: "x", seite: "-4", distribution: "" })).toEqual({ + seite: 1, + }); + }); +}); + +describe.runIf(datenbankVerfuegbar)("Verlauf und Aufräumen", () => { + let cookie: string; + let benutzerId: string; + let serverIds: Record; + + beforeEach(async () => { + await datenbankLeeren(); + await katalogSynchronisieren(); + const op = await angemeldet("OPERATOR"); + cookie = op.cookie; + benutzerId = op.benutzer.id; + serverIds = {}; + for (const name of ["rz1", "rz2"]) { + serverIds[name] = ( + await testPrisma.server.create({ + data: { + name, + titel: name, + url: "https://127.0.0.1:8006", + tokenId: "a@pve!b", + tokenGeheimnis: verschluesseln("x"), + fingerabdruck: "AA", + }, + }) + ).id; + } + }); + + let nr = 0; + const auftrag = async (daten: Record) => + await testPrisma.auftrag.create({ + data: { + kurzId: `d-t${String(nr++).padStart(4, "0")}`, + art: "AUSROLLEN", + status: "ERFOLGREICH", + quelle: "WEB", + serverId: serverIds["rz1"]!, + erstelltVonId: benutzerId, + anfrage: {}, + ...daten, + } as never, + }); + + it("filtert nach Server, Typ, Distribution und Status und blättert", async () => { + await auftrag({ gastTyp: "VM", distributionId: "debian-13" }); + await auftrag({ gastTyp: "LXC", distributionId: "ubuntu-24.04", status: "FEHLGESCHLAGEN" }); + await auftrag({ gastTyp: "LXC", distributionId: "debian-13", serverId: serverIds["rz2"] }); + await auftrag({ art: "TEMPLATE_BAUEN", gastTyp: "VM", distributionId: "debian-13" }); + + const holen = async (abfrage: string) => + (await (await liste.GET(anfrage("GET", `/api/v1/auftraege?${abfrage}`, cookie))).json()) as { + eintraege: Array<{ kurzId: string; art: string; gastTyp: string }>; + gesamt: number; + seiten: number; + }; + expect((await holen("")).gesamt).toBe(4); + expect((await holen("typ=lxc")).gesamt).toBe(2); + expect((await holen("typ=vm")).gesamt).toBe(1); + expect((await holen("typ=template")).eintraege[0]!.art).toBe("TEMPLATE_BAUEN"); + expect((await holen("server=rz2")).gesamt).toBe(1); + expect((await holen("distribution=debian-13&typ=lxc")).gesamt).toBe(1); + expect((await holen("status=FEHLGESCHLAGEN")).gesamt).toBe(1); + + for (let i = 0; i < VERLAUF_PRO_SEITE; i++) await auftrag({ gastTyp: "VM" }); + const erste = await holen(""); + const zweite = await holen("seite=2"); + expect(erste.seiten).toBe(2); + expect(erste.eintraege).toHaveLength(VERLAUF_PRO_SEITE); + expect(zweite.eintraege).toHaveLength(4); + expect(new Set([...erste.eintraege, ...zweite.eintraege].map((e) => e.kurzId)).size).toBe( + VERLAUF_PRO_SEITE + 4, + ); + }); + + const starten = async (kurzId: string) => + await aufraeumenRoute.POST( + anfrage("POST", `/api/v1/auftraege/${kurzId}/aufraeumen`, cookie), + kontext({ id: kurzId }), + ); + + it("Aufräumen nur bei Fehlschlag/Abbruch mit angelegtem Gast, nie doppelt", async () => { + const ok = await auftrag({ gastTyp: "VM", angelegt: { vmid: 120, knoten: "pve01", typ: "VM" } }); + const nichts = await auftrag({ gastTyp: "VM", status: "FEHLGESCHLAGEN", angelegt: { knoten: "pve01" } }); + const kaputt = await auftrag({ + gastTyp: "LXC", + name: "ct01", + status: "ABGEBROCHEN", + angelegt: { vmid: 121, knoten: "pve01", typ: "LXC" }, + }); + + expect((await auftragHolen(ok.kurzId)).aufraeumbar).toBeNull(); + expect((await auftragHolen(nichts.kurzId)).aufraeumbar).toBeNull(); + expect((await auftragHolen(kaputt.kurzId)).aufraeumbar).toEqual({ + typ: "LXC", + vmid: 121, + knoten: "pve01", + }); + + expect((await starten(ok.kurzId)).status).toBe(409); + expect((await starten(nichts.kurzId)).status).toBe(409); + const gestartet = await starten(kaputt.kurzId); + expect(gestartet.status).toBe(202); + const { kurzId } = (await gestartet.json()) as { kurzId: string }; + expect(kurzId).toMatch(/^c-/); + const neu = await testPrisma.auftrag.findUniqueOrThrow({ where: { kurzId } }); + expect(neu).toMatchObject({ art: "AUFRAEUMEN", bezugId: kaputt.id, gastTyp: "LXC", name: "ct01" }); + expect(neu.anfrage).toEqual({ auftrag: kaputt.kurzId, typ: "LXC", vmid: 121, knoten: "pve01" }); + + const doppelt = await starten(kaputt.kurzId); + expect(doppelt.status).toBe(409); + expect((await doppelt.json()).fehler.nachricht).toContain(kurzId); + expect(await testPrisma.protokoll.count({ where: { aktion: "Aufräumen gestartet" } })).toBe(1); + }); +});