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>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 <job-id>",
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -140,6 +140,8 @@ func (u *umgebung) wurzel() *cobra.Command {
|
||||
u.statusBefehl(),
|
||||
u.logsBefehl(),
|
||||
u.cancelBefehl(),
|
||||
u.historyBefehl(),
|
||||
u.cleanupBefehl(),
|
||||
)
|
||||
hilfeEindeutschen(wurzel)
|
||||
return wurzel
|
||||
|
||||
@@ -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=<kurzId>`, 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 <kurzId>)“. 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`.
|
||||
|
||||
@@ -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 | – |
|
||||
|
||||
@@ -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<string | null>(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<string, unknown> | 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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
@@ -105,9 +128,22 @@ export function LiveAnsicht({
|
||||
: (auftrag.name ?? "Ausrollen")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
|
||||
<Status art={art}>{text}</Status>
|
||||
{abbrechenErlaubt && !ENDE.has(auftrag.status) ? (
|
||||
{darfAusrollen && auftrag.aufraeumbar ? (
|
||||
<Knopf variante="gefahr" onClick={() => void aufraeumen()}>
|
||||
Aufräumen
|
||||
</Knopf>
|
||||
) : null}
|
||||
{darfAusrollen && auftrag.art === "AUSROLLEN" && ENDE.has(auftrag.status) ? (
|
||||
<Link
|
||||
href={`/ausrollen?wiederholen=${auftrag.kurzId}`}
|
||||
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
|
||||
>
|
||||
Wiederholen
|
||||
</Link>
|
||||
) : null}
|
||||
{darfAusrollen && !ENDE.has(auftrag.status) ? (
|
||||
<Knopf
|
||||
variante="gefahr"
|
||||
onClick={() => void apiAufruf("POST", `/api/v1/auftraege/${auftrag.kurzId}/abbrechen`)}
|
||||
@@ -118,6 +154,8 @@ export function LiveAnsicht({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{aktionFehler ? <Status art="fehler">{aktionFehler}</Status> : null}
|
||||
|
||||
<TerminalKarte version={version} kopf={auftrag.kopf} phasen={auftrag.phasen} ergebnis={ergebnis} />
|
||||
|
||||
{typeof ssh === "string" ? (
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:flex lg:items-end">
|
||||
{felder.map((f) => (
|
||||
<Feld key={f.name} id={`filter-${f.name}`} label={f.label}>
|
||||
<select
|
||||
id={`filter-${f.name}`}
|
||||
className={`${EINGABE_KLASSE} lg:w-44`}
|
||||
value={parameter.get(f.name) ?? ""}
|
||||
onChange={(e) => setze(f.name, e.target.value)}
|
||||
>
|
||||
<option value="">alle</option>
|
||||
{f.optionen.map((o) => (
|
||||
<option key={o.wert} value={o.wert}>
|
||||
{o.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Feld>
|
||||
))}
|
||||
{aktiv ? (
|
||||
<Knopf variante="sekundaer" className="col-span-2 sm:col-span-1" onClick={() => router.push(pfad)}>
|
||||
Filter zurücksetzen
|
||||
</Knopf>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
|
||||
/** Verlauf aller Aufträge, filterbar nach Server, Typ, Distribution und Status (Stand in der URL). */
|
||||
export default async function Verlauf({ searchParams }: { searchParams: Promise<Suche> }) {
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
|
||||
<header className="flex flex-wrap items-end justify-between gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
|
||||
<p className="text-muted text-sm">
|
||||
{gesamt} {gesamt === 1 ? "Auftrag" : "Aufträge"}
|
||||
</p>
|
||||
</header>
|
||||
<FilterLeiste
|
||||
server={server.map((s) => ({ wert: s.name, text: s.titel }))}
|
||||
distributionen={distributionen.map((d) => ({ wert: d.id, text: d.name }))}
|
||||
/>
|
||||
<Karte>
|
||||
{auftraege.length === 0 ? (
|
||||
<Status art="offen">Noch keine Aufträge.</Status>
|
||||
{eintraege.length === 0 ? (
|
||||
<Status art="offen">
|
||||
{gesamt === 0 && seite === 1 ? "Keine Aufträge." : "Keine Aufträge auf dieser Seite."}
|
||||
</Status>
|
||||
) : (
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{auftraege.map((a) => {
|
||||
{eintraege.map((a) => {
|
||||
const [art, text] = STATUS_ANZEIGE[a.status];
|
||||
return (
|
||||
<li key={a.id}>
|
||||
@@ -34,9 +65,15 @@ export default async function Verlauf() {
|
||||
<span className="font-medium">{a.name ?? ART_TEXT[a.art]}</span>
|
||||
<span className="text-muted text-sm">
|
||||
{" "}
|
||||
· {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}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted text-sm sm:w-28">{zeitpunkt(a.erstelltAm)}</span>
|
||||
@@ -50,6 +87,27 @@ export default async function Verlauf() {
|
||||
</ul>
|
||||
)}
|
||||
</Karte>
|
||||
{seiten > 1 ? (
|
||||
<nav aria-label="Seiten" className="flex items-center justify-between gap-4 text-sm">
|
||||
{seite > 1 ? (
|
||||
<Link className="text-akzent hover:underline" href={seitenLink(seite - 1)}>
|
||||
← Neuere
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className="text-muted">
|
||||
Seite {seite} von {seiten}
|
||||
</span>
|
||||
{seite < seiten ? (
|
||||
<Link className="text-akzent hover:underline" href={seitenLink(seite + 1)}>
|
||||
Ältere →
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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=<kurzId>`: 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
|
||||
<p className="text-muted text-sm">
|
||||
Schritt {schritt + 1} von {SCHRITTE.length} · {SCHRITTE[schritt]}
|
||||
</p>
|
||||
{wiederholung ? (
|
||||
<Status art="offen" className="mt-2 text-sm">
|
||||
Wiederholung von {wiederholung.kurzId} – Name, VMID und eine feste IP neu angeben.
|
||||
</Status>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[14rem_1fr]">
|
||||
|
||||
@@ -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 <Assistent daten={daten} vorauswahl={vorauswahl} />;
|
||||
return (
|
||||
<Assistent
|
||||
daten={daten}
|
||||
vorauswahl={vorauswahl}
|
||||
wiederholung={await wiederholungLaden((await searchParams).wiederholen)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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/<kurzId>/ereignisse. */
|
||||
|
||||
@@ -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 <kurzId>)“, 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<Ressource[]>("/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<Record<string, unknown>>(`${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<string>(`${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<string>(gastPfad(), { "destroy-unreferenced-disks": true }),
|
||||
{ zeitlimitMs: 15 * 60 * 1000 },
|
||||
);
|
||||
return { detail: `${wort} ${e.vmid} mit Disks gelöscht` };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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<string>(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<Record<string, string>>(`${vmPfad()}/config`);
|
||||
|
||||
@@ -24,7 +24,7 @@ const ENDE = new Set<AuftragSicht["status"]>(["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<AuftragSicht, "abschluss">): Abschluss | null {
|
||||
export function abschlussAus(a: Omit<AuftragSicht, "abschluss" | "aufraeumbar">): Abschluss | null {
|
||||
if (!ENDE.has(a.status)) return null;
|
||||
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
|
||||
const gesamt =
|
||||
@@ -53,5 +53,10 @@ export function abschlussAus(a: Omit<AuftragSicht, "abschluss">): 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,6 +29,7 @@ const KOMPONENTEN: Record<string, Schema> = {
|
||||
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<string, Record<string, Operation>> = {
|
||||
},
|
||||
},
|
||||
"/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<string, Record<string, Operation>> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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",
|
||||
|
||||
@@ -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<Auftrag, "art" | "status" | "angelegt" | "gastTyp" | "knoten">,
|
||||
): 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<AuftragSicht, "abschluss"> = {
|
||||
const sicht: Omit<AuftragSicht, "abschluss" | "aufraeumbar"> = {
|
||||
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<AuftragSicht> {
|
||||
return auftragSicht(auftrag);
|
||||
}
|
||||
|
||||
export async function auftraegeListe(
|
||||
filter: { serverId?: string; grenze?: number } = {},
|
||||
): Promise<AuftragSicht[]> {
|
||||
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<typeof VerlaufFilter>;
|
||||
|
||||
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<string, string | string[] | undefined>): VerlaufFilter {
|
||||
const roh: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(parameter)) {
|
||||
const wert = Array.isArray(v) ? v[0] : v;
|
||||
if (wert) roh[k] = wert;
|
||||
}
|
||||
const geprueft: Record<string, unknown> = {};
|
||||
for (const schluessel of Object.keys(VerlaufFilter.shape) as Array<keyof VerlaufFilter>) {
|
||||
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<LogZeile[]> {
|
||||
@@ -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<Auftrag> {
|
||||
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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<void>> = [];
|
||||
afterEach(async () => {
|
||||
while (aufraeumen.length) await aufraeumen.pop()!();
|
||||
});
|
||||
|
||||
type Gast = {
|
||||
type: "qemu" | "lxc";
|
||||
node: string;
|
||||
status: string;
|
||||
template?: number;
|
||||
config: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, { status: string; detail?: string; ursache?: string }> = {};
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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"))
|
||||
|
||||
@@ -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<string, string>;
|
||||
|
||||
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<string, unknown>) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user