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
269// doRequest builds and executes a single HTTP request to the admin API.
270func (c *Client) doRequest(ctx context.Context, method, path string, jsonBody []byte) (*http.Response, error) {
271 reqURL := c.baseURL + "/admin" + path
272
273 var bodyReader io.Reader
274 if jsonBody != nil {
275 bodyReader = bytes.NewReader(jsonBody)
276 }
277
278 req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
279 if err != nil {
280 return nil, fmt.Errorf("failed to create request: %w", err)
281 }
282
283 req.Header.Set("Content-Type", "application/json")
284 req.Header.Set("Accept", "application/json")
285
286 bearer, err := c.bearerToken(ctx)
287 if err != nil {
288 return nil, err
289 }
290 if bearer != "" {
291 req.Header.Set("Authorization", "Bearer "+bearer)
292 }
293
294 resp, err := c.httpClient.Do(req)
295 if err != nil {
296 return nil, err
297 }
298
299 return resp, nil
300}
301
302// parseResponse parses a JSON response into the given target.
303func parseResponse(resp *http.Response, target any) error {
304 defer resp.Body.Close()
305
306 if resp.StatusCode >= 400 {
307 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
308 return parseErrorResponse(resp, body)
309 }
310
311 if target != nil {
312 if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
313 return fmt.Errorf("failed to decode response: %w", err)
314 }
315 }
316
317 return nil
318}
319
320// HTTPError represents an HTTP error response with a status code.
321// Use errors.As to check for specific status codes in error handling.
322type HTTPError struct {
323 StatusCode int
324 Status string
325 Body string
326 Cause string
327 Hint string
328 Logs []string
329 Structured bool
330}
331
332func (e *HTTPError) Error() string {
333 return e.Status + ": " + e.Body
334}
335
336func parseErrorResponse(resp *http.Response, body []byte) error {
337 msg := string(body)
338 structured := false
339 var errResp struct {
340 Error string `json:"error"`
341 Cause string `json:"cause"`
342 Hint string `json:"hint"`
343 Logs []string `json:"logs"`
344 }
345 if err := json.Unmarshal(body, &errResp); err == nil {
346 structured = errResp.Cause != "" || errResp.Hint != "" || len(errResp.Logs) > 0
347 if errResp.Error != "" {
348 msg = errResp.Error
349 }
350 }
351 return &HTTPError{
352 StatusCode: resp.StatusCode,
353 Status: resp.Status,
354 Body: msg,
355 Cause: errResp.Cause,
356 Hint: errResp.Hint,
357 Logs: errResp.Logs,
358 Structured: structured,
359 }
360}
361
362func isRetryableRequestError(err error) bool {
363 return !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded)
364}
365
366func isRetryableStatus(status int) bool {
367 // Only retry gateway errors; retrying 500 on non-idempotent POSTs
368 // (deploy, restart, reload) can cause duplicate state changes.
369 return status == 502 || status == 503 || status == 504
370}
371
372func retryDelay(attempt int) time.Duration {
373 if attempt <= 1 {
374 return retryBaseDelay
375 }
376 return retryBaseDelay * time.Duration(1<<(attempt-1))
377}
378
379// requestWithRetry performs an HTTP request and retries transient failures.
380// Retries occur on transport errors and gateway errors (502, 503, 504).
381func (c *Client) requestWithRetry(ctx context.Context, method, path string, body any) (*http.Response, error) {
382 var lastErr error
383
384 for attempt := 1; attempt <= retryMaxAttempts; attempt++ {
385 resp, err := c.request(ctx, method, path, body)
386 if err != nil {
387 lastErr = err
388 if attempt == retryMaxAttempts || !isRetryableRequestError(err) {
389 return nil, err
390 }
391 } else {
392 if !isRetryableStatus(resp.StatusCode) {
393 return resp, nil
394 }
395
396 respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
397 _ = resp.Body.Close()
398 lastErr = parseErrorResponse(resp, respBody)
399 if attempt == retryMaxAttempts {
400 return nil, lastErr
401 }
402 }
403
404 delay := retryDelay(attempt)
405 timer := time.NewTimer(delay)
406 select {
407 case <-ctx.Done():
408 timer.Stop()
409 return nil, ctx.Err()
410 case <-timer.C:
411 }
412 }
413
414 if lastErr != nil {
415 return nil, lastErr
416 }
417 return nil, fmt.Errorf("request failed after retries")
418}
419
420// Routes API
421
422// Type aliases for API types using shared DTO package.
423type RouteInfo = dto.RouteInfo
424type Attachment = dto.Attachment
425
426// ListRoutes returns all configured routes.
427func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
428 resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
429 if err != nil {
430 return nil, err
431 }
432
433 var result struct {
434 Routes []domain.Route `json:"routes"`
435 }
436 if err := parseResponse(resp, &result); err != nil {
437 return nil, err
438 }
439
440 return result.Routes, nil
441}
442
443// ListRoutesWithDetails returns routes with network and attachment info.
444func (c *Client) ListRoutesWithDetails(ctx context.Context) ([]RouteInfo, error) {
445 resp, err := c.request(ctx, http.MethodGet, "/routes?detailed=true", nil)
446 if err != nil {
447 return nil, err
448 }
449
450 var result struct {
451 Routes []RouteInfo `json:"routes"`
452 }
453 if err := parseResponse(resp, &result); err != nil {
454 return nil, err
455 }
456
457 return result.Routes, nil
458}
459
460// ListNetworks returns Gordon-managed networks.
461func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
462 resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
463 if err != nil {
464 return nil, err
465 }
466
467 var result struct {
468 Networks []*domain.NetworkInfo `json:"networks"`
469 }
470 if err := parseResponse(resp, &result); err != nil {
471 return nil, err
472 }
473
474 return result.Networks, nil
475}
476
477// ListAttachments returns attachments for a domain.
478func (c *Client) ListAttachments(ctx context.Context, routeDomain string) ([]Attachment, error) {
479 if routeDomain == "" {
480 return nil, fmt.Errorf("domain is required")
481 }
482 path := "/routes/" + url.PathEscape(routeDomain) + "/attachments"
483 resp, err := c.request(ctx, http.MethodGet, path, nil)
484 if err != nil {
485 return nil, err
486 }
487
488 var result struct {
489 Attachments []Attachment `json:"attachments"`
490 }
491 if err := parseResponse(resp, &result); err != nil {
492 return nil, err
493 }
494
495 return result.Attachments, nil
496}
497
498// GetRoute returns a specific route by domain.
499func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
500 resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
501 if err != nil {
502 return nil, err
503 }
504
505 var route domain.Route
506 if err := parseResponse(resp, &route); err != nil {
507 var httpErr *HTTPError
508 if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
509 return nil, domain.ErrRouteNotFound
510 }
511 return nil, err
512 }
513
514 return &route, nil
515}
516
517// GetRouteCleanupPreview returns retained cleanup state for a route that may no longer be configured.
518func (c *Client) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
519 resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain)+"/cleanup", nil)
520 if err != nil {
521 return nil, err
522 }
523
524 var report domain.CleanupReport
525 if err := parseResponse(resp, &report); err != nil {
526 return nil, err
527 }
528
529 return &report, nil
530}
531
532// FindRoutesByImage returns all routes associated with the given image name.
533func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
534 resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil)
535 if err != nil {
536 return nil, err
537 }
538
539 var result struct {
540 Image string `json:"image"`
541 Routes []domain.Route `json:"routes"`
542 }
543 if err := parseResponse(resp, &result); err != nil {
544 return nil, err
545 }
546
547 return result.Routes, nil
548}
549
550// AddRoute adds a new route.
551func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
552 resp, err := c.request(ctx, http.MethodPost, "/routes", route)
553 if err != nil {
554 return err
555 }
556 return parseResponse(resp, nil)
557}
558
559// UpdateRoute updates an existing route.
560func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
561 resp, err := c.request(ctx, http.MethodPut, "/routes/"+url.PathEscape(route.Domain), route)
562 if err != nil {
563 return err
564 }
565 return parseResponse(resp, nil)
566}
567
568// RemoveRoute removes a route by domain.
569func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
570 _, err := c.RemoveRouteWithCleanup(ctx, routeDomain)
571 return err
572}
573
574// RemoveRouteWithCleanup removes a route and returns the server cleanup report.
575func (c *Client) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) {
576 resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)
577 if err != nil {
578 return nil, fmt.Errorf("delete route %s failed: %w", routeDomain, err)
579 }
580 var result dto.RouteDeleteResponse
581 if err := parseResponse(resp, &result); err != nil {
582 return nil, fmt.Errorf("parse delete route %s response: %w", routeDomain, err)
583 }
584 return &result, nil
585}
586
587func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
588 resp, err := c.request(ctx, http.MethodPost, "/bootstrap", req)
589 if err != nil {
590 return nil, err
591 }
592 var result dto.BootstrapResponse
593 if err := parseResponse(resp, &result); err != nil {
594 return nil, err
595 }
596 return &result, nil
597}
598
599// Secrets API
600
601// AttachmentSecrets represents secrets for an attachment container.
602type AttachmentSecrets struct {
603 Service string `json:"service"`
604 Keys []string `json:"keys"`
605}
606
607// SecretsListResult contains domain secrets and any attachment secrets.
608type SecretsListResult struct {
609 Domain string `json:"domain"`
610 Keys []string `json:"keys"`
611 Attachments []AttachmentSecrets `json:"attachments,omitempty"`
612}
613
614// ListSecrets returns the list of secret keys for a domain.
615func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
616 result, err := c.ListSecretsWithAttachments(ctx, secretDomain)
617 if err != nil {
618 return nil, err
619 }
620 return result.Keys, nil
621}
622
623// ListSecretsWithAttachments returns domain secrets and attachment secrets.
624func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) {
625 resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
626 if err != nil {
627 return nil, err
628 }
629
630 var result SecretsListResult
631 if err := parseResponse(resp, &result); err != nil {
632 return nil, err
633 }
634
635 return &result, nil
636}
637
638// SetSecrets sets secrets for a domain.
639func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
640 resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets)
641 if err != nil {
642 return err
643 }
644 return parseResponse(resp, nil)
645}
646
647// DeleteSecret removes a secret from a domain.
648func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {
649 resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil)
650 if err != nil {
651 return err
652 }
653 return parseResponse(resp, nil)
654}
655
656// SetAttachmentSecrets sets secrets for an attachment container.
657func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error {
658 path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service)
659 resp, err := c.request(ctx, http.MethodPost, path, secrets)
660 if err != nil {
661 return err
662 }
663 return parseResponse(resp, nil)
664}
665
666// DeleteAttachmentSecret removes a secret from an attachment container.
667func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error {
668 path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key)
669 resp, err := c.request(ctx, http.MethodDelete, path, nil)
670 if err != nil {
671 return err
672 }
673 return parseResponse(resp, nil)
674}
675
676// Status API
677
678// Status represents the Gordon server status.
679type Status struct {
680 Routes int `json:"routes"`
681 RegistryDomain string `json:"registry_domain"`
682 RegistryPort int `json:"registry_port"`
683 ServerPort int `json:"server_port"`
684 AutoRoute bool `json:"auto_route"`
685 NetworkIsolation bool `json:"network_isolation"`
686 ContainerStatus map[string]string `json:"container_status"`
687}
688
689// GetTLSStatus returns the public TLS/ACME status.
690func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
691 resp, err := c.request(ctx, http.MethodGet, "/tls/status", nil)
692 if err != nil {
693 return nil, err
694 }
695
696 var status dto.TLSStatusResponse
697 if err := parseResponse(resp, &status); err != nil {
698 return nil, err
699 }
700
701 return &status, nil
702}
703
704// GetTrafficStatus returns the traffic plane status.
705func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
706 resp, err := c.request(ctx, http.MethodGet, "/traffic/status", nil)
707 if err != nil {
708 return nil, fmt.Errorf("request traffic status: %w", err)
709 }
710
711 var status dto.TrafficStatusResponse
712 if err := parseResponse(resp, &status); err != nil {
713 return nil, fmt.Errorf("parse traffic status response: %w", err)
714 }
715
716 return &status, nil
717}
718
719// GetStatus returns the Gordon server status.
720func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
721 resp, err := c.request(ctx, http.MethodGet, "/status", nil)
722 if err != nil {
723 return nil, err
724 }
725
726 var status Status
727 if err := parseResponse(resp, &status); err != nil {
728 return nil, err
729 }
730
731 return &status, nil
732}
733
734// RouteHealth represents the health status of a route.
735type RouteHealth struct {
736 ContainerStatus string `json:"container_status"`
737 HTTPStatus int `json:"http_status"`
738 ResponseTimeMs int64 `json:"response_time_ms"`
739 Healthy bool `json:"healthy"`
740 Error string `json:"error"`
741}
742
743// Backups API
744
745// Images API
746
747// ListImages returns runtime images and registry tags from the admin API.
748func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
749 resp, err := c.request(ctx, http.MethodGet, "/images", nil)
750 if err != nil {
751 return nil, err
752 }
753
754 var result dto.ImagesResponse
755 if err := parseResponse(resp, &result); err != nil {
756 return nil, err
757 }
758
759 return result.Images, nil
760}
761
762// PruneImages prunes runtime and registry images.
763func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
764 if req.KeepLast != nil && *req.KeepLast < 0 {
765 return nil, fmt.Errorf("keep_last must be >= 0")
766 }
767
768 resp, err := c.request(ctx, http.MethodPost, "/images/prune", req)
769 if err != nil {
770 return nil, err
771 }
772
773 var result dto.ImagePruneResponse
774 if err := parseResponse(resp, &result); err != nil {
775 return nil, err
776 }
777
778 return &result, nil
779}
780
781// ListBackups returns backups globally or for a domain.
782func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
783 path := "/backups"
784 if backupDomain != "" {
785 path += "/" + url.PathEscape(backupDomain)
786 }
787
788 resp, err := c.request(ctx, http.MethodGet, path, nil)
789 if err != nil {
790 return nil, err
791 }
792
793 var result dto.BackupsResponse
794 if err := parseResponse(resp, &result); err != nil {
795 return nil, err
796 }
797
798 return result.Backups, nil
799}
800
801// BackupStatus returns aggregate backup status.
802func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
803 resp, err := c.request(ctx, http.MethodGet, "/backups/status", nil)
804 if err != nil {
805 return nil, err
806 }
807
808 var result dto.BackupsResponse
809 if err := parseResponse(resp, &result); err != nil {
810 return nil, err
811 }
812
813 return result.Backups, nil
814}
815
816// RunBackup triggers a backup for a domain.
817func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
818 if backupDomain == "" {
819 return nil, fmt.Errorf("domain cannot be empty")
820 }
821
822 resp, err := c.request(ctx, http.MethodPost, "/backups/"+url.PathEscape(backupDomain), dto.BackupRunRequest{DB: dbName})
823 if err != nil {
824 return nil, err
825 }
826
827 var result dto.BackupRunResponse
828 if err := parseResponse(resp, &result); err != nil {
829 return nil, err
830 }
831
832 return &result, nil
833}
834
835// DetectDatabases detects supported databases for a domain.
836func (c *Client) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
837 if backupDomain == "" {
838 return nil, fmt.Errorf("domain cannot be empty")
839 }
840
841 resp, err := c.request(ctx, http.MethodGet, "/backups/"+url.PathEscape(backupDomain)+"/detect", nil)
842 if err != nil {
843 return nil, err
844 }
845
846 var result dto.BackupDetectResponse
847 if err := parseResponse(resp, &result); err != nil {
848 return nil, err
849 }
850
851 return result.Databases, nil
852}
853
854// ListVolumeBackups returns volume backups globally or for a domain.
855func (c *Client) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
856 path := "/backups/volumes"
857 if backupDomain != "" {
858 path += "/" + url.PathEscape(backupDomain)
859 }
860
861 resp, err := c.request(ctx, http.MethodGet, path, nil)
862 if err != nil {
863 return nil, err
864 }
865
866 var result dto.VolumeBackupsResponse
867 if err := parseResponse(resp, &result); err != nil {
868 return nil, err
869 }
870 return result.Backups, nil
871}
872
873// VolumeBackupStatus returns aggregate volume backup status.
874func (c *Client) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
875 resp, err := c.request(ctx, http.MethodGet, "/backups/volumes/status", nil)
876 if err != nil {
877 return nil, err
878 }
879
880 var result dto.VolumeBackupsResponse
881 if err := parseResponse(resp, &result); err != nil {
882 return nil, err
883 }
884 return result.Backups, nil
885}
886
887// RunVolumeBackups triggers volume backups.
888func (c *Client) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
889 path := "/backups/volumes"
890 if backupDomain != "" {
891 path += "/" + url.PathEscape(backupDomain)
892 }
893
894 resp, err := c.request(ctx, http.MethodPost, path, dto.VolumeBackupRunRequest{Volume: volumeName})
895 if err != nil {
896 return nil, err
897 }
898
899 var result dto.VolumeBackupRunResponse
900 if err := parseResponse(resp, &result); err != nil {
901 return nil, err
902 }
903 if resp.StatusCode == http.StatusPartialContent {
904 if result.Error == "" {
905 result.Error = "volume backup run partially failed"
906 }
907 return &result, fmt.Errorf("volume backup run partially failed: %s", result.Error)
908 }
909 return &result, nil
910}
911
912// GetHealth returns health status for all routes with HTTP probing.
913func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
914 resp, err := c.request(ctx, http.MethodGet, "/health", nil)
915 if err != nil {
916 return nil, err
917 }
918
919 var result struct {
920 Health map[string]*RouteHealth `json:"health"`
921 }
922 if err := parseResponse(resp, &result); err != nil {
923 return nil, err
924 }
925
926 return result.Health, nil
927}
928
929// Reload triggers a configuration reload.
930func (c *Client) Reload(ctx context.Context) error {
931 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/reload", nil)
932 if err != nil {
933 return err
934 }
935 return parseResponse(resp, nil)
936}
937
938// Config API
939
940// Config represents the Gordon configuration.
941type Config struct {
942 Server struct {
943 Port int `json:"port"`
944 RegistryPort int `json:"registry_port"`
945 RegistryDomain string `json:"registry_domain"`
946 DataDir string `json:"data_dir,omitempty"`
947 } `json:"server"`
948 AutoRoute struct {
949 Enabled bool `json:"enabled"`
950 } `json:"auto_route"`
951 NetworkIsolation struct {
952 Enabled bool `json:"enabled"`
953 Prefix string `json:"prefix"`
954 } `json:"network_isolation"`
955 Volumes struct {
956 AutoCreate bool `json:"auto_create"`
957 Prefix string `json:"prefix"`
958 Preserve bool `json:"preserve"`
959 } `json:"volumes"`
960 Routes []domain.Route `json:"routes"`
961 ExternalRoutes []ExternalRoute `json:"external_routes"`
962}
963
964// ExternalRoute represents a redacted external route config entry.
965type ExternalRoute struct {
966 Domain string `json:"domain"`
967 Target string `json:"target,omitempty"`
968}
969
970// GetConfig returns the Gordon configuration.
971func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
972 resp, err := c.request(ctx, http.MethodGet, "/config", nil)
973 if err != nil {
974 return nil, err
975 }
976
977 var config Config
978 if err := parseResponse(resp, &config); err != nil {
979 return nil, err
980 }
981
982 return &config, nil
983}
984
985// Ping checks if the remote Gordon instance is reachable.
986func (c *Client) Ping(ctx context.Context) error {
987 _, err := c.GetStatus(ctx)
988 return err
989}
990
991// Deploy API
992
993// DeployResult contains the result of a deployment.
994type DeployResult struct {
995 Status string `json:"status"`
996 ContainerID string `json:"container_id"`
997 Domain string `json:"domain"`
998}
999
1000// Deploy triggers a deployment for the specified domain.
1001func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) {
1002 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil)
1003 if err != nil {
1004 return nil, err
1005 }
1006
1007 var result DeployResult
1008 if err := parseResponse(resp, &result); err != nil {
1009 return nil, err
1010 }
1011
1012 return &result, nil
1013}
1014
1015// DeployIntent tells the server that a CLI-managed push is about to happen,
1016// suppressing event-based deploys for this image.
1017func (c *Client) DeployIntent(ctx context.Context, imageName string) error {
1018 imageName = strings.TrimSpace(imageName)
1019 if imageName == "" {
1020 return fmt.Errorf("image name cannot be empty")
1021 }
1022 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy-intent/"+url.PathEscape(imageName), nil)
1023 if err != nil {
1024 return err
1025 }
1026 return parseResponse(resp, nil)
1027}
1028
1029// Restart API
1030
1031// RestartResult contains the result of a restart.
1032type RestartResult struct {
1033 Status string `json:"status"`
1034 Domain string `json:"domain"`
1035}
1036
1037// Restart triggers a container restart for the specified domain.
1038func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*RestartResult, error) {
1039 if restartDomain == "" {
1040 return nil, fmt.Errorf("domain cannot be empty")
1041 }
1042 path := "/restart/" + url.PathEscape(restartDomain)
1043 if withAttachments {
1044 path += "?attachments=true"
1045 }
1046 resp, err := c.requestWithRetry(ctx, http.MethodPost, path, nil)
1047 if err != nil {
1048 return nil, err
1049 }
1050
1051 var result RestartResult
1052 if err := parseResponse(resp, &result); err != nil {
1053 return nil, err
1054 }
1055
1056 return &result, nil
1057}
1058
1059// Tags API
1060
1061// ListTags returns available tags for a repository.
1062func (c *Client) ListTags(ctx context.Context, repository string) ([]string, error) {
1063 if repository == "" {
1064 return nil, fmt.Errorf("repository cannot be empty")
1065 }
1066 resp, err := c.request(ctx, http.MethodGet, "/tags/"+url.PathEscape(repository), nil)
1067 if err != nil {
1068 return nil, err
1069 }
1070 var result struct {
1071 Repository string `json:"repository"`
1072 Tags []string `json:"tags"`
1073 }
1074 if err := parseResponse(resp, &result); err != nil {
1075 return nil, err
1076 }
1077 return result.Tags, nil
1078}
1079
1080// Logs API
1081
1082// GetProcessLogs returns Gordon process logs.
1083func (c *Client) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
1084 path := fmt.Sprintf("/logs?lines=%d", lines)
1085 resp, err := c.request(ctx, http.MethodGet, path, nil)
1086 if err != nil {
1087 return nil, err
1088 }
1089
1090 var result struct {
1091 Lines []string `json:"lines"`
1092 }
1093 if err := parseResponse(resp, &result); err != nil {
1094 return nil, err
1095 }
1096
1097 return result.Lines, nil
1098}
1099
1100// GetContainerLogs returns container logs for a specific domain.
1101func (c *Client) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
1102 path := fmt.Sprintf("/logs/%s?lines=%d", url.PathEscape(logDomain), lines)
1103 resp, err := c.request(ctx, http.MethodGet, path, nil)
1104 if err != nil {
1105 return nil, err
1106 }
1107
1108 var result struct {
1109 Domain string `json:"domain"`
1110 Lines []string `json:"lines"`
1111 }
1112 if err := parseResponse(resp, &result); err != nil {
1113 return nil, err
1114 }
1115
1116 return result.Lines, nil
1117}
1118
1119// StreamProcessLogs returns a channel that streams Gordon process log lines via SSE.
1120// The caller is responsible for reading from the channel until it's closed.
1121func (c *Client) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
1122 path := fmt.Sprintf("/logs?lines=%d&follow=true", lines)
1123 return c.streamLogs(ctx, path)
1124}
1125
1126// StreamContainerLogs returns a channel that streams container log lines via SSE.
1127// The caller is responsible for reading from the channel until it's closed.
1128func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
1129 path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", url.PathEscape(logDomain), lines)
1130 return c.streamLogs(ctx, path)
1131}
1132
1133// Attachments Config API
1134
1135// ListOrphanedAttachments returns running attachment containers no longer configured.
1136func (c *Client) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) {
1137 resp, err := c.request(ctx, http.MethodGet, "/attachments/orphans", nil)
1138 if err != nil {
1139 return nil, err
1140 }
1141 var result struct {
1142 Attachments []domain.CleanupAttachment `json:"attachments"`
1143 }
1144 if err := parseResponse(resp, &result); err != nil {
1145 return nil, err
1146 }
1147 return result.Attachments, nil
1148}
1149
1150// CleanupOrphanedAttachments optionally stops/removes orphaned attachment containers.
1151func (c *Client) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) {
1152 path := "/attachments/prune"
1153 params := url.Values{}
1154 if stop {
1155 params.Set("stop", "true")
1156 }
1157 if owner != "" {
1158 params.Set("owner", owner)
1159 }
1160 if encoded := params.Encode(); encoded != "" {
1161 path += "?" + encoded
1162 }
1163 resp, err := c.request(ctx, http.MethodPost, path, nil)
1164 if err != nil {
1165 return nil, fmt.Errorf("cleanup orphaned attachments failed: %w", err)
1166 }
1167 var result domain.CleanupReport
1168 if err := parseResponse(resp, &result); err != nil {
1169 return nil, fmt.Errorf("parse orphaned attachment cleanup response: %w", err)
1170 }
1171 return &result, nil
1172}
1173
1174// GetAllAttachmentsConfig returns all configured attachments.
1175func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
1176 resp, err := c.request(ctx, http.MethodGet, "/attachments", nil)
1177 if err != nil {
1178 return nil, err
1179 }
1180
1181 var result struct {
1182 Attachments map[string][]string `json:"attachments"`
1183 }
1184 if err := parseResponse(resp, &result); err != nil {
1185 return nil, err
1186 }
1187
1188 return result.Attachments, nil
1189}
1190
1191// GetAttachmentsConfig returns attachments for a specific domain/group from config.
1192func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
1193 if domainOrGroup == "" {
1194 return nil, fmt.Errorf("domain or group is required")
1195 }
1196 path := "/attachments/" + url.PathEscape(domainOrGroup)
1197 resp, err := c.request(ctx, http.MethodGet, path, nil)
1198 if err != nil {
1199 return nil, err
1200 }
1201
1202 var result struct {
1203 Target string `json:"target"`
1204 Images []string `json:"images"`
1205 }
1206 if err := parseResponse(resp, &result); err != nil {
1207 return nil, err
1208 }
1209
1210 return result.Images, nil
1211}
1212
1213// FindAttachmentTargetsByImage returns all attachment targets associated with the given image name.
1214func (c *Client) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
1215 resp, err := c.request(ctx, http.MethodGet, "/attachments/by-image/"+url.PathEscape(imageName), nil)
1216 if err != nil {
1217 return nil, err
1218 }
1219
1220 var result dto.AttachmentTargetsByImageResponse
1221 if err := parseResponse(resp, &result); err != nil {
1222 return nil, err
1223 }
1224
1225 return result.Targets, nil
1226}
1227
1228// AddAttachment adds an attachment to a domain/group.
1229func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
1230 if domainOrGroup == "" {
1231 return fmt.Errorf("domain or group is required")
1232 }
1233 if image == "" {
1234 return fmt.Errorf("image is required")
1235 }
1236 path := "/attachments/" + url.PathEscape(domainOrGroup)
1237 resp, err := c.request(ctx, http.MethodPost, path, struct {
1238 Image string `json:"image"`
1239 }{Image: image})
1240 if err != nil {
1241 return err
1242 }
1243 return parseResponse(resp, nil)
1244}
1245
1246// RemoveAttachment removes an attachment from a domain/group.
1247func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
1248 if domainOrGroup == "" {
1249 return fmt.Errorf("domain or group is required")
1250 }
1251 if image == "" {
1252 return fmt.Errorf("image is required")
1253 }
1254 path := "/attachments/" + url.PathEscape(domainOrGroup) + "/" + url.PathEscape(image)
1255 resp, err := c.request(ctx, http.MethodDelete, path, nil)
1256 if err != nil {
1257 return err
1258 }
1259 return parseResponse(resp, nil)
1260}
1261
1262func (c *Client) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
1263 resp, err := c.request(ctx, http.MethodGet, "/autoroute/allowed-domains", nil)
1264 if err != nil {
1265 return nil, err
1266 }
1267
1268 var result dto.AutoRouteAllowedDomainsResponse
1269 if err := parseResponse(resp, &result); err != nil {
1270 return nil, err
1271 }
1272
1273 return result.Domains, nil
1274}
1275
1276func (c *Client) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1277 if strings.TrimSpace(pattern) == "" {
1278 return fmt.Errorf("pattern must not be empty")
1279 }
1280 resp, err := c.request(ctx, http.MethodPost, "/autoroute/allowed-domains", dto.AutoRouteAllowedDomainRequest{Pattern: pattern})
1281 if err != nil {
1282 return err
1283 }
1284 return parseResponse(resp, nil)
1285}
1286
1287func (c *Client) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1288 if strings.TrimSpace(pattern) == "" {
1289 return fmt.Errorf("pattern must not be empty")
1290 }
1291 resp, err := c.request(ctx, http.MethodDelete, "/autoroute/allowed-domains/"+url.PathEscape(pattern), nil)
1292 if err != nil {
1293 return err
1294 }
1295 return parseResponse(resp, nil)
1296}
1297
1298// openSSEStream opens an SSE connection to the given admin path and returns the response.
1299func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response, error) {
1300 streamURL := c.baseURL + "/admin" + path
1301
1302 req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
1303 if err != nil {
1304 return nil, fmt.Errorf("failed to create request: %w", err)
1305 }
1306
1307 req.Header.Set("Accept", "text/event-stream")
1308 bearer, err := c.bearerToken(ctx)
1309 if err != nil {
1310 return nil, err
1311 }
1312 if bearer != "" {
1313 req.Header.Set("Authorization", "Bearer "+bearer)
1314 }
1315
1316 // Use the same transport as the main client (honoring TLS config and
1317 // custom transports) but without a timeout so streaming doesn't get cut off.
1318 streamClient := &http.Client{
1319 Transport: c.httpClient.Transport,
1320 }
1321 resp, err := streamClient.Do(req)
1322 if err != nil {
1323 return nil, fmt.Errorf("failed to connect: %w", err)
1324 }
1325
1326 if resp.StatusCode >= 400 {
1327 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
1328 resp.Body.Close()
1329 return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
1330 }
1331
1332 return resp, nil
1333}
1334
1335// streamLogs handles SSE streaming for log endpoints.
1336func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
1337 resp, err := c.openSSEStream(ctx, path)
1338 if err != nil {
1339 return nil, err
1340 }
1341
1342 ch := make(chan string, 100)
1343
1344 go func() {
1345 defer close(ch)
1346 defer resp.Body.Close()
1347
1348 buf := make([]byte, 4096)
1349 var lineBuffer strings.Builder
1350
1351 for {
1352 select {
1353 case <-ctx.Done():
1354 return
1355 default:
1356 }
1357
1358 n, err := resp.Body.Read(buf)
1359 if n > 0 {
1360 lineBuffer.Write(buf[:n])
1361 }
1362 if err != nil {
1363 return // EOF or context cancellation — clean exit
1364 }
1365
1366 // Process complete SSE events
1367 for {
1368 data := lineBuffer.String()
1369 event, remaining, ok := strings.Cut(data, "\n\n")
1370 if !ok {
1371 break
1372 }
1373
1374 lineBuffer.Reset()
1375 lineBuffer.WriteString(remaining)
1376
1377 // Parse SSE data lines
1378 for line := range strings.SplitSeq(event, "\n") {
1379 logLine, ok := strings.CutPrefix(line, "data: ")
1380 if ok {
1381 select {
1382 case ch <- logLine:
1383 case <-ctx.Done():
1384 return
1385 }
1386 }
1387 }
1388 }
1389 }
1390 }()
1391
1392 return ch, nil
1393}
1394
1395// VerifyAuth checks if authentication session is valid.
1396func (c *Client) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) {
1397 resp, err := c.request(ctx, http.MethodGet, "/auth/verify", nil)
1398 if err != nil {
1399 return nil, err
1400 }
1401
1402 var result dto.AuthVerifyResponse
1403 if err := parseResponse(resp, &result); err != nil {
1404 return nil, err
1405 }
1406
1407 return &result, nil
1408}
1409
1410// ListVolumes lists all volumes via the admin API.
1411func (c *Client) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
1412 resp, err := c.request(ctx, http.MethodGet, "/volumes", nil)
1413 if err != nil {
1414 return nil, err
1415 }
1416
1417 var volumes []dto.Volume
1418 if err := parseResponse(resp, &volumes); err != nil {
1419 return nil, err
1420 }
1421
1422 return volumes, nil
1423}
1424
1425// PruneVolumes prunes orphaned volumes via the admin API.
1426func (c *Client) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
1427 resp, err := c.request(ctx, http.MethodPost, "/volumes/prune", req)
1428 if err != nil {
1429 return nil, err
1430 }
1431
1432 var result dto.VolumePruneResponse
1433 if err := parseResponse(resp, &result); err != nil {
1434 return nil, err
1435 }
1436
1437 return &result, nil
1438}