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