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>
309 lines
9.2 KiB
Go
309 lines
9.2 KiB
Go
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)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 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")
|