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")