feat(cli): proxdeploy in Go

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>
This commit is contained in:
2026-10-12 15:08:41 +02:00
co-authored by Claude Opus 5.5
parent 9fdced62db
commit bd4bca5399
36 changed files with 5960 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
package befehle
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"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
},
}
}