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 Message string `json:"message"`
378 Cause string `json:"cause"`
379 Hint string `json:"hint"`
380 Logs []string `json:"logs"`
381 }
382 if err := json.Unmarshal(body, &errResp); err == nil {
383 structured = errResp.Cause != "" || errResp.Hint != "" || len(errResp.Logs) > 0
384 if errResp.Message != "" {
385 msg = errResp.Message
386 } else if errResp.Error != "" {
387 msg = errResp.Error
388 }
389 }
390 return &HTTPError{
391 StatusCode: resp.StatusCode,
392 Status: resp.Status,
393 Body: msg,
394 Cause: errResp.Cause,
395 Hint: errResp.Hint,
396 Logs: errResp.Logs,
397 Structured: structured,
398 }
399}
400
401func isRetryableRequestError(err error) bool {
402 var transportErr *requestTransportError
403 return errors.As(err, &transportErr) &&
404 !errors.Is(err, context.Canceled) &&
405 !errors.Is(err, context.DeadlineExceeded)
406}
407
408func isIdempotentMethod(method string) bool {
409 switch method {
410 case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace,
411 http.MethodPut, http.MethodDelete:
412 return true
413 default:
414 return false
415 }
416}
417
418func isRetryableStatus(status int) bool {
419 // Gateway errors can be transient; requestWithRetry separately ensures the
420 // method is safe to replay before attempting another request.
421 return status == 502 || status == 503 || status == 504
422}
423
424func retryDelay(attempt int) time.Duration {
425 if attempt <= 1 {
426 return retryBaseDelay
427 }
428 return retryBaseDelay * time.Duration(1<<(attempt-1))
429}
430
431// requestWithRetry performs an HTTP request and retries transient failures
432// only when the HTTP method is idempotent. A transient failure from a
433// non-idempotent request is returned as OutcomeUnknownError without replaying it.
434func (c *Client) requestWithRetry(ctx context.Context, method, path string, body any) (*http.Response, error) {
435 var lastErr error
436 idempotent := isIdempotentMethod(method)
437
438 for attempt := 1; attempt <= retryMaxAttempts; attempt++ {
439 resp, err := c.request(ctx, method, path, body)
440 if err != nil {
441 if !idempotent {
442 var transportErr *requestTransportError
443 if errors.As(err, &transportErr) {
444 return nil, &OutcomeUnknownError{Method: method, Path: path, Err: err}
445 }
446 return nil, err
447 }
448 lastErr = err
449 if attempt == retryMaxAttempts || !isRetryableRequestError(err) {
450 return nil, err
451 }
452 } else {
453 if !isRetryableStatus(resp.StatusCode) {
454 return resp, nil
455 }
456
457 respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
458 _ = resp.Body.Close()
459 lastErr = parseErrorResponse(resp, respBody)
460 if !idempotent {
461 return nil, &OutcomeUnknownError{Method: method, Path: path, Err: lastErr}
462 }
463 if attempt == retryMaxAttempts {
464 return nil, lastErr
465 }
466 }
467
468 delay := retryDelay(attempt)
469 timer := time.NewTimer(delay)
470 select {
471 case <-ctx.Done():
472 timer.Stop()
473 return nil, ctx.Err()
474 case <-timer.C:
475 }
476 }
477
478 if lastErr != nil {
479 return nil, lastErr
480 }
481 return nil, fmt.Errorf("request failed after retries")
482}
483
484// Status API
485
486// Status represents the Gordon server status.
487type Status struct {
488 Apps int `json:"apps"`
489 RegistryDomain string `json:"registry_domain"`
490 RegistryPort int `json:"registry_port"`
491 ServerPort int `json:"server_port"`
492 NetworkIsolation bool `json:"network_isolation"`
493 ContainerStatus map[string]string `json:"container_status"`
494}
495
496func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
497 resp, err := c.request(ctx, http.MethodGet, "/tls/status", nil)
498 if err != nil {
499 return nil, err
500 }
501
502 var status dto.TLSStatusResponse
503 if err := parseResponse(resp, &status); err != nil {
504 return nil, err
505 }
506
507 return &status, nil
508}
509
510// GetTrafficStatus returns the traffic plane status.
511func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
512 resp, err := c.request(ctx, http.MethodGet, "/traffic/status", nil)
513 if err != nil {
514 return nil, fmt.Errorf("request traffic status: %w", err)
515 }
516
517 var status dto.TrafficStatusResponse
518 if err := parseResponse(resp, &status); err != nil {
519 return nil, fmt.Errorf("parse traffic status response: %w", err)
520 }
521
522 return &status, nil
523}
524
525// GetCA returns the server's internal root CA certificate and status.
526func (c *Client) GetCA(ctx context.Context) (*dto.CAResponse, error) {
527 resp, err := c.requestWithRetry(ctx, http.MethodGet, "/ca", nil)
528 if err != nil {
529 return nil, fmt.Errorf("request CA: %w", err)
530 }
531
532 var ca dto.CAResponse
533 if err := parseResponse(resp, &ca); err != nil {
534 return nil, fmt.Errorf("parse CA response: %w", err)
535 }
536
537 return &ca, nil
538}
539
540// GetStatus returns the Gordon server status.
541func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
542 resp, err := c.requestWithRetry(ctx, http.MethodGet, "/status", nil)
543 if err != nil {
544 return nil, err
545 }
546
547 var status Status
548 if err := parseResponse(resp, &status); err != nil {
549 return nil, err
550 }
551
552 return &status, nil
553}
554
555// RouteHealth represents the health status of a route.
556type RouteHealth struct {
557 ContainerStatus string `json:"container_status"`
558 HTTPStatus int `json:"http_status"`
559 ResponseTimeMs int64 `json:"response_time_ms"`
560 Healthy bool `json:"healthy"`
561 Error string `json:"error"`
562}
563
564// Backups API
565
566// Images API
567
568// ListImages returns runtime images and registry tags from the admin API.
569func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
570 resp, err := c.request(ctx, http.MethodGet, "/images", nil)
571 if err != nil {
572 return nil, err
573 }
574
575 var result dto.ImagesResponse
576 if err := parseResponse(resp, &result); err != nil {
577 return nil, err
578 }
579
580 return result.Images, nil
581}
582
583// PruneImages prunes runtime and registry images.
584func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
585 if req.KeepLast != nil && *req.KeepLast < 0 {
586 return nil, fmt.Errorf("keep_last must be >= 0")
587 }
588
589 resp, err := c.request(ctx, http.MethodPost, "/images/prune", req)
590 if err != nil {
591 return nil, err
592 }
593
594 var result dto.ImagePruneResponse
595 if err := parseResponse(resp, &result); err != nil {
596 return nil, err
597 }
598
599 return &result, nil
600}
601
602// ListBackups returns backups globally or for a domain.
603func (c *Client) ListBackups(ctx context.Context, app string) ([]dto.BackupJob, error) {
604 path := "/backups"
605 if app != "" {
606 path += "/" + url.PathEscape(app)
607 }
608
609 resp, err := c.request(ctx, http.MethodGet, path, nil)
610 if err != nil {
611 return nil, err
612 }
613
614 var result dto.BackupsResponse
615 if err := parseResponse(resp, &result); err != nil {
616 return nil, err
617 }
618
619 return result.Backups, nil
620}
621
622// BackupStatus returns stored backups plus declared targets that have no
623// completed backup yet.
624func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
625 resp, err := c.request(ctx, http.MethodGet, "/backups/status", nil)
626 if err != nil {
627 return nil, err
628 }
629
630 var result dto.BackupsResponse
631 if err := parseResponse(resp, &result); err != nil {
632 return nil, err
633 }
634
635 return result.Backups, nil
636}
637
638// RunBackup runs one declared database backup of one app. service and
639// database are explicit selectors.
640func (c *Client) RunBackup(ctx context.Context, app, service, database string) (*dto.BackupRunResponse, error) {
641 if app == "" {
642 return nil, fmt.Errorf("app cannot be empty")
643 }
644
645 resp, err := c.request(ctx, http.MethodPost, "/backups/"+url.PathEscape(app),
646 dto.BackupRunRequest{Service: service, Database: database})
647 if err != nil {
648 return nil, err
649 }
650
651 var result dto.BackupRunResponse
652 if err := parseResponse(resp, &result); err != nil {
653 return nil, err
654 }
655
656 return &result, nil
657}
658
659// ListVolumeBackups returns volume backups globally or for one app.
660func (c *Client) ListVolumeBackups(ctx context.Context, app string) ([]dto.VolumeBackupJob, error) {
661 path := "/backups/volumes"
662 if app != "" {
663 path += "/" + url.PathEscape(app)
664 }
665
666 resp, err := c.request(ctx, http.MethodGet, path, nil)
667 if err != nil {
668 return nil, err
669 }
670
671 var result dto.VolumeBackupsResponse
672 if err := parseResponse(resp, &result); err != nil {
673 return nil, err
674 }
675 return result.Backups, nil
676}
677
678// VolumeBackupStatus returns aggregate volume backup status.
679func (c *Client) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
680 resp, err := c.request(ctx, http.MethodGet, "/backups/volumes/status", nil)
681 if err != nil {
682 return nil, err
683 }
684
685 var result dto.VolumeBackupsResponse
686 if err := parseResponse(resp, &result); err != nil {
687 return nil, err
688 }
689 return result.Backups, nil
690}
691
692// RunVolumeBackups runs one declared volume backup of one app. service
693// and volume are explicit selectors.
694func (c *Client) RunVolumeBackups(ctx context.Context, app, service, volume string) (*dto.VolumeBackupRunResponse, error) {
695 if app == "" {
696 return nil, fmt.Errorf("app cannot be empty")
697 }
698 path := "/backups/volumes/" + url.PathEscape(app)
699
700 resp, err := c.request(ctx, http.MethodPost, path, dto.VolumeBackupRunRequest{Service: service, Volume: volume})
701 if err != nil {
702 return nil, err
703 }
704
705 var result dto.VolumeBackupRunResponse
706 if err := parseResponse(resp, &result); err != nil {
707 return nil, err
708 }
709 if resp.StatusCode == http.StatusPartialContent {
710 if result.Error == "" {
711 result.Error = "volume backup run partially failed"
712 }
713 return &result, fmt.Errorf("volume backup run partially failed: %s", result.Error)
714 }
715 return &result, nil
716}
717
718// GetHealth returns health status for all routes with HTTP probing.
719func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
720 resp, err := c.request(ctx, http.MethodGet, "/health", nil)
721 if err != nil {
722 return nil, err
723 }
724
725 var result struct {
726 Health map[string]*RouteHealth `json:"health"`
727 }
728 if err := parseResponse(resp, &result); err != nil {
729 return nil, err
730 }
731
732 return result.Health, nil
733}
734
735// Reload triggers a configuration reload.
736func (c *Client) Reload(ctx context.Context) error {
737 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/reload", nil)
738 if err != nil {
739 return err
740 }
741 return parseResponse(resp, nil)
742}
743
744// Config API
745
746// Config represents the Gordon configuration.
747type Config struct {
748 Server struct {
749 Port int `json:"port"`
750 RegistryPort int `json:"registry_port"`
751 RegistryDomain string `json:"registry_domain"`
752 DataDir string `json:"data_dir,omitempty"`
753 } `json:"server"`
754 NetworkIsolation struct {
755 Enabled bool `json:"enabled"`
756 Prefix string `json:"prefix"`
757 } `json:"network_isolation"`
758 Volumes struct {
759 AutoCreate bool `json:"auto_create"`
760 Prefix string `json:"prefix"`
761 Preserve bool `json:"preserve"`
762 } `json:"volumes"`
763 ExternalRoutes []ExternalRoute `json:"external_routes"`
764}
765
766// ExternalRoute represents a redacted external route config entry.
767type ExternalRoute struct {
768 Domain string `json:"domain"`
769 Target string `json:"target,omitempty"`
770}
771
772// GetConfig returns the Gordon configuration.
773func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
774 resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
775 if err != nil {
776 return nil, err
777 }
778
779 var result struct {
780 Networks []*domain.NetworkInfo `json:"networks"`
781 }
782 if err := parseResponse(resp, &result); err != nil {
783 return nil, err
784 }
785
786 return result.Networks, nil
787}
788
789// GetConfig returns the Gordon configuration.
790func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
791 resp, err := c.request(ctx, http.MethodGet, "/config", nil)
792 if err != nil {
793 return nil, err
794 }
795
796 var config Config
797 if err := parseResponse(resp, &config); err != nil {
798 return nil, err
799 }
800
801 return &config, nil
802}
803
804// Ping checks if the remote Gordon instance is reachable.
805func (c *Client) Ping(ctx context.Context) error {
806 _, err := c.GetStatus(ctx)
807 return err
808}
809
810func (c *Client) ListTags(ctx context.Context, repository string) ([]string, error) {
811 if repository == "" {
812 return nil, fmt.Errorf("repository cannot be empty")
813 }
814 resp, err := c.request(ctx, http.MethodGet, "/tags/"+url.PathEscape(repository), nil)
815 if err != nil {
816 return nil, err
817 }
818 var result struct {
819 Repository string `json:"repository"`
820 Tags []string `json:"tags"`
821 }
822 if err := parseResponse(resp, &result); err != nil {
823 return nil, err
824 }
825 return result.Tags, nil
826}
827
828// Logs API
829
830// GetProcessLogs returns Gordon process logs.
831func (c *Client) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
832 path := fmt.Sprintf("/logs?lines=%d", lines)
833 resp, err := c.request(ctx, http.MethodGet, path, nil)
834 if err != nil {
835 return nil, err
836 }
837
838 var result struct {
839 Lines []string `json:"lines"`
840 }
841 if err := parseResponse(resp, &result); err != nil {
842 return nil, err
843 }
844
845 return result.Lines, nil
846}
847
848// GetContainerLogs returns container logs for a specific domain.
849func (c *Client) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
850 path := fmt.Sprintf("/logs/%s?lines=%d", url.PathEscape(logDomain), lines)
851 resp, err := c.request(ctx, http.MethodGet, path, nil)
852 if err != nil {
853 return nil, err
854 }
855
856 var result struct {
857 Domain string `json:"domain"`
858 Lines []string `json:"lines"`
859 }
860 if err := parseResponse(resp, &result); err != nil {
861 return nil, err
862 }
863
864 return result.Lines, nil
865}
866
867// StreamProcessLogs returns a channel that streams Gordon process log lines via SSE.
868// The caller is responsible for reading from the channel until it's closed.
869func (c *Client) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
870 path := fmt.Sprintf("/logs?lines=%d&follow=true", lines)
871 return c.streamLogs(ctx, path)
872}
873
874// StreamContainerLogs returns a channel that streams container log lines via SSE.
875// The caller is responsible for reading from the channel until it's closed.
876func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
877 path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", url.PathEscape(logDomain), lines)
878 return c.streamLogs(ctx, path)
879}
880
881// Attachments Config API
882
883// ListOrphanedAttachments returns running attachment containers no longer configured.
884func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response, error) {
885 streamURL := c.baseURL + "/admin" + path
886
887 req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
888 if err != nil {
889 return nil, fmt.Errorf("failed to create request: %w", err)
890 }
891
892 req.Header.Set("Accept", "text/event-stream")
893 bearer, err := c.bearerToken(ctx)
894 if err != nil {
895 return nil, err
896 }
897 if bearer != "" {
898 req.Header.Set("Authorization", "Bearer "+bearer)
899 }
900
901 // Use the same transport as the main client (honoring TLS config and
902 // custom transports) but without a timeout so streaming doesn't get cut off.
903 streamClient := &http.Client{
904 Transport: c.httpClient.Transport,
905 CheckRedirect: c.httpClient.CheckRedirect,
906 }
907 resp, err := streamClient.Do(req)
908 if err != nil {
909 return nil, fmt.Errorf("failed to connect: %w", err)
910 }
911
912 if resp.StatusCode >= 400 {
913 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
914 resp.Body.Close()
915 return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
916 }
917
918 return resp, nil
919}
920
921// streamLogs handles SSE streaming for log endpoints.
922func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
923 resp, err := c.openSSEStream(ctx, path)
924 if err != nil {
925 return nil, err
926 }
927
928 ch := make(chan string, 100)
929
930 go func() {
931 defer close(ch)
932 defer resp.Body.Close()
933
934 buf := make([]byte, 4096)
935 var lineBuffer strings.Builder
936
937 for {
938 select {
939 case <-ctx.Done():
940 return
941 default:
942 }
943
944 n, err := resp.Body.Read(buf)
945 if n > 0 {
946 lineBuffer.Write(buf[:n])
947 }
948 if err != nil {
949 return // EOF or context cancellation — clean exit
950 }
951
952 // Process complete SSE events
953 for {
954 data := lineBuffer.String()
955 event, remaining, ok := strings.Cut(data, "\n\n")
956 if !ok {
957 break
958 }
959
960 lineBuffer.Reset()
961 lineBuffer.WriteString(remaining)
962
963 // Parse SSE data lines
964 for line := range strings.SplitSeq(event, "\n") {
965 logLine, ok := strings.CutPrefix(line, "data: ")
966 if ok {
967 select {
968 case ch <- logLine:
969 case <-ctx.Done():
970 return
971 }
972 }
973 }
974 }
975 }
976 }()
977
978 return ch, nil
979}
980
981// VerifyAuth checks if authentication session is valid.
982func (c *Client) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) {
983 resp, err := c.request(ctx, http.MethodGet, "/auth/verify", nil)
984 if err != nil {
985 return nil, err
986 }
987
988 var result dto.AuthVerifyResponse
989 if err := parseResponse(resp, &result); err != nil {
990 return nil, err
991 }
992
993 return &result, nil
994}
995
996// ListVolumes lists all volumes via the admin API.
997func (c *Client) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
998 resp, err := c.request(ctx, http.MethodGet, "/volumes", nil)
999 if err != nil {
1000 return nil, err
1001 }
1002
1003 var volumes []dto.Volume
1004 if err := parseResponse(resp, &volumes); err != nil {
1005 return nil, err
1006 }
1007
1008 return volumes, nil
1009}
1010
1011// PruneVolumes prunes orphaned volumes via the admin API.
1012func (c *Client) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
1013 resp, err := c.request(ctx, http.MethodPost, "/volumes/prune", req)
1014 if err != nil {
1015 return nil, err
1016 }
1017
1018 var result dto.VolumePruneResponse
1019 if err := parseResponse(resp, &result); err != nil {
1020 return nil, err
1021 }
1022
1023 return &result, nil
1024}