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:
@@ -0,0 +1,287 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client spricht die REST-API von ProxDeploy. Die Typen der Antworten sind aus openapi.json
|
||||
// erzeugt (typen.gen.go); Anfragen gehen als Map hinaus, damit nur gesetzte Flags mitkommen und
|
||||
// Vorlage und Server-Standards den Rest füllen.
|
||||
type Client struct {
|
||||
// Basis-URL der Instanz, z. B. https://proxdeploy.example (ohne /api/v1).
|
||||
URL string
|
||||
Token string
|
||||
Version string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func Neu(basis, token, version string) *Client {
|
||||
return &Client{
|
||||
URL: strings.TrimRight(basis, "/"),
|
||||
Token: token,
|
||||
Version: version,
|
||||
HTTP: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Fehler ist eine Fehlerantwort der API (`{ fehler: { code, nachricht, hinweis?, felder? } }`).
|
||||
type Fehler struct {
|
||||
Status int
|
||||
Code string
|
||||
Nachricht string
|
||||
Hinweis string
|
||||
Felder map[string]string
|
||||
}
|
||||
|
||||
func (f *Fehler) Error() string { return f.Nachricht }
|
||||
|
||||
// VerbindungsFehler: die Instanz war nicht erreichbar oder hat keine API-Antwort geliefert.
|
||||
type VerbindungsFehler struct{ Ursache error }
|
||||
|
||||
func (f *VerbindungsFehler) Error() string {
|
||||
return fmt.Sprintf("ProxDeploy nicht erreichbar: %v", f.Ursache)
|
||||
}
|
||||
func (f *VerbindungsFehler) Unwrap() error { return f.Ursache }
|
||||
|
||||
// IstAnmeldefehler: Token fehlt, ist abgelaufen oder widerrufen.
|
||||
func IstAnmeldefehler(err error) bool {
|
||||
var f *Fehler
|
||||
return errors.As(err, &f) && f.Status == http.StatusUnauthorized
|
||||
}
|
||||
|
||||
func (c *Client) anfrage(ctx context.Context, methode, pfad string, rumpf any) (*http.Request, error) {
|
||||
var koerper io.Reader
|
||||
if rumpf != nil {
|
||||
daten, err := json.Marshal(rumpf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
koerper = bytes.NewReader(daten)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, methode, c.URL+"/api/v1"+pfad, koerper)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rumpf != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "proxdeploy-cli/"+c.Version)
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func fehlerLesen(antwort *http.Response) error {
|
||||
daten, _ := io.ReadAll(io.LimitReader(antwort.Body, 1<<20))
|
||||
var huelle struct {
|
||||
Fehler *struct {
|
||||
Code string `json:"code"`
|
||||
Nachricht string `json:"nachricht"`
|
||||
Hinweis string `json:"hinweis"`
|
||||
Felder map[string]string `json:"felder"`
|
||||
} `json:"fehler"`
|
||||
}
|
||||
if json.Unmarshal(daten, &huelle) != nil || huelle.Fehler == nil {
|
||||
// Kein API-Format: falsche URL, Reverse-Proxy-Fehlerseite o. Ä.
|
||||
return &VerbindungsFehler{Ursache: fmt.Errorf("HTTP %d ohne API-Antwort – stimmt die URL?", antwort.StatusCode)}
|
||||
}
|
||||
return &Fehler{
|
||||
Status: antwort.StatusCode,
|
||||
Code: huelle.Fehler.Code,
|
||||
Nachricht: huelle.Fehler.Nachricht,
|
||||
Hinweis: huelle.Fehler.Hinweis,
|
||||
Felder: huelle.Fehler.Felder,
|
||||
}
|
||||
}
|
||||
|
||||
// Aufrufen schickt eine Anfrage und liest die JSON-Antwort nach `ziel` (nil = verwerfen).
|
||||
func (c *Client) Aufrufen(ctx context.Context, methode, pfad string, rumpf, ziel any) error {
|
||||
req, err := c.anfrage(ctx, methode, pfad, rumpf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
antwort, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return &VerbindungsFehler{Ursache: err}
|
||||
}
|
||||
defer antwort.Body.Close()
|
||||
if antwort.StatusCode >= 400 {
|
||||
return fehlerLesen(antwort)
|
||||
}
|
||||
if ziel == nil || antwort.StatusCode == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasPrefix(antwort.Header.Get("Content-Type"), "application/json") {
|
||||
return &VerbindungsFehler{Ursache: errors.New("Antwort ist kein JSON – stimmt die URL?")}
|
||||
}
|
||||
if err := json.NewDecoder(antwort.Body).Decode(ziel); err != nil {
|
||||
return fmt.Errorf("Antwort nicht lesbar: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Ich(ctx context.Context) (Ich, error) {
|
||||
var ich Ich
|
||||
return ich, c.Aufrufen(ctx, http.MethodGet, "/ich", nil, &ich)
|
||||
}
|
||||
|
||||
func (c *Client) TokenAnmelden(ctx context.Context, anmeldung TokenAnmeldung) (NeuesToken, error) {
|
||||
var token NeuesToken
|
||||
return token, c.Aufrufen(ctx, http.MethodPost, "/auth/token", anmeldung, &token)
|
||||
}
|
||||
|
||||
func (c *Client) ServerListe(ctx context.Context) ([]Server, error) {
|
||||
var liste []Server
|
||||
return liste, c.Aufrufen(ctx, http.MethodGet, "/server", nil, &liste)
|
||||
}
|
||||
|
||||
func (c *Client) DistributionenListe(ctx context.Context, typ string) ([]Distribution, error) {
|
||||
pfad := "/distributionen"
|
||||
if typ != "" {
|
||||
pfad += "?typ=" + url.QueryEscape(typ)
|
||||
}
|
||||
var liste []Distribution
|
||||
return liste, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &liste)
|
||||
}
|
||||
|
||||
func (c *Client) ProfileListe(ctx context.Context) ([]Profil, error) {
|
||||
var liste []Profil
|
||||
return liste, c.Aufrufen(ctx, http.MethodGet, "/profile", nil, &liste)
|
||||
}
|
||||
|
||||
func (c *Client) VorlagenListe(ctx context.Context) ([]Vorlage, error) {
|
||||
var liste []Vorlage
|
||||
return liste, c.Aufrufen(ctx, http.MethodGet, "/vorlagen", nil, &liste)
|
||||
}
|
||||
|
||||
func (c *Client) TemplatesListe(ctx context.Context, server string) ([]Template, error) {
|
||||
var liste []Template
|
||||
return liste, c.Aufrufen(ctx, http.MethodGet, "/server/"+url.PathEscape(server)+"/templates", nil, &liste)
|
||||
}
|
||||
|
||||
func (c *Client) Ausrollen(ctx context.Context, anfrage map[string]any) (AuftragAngenommen, error) {
|
||||
var a AuftragAngenommen
|
||||
return a, c.Aufrufen(ctx, http.MethodPost, "/auftraege", anfrage, &a)
|
||||
}
|
||||
|
||||
func (c *Client) Planen(ctx context.Context, anfrage map[string]any) (Plan, error) {
|
||||
var p Plan
|
||||
return p, c.Aufrufen(ctx, http.MethodPost, "/auftraege/plan", anfrage, &p)
|
||||
}
|
||||
|
||||
func (c *Client) TemplateBauen(ctx context.Context, server string, anfrage map[string]any) (AuftragAngenommen, error) {
|
||||
var a AuftragAngenommen
|
||||
return a, c.Aufrufen(ctx, http.MethodPost, "/server/"+url.PathEscape(server)+"/templates", anfrage, &a)
|
||||
}
|
||||
|
||||
func (c *Client) Auftrag(ctx context.Context, id string) (Auftrag, error) {
|
||||
var a Auftrag
|
||||
return a, c.Aufrufen(ctx, http.MethodGet, "/auftraege/"+url.PathEscape(id), nil, &a)
|
||||
}
|
||||
|
||||
func (c *Client) Log(ctx context.Context, id, ab string) ([]LogZeile, error) {
|
||||
var zeilen []LogZeile
|
||||
pfad := "/auftraege/" + url.PathEscape(id) + "/log"
|
||||
if ab != "" {
|
||||
pfad += "?ab=" + url.QueryEscape(ab)
|
||||
}
|
||||
return zeilen, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &zeilen)
|
||||
}
|
||||
|
||||
func (c *Client) Abbrechen(ctx context.Context, id string) error {
|
||||
return c.Aufrufen(ctx, http.MethodPost, "/auftraege/"+url.PathEscape(id)+"/abbrechen", nil, nil)
|
||||
}
|
||||
|
||||
// Ereignis aus dem SSE-Strom eines Auftrags.
|
||||
type Ereignis struct {
|
||||
ID string
|
||||
Art string // stand, log, ende
|
||||
Daten []byte
|
||||
}
|
||||
|
||||
// Ereignisse liest den SSE-Strom bis zum Ende oder Abbruch. `ab` ist die letzte gesehene Event-ID
|
||||
// (Log-Zeile); ein Wiederverbinden mit ihr setzt genau dort fort.
|
||||
func (c *Client) Ereignisse(ctx context.Context, id, ab string, beiEreignis func(Ereignis) error) error {
|
||||
req, err := c.anfrage(ctx, http.MethodGet, "/auftraege/"+url.PathEscape(id)+"/ereignisse", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
if ab != "" {
|
||||
req.Header.Set("Last-Event-ID", ab)
|
||||
}
|
||||
// Ohne Zeitlimit: der Strom läuft, solange der Auftrag läuft (Herzschlag alle 15 s).
|
||||
strom := &http.Client{Transport: c.HTTP.Transport}
|
||||
antwort, err := strom.Do(req)
|
||||
if err != nil {
|
||||
return &VerbindungsFehler{Ursache: err}
|
||||
}
|
||||
defer antwort.Body.Close()
|
||||
if antwort.StatusCode >= 400 {
|
||||
return fehlerLesen(antwort)
|
||||
}
|
||||
if !strings.HasPrefix(antwort.Header.Get("Content-Type"), "text/event-stream") {
|
||||
return &VerbindungsFehler{Ursache: errors.New("kein Ereignisstrom (puffert ein Proxy?)")}
|
||||
}
|
||||
return sseLesen(antwort.Body, beiEreignis)
|
||||
}
|
||||
|
||||
func sseLesen(r io.Reader, beiEreignis func(Ereignis) error) error {
|
||||
leser := bufio.NewReaderSize(r, 64*1024)
|
||||
var e Ereignis
|
||||
var daten bytes.Buffer
|
||||
for {
|
||||
zeile, err := leser.ReadString('\n')
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return &VerbindungsFehler{Ursache: err}
|
||||
}
|
||||
zeile = strings.TrimRight(zeile, "\r\n")
|
||||
switch {
|
||||
case zeile == "":
|
||||
if e.Art != "" || daten.Len() > 0 {
|
||||
if e.Art == "" {
|
||||
e.Art = "message"
|
||||
}
|
||||
// Kopie: der Puffer wird für das nächste Ereignis wiederverwendet.
|
||||
e.Daten = bytes.Clone(bytes.TrimSuffix(daten.Bytes(), []byte("\n")))
|
||||
if err := beiEreignis(e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
e = Ereignis{}
|
||||
daten.Reset()
|
||||
case strings.HasPrefix(zeile, ":"):
|
||||
// Kommentar (Herzschlag)
|
||||
default:
|
||||
feld, wert, _ := strings.Cut(zeile, ":")
|
||||
wert = strings.TrimPrefix(wert, " ")
|
||||
switch feld {
|
||||
case "id":
|
||||
e.ID = wert
|
||||
case "event":
|
||||
e.Art = wert
|
||||
case "data":
|
||||
daten.WriteString(wert)
|
||||
daten.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ende signalisiert, dass der Strom regulär geschlossen wurde (Ereignis `ende`).
|
||||
var Ende = errors.New("ende")
|
||||
Reference in New Issue
Block a user