Befehle login, servers, distros, profiles, presets, vm, lxc, template list|build, batch, status, logs --follow, cancel. Renderer im Aufbau der Vorgabe (zeichengenau per Golden-File), 24-bit/256/ohne Farbe, --ascii, --quiet, --json, --dry-run; live im Terminal, zeilenweise ohne. SSE mit Wiederverbinden und Polling als Rückfall. Typen aus openapi.json per oapi-codegen; Release-Builds per cli/bauen.sh. Refs #013 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
329 lines
7.8 KiB
Go
329 lines
7.8 KiB
Go
package befehle
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/mattn/go-runewidth"
|
||
"golang.org/x/term"
|
||
|
||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
|
||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe"
|
||
)
|
||
|
||
var endzustaende = map[api.AuftragStatus]bool{
|
||
api.AuftragStatusERFOLGREICH: true,
|
||
api.AuftragStatusFEHLGESCHLAGEN: true,
|
||
api.AuftragStatusABGEBROCHEN: true,
|
||
api.AuftragStatusAUFGERAEUMT: true,
|
||
}
|
||
|
||
func wert(p *string) string {
|
||
if p == nil {
|
||
return ""
|
||
}
|
||
return *p
|
||
}
|
||
|
||
func zeitAus(p *string) (time.Time, bool) {
|
||
if p == nil {
|
||
return time.Time{}, false
|
||
}
|
||
t, err := time.Parse(time.RFC3339Nano, *p)
|
||
return t, err == nil
|
||
}
|
||
|
||
// phasenAus übernimmt die Phasen der API unverändert; nur die Laufzeit rechnet die CLI selbst.
|
||
func phasenAus(a api.Auftrag, jetzt time.Time) []ausgabe.Phase {
|
||
liste := make([]ausgabe.Phase, 0, len(a.Phasen))
|
||
for _, p := range a.Phasen {
|
||
phase := ausgabe.Phase{
|
||
Titel: p.Titel,
|
||
Status: string(p.Status),
|
||
Detail: wert(p.Detail),
|
||
Ursache: wert(p.Ursache),
|
||
Hinweis: wert(p.Hinweis),
|
||
}
|
||
if seit, ok := zeitAus(p.GestartetAm); ok && p.Status == api.PhaseStatusLAEUFT {
|
||
phase.Laufzeit = jetzt.Sub(seit)
|
||
}
|
||
liste = append(liste, phase)
|
||
}
|
||
return liste
|
||
}
|
||
|
||
func boxAus(a *api.Abschluss) ausgabe.Box {
|
||
box := ausgabe.Box{Ok: a.Ok, Titel: a.Titel, Dauer: wert(a.Dauer)}
|
||
for _, z := range a.Zeilen {
|
||
if len(z) == 2 {
|
||
box.Zeilen = append(box.Zeilen, [2]string{z[0], z[1]})
|
||
}
|
||
}
|
||
return box
|
||
}
|
||
|
||
// darsteller zeichnet einen Auftrag: live im Terminal (an Ort und Stelle neu), sonst zeilenweise.
|
||
type darsteller struct {
|
||
u *umgebung
|
||
s ausgabe.Stil
|
||
live bool
|
||
gezeichnet int // Bildschirmzeilen der letzten Zeichnung (live)
|
||
kopf bool
|
||
gedruckt int // Anzahl schon gedruckter Phasen (zeilenweise)
|
||
takt int
|
||
}
|
||
|
||
func (u *umgebung) neuerDarsteller() *darsteller {
|
||
return &darsteller{u: u, s: u.stil(), live: u.terminal && !u.opt.still && !u.opt.json}
|
||
}
|
||
|
||
func (d *darsteller) bildschirmzeilen(zeilen []string) int {
|
||
spalten := 0
|
||
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil {
|
||
spalten = w
|
||
}
|
||
n := 0
|
||
for _, z := range zeilen {
|
||
breite := runewidth.StringWidth(ohneFarben(z))
|
||
if spalten > 0 && breite > spalten {
|
||
n += (breite + spalten - 1) / spalten
|
||
} else {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
func ohneFarben(text string) string {
|
||
var b strings.Builder
|
||
in := false
|
||
for _, r := range text {
|
||
switch {
|
||
case r == '\x1b':
|
||
in = true
|
||
case in && r == 'm':
|
||
in = false
|
||
case !in:
|
||
b.WriteRune(r)
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// zeichnen gibt den Stand aus. `fertig`: der Auftrag ist beendet, die Box kommt dazu.
|
||
func (d *darsteller) zeichnen(a api.Auftrag, fertig bool) {
|
||
if d.u.opt.json {
|
||
return
|
||
}
|
||
phasen := phasenAus(a, time.Now())
|
||
if d.u.opt.still {
|
||
if fertig && a.Abschluss != nil {
|
||
fmt.Fprintln(d.u.aus, strings.Join(d.s.BoxZeilen(boxAus(a.Abschluss)), "\n"))
|
||
}
|
||
return
|
||
}
|
||
breite := ausgabe.Spaltenbreite(phasen)
|
||
|
||
if d.live {
|
||
sichtbar := make([]ausgabe.Phase, 0, len(phasen))
|
||
for _, p := range phasen {
|
||
if p.Status != "OFFEN" || fertig {
|
||
sichtbar = append(sichtbar, p)
|
||
}
|
||
}
|
||
zeilen := []string{d.s.Kopf(Version, a.Kopf, false), ""}
|
||
zeilen = append(zeilen, d.s.PhasenZeilenMit(sichtbar, breite, d.takt)...)
|
||
if fertig && a.Abschluss != nil {
|
||
zeilen = append(zeilen, "")
|
||
zeilen = append(zeilen, d.s.BoxZeilen(boxAus(a.Abschluss))...)
|
||
}
|
||
var b strings.Builder
|
||
if d.gezeichnet > 0 {
|
||
fmt.Fprintf(&b, "\x1b[%dF\x1b[J", d.gezeichnet)
|
||
}
|
||
b.WriteString(strings.Join(zeilen, "\n"))
|
||
b.WriteString("\n")
|
||
fmt.Fprint(d.u.aus, b.String())
|
||
d.gezeichnet = d.bildschirmzeilen(zeilen)
|
||
return
|
||
}
|
||
|
||
// Zeilenweise (Pipe, Datei, CI): jede Phase einmal, sobald sie abgeschlossen ist. Die Kopfzeile
|
||
// kommt nach der ersten Phase – dann stehen Node und Template fest.
|
||
abgeschlossen := 0
|
||
for _, p := range phasen {
|
||
if p.Status == "OFFEN" || p.Status == "LAEUFT" {
|
||
break
|
||
}
|
||
abgeschlossen++
|
||
}
|
||
if !d.kopf && (abgeschlossen > 0 || fertig) {
|
||
fmt.Fprintln(d.u.aus, d.s.Kopf(Version, a.Kopf, false))
|
||
fmt.Fprintln(d.u.aus)
|
||
d.kopf = true
|
||
}
|
||
if abgeschlossen > d.gedruckt {
|
||
for _, z := range d.s.PhasenZeilenMit(phasen[d.gedruckt:abgeschlossen], breite, 0) {
|
||
fmt.Fprintln(d.u.aus, z)
|
||
}
|
||
d.gedruckt = abgeschlossen
|
||
}
|
||
if fertig {
|
||
if rest := phasen[d.gedruckt:]; len(rest) > 0 {
|
||
for _, z := range d.s.PhasenZeilenMit(rest, breite, 0) {
|
||
fmt.Fprintln(d.u.aus, z)
|
||
}
|
||
}
|
||
if a.Abschluss != nil {
|
||
fmt.Fprintln(d.u.aus)
|
||
fmt.Fprintln(d.u.aus, strings.Join(d.s.BoxZeilen(boxAus(a.Abschluss)), "\n"))
|
||
}
|
||
}
|
||
}
|
||
|
||
// folgen hängt sich an einen Auftrag, bis er fertig ist. Fehlgeschlagen → Exit 1.
|
||
func (u *umgebung) folgen(ctx context.Context, c *api.Client, id string) (api.Auftrag, error) {
|
||
d := u.neuerDarsteller()
|
||
staende := make(chan api.Auftrag, 16)
|
||
stromFehler := make(chan error, 1)
|
||
strCtx, stopp := context.WithCancel(ctx)
|
||
defer stopp()
|
||
|
||
go func() {
|
||
defer close(staende)
|
||
stromFehler <- u.stroemen(strCtx, c, id, staende)
|
||
}()
|
||
|
||
var letzter *api.Auftrag
|
||
uhr := time.NewTicker(150 * time.Millisecond)
|
||
defer uhr.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
if d.live && letzter != nil {
|
||
d.zeichnen(*letzter, false)
|
||
}
|
||
fmt.Fprintln(u.fehler, d.s.Muted(fmt.Sprintf("\nAbgehängt – der Auftrag läuft weiter: proxdeploy status %s", id)))
|
||
return api.Auftrag{}, &exitFehler{code: 130, gemeldet: true}
|
||
case a, offen := <-staende:
|
||
if !offen {
|
||
if err := <-stromFehler; err != nil {
|
||
return api.Auftrag{}, err
|
||
}
|
||
if letzter == nil {
|
||
return api.Auftrag{}, errors.New("kein Stand erhalten")
|
||
}
|
||
return u.abschliessen(d, *letzter)
|
||
}
|
||
letzter = &a
|
||
if endzustaende[a.Status] {
|
||
return u.abschliessen(d, a)
|
||
}
|
||
d.zeichnen(a, false)
|
||
case <-uhr.C:
|
||
if d.live && letzter != nil {
|
||
d.takt++
|
||
d.zeichnen(*letzter, false)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (u *umgebung) abschliessen(d *darsteller, a api.Auftrag) (api.Auftrag, error) {
|
||
d.zeichnen(a, true)
|
||
if u.opt.json {
|
||
if err := u.jsonAusgeben(a); err != nil {
|
||
return a, err
|
||
}
|
||
}
|
||
if a.Status != api.AuftragStatusERFOLGREICH {
|
||
return a, &exitFehler{code: ExitFehlschlag, gemeldet: true}
|
||
}
|
||
return a, nil
|
||
}
|
||
|
||
// stroemen liefert Stände per SSE; bricht der Strom ab, wird mit der letzten Event-ID neu
|
||
// verbunden. Klappt SSE dreimal hintereinander nicht (z. B. ein puffernder Proxy), wird alle 2 s
|
||
// abgefragt.
|
||
func (u *umgebung) stroemen(ctx context.Context, c *api.Client, id string, staende chan<- api.Auftrag) error {
|
||
letzteID := ""
|
||
fehlschlaege := 0
|
||
for fehlschlaege < 3 {
|
||
erhalten := false
|
||
err := c.Ereignisse(ctx, id, letzteID, func(e api.Ereignis) error {
|
||
if e.ID != "" {
|
||
letzteID = e.ID
|
||
}
|
||
switch e.Art {
|
||
case "stand":
|
||
var a api.Auftrag
|
||
if err := json.Unmarshal(e.Daten, &a); err != nil {
|
||
return fmt.Errorf("Stand nicht lesbar: %w", err)
|
||
}
|
||
erhalten = true
|
||
fehlschlaege = 0
|
||
select {
|
||
case staende <- a:
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
}
|
||
case "ende":
|
||
return api.Ende
|
||
}
|
||
return nil
|
||
})
|
||
switch {
|
||
case errors.Is(err, api.Ende):
|
||
return nil
|
||
case ctx.Err() != nil:
|
||
return nil
|
||
case api.IstAnmeldefehler(err):
|
||
return err
|
||
}
|
||
var apiFehler *api.Fehler
|
||
if errors.As(err, &apiFehler) {
|
||
return err
|
||
}
|
||
if !erhalten {
|
||
fehlschlaege++
|
||
}
|
||
select {
|
||
case <-time.After(time.Duration(fehlschlaege+1) * time.Second):
|
||
case <-ctx.Done():
|
||
return nil
|
||
}
|
||
}
|
||
|
||
for {
|
||
a, err := c.Auftrag(ctx, id)
|
||
if err != nil {
|
||
if ctx.Err() != nil {
|
||
return nil
|
||
}
|
||
var verbindung *api.VerbindungsFehler
|
||
if !errors.As(err, &verbindung) {
|
||
return err
|
||
}
|
||
} else {
|
||
select {
|
||
case staende <- a:
|
||
case <-ctx.Done():
|
||
return nil
|
||
}
|
||
if endzustaende[a.Status] {
|
||
return nil
|
||
}
|
||
}
|
||
select {
|
||
case <-time.After(2 * time.Second):
|
||
case <-ctx.Done():
|
||
return nil
|
||
}
|
||
}
|
||
}
|