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>
321 lines
9.5 KiB
Go
321 lines
9.5 KiB
Go
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"
|
||
)
|
||
|
||
func eineID(befehl string) cobra.PositionalArgs {
|
||
return func(_ *cobra.Command, argumente []string) error {
|
||
if len(argumente) != 1 {
|
||
return bedienfehler("Auftrags-ID fehlt: proxdeploy %s <job-id>", befehl)
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func (u *umgebung) statusBefehl() *cobra.Command {
|
||
var folgen bool
|
||
befehl := &cobra.Command{
|
||
Use: "status <job-id>",
|
||
Short: "Stand eines Auftrags (mit --follow live bis zum Ende)",
|
||
Args: eineID("status"),
|
||
RunE: func(cmd *cobra.Command, argumente []string) error {
|
||
c, _, err := u.client()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if folgen {
|
||
_, err := u.folgen(cmd.Context(), c, argumente[0])
|
||
return err
|
||
}
|
||
a, err := c.Auftrag(cmd.Context(), argumente[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fertig := endzustaende[a.Status]
|
||
if u.opt.json {
|
||
if err := u.jsonAusgeben(a); err != nil {
|
||
return err
|
||
}
|
||
} else {
|
||
d := u.neuerDarsteller()
|
||
d.live = true // einmal vollständig zeichnen, ohne Neuzeichnen
|
||
d.zeichnen(a, fertig)
|
||
}
|
||
if fertig && a.Status != api.AuftragStatusERFOLGREICH {
|
||
return &exitFehler{code: ExitFehlschlag, gemeldet: true}
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
befehl.Flags().BoolVarP(&folgen, "follow", "f", false, "live verfolgen bis zum Ende")
|
||
return befehl
|
||
}
|
||
|
||
func (u *umgebung) logsBefehl() *cobra.Command {
|
||
var folgen bool
|
||
befehl := &cobra.Command{
|
||
Use: "logs <job-id>",
|
||
Short: "Detail-Log eines Auftrags (mit --follow live)",
|
||
Args: eineID("logs"),
|
||
RunE: func(cmd *cobra.Command, argumente []string) error {
|
||
c, _, err := u.client()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
id := argumente[0]
|
||
zeilen, err := c.Log(cmd.Context(), id, "")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, z := range zeilen {
|
||
u.logZeile(z)
|
||
}
|
||
if !folgen {
|
||
return nil
|
||
}
|
||
letzte := ""
|
||
if len(zeilen) > 0 {
|
||
letzte = zeilen[len(zeilen)-1].Id
|
||
}
|
||
return u.logFolgen(cmd.Context(), c, id, letzte)
|
||
},
|
||
}
|
||
befehl.Flags().BoolVarP(&folgen, "follow", "f", false, "neue Zeilen live anhängen, bis der Auftrag fertig ist")
|
||
return befehl
|
||
}
|
||
|
||
func (u *umgebung) logZeile(z api.LogZeile) {
|
||
if u.opt.json {
|
||
daten, _ := json.Marshal(z)
|
||
fmt.Fprintln(u.aus, string(daten))
|
||
return
|
||
}
|
||
s := u.stil()
|
||
zeit := z.Zeit
|
||
if t, err := time.Parse(time.RFC3339Nano, z.Zeit); err == nil {
|
||
zeit = t.Local().Format("15:04:05")
|
||
}
|
||
phase := ""
|
||
if z.Phase != nil {
|
||
phase = *z.Phase
|
||
}
|
||
nachricht := z.Nachricht
|
||
switch z.Stufe {
|
||
case "error":
|
||
nachricht = s.Rot(nachricht)
|
||
case "warn":
|
||
nachricht = s.Amber(nachricht)
|
||
default:
|
||
nachricht = s.Paper(nachricht)
|
||
}
|
||
fmt.Fprintf(u.aus, "%s %s %s\n", s.Muted(zeit), s.Muted(fmt.Sprintf("%-16s", phase)), nachricht)
|
||
}
|
||
|
||
// logFolgen hängt neue Zeilen an (SSE-Ereignis `log`), bis der Auftrag fertig ist.
|
||
func (u *umgebung) logFolgen(ctx context.Context, c *api.Client, id, ab string) error {
|
||
for versuch := 0; versuch < 5; versuch++ {
|
||
err := c.Ereignisse(ctx, id, ab, func(e api.Ereignis) error {
|
||
switch e.Art {
|
||
case "log":
|
||
var zeilen []api.LogZeile
|
||
if err := json.Unmarshal(e.Daten, &zeilen); err != nil {
|
||
return err
|
||
}
|
||
for _, z := range zeilen {
|
||
u.logZeile(z)
|
||
ab = z.Id
|
||
}
|
||
case "ende":
|
||
return api.Ende
|
||
}
|
||
return nil
|
||
})
|
||
if errors.Is(err, api.Ende) || ctx.Err() != nil {
|
||
return nil
|
||
}
|
||
var apiFehler *api.Fehler
|
||
if errors.As(err, &apiFehler) {
|
||
return err
|
||
}
|
||
time.Sleep(time.Duration(versuch+1) * time.Second)
|
||
}
|
||
return &api.VerbindungsFehler{Ursache: errors.New("Ereignisstrom bricht wiederholt ab")}
|
||
}
|
||
|
||
func (u *umgebung) cancelBefehl() *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "cancel <job-id>",
|
||
Short: "Auftrag abbrechen (hält an der nächsten Phasengrenze)",
|
||
Args: eineID("cancel"),
|
||
RunE: func(cmd *cobra.Command, argumente []string) error {
|
||
c, _, err := u.client()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := c.Abbrechen(cmd.Context(), argumente[0]); err != nil {
|
||
return err
|
||
}
|
||
if !u.opt.json {
|
||
fmt.Fprintln(u.aus, u.stil().Amber("Abbruch angefordert – "+strings.TrimSpace(argumente[0])+" hält an der nächsten Phasengrenze."))
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|