1// Package remote provides an HTTP client for connecting to remote Gordon instances.
2package remote
3
4import (
5 "bytes"
6 "context"
7 "crypto/tls"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "net/http"
13 "net/url"
14 "os"
15 "strings"
16 "sync"
17 "time"
18
19 "github.com/golang-jwt/jwt/v5"
20
21 "github.com/bnema/gordon/internal/adapters/dto"
22 "github.com/bnema/gordon/internal/domain"
23)
24
25// Client is an HTTP client for the Gordon admin API.
26type Client struct {
27 baseURL string
28 token string
29 httpClient *http.Client
30 insecureTLS bool
31
32 // Ephemeral admin token exchange fields.
33 mu sync.Mutex // protects ephemeral and ephemeralExp
34 subject string // JWT subject extracted from long-lived token
35 ephemeral string // cached ephemeral admin token
36 ephemeralExp time.Time // expiry of cached ephemeral token
37}
38
39var (
40 retryMaxAttempts = 4
41 retryBaseDelay = 250 * time.Millisecond
42)
43
44const maxErrorBodySize int64 = 1024 // cap error response reads at 1 KB
45
46// ClientOption configures the Client.
47type ClientOption func(*Client)
48
49// NewClient creates a new remote Gordon client.
50func NewClient(baseURL string, opts ...ClientOption) *Client {
51 // Normalize base URL
52 baseURL = strings.TrimSuffix(baseURL, "/")
53
54 c := &Client{
55 baseURL: baseURL,
56 httpClient: &http.Client{
57 Timeout: 2 * time.Minute,
58 },
59 }
60
61 for _, opt := range opts {
62 opt(c)
63 }
64
65 c.applyTLSConfig()
66
67 return c
68}
69
70// WithToken sets the authentication token.
71// The token must be a valid JWT — the subject claim is extracted for
72// ephemeral token exchange. If parsing fails, requests will return
73// an error rather than sending the long-lived token directly.
74func WithToken(token string) ClientOption {
75 return func(c *Client) {
76 c.token = token
77 c.subject = extractJWTSubject(token)
78 }
79}
80
81// extractJWTSubject parses a JWT without verification and returns the
82// "sub" claim. Returns "" if the token is not a valid JWT or has no sub.
83func extractJWTSubject(tokenStr string) string {
84 if tokenStr == "" {
85 return ""
86 }
87 parser := jwt.NewParser()
88 claims := jwt.MapClaims{}
89 // We only need the claims; signature verification happens server-side.
90 _, _, err := parser.ParseUnverified(tokenStr, claims)
91 if err != nil {
92 return ""
93 }
94 sub, _ := claims.GetSubject()
95 return sub
96}
97
98// WithHTTPClient sets a custom HTTP client.
99func WithHTTPClient(client *http.Client) ClientOption {
100 return func(c *Client) {
101 c.httpClient = client
102 }
103}
104
105// WithTimeout sets the HTTP client timeout.
106func WithTimeout(timeout time.Duration) ClientOption {
107 return func(c *Client) {
108 c.httpClient.Timeout = timeout
109 }
110}
111
112// WithInsecureTLS disables TLS certificate verification for remote admin API requests.
113func WithInsecureTLS(insecure bool) ClientOption {
114 return func(c *Client) {
115 c.insecureTLS = insecure
116 }
117}
118
119// ephemeralMargin is the safety margin before expiry at which
120// the cached ephemeral token is considered stale.
121const ephemeralMargin = 30 * time.Second
122
123// ephemeralValid reports whether the cached ephemeral token is still usable.
124func (c *Client) ephemeralValid() bool {
125 return c.ephemeral != "" && time.Now().Before(c.ephemeralExp.Add(-ephemeralMargin))
126}
127
128// exchangeToken exchanges the long-lived token for a short-lived ephemeral
129// admin token via /auth/token. Follows the same pattern as ExchangeRegistryToken.
130// The caller must hold c.mu.
131func (c *Client) exchangeToken(ctx context.Context) error {
132 url := c.baseURL + "/auth/token?scope=admin:*:*&service=gordon-registry"
133
134 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
135 if err != nil {
136 return fmt.Errorf("ephemeral token exchange: %w", err)
137 }
138
139 req.SetBasicAuth(c.subject, c.token)
140 req.Header.Set("Accept", "application/json")
141
142 resp, err := c.httpClient.Do(req)
143 if err != nil {
144 return fmt.Errorf("ephemeral token exchange: %w", err)
145 }
146 defer resp.Body.Close()
147
148 if resp.StatusCode >= 400 {
149 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
150 var errResp struct {
151 Error string `json:"error"`
152 }
153 if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
154 return fmt.Errorf("ephemeral token exchange: %s: %s", resp.Status, errResp.Error)
155 }
156 return fmt.Errorf("ephemeral token exchange: %s: %s", resp.Status, string(body))
157 }
158
159 var result dto.TokenResponse
160 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
161 return fmt.Errorf("ephemeral token exchange: decode: %w", err)
162 }
163
164 token := result.Token
165 if token == "" {
166 token = result.AccessToken
167 }
168 if token == "" {
169 return fmt.Errorf("ephemeral token exchange returned empty token")
170 }
171
172 if result.ExpiresIn <= 0 {
173 return fmt.Errorf("ephemeral token exchange: invalid expires_in value: %d", result.ExpiresIn)
174 }
175
176 c.ephemeral = token
177 c.ephemeralExp = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
178 return nil
179}
180
181// bearerToken returns the token to use in Authorization headers.
182// It exchanges the long-lived token for an ephemeral one via /auth/token.
183// If subject extraction failed, uses "unknown" — the server validates everything.
184func (c *Client) bearerToken(ctx context.Context) (string, error) {
185 if c.token == "" {
186 return "", nil
187 }
188 if c.subject == "" {
189 c.subject = "unknown"
190 }
191 c.mu.Lock()
192 defer c.mu.Unlock()
193 if !c.ephemeralValid() {
194 if err := c.exchangeToken(ctx); err != nil {
195 return "", err
196 }
197 }
198 return c.ephemeral, nil
199}
200
201func (c *Client) applyTLSConfig() {
202 if !c.insecureTLS {
203 return
204 }
205
206 fmt.Fprintf(os.Stderr, "WARNING: TLS certificate verification disabled for %s\n", c.baseURL)
207
208 var transport *http.Transport
209 switch t := c.httpClient.Transport.(type) {
210 case *http.Transport:
211 transport = t.Clone()
212 case nil:
213 transport = http.DefaultTransport.(*http.Transport).Clone()
214 default:
215 // Cannot apply InsecureSkipVerify to a non-*http.Transport.
216 // Log a warning and leave the transport intact rather than
217 // silently replacing it (which would drop caller-provided behavior).
218 fmt.Fprintf(os.Stderr, "WARNING: --insecure requires *http.Transport, got %T; TLS override not applied\n", t)
219 return
220 }
221
222 if transport.TLSClientConfig == nil {
223 transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
224 } else {
225 transport.TLSClientConfig.MinVersion = tls.VersionTLS12
226 }
227 //nolint:gosec // Explicit CLI opt-in via --insecure for self-signed/private cert deployments.
228 transport.TLSClientConfig.InsecureSkipVerify = true
229 c.httpClient.Transport = transport
230}
231
232// request performs an HTTP request to the admin API.
233func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
234 // Marshal body once so it can be replayed on 401 retry.
235 var jsonBody []byte
236 if body != nil {
237 var err error
238 jsonBody, err = json.Marshal(body)
239 if err != nil {
240 return nil, fmt.Errorf("failed to marshal request body: %w", err)
241 }
242 }
243
244 resp, err := c.doRequest(ctx, method, path, jsonBody)
245 if err != nil {
246 return nil, err
247 }
248
249 // On 401: invalidate ephemeral, re-exchange, retry once.
250 if resp.StatusCode == http.StatusUnauthorized && c.subject != "" && c.ephemeral != "" {
251 // Drain and close the first response.
252 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxErrorBodySize))
253 resp.Body.Close()
254
255 c.mu.Lock()
256 c.ephemeral = ""
257 c.ephemeralExp = time.Time{}
258 if exErr := c.exchangeToken(ctx); exErr != nil {
259 c.mu.Unlock()
260 return nil, fmt.Errorf("token re-exchange after 401: %w", exErr)
261 }
262 c.mu.Unlock()
263 return c.doRequest(ctx, method, path, jsonBody)
264 }
265
266 return resp, nil
267}
268
269type requestTransportError struct {
270 err error
271}
272
273func (e *requestTransportError) Error() string {
274 return e.err.Error()
275}
276
277func (e *requestTransportError) Unwrap() error {
278 return e.err
279}
280
281// doRequest builds and executes a single HTTP request to the admin API.
282func (c *Client) doRequest(ctx context.Context, method, path string, jsonBody []byte) (*http.Response, error) {
283 reqURL := c.baseURL + "/admin" + path
284
285 var bodyReader io.Reader
286 if jsonBody != nil {
287 bodyReader = bytes.NewReader(jsonBody)
288 }
289
290 req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
291 if err != nil {
292 return nil, fmt.Errorf("failed to create request: %w", err)
293 }
294
295 req.Header.Set("Content-Type", "application/json")
296 req.Header.Set("Accept", "application/json")
297
298 bearer, err := c.bearerToken(ctx)
299 if err != nil {
300 return nil, err
301 }
302 if bearer != "" {
303 req.Header.Set("Authorization", "Bearer "+bearer)
304 }
305
306 resp, err := c.httpClient.Do(req)
307 if err != nil {
308 return nil, &requestTransportError{
309 err: fmt.Errorf("%s %s transport request: %w", method, path, err),
310 }
311 }
312
313 return resp, nil
314}
315
316// parseResponse parses a JSON response into the given target.
317func parseResponse(resp *http.Response, target any) error {
318 defer resp.Body.Close()
319
320 if resp.StatusCode >= 400 {
321 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
322 return parseErrorResponse(resp, body)
323 }
324
325 if target != nil {
326 if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
327 return fmt.Errorf("failed to decode response: %w", err)
328 }
329 }
330
331 return nil
332}
333
334// HTTPError represents an HTTP error response with a status code.
335// Use errors.As to check for specific status codes in error handling.
336type HTTPError struct {
337 StatusCode int
338 Status string
339 Body string
340 Cause string
341 Hint string
342 Logs []string
343 Structured bool
344}
345
346func (e *HTTPError) Error() string {
347 return e.Status + ": " + e.Body
348}
349
350// OutcomeUnknownError reports that a non-idempotent request may have completed
351// even though the client did not receive a definitive response.
352type OutcomeUnknownError struct {
353 Method string
354 Path string
355 Err error
356}
357
358func (e *OutcomeUnknownError) Error() string {
359 return fmt.Sprintf(
360 "%s %s outcome unknown: request may have completed; inspect current state before retrying: %v",
361 e.Method,
362 e.Path,
363 e.Err,
364 )
365}
366
367// Unwrap returns the transport or HTTP error that made the outcome ambiguous.
368func (e *OutcomeUnknownError) Unwrap() error {
369 return e.Err
370}
371
372func parseErrorResponse(resp *http.Response, body []byte) error {
373 msg := string(body)
374 structured := false
375 var errResp struct {
376 Error string `json:"error"`
377 Cause string `json:"cause"`
378 Hint string `json:"hint"`
379 Logs []string `json:"logs"`
380 }
381 if err := json.Unmarshal(body, &errResp); err == nil {
382 structured = errResp.Cause != "" || errResp.Hint != "" || len(errResp.Logs) > 0
383 if errResp.Error != "" {
384 msg = errResp.Error
385 }
386 }
387 return &HTTPError{
388 StatusCode: resp.StatusCode,
389 Status: resp.Status,
390 Body: msg,
391 Cause: errResp.Cause,
392 Hint: errResp.Hint,
393 Logs: errResp.Logs,
394 Structured: structured,
395 }
396}
397
398func isRetryableRequestError(err error) bool {
399 var transportErr *requestTransportError
400 return errors.As(err, &transportErr) &&
401 !errors.Is(err, context.Canceled) &&
402 !errors.Is(err, context.DeadlineExceeded)
403}
404
405func isIdempotentMethod(method string) bool {
406 switch method {
407 case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace,
408 http.MethodPut, http.MethodDelete:
409 return true
410 default:
411 return false
412 }
413}
414
415func isRetryableStatus(status int) bool {
416 // Gateway errors can be transient; requestWithRetry separately ensures the
417 // method is safe to replay before attempting another request.
418 return status == 502 || status == 503 || status == 504
419}
420
421func retryDelay(attempt int) time.Duration {
422 if attempt <= 1 {
423 return retryBaseDelay
424 }
425 return retryBaseDelay * time.Duration(1<<(attempt-1))
426}
427
428// requestWithRetry performs an HTTP request and retries transient failures
429// only when the HTTP method is idempotent. A transient failure from a
430// non-idempotent request is returned as OutcomeUnknownError without replaying it.
431func (c *Client) requestWithRetry(ctx context.Context, method, path string, body any) (*http.Response, error) {
432 var lastErr error
433 idempotent := isIdempotentMethod(method)
434
435 for attempt := 1; attempt <= retryMaxAttempts; attempt++ {
436 resp, err := c.request(ctx, method, path, body)
437 if err != nil {
438 if !idempotent {
439 var transportErr *requestTransportError
440 if errors.As(err, &transportErr) {
441 return nil, &OutcomeUnknownError{Method: method, Path: path, Err: err}
442 }
443 return nil, err
444 }
445 lastErr = err
446 if attempt == retryMaxAttempts || !isRetryableRequestError(err) {
447 return nil, err
448 }
449 } else {
450 if !isRetryableStatus(resp.StatusCode) {
451 return resp, nil
452 }
453
454 respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
455 _ = resp.Body.Close()
456 lastErr = parseErrorResponse(resp, respBody)
457 if !idempotent {
458 return nil, &OutcomeUnknownError{Method: method, Path: path, Err: lastErr}
459 }
460 if attempt == retryMaxAttempts {
461 return nil, lastErr
462 }
463 }
464
465 delay := retryDelay(attempt)
466 timer := time.NewTimer(delay)
467 select {
468 case <-ctx.Done():
469 timer.Stop()
470 return nil, ctx.Err()
471 case <-timer.C:
472 }
473 }
474
475 if lastErr != nil {
476 return nil, lastErr
477 }
478 return nil, fmt.Errorf("request failed after retries")
479}
480
481// Routes API
482
483// Type aliases for API types using shared DTO package.
484type RouteInfo = dto.RouteInfo
485type Attachment = dto.Attachment
486
487// ListRoutes returns all configured routes.
488func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
489 resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
490 if err != nil {
491 return nil, err
492 }
493
494 var result struct {
495 Routes []domain.Route `json:"routes"`
496 }
497 if err := parseResponse(resp, &result); err != nil {
498 return nil, err
499 }
500
501 return result.Routes, nil
502}
503
504// ListRoutesWithDetails returns routes with network and attachment info.
505func (c *Client) ListRoutesWithDetails(ctx context.Context) ([]RouteInfo, error) {
506 resp, err := c.request(ctx, http.MethodGet, "/routes?detailed=true", nil)
507 if err != nil {
508 return nil, err
509 }
510
511 var result struct {
512 Routes []RouteInfo `json:"routes"`
513 }
514 if err := parseResponse(resp, &result); err != nil {
515 return nil, err
516 }
517
518 return result.Routes, nil
519}
520
521// ListNetworks returns Gordon-managed networks.
522func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
523 resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
524 if err != nil {
525 return nil, err
526 }
527
528 var result struct {
529 Networks []*domain.NetworkInfo `json:"networks"`
530 }
531 if err := parseResponse(resp, &result); err != nil {
532 return nil, err
533 }
534
535 return result.Networks, nil
536}
537
538// ListAttachments returns attachments for a domain.
539func (c *Client) ListAttachments(ctx context.Context, routeDomain string) ([]Attachment, error) {
540 if routeDomain == "" {
541 return nil, fmt.Errorf("domain is required")
542 }
543 path := "/routes/" + url.PathEscape(routeDomain) + "/attachments"
544 resp, err := c.request(ctx, http.MethodGet, path, nil)
545 if err != nil {
546 return nil, err
547 }
548
549 var result struct {
550 Attachments []Attachment `json:"attachments"`
551 }
552 if err := parseResponse(resp, &result); err != nil {
553 return nil, err
554 }
555
556 return result.Attachments, nil
557}
558
559// GetRoute returns a specific route by domain.
560func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
561 resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
562 if err != nil {
563 return nil, err
564 }
565
566 var route domain.Route
567 if err := parseResponse(resp, &route); err != nil {
568 var httpErr *HTTPError
569 if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
570 return nil, domain.ErrRouteNotFound
571 }
572 return nil, err
573 }
574
575 return &route, nil
576}
577
578// GetRouteCleanupPreview returns retained cleanup state for a route that may no longer be configured.
579func (c *Client) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
580 resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain)+"/cleanup", nil)
581 if err != nil {
582 return nil, err
583 }
584
585 var report domain.CleanupReport
586 if err := parseResponse(resp, &report); err != nil {
587 return nil, err
588 }
589
590 return &report, nil
591}
592
593// FindRoutesByImage returns all routes associated with the given image name.
594func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
595 resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil)
596 if err != nil {
597 return nil, err
598 }
599
600 var result struct {
601 Image string `json:"image"`
602 Routes []domain.Route `json:"routes"`
603 }
604 if err := parseResponse(resp, &result); err != nil {
605 return nil, err
606 }
607
608 return result.Routes, nil
609}
610
611// AddRoute adds a new route.
612func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
613 resp, err := c.request(ctx, http.MethodPost, "/routes", route)
614 if err != nil {
615 return err
616 }
617 return parseResponse(resp, nil)
618}
619
620// UpdateRoute updates an existing route.
621func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
622 resp, err := c.request(ctx, http.MethodPut, "/routes/"+url.PathEscape(route.Domain), route)
623 if err != nil {
624 return err
625 }
626 return parseResponse(resp, nil)
627}
628
629// RemoveRoute removes a route by domain.
630func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
631 _, err := c.RemoveRouteWithCleanup(ctx, routeDomain)
632 return err
633}
634
635// RemoveRouteWithCleanup removes a route and returns the server cleanup report.
636func (c *Client) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) {
637 resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)
638 if err != nil {
639 return nil, fmt.Errorf("delete route %s failed: %w", routeDomain, err)
640 }
641 var result dto.RouteDeleteResponse
642 if err := parseResponse(resp, &result); err != nil {
643 return nil, fmt.Errorf("parse delete route %s response: %w", routeDomain, err)
644 }
645 return &result, nil
646}
647
648func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
649 resp, err := c.request(ctx, http.MethodPost, "/bootstrap", req)
650 if err != nil {
651 return nil, err
652 }
653 var result dto.BootstrapResponse
654 if err := parseResponse(resp, &result); err != nil {
655 return nil, err
656 }
657 return &result, nil
658}
659
660// Secrets API
661
662// AttachmentSecrets represents secrets for an attachment container.
663type AttachmentSecrets struct {
664 Service string `json:"service"`
665 Keys []string `json:"keys"`
666}
667
668// SecretsListResult contains domain secrets and any attachment secrets.
669type SecretsListResult struct {
670 Domain string `json:"domain"`
671 Keys []string `json:"keys"`
672 Attachments []AttachmentSecrets `json:"attachments,omitempty"`
673}
674
675// ListSecrets returns the list of secret keys for a domain.
676func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
677 result, err := c.ListSecretsWithAttachments(ctx, secretDomain)
678 if err != nil {
679 return nil, err
680 }
681 return result.Keys, nil
682}
683
684// ListSecretsWithAttachments returns domain secrets and attachment secrets.
685func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) {
686 resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
687 if err != nil {
688 return nil, err
689 }
690
691 var result SecretsListResult
692 if err := parseResponse(resp, &result); err != nil {
693 return nil, err
694 }
695
696 return &result, nil
697}
698
699// SetSecrets sets secrets for a domain.
700func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
701 resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets)
702 if err != nil {
703 return err
704 }
705 return parseResponse(resp, nil)
706}
707
708// DeleteSecret removes a secret from a domain.
709func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {
710 resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil)
711 if err != nil {
712 return err
713 }
714 return parseResponse(resp, nil)
715}
716
717// SetAttachmentSecrets sets secrets for an attachment container.
718func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error {
719 path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service)
720 resp, err := c.request(ctx, http.MethodPost, path, secrets)
721 if err != nil {
722 return err
723 }
724 return parseResponse(resp, nil)
725}
726
727// DeleteAttachmentSecret removes a secret from an attachment container.
728func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error {
729 path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key)
730 resp, err := c.request(ctx, http.MethodDelete, path, nil)
731 if err != nil {
732 return err
733 }
734 return parseResponse(resp, nil)
735}
736
737// Status API
738
739// Status represents the Gordon server status.
740type Status struct {
741 Routes int `json:"routes"`
742 RegistryDomain string `json:"registry_domain"`
743 RegistryPort int `json:"registry_port"`
744 ServerPort int `json:"server_port"`
745 AutoRoute bool `json:"auto_route"`
746 NetworkIsolation bool `json:"network_isolation"`
747 ContainerStatus map[string]string `json:"container_status"`
748}
749
750// GetTLSStatus returns the public TLS/ACME status.
751func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
752 resp, err := c.request(ctx, http.MethodGet, "/tls/status", nil)
753 if err != nil {
754 return nil, err
755 }
756
757 var status dto.TLSStatusResponse
758 if err := parseResponse(resp, &status); err != nil {
759 return nil, err
760 }
761
762 return &status, nil
763}
764
765// GetTrafficStatus returns the traffic plane status.
766func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
767 resp, err := c.request(ctx, http.MethodGet, "/traffic/status", nil)
768 if err != nil {
769 return nil, fmt.Errorf("request traffic status: %w", err)
770 }
771
772 var status dto.TrafficStatusResponse
773 if err := parseResponse(resp, &status); err != nil {
774 return nil, fmt.Errorf("parse traffic status response: %w", err)
775 }
776
777 return &status, nil
778}
779
780// GetStatus returns the Gordon server status.
781func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
782 resp, err := c.requestWithRetry(ctx, http.MethodGet, "/status", nil)
783 if err != nil {
784 return nil, err
785 }
786
787 var status Status
788 if err := parseResponse(resp, &status); err != nil {
789 return nil, err
790 }
791
792 return &status, nil
793}
794
795// RouteHealth represents the health status of a route.
796type RouteHealth struct {
797 ContainerStatus string `json:"container_status"`
798 HTTPStatus int `json:"http_status"`
799 ResponseTimeMs int64 `json:"response_time_ms"`
800 Healthy bool `json:"healthy"`
801 Error string `json:"error"`
802}
803
804// Backups API
805
806// Images API
807
808// ListImages returns runtime images and registry tags from the admin API.
809func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
810 resp, err := c.request(ctx, http.MethodGet, "/images", nil)
811 if err != nil {
812 return nil, err
813 }
814
815 var result dto.ImagesResponse
816 if err := parseResponse(resp, &result); err != nil {
817 return nil, err
818 }
819
820 return result.Images, nil
821}
822
823// PruneImages prunes runtime and registry images.
824func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
825 if req.KeepLast != nil && *req.KeepLast < 0 {
826 return nil, fmt.Errorf("keep_last must be >= 0")
827 }
828
829 resp, err := c.request(ctx, http.MethodPost, "/images/prune", req)
830 if err != nil {
831 return nil, err
832 }
833
834 var result dto.ImagePruneResponse
835 if err := parseResponse(resp, &result); err != nil {
836 return nil, err
837 }
838
839 return &result, nil
840}
841
842// ListBackups returns backups globally or for a domain.
843func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
844 path := "/backups"
845 if backupDomain != "" {
846 path += "/" + url.PathEscape(backupDomain)
847 }
848
849 resp, err := c.request(ctx, http.MethodGet, path, nil)
850 if err != nil {
851 return nil, err
852 }
853
854 var result dto.BackupsResponse
855 if err := parseResponse(resp, &result); err != nil {
856 return nil, err
857 }
858
859 return result.Backups, nil
860}
861
862// BackupStatus returns aggregate backup status.
863func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
864 resp, err := c.request(ctx, http.MethodGet, "/backups/status", nil)
865 if err != nil {
866 return nil, err
867 }
868
869 var result dto.BackupsResponse
870 if err := parseResponse(resp, &result); err != nil {
871 return nil, err
872 }
873
874 return result.Backups, nil
875}
876
877// RunBackup triggers a backup for a domain.
878func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
879 if backupDomain == "" {
880 return nil, fmt.Errorf("domain cannot be empty")
881 }
882
883 resp, err := c.request(ctx, http.MethodPost, "/backups/"+url.PathEscape(backupDomain), dto.BackupRunRequest{DB: dbName})
884 if err != nil {
885 return nil, err
886 }
887
888 var result dto.BackupRunResponse
889 if err := parseResponse(resp, &result); err != nil {
890 return nil, err
891 }
892
893 return &result, nil
894}
895
896// DetectDatabases detects supported databases for a domain.
897func (c *Client) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
898 if backupDomain == "" {
899 return nil, fmt.Errorf("domain cannot be empty")
900 }
901
902 resp, err := c.request(ctx, http.MethodGet, "/backups/"+url.PathEscape(backupDomain)+"/detect", nil)
903 if err != nil {
904 return nil, err
905 }
906
907 var result dto.BackupDetectResponse
908 if err := parseResponse(resp, &result); err != nil {
909 return nil, err
910 }
911
912 return result.Databases, nil
913}
914
915// ListVolumeBackups returns volume backups globally or for a domain.
916func (c *Client) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
917 path := "/backups/volumes"
918 if backupDomain != "" {
919 path += "/" + url.PathEscape(backupDomain)
920 }
921
922 resp, err := c.request(ctx, http.MethodGet, path, nil)
923 if err != nil {
924 return nil, err
925 }
926
927 var result dto.VolumeBackupsResponse
928 if err := parseResponse(resp, &result); err != nil {
929 return nil, err
930 }
931 return result.Backups, nil
932}
933
934// VolumeBackupStatus returns aggregate volume backup status.
935func (c *Client) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
936 resp, err := c.request(ctx, http.MethodGet, "/backups/volumes/status", nil)
937 if err != nil {
938 return nil, err
939 }
940
941 var result dto.VolumeBackupsResponse
942 if err := parseResponse(resp, &result); err != nil {
943 return nil, err
944 }
945 return result.Backups, nil
946}
947
948// RunVolumeBackups triggers volume backups.
949func (c *Client) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
950 path := "/backups/volumes"
951 if backupDomain != "" {
952 path += "/" + url.PathEscape(backupDomain)
953 }
954
955 resp, err := c.request(ctx, http.MethodPost, path, dto.VolumeBackupRunRequest{Volume: volumeName})
956 if err != nil {
957 return nil, err
958 }
959
960 var result dto.VolumeBackupRunResponse
961 if err := parseResponse(resp, &result); err != nil {
962 return nil, err
963 }
964 if resp.StatusCode == http.StatusPartialContent {
965 if result.Error == "" {
966 result.Error = "volume backup run partially failed"
967 }
968 return &result, fmt.Errorf("volume backup run partially failed: %s", result.Error)
969 }
970 return &result, nil
971}
972
973// GetHealth returns health status for all routes with HTTP probing.
974func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
975 resp, err := c.request(ctx, http.MethodGet, "/health", nil)
976 if err != nil {
977 return nil, err
978 }
979
980 var result struct {
981 Health map[string]*RouteHealth `json:"health"`
982 }
983 if err := parseResponse(resp, &result); err != nil {
984 return nil, err
985 }
986
987 return result.Health, nil
988}
989
990// Reload triggers a configuration reload.
991func (c *Client) Reload(ctx context.Context) error {
992 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/reload", nil)
993 if err != nil {
994 return err
995 }
996 return parseResponse(resp, nil)
997}
998
999// Config API
1000
1001// Config represents the Gordon configuration.
1002type Config struct {
1003 Server struct {
1004 Port int `json:"port"`
1005 RegistryPort int `json:"registry_port"`
1006 RegistryDomain string `json:"registry_domain"`
1007 DataDir string `json:"data_dir,omitempty"`
1008 } `json:"server"`
1009 AutoRoute struct {
1010 Enabled bool `json:"enabled"`
1011 } `json:"auto_route"`
1012 NetworkIsolation struct {
1013 Enabled bool `json:"enabled"`
1014 Prefix string `json:"prefix"`
1015 } `json:"network_isolation"`
1016 Volumes struct {
1017 AutoCreate bool `json:"auto_create"`
1018 Prefix string `json:"prefix"`
1019 Preserve bool `json:"preserve"`
1020 } `json:"volumes"`
1021 Routes []domain.Route `json:"routes"`
1022 ExternalRoutes []ExternalRoute `json:"external_routes"`
1023}
1024
1025// ExternalRoute represents a redacted external route config entry.
1026type ExternalRoute struct {
1027 Domain string `json:"domain"`
1028 Target string `json:"target,omitempty"`
1029}
1030
1031// GetConfig returns the Gordon configuration.
1032func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
1033 resp, err := c.request(ctx, http.MethodGet, "/config", nil)
1034 if err != nil {
1035 return nil, err
1036 }
1037
1038 var config Config
1039 if err := parseResponse(resp, &config); err != nil {
1040 return nil, err
1041 }
1042
1043 return &config, nil
1044}
1045
1046// Ping checks if the remote Gordon instance is reachable.
1047func (c *Client) Ping(ctx context.Context) error {
1048 _, err := c.GetStatus(ctx)
1049 return err
1050}
1051
1052// Deploy API
1053
1054// DeployResult contains the result of a deployment.
1055type DeployResult struct {
1056 Status string `json:"status"`
1057 ContainerID string `json:"container_id"`
1058 Domain string `json:"domain"`
1059}
1060
1061// Deploy triggers a deployment for the specified domain.
1062func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) {
1063 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil)
1064 if err != nil {
1065 return nil, err
1066 }
1067
1068 var result DeployResult
1069 if err := parseResponse(resp, &result); err != nil {
1070 return nil, err
1071 }
1072
1073 return &result, nil
1074}
1075
1076// DeployIntent tells the server that a CLI-managed push is about to happen,
1077// suppressing event-based deploys for this image.
1078func (c *Client) DeployIntent(ctx context.Context, imageName string) error {
1079 imageName = strings.TrimSpace(imageName)
1080 if imageName == "" {
1081 return fmt.Errorf("image name cannot be empty")
1082 }
1083 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy-intent/"+url.PathEscape(imageName), nil)
1084 if err != nil {
1085 return err
1086 }
1087 return parseResponse(resp, nil)
1088}
1089
1090// Restart API
1091
1092// RestartResult contains the result of a restart.
1093type RestartResult struct {
1094 Status string `json:"status"`
1095 Domain string `json:"domain"`
1096}
1097
1098// Restart triggers a container restart for the specified domain.
1099func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*RestartResult, error) {
1100 if restartDomain == "" {
1101 return nil, fmt.Errorf("domain cannot be empty")
1102 }
1103 path := "/restart/" + url.PathEscape(restartDomain)
1104 if withAttachments {
1105 path += "?attachments=true"
1106 }
1107 resp, err := c.requestWithRetry(ctx, http.MethodPost, path, nil)
1108 if err != nil {
1109 return nil, err
1110 }
1111
1112 var result RestartResult
1113 if err := parseResponse(resp, &result); err != nil {
1114 return nil, err
1115 }
1116
1117 return &result, nil
1118}
1119
1120// Tags API
1121
1122// ListTags returns available tags for a repository.
1123func (c *Client) ListTags(ctx context.Context, repository string) ([]string, error) {
1124 if repository == "" {
1125 return nil, fmt.Errorf("repository cannot be empty")
1126 }
1127 resp, err := c.request(ctx, http.MethodGet, "/tags/"+url.PathEscape(repository), nil)
1128 if err != nil {
1129 return nil, err
1130 }
1131 var result struct {
1132 Repository string `json:"repository"`
1133 Tags []string `json:"tags"`
1134 }
1135 if err := parseResponse(resp, &result); err != nil {
1136 return nil, err
1137 }
1138 return result.Tags, nil
1139}
1140
1141// Logs API
1142
1143// GetProcessLogs returns Gordon process logs.
1144func (c *Client) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
1145 path := fmt.Sprintf("/logs?lines=%d", lines)
1146 resp, err := c.request(ctx, http.MethodGet, path, nil)
1147 if err != nil {
1148 return nil, err
1149 }
1150
1151 var result struct {
1152 Lines []string `json:"lines"`
1153 }
1154 if err := parseResponse(resp, &result); err != nil {
1155 return nil, err
1156 }
1157
1158 return result.Lines, nil
1159}
1160
1161// GetContainerLogs returns container logs for a specific domain.
1162func (c *Client) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
1163 path := fmt.Sprintf("/logs/%s?lines=%d", url.PathEscape(logDomain), lines)
1164 resp, err := c.request(ctx, http.MethodGet, path, nil)
1165 if err != nil {
1166 return nil, err
1167 }
1168
1169 var result struct {
1170 Domain string `json:"domain"`
1171 Lines []string `json:"lines"`
1172 }
1173 if err := parseResponse(resp, &result); err != nil {
1174 return nil, err
1175 }
1176
1177 return result.Lines, nil
1178}
1179
1180// StreamProcessLogs returns a channel that streams Gordon process log lines via SSE.
1181// The caller is responsible for reading from the channel until it's closed.
1182func (c *Client) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
1183 path := fmt.Sprintf("/logs?lines=%d&follow=true", lines)
1184 return c.streamLogs(ctx, path)
1185}
1186
1187// StreamContainerLogs returns a channel that streams container log lines via SSE.
1188// The caller is responsible for reading from the channel until it's closed.
1189func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
1190 path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", url.PathEscape(logDomain), lines)
1191 return c.streamLogs(ctx, path)
1192}
1193
1194// Attachments Config API
1195
1196// ListOrphanedAttachments returns running attachment containers no longer configured.
1197func (c *Client) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) {
1198 resp, err := c.request(ctx, http.MethodGet, "/attachments/orphans", nil)
1199 if err != nil {
1200 return nil, err
1201 }
1202 var result struct {
1203 Attachments []domain.CleanupAttachment `json:"attachments"`
1204 }
1205 if err := parseResponse(resp, &result); err != nil {
1206 return nil, err
1207 }
1208 return result.Attachments, nil
1209}
1210
1211// CleanupOrphanedAttachments optionally stops/removes orphaned attachment containers.
1212func (c *Client) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) {
1213 path := "/attachments/prune"
1214 params := url.Values{}
1215 if stop {
1216 params.Set("stop", "true")
1217 }
1218 if owner != "" {
1219 params.Set("owner", owner)
1220 }
1221 if encoded := params.Encode(); encoded != "" {
1222 path += "?" + encoded
1223 }
1224 resp, err := c.request(ctx, http.MethodPost, path, nil)
1225 if err != nil {
1226 return nil, fmt.Errorf("cleanup orphaned attachments failed: %w", err)
1227 }
1228 var result domain.CleanupReport
1229 if err := parseResponse(resp, &result); err != nil {
1230 return nil, fmt.Errorf("parse orphaned attachment cleanup response: %w", err)
1231 }
1232 return &result, nil
1233}
1234
1235// GetAllAttachmentsConfig returns all configured attachments.
1236func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
1237 resp, err := c.request(ctx, http.MethodGet, "/attachments", nil)
1238 if err != nil {
1239 return nil, err
1240 }
1241
1242 var result struct {
1243 Attachments map[string][]string `json:"attachments"`
1244 }
1245 if err := parseResponse(resp, &result); err != nil {
1246 return nil, err
1247 }
1248
1249 return result.Attachments, nil
1250}
1251
1252// GetAttachmentsConfig returns attachments for a specific domain/group from config.
1253func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
1254 if domainOrGroup == "" {
1255 return nil, fmt.Errorf("domain or group is required")
1256 }
1257 path := "/attachments/" + url.PathEscape(domainOrGroup)
1258 resp, err := c.request(ctx, http.MethodGet, path, nil)
1259 if err != nil {
1260 return nil, err
1261 }
1262
1263 var result struct {
1264 Target string `json:"target"`
1265 Images []string `json:"images"`
1266 }
1267 if err := parseResponse(resp, &result); err != nil {
1268 return nil, err
1269 }
1270
1271 return result.Images, nil
1272}
1273
1274// FindAttachmentTargetsByImage returns all attachment targets associated with the given image name.
1275func (c *Client) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
1276 resp, err := c.request(ctx, http.MethodGet, "/attachments/by-image/"+url.PathEscape(imageName), nil)
1277 if err != nil {
1278 return nil, err
1279 }
1280
1281 var result dto.AttachmentTargetsByImageResponse
1282 if err := parseResponse(resp, &result); err != nil {
1283 return nil, err
1284 }
1285
1286 return result.Targets, nil
1287}
1288
1289// AddAttachment adds an attachment to a domain/group.
1290func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
1291 if domainOrGroup == "" {
1292 return fmt.Errorf("domain or group is required")
1293 }
1294 if image == "" {
1295 return fmt.Errorf("image is required")
1296 }
1297 path := "/attachments/" + url.PathEscape(domainOrGroup)
1298 resp, err := c.request(ctx, http.MethodPost, path, struct {
1299 Image string `json:"image"`
1300 }{Image: image})
1301 if err != nil {
1302 return err
1303 }
1304 return parseResponse(resp, nil)
1305}
1306
1307// RemoveAttachment removes an attachment from a domain/group.
1308func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
1309 if domainOrGroup == "" {
1310 return fmt.Errorf("domain or group is required")
1311 }
1312 if image == "" {
1313 return fmt.Errorf("image is required")
1314 }
1315 path := "/attachments/" + url.PathEscape(domainOrGroup) + "/" + url.PathEscape(image)
1316 resp, err := c.request(ctx, http.MethodDelete, path, nil)
1317 if err != nil {
1318 return err
1319 }
1320 return parseResponse(resp, nil)
1321}
1322
1323func (c *Client) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
1324 resp, err := c.request(ctx, http.MethodGet, "/autoroute/allowed-domains", nil)
1325 if err != nil {
1326 return nil, err
1327 }
1328
1329 var result dto.AutoRouteAllowedDomainsResponse
1330 if err := parseResponse(resp, &result); err != nil {
1331 return nil, err
1332 }
1333
1334 return result.Domains, nil
1335}
1336
1337func (c *Client) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1338 if strings.TrimSpace(pattern) == "" {
1339 return fmt.Errorf("pattern must not be empty")
1340 }
1341 resp, err := c.request(ctx, http.MethodPost, "/autoroute/allowed-domains", dto.AutoRouteAllowedDomainRequest{Pattern: pattern})
1342 if err != nil {
1343 return err
1344 }
1345 return parseResponse(resp, nil)
1346}
1347
1348func (c *Client) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1349 if strings.TrimSpace(pattern) == "" {
1350 return fmt.Errorf("pattern must not be empty")
1351 }
1352 resp, err := c.request(ctx, http.MethodDelete, "/autoroute/allowed-domains/"+url.PathEscape(pattern), nil)
1353 if err != nil {
1354 return err
1355 }
1356 return parseResponse(resp, nil)
1357}
1358
1359// openSSEStream opens an SSE connection to the given admin path and returns the response.
1360func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response, error) {
1361 streamURL := c.baseURL + "/admin" + path
1362
1363 req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
1364 if err != nil {
1365 return nil, fmt.Errorf("failed to create request: %w", err)
1366 }
1367
1368 req.Header.Set("Accept", "text/event-stream")
1369 bearer, err := c.bearerToken(ctx)
1370 if err != nil {
1371 return nil, err
1372 }
1373 if bearer != "" {
1374 req.Header.Set("Authorization", "Bearer "+bearer)
1375 }
1376
1377 // Use the same transport as the main client (honoring TLS config and
1378 // custom transports) but without a timeout so streaming doesn't get cut off.
1379 streamClient := &http.Client{
1380 Transport: c.httpClient.Transport,
1381 }
1382 resp, err := streamClient.Do(req)
1383 if err != nil {
1384 return nil, fmt.Errorf("failed to connect: %w", err)
1385 }
1386
1387 if resp.StatusCode >= 400 {
1388 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
1389 resp.Body.Close()
1390 return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
1391 }
1392
1393 return resp, nil
1394}
1395
1396// streamLogs handles SSE streaming for log endpoints.
1397func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
1398 resp, err := c.openSSEStream(ctx, path)
1399 if err != nil {
1400 return nil, err
1401 }
1402
1403 ch := make(chan string, 100)
1404
1405 go func() {
1406 defer close(ch)
1407 defer resp.Body.Close()
1408
1409 buf := make([]byte, 4096)
1410 var lineBuffer strings.Builder
1411
1412 for {
1413 select {
1414 case <-ctx.Done():
1415 return
1416 default:
1417 }
1418
1419 n, err := resp.Body.Read(buf)
1420 if n > 0 {
1421 lineBuffer.Write(buf[:n])
1422 }
1423 if err != nil {
1424 return // EOF or context cancellation — clean exit
1425 }
1426
1427 // Process complete SSE events
1428 for {
1429 data := lineBuffer.String()
1430 event, remaining, ok := strings.Cut(data, "\n\n")
1431 if !ok {
1432 break
1433 }
1434
1435 lineBuffer.Reset()
1436 lineBuffer.WriteString(remaining)
1437
1438 // Parse SSE data lines
1439 for line := range strings.SplitSeq(event, "\n") {
1440 logLine, ok := strings.CutPrefix(line, "data: ")
1441 if ok {
1442 select {
1443 case ch <- logLine:
1444 case <-ctx.Done():
1445 return
1446 }
1447 }
1448 }
1449 }
1450 }
1451 }()
1452
1453 return ch, nil
1454}
1455
1456// VerifyAuth checks if authentication session is valid.
1457func (c *Client) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) {
1458 resp, err := c.request(ctx, http.MethodGet, "/auth/verify", nil)
1459 if err != nil {
1460 return nil, err
1461 }
1462
1463 var result dto.AuthVerifyResponse
1464 if err := parseResponse(resp, &result); err != nil {
1465 return nil, err
1466 }
1467
1468 return &result, nil
1469}
1470
1471// ListVolumes lists all volumes via the admin API.
1472func (c *Client) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
1473 resp, err := c.request(ctx, http.MethodGet, "/volumes", nil)
1474 if err != nil {
1475 return nil, err
1476 }
1477
1478 var volumes []dto.Volume
1479 if err := parseResponse(resp, &volumes); err != nil {
1480 return nil, err
1481 }
1482
1483 return volumes, nil
1484}
1485
1486// PruneVolumes prunes orphaned volumes via the admin API.
1487func (c *Client) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
1488 resp, err := c.request(ctx, http.MethodPost, "/volumes/prune", req)
1489 if err != nil {
1490 return nil, err
1491 }
1492
1493 var result dto.VolumePruneResponse
1494 if err := parseResponse(resp, &result); err != nil {
1495 return nil, err
1496 }
1497
1498 return &result, nil
1499}