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 "time"
17
18 "github.com/bnema/gordon/internal/adapters/dto"
19 "github.com/bnema/gordon/internal/domain"
20)
21
22// Client is an HTTP client for the Gordon admin API.
23type Client struct {
24 baseURL string
25 token string
26 httpClient *http.Client
27 insecureTLS bool
28 onTokenRefreshed func(newToken string) // optional callback to persist a refreshed token
29}
30
31var (
32 retryMaxAttempts = 4
33 retryBaseDelay = 250 * time.Millisecond
34)
35
36const maxErrorBodySize int64 = 1024 // cap error response reads at 1 KB
37
38// ClientOption configures the Client.
39type ClientOption func(*Client)
40
41// NewClient creates a new remote Gordon client.
42func NewClient(baseURL string, opts ...ClientOption) *Client {
43 // Normalize base URL
44 baseURL = strings.TrimSuffix(baseURL, "/")
45
46 c := &Client{
47 baseURL: baseURL,
48 httpClient: &http.Client{
49 Timeout: 2 * time.Minute,
50 },
51 }
52
53 for _, opt := range opts {
54 opt(c)
55 }
56
57 c.applyTLSConfig()
58
59 return c
60}
61
62// WithToken sets the authentication token.
63func WithToken(token string) ClientOption {
64 return func(c *Client) {
65 c.token = token
66 }
67}
68
69// WithHTTPClient sets a custom HTTP client.
70func WithHTTPClient(client *http.Client) ClientOption {
71 return func(c *Client) {
72 c.httpClient = client
73 }
74}
75
76// WithTimeout sets the HTTP client timeout.
77func WithTimeout(timeout time.Duration) ClientOption {
78 return func(c *Client) {
79 c.httpClient.Timeout = timeout
80 }
81}
82
83// WithInsecureTLS disables TLS certificate verification for remote admin API requests.
84func WithInsecureTLS(insecure bool) ClientOption {
85 return func(c *Client) {
86 c.insecureTLS = insecure
87 }
88}
89
90// WithTokenRefreshCallback sets a callback that is invoked when the server
91// returns a refreshed token in the X-Gordon-Token response header.
92// The CLI uses this to persist the new token to the remotes config atomically.
93func WithTokenRefreshCallback(fn func(newToken string)) ClientOption {
94 return func(c *Client) {
95 c.onTokenRefreshed = fn
96 }
97}
98
99// observeTokenRotation checks the X-Gordon-Token header on a response and, if
100// the server issued a refreshed token, updates the client's in-memory token and
101// invokes the optional persistence callback. Panics in the callback are caught
102// so they never propagate to the caller.
103func (c *Client) observeTokenRotation(resp *http.Response) {
104 newToken := resp.Header.Get("X-Gordon-Token")
105 if newToken == "" || newToken == c.token {
106 return
107 }
108 c.token = newToken
109 if c.onTokenRefreshed != nil {
110 func() {
111 defer func() {
112 if r := recover(); r != nil {
113 fmt.Fprintf(os.Stderr, "warning: token refresh callback panicked: %v\n", r)
114 }
115 }()
116 c.onTokenRefreshed(newToken)
117 }()
118 }
119}
120
121func (c *Client) applyTLSConfig() {
122 if !c.insecureTLS {
123 return
124 }
125
126 fmt.Fprintf(os.Stderr, "WARNING: TLS certificate verification disabled for %s\n", c.baseURL)
127
128 var transport *http.Transport
129 switch t := c.httpClient.Transport.(type) {
130 case *http.Transport:
131 transport = t.Clone()
132 case nil:
133 transport = http.DefaultTransport.(*http.Transport).Clone()
134 default:
135 // Cannot apply InsecureSkipVerify to a non-*http.Transport.
136 // Log a warning and leave the transport intact rather than
137 // silently replacing it (which would drop caller-provided behavior).
138 fmt.Fprintf(os.Stderr, "WARNING: --insecure requires *http.Transport, got %T; TLS override not applied\n", t)
139 return
140 }
141
142 if transport.TLSClientConfig == nil {
143 transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
144 } else {
145 transport.TLSClientConfig.MinVersion = tls.VersionTLS12
146 }
147 //nolint:gosec // Explicit CLI opt-in via --insecure for self-signed/private cert deployments.
148 transport.TLSClientConfig.InsecureSkipVerify = true
149 c.httpClient.Transport = transport
150}
151
152// request performs an HTTP request to the admin API.
153func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
154 url := c.baseURL + "/admin" + path
155
156 var bodyReader io.Reader
157 if body != nil {
158 jsonBody, err := json.Marshal(body)
159 if err != nil {
160 return nil, fmt.Errorf("failed to marshal request body: %w", err)
161 }
162 bodyReader = bytes.NewReader(jsonBody)
163 }
164
165 req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
166 if err != nil {
167 return nil, fmt.Errorf("failed to create request: %w", err)
168 }
169
170 req.Header.Set("Content-Type", "application/json")
171 req.Header.Set("Accept", "application/json")
172
173 if c.token != "" {
174 req.Header.Set("Authorization", "Bearer "+c.token)
175 }
176
177 resp, err := c.httpClient.Do(req)
178 if err != nil {
179 return nil, err
180 }
181
182 // Observe token rotation: the server re-signs and returns the token via X-Gordon-Token.
183 c.observeTokenRotation(resp)
184
185 return resp, nil
186}
187
188// parseResponse parses a JSON response into the given target.
189func parseResponse(resp *http.Response, target any) error {
190 defer resp.Body.Close()
191
192 if resp.StatusCode >= 400 {
193 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
194 return parseErrorResponse(resp, body)
195 }
196
197 if target != nil {
198 if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
199 return fmt.Errorf("failed to decode response: %w", err)
200 }
201 }
202
203 return nil
204}
205
206// HTTPError represents an HTTP error response with a status code.
207// Use errors.As to check for specific status codes in error handling.
208type HTTPError struct {
209 StatusCode int
210 Status string
211 Body string
212}
213
214func (e *HTTPError) Error() string {
215 return e.Status + ": " + e.Body
216}
217
218func parseErrorResponse(resp *http.Response, body []byte) error {
219 msg := string(body)
220 var errResp struct {
221 Error string `json:"error"`
222 }
223 if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
224 msg = errResp.Error
225 }
226 return &HTTPError{
227 StatusCode: resp.StatusCode,
228 Status: resp.Status,
229 Body: msg,
230 }
231}
232
233func isRetryableRequestError(err error) bool {
234 return !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded)
235}
236
237func isRetryableStatus(status int) bool {
238 // Only retry gateway errors; retrying 500 on non-idempotent POSTs
239 // (deploy, restart, reload) can cause duplicate state changes.
240 return status == 502 || status == 503 || status == 504
241}
242
243func retryDelay(attempt int) time.Duration {
244 if attempt <= 1 {
245 return retryBaseDelay
246 }
247 return retryBaseDelay * time.Duration(1<<(attempt-1))
248}
249
250// requestWithRetry performs an HTTP request and retries transient failures.
251// Retries occur on transport errors and gateway errors (502, 503, 504).
252func (c *Client) requestWithRetry(ctx context.Context, method, path string, body any) (*http.Response, error) {
253 var lastErr error
254
255 for attempt := 1; attempt <= retryMaxAttempts; attempt++ {
256 resp, err := c.request(ctx, method, path, body)
257 if err != nil {
258 lastErr = err
259 if attempt == retryMaxAttempts || !isRetryableRequestError(err) {
260 return nil, err
261 }
262 } else {
263 if !isRetryableStatus(resp.StatusCode) {
264 return resp, nil
265 }
266
267 respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
268 _ = resp.Body.Close()
269 lastErr = parseErrorResponse(resp, respBody)
270 if attempt == retryMaxAttempts {
271 return nil, lastErr
272 }
273 }
274
275 delay := retryDelay(attempt)
276 timer := time.NewTimer(delay)
277 select {
278 case <-ctx.Done():
279 timer.Stop()
280 return nil, ctx.Err()
281 case <-timer.C:
282 }
283 }
284
285 if lastErr != nil {
286 return nil, lastErr
287 }
288 return nil, fmt.Errorf("request failed after retries")
289}
290
291// Routes API
292
293// Type aliases for API types using shared DTO package.
294type RouteInfo = dto.RouteInfo
295type Attachment = dto.Attachment
296
297// ListRoutes returns all configured routes.
298func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
299 resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
300 if err != nil {
301 return nil, err
302 }
303
304 var result struct {
305 Routes []domain.Route `json:"routes"`
306 }
307 if err := parseResponse(resp, &result); err != nil {
308 return nil, err
309 }
310
311 return result.Routes, nil
312}
313
314// ListRoutesWithDetails returns routes with network and attachment info.
315func (c *Client) ListRoutesWithDetails(ctx context.Context) ([]RouteInfo, error) {
316 resp, err := c.request(ctx, http.MethodGet, "/routes?detailed=true", nil)
317 if err != nil {
318 return nil, err
319 }
320
321 var result struct {
322 Routes []RouteInfo `json:"routes"`
323 }
324 if err := parseResponse(resp, &result); err != nil {
325 return nil, err
326 }
327
328 return result.Routes, nil
329}
330
331// ListNetworks returns Gordon-managed networks.
332func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
333 resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
334 if err != nil {
335 return nil, err
336 }
337
338 var result struct {
339 Networks []*domain.NetworkInfo `json:"networks"`
340 }
341 if err := parseResponse(resp, &result); err != nil {
342 return nil, err
343 }
344
345 return result.Networks, nil
346}
347
348// ListAttachments returns attachments for a domain.
349func (c *Client) ListAttachments(ctx context.Context, routeDomain string) ([]Attachment, error) {
350 if routeDomain == "" {
351 return nil, fmt.Errorf("domain is required")
352 }
353 path := "/routes/" + url.PathEscape(routeDomain) + "/attachments"
354 resp, err := c.request(ctx, http.MethodGet, path, nil)
355 if err != nil {
356 return nil, err
357 }
358
359 var result struct {
360 Attachments []Attachment `json:"attachments"`
361 }
362 if err := parseResponse(resp, &result); err != nil {
363 return nil, err
364 }
365
366 return result.Attachments, nil
367}
368
369// GetRoute returns a specific route by domain.
370func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
371 resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
372 if err != nil {
373 return nil, err
374 }
375
376 var route domain.Route
377 if err := parseResponse(resp, &route); err != nil {
378 return nil, err
379 }
380
381 return &route, nil
382}
383
384// FindRoutesByImage returns all routes associated with the given image name.
385func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
386 resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil)
387 if err != nil {
388 return nil, err
389 }
390
391 var result struct {
392 Image string `json:"image"`
393 Routes []domain.Route `json:"routes"`
394 }
395 if err := parseResponse(resp, &result); err != nil {
396 return nil, err
397 }
398
399 return result.Routes, nil
400}
401
402// AddRoute adds a new route.
403func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
404 resp, err := c.request(ctx, http.MethodPost, "/routes", route)
405 if err != nil {
406 return err
407 }
408 return parseResponse(resp, nil)
409}
410
411// UpdateRoute updates an existing route.
412func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
413 resp, err := c.request(ctx, http.MethodPut, "/routes/"+url.PathEscape(route.Domain), route)
414 if err != nil {
415 return err
416 }
417 return parseResponse(resp, nil)
418}
419
420// RemoveRoute removes a route by domain.
421func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
422 resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)
423 if err != nil {
424 return err
425 }
426 return parseResponse(resp, nil)
427}
428
429func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
430 resp, err := c.request(ctx, http.MethodPost, "/bootstrap", req)
431 if err != nil {
432 return nil, err
433 }
434 var result dto.BootstrapResponse
435 if err := parseResponse(resp, &result); err != nil {
436 return nil, err
437 }
438 return &result, nil
439}
440
441// Secrets API
442
443// AttachmentSecrets represents secrets for an attachment container.
444type AttachmentSecrets struct {
445 Service string `json:"service"`
446 Keys []string `json:"keys"`
447}
448
449// SecretsListResult contains domain secrets and any attachment secrets.
450type SecretsListResult struct {
451 Domain string `json:"domain"`
452 Keys []string `json:"keys"`
453 Attachments []AttachmentSecrets `json:"attachments,omitempty"`
454}
455
456// ListSecrets returns the list of secret keys for a domain.
457func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
458 result, err := c.ListSecretsWithAttachments(ctx, secretDomain)
459 if err != nil {
460 return nil, err
461 }
462 return result.Keys, nil
463}
464
465// ListSecretsWithAttachments returns domain secrets and attachment secrets.
466func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) {
467 resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
468 if err != nil {
469 return nil, err
470 }
471
472 var result SecretsListResult
473 if err := parseResponse(resp, &result); err != nil {
474 return nil, err
475 }
476
477 return &result, nil
478}
479
480// SetSecrets sets secrets for a domain.
481func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
482 resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets)
483 if err != nil {
484 return err
485 }
486 return parseResponse(resp, nil)
487}
488
489// DeleteSecret removes a secret from a domain.
490func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {
491 resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil)
492 if err != nil {
493 return err
494 }
495 return parseResponse(resp, nil)
496}
497
498// SetAttachmentSecrets sets secrets for an attachment container.
499func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error {
500 path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service)
501 resp, err := c.request(ctx, http.MethodPost, path, secrets)
502 if err != nil {
503 return err
504 }
505 return parseResponse(resp, nil)
506}
507
508// DeleteAttachmentSecret removes a secret from an attachment container.
509func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error {
510 path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key)
511 resp, err := c.request(ctx, http.MethodDelete, path, nil)
512 if err != nil {
513 return err
514 }
515 return parseResponse(resp, nil)
516}
517
518// Status API
519
520// Status represents the Gordon server status.
521type Status struct {
522 Routes int `json:"routes"`
523 RegistryDomain string `json:"registry_domain"`
524 RegistryPort int `json:"registry_port"`
525 ServerPort int `json:"server_port"`
526 AutoRoute bool `json:"auto_route"`
527 NetworkIsolation bool `json:"network_isolation"`
528 ContainerStatus map[string]string `json:"container_status"`
529}
530
531// GetStatus returns the Gordon server status.
532func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
533 resp, err := c.request(ctx, http.MethodGet, "/status", nil)
534 if err != nil {
535 return nil, err
536 }
537
538 var status Status
539 if err := parseResponse(resp, &status); err != nil {
540 return nil, err
541 }
542
543 return &status, nil
544}
545
546// RouteHealth represents the health status of a route.
547type RouteHealth struct {
548 ContainerStatus string `json:"container_status"`
549 HTTPStatus int `json:"http_status"`
550 ResponseTimeMs int64 `json:"response_time_ms"`
551 Healthy bool `json:"healthy"`
552 Error string `json:"error"`
553}
554
555// Backups API
556
557// Images API
558
559// ListImages returns runtime images and registry tags from the admin API.
560func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
561 resp, err := c.request(ctx, http.MethodGet, "/images", nil)
562 if err != nil {
563 return nil, err
564 }
565
566 var result dto.ImagesResponse
567 if err := parseResponse(resp, &result); err != nil {
568 return nil, err
569 }
570
571 return result.Images, nil
572}
573
574// PruneImages prunes runtime and registry images.
575func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
576 if req.KeepLast != nil && *req.KeepLast < 0 {
577 return nil, fmt.Errorf("keep_last must be >= 0")
578 }
579
580 resp, err := c.request(ctx, http.MethodPost, "/images/prune", req)
581 if err != nil {
582 return nil, err
583 }
584
585 var result dto.ImagePruneResponse
586 if err := parseResponse(resp, &result); err != nil {
587 return nil, err
588 }
589
590 return &result, nil
591}
592
593// ListBackups returns backups globally or for a domain.
594func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
595 path := "/backups"
596 if backupDomain != "" {
597 path += "/" + url.PathEscape(backupDomain)
598 }
599
600 resp, err := c.request(ctx, http.MethodGet, path, nil)
601 if err != nil {
602 return nil, err
603 }
604
605 var result dto.BackupsResponse
606 if err := parseResponse(resp, &result); err != nil {
607 return nil, err
608 }
609
610 return result.Backups, nil
611}
612
613// BackupStatus returns aggregate backup status.
614func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
615 resp, err := c.request(ctx, http.MethodGet, "/backups/status", nil)
616 if err != nil {
617 return nil, err
618 }
619
620 var result dto.BackupsResponse
621 if err := parseResponse(resp, &result); err != nil {
622 return nil, err
623 }
624
625 return result.Backups, nil
626}
627
628// RunBackup triggers a backup for a domain.
629func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
630 if backupDomain == "" {
631 return nil, fmt.Errorf("domain cannot be empty")
632 }
633
634 resp, err := c.request(ctx, http.MethodPost, "/backups/"+url.PathEscape(backupDomain), dto.BackupRunRequest{DB: dbName})
635 if err != nil {
636 return nil, err
637 }
638
639 var result dto.BackupRunResponse
640 if err := parseResponse(resp, &result); err != nil {
641 return nil, err
642 }
643
644 return &result, nil
645}
646
647// DetectDatabases detects supported databases for a domain.
648func (c *Client) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
649 if backupDomain == "" {
650 return nil, fmt.Errorf("domain cannot be empty")
651 }
652
653 resp, err := c.request(ctx, http.MethodGet, "/backups/"+url.PathEscape(backupDomain)+"/detect", nil)
654 if err != nil {
655 return nil, err
656 }
657
658 var result dto.BackupDetectResponse
659 if err := parseResponse(resp, &result); err != nil {
660 return nil, err
661 }
662
663 return result.Databases, nil
664}
665
666// GetHealth returns health status for all routes with HTTP probing.
667func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
668 resp, err := c.request(ctx, http.MethodGet, "/health", nil)
669 if err != nil {
670 return nil, err
671 }
672
673 var result struct {
674 Health map[string]*RouteHealth `json:"health"`
675 }
676 if err := parseResponse(resp, &result); err != nil {
677 return nil, err
678 }
679
680 return result.Health, nil
681}
682
683// Reload triggers a configuration reload.
684func (c *Client) Reload(ctx context.Context) error {
685 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/reload", nil)
686 if err != nil {
687 return err
688 }
689 return parseResponse(resp, nil)
690}
691
692// Config API
693
694// Config represents the Gordon configuration.
695type Config struct {
696 Server struct {
697 Port int `json:"port"`
698 RegistryPort int `json:"registry_port"`
699 RegistryDomain string `json:"registry_domain"`
700 DataDir string `json:"data_dir"`
701 } `json:"server"`
702 AutoRoute struct {
703 Enabled bool `json:"enabled"`
704 } `json:"auto_route"`
705 NetworkIsolation struct {
706 Enabled bool `json:"enabled"`
707 Prefix string `json:"prefix"`
708 } `json:"network_isolation"`
709 Routes []domain.Route `json:"routes"`
710 ExternalRoutes map[string]string `json:"external_routes"`
711}
712
713// GetConfig returns the Gordon configuration.
714func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
715 resp, err := c.request(ctx, http.MethodGet, "/config", nil)
716 if err != nil {
717 return nil, err
718 }
719
720 var config Config
721 if err := parseResponse(resp, &config); err != nil {
722 return nil, err
723 }
724
725 return &config, nil
726}
727
728// Ping checks if the remote Gordon instance is reachable.
729func (c *Client) Ping(ctx context.Context) error {
730 _, err := c.GetStatus(ctx)
731 return err
732}
733
734// Deploy API
735
736// DeployResult contains the result of a deployment.
737type DeployResult struct {
738 Status string `json:"status"`
739 ContainerID string `json:"container_id"`
740 Domain string `json:"domain"`
741}
742
743// Deploy triggers a deployment for the specified domain.
744func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) {
745 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil)
746 if err != nil {
747 return nil, err
748 }
749
750 var result DeployResult
751 if err := parseResponse(resp, &result); err != nil {
752 return nil, err
753 }
754
755 return &result, nil
756}
757
758// DeployIntent tells the server that a CLI-managed push is about to happen,
759// suppressing event-based deploys for this image.
760func (c *Client) DeployIntent(ctx context.Context, imageName string) error {
761 imageName = strings.TrimSpace(imageName)
762 if imageName == "" {
763 return fmt.Errorf("image name cannot be empty")
764 }
765 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy-intent/"+url.PathEscape(imageName), nil)
766 if err != nil {
767 return err
768 }
769 return parseResponse(resp, nil)
770}
771
772// Restart API
773
774// RestartResult contains the result of a restart.
775type RestartResult struct {
776 Status string `json:"status"`
777 Domain string `json:"domain"`
778}
779
780// Restart triggers a container restart for the specified domain.
781func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*RestartResult, error) {
782 if restartDomain == "" {
783 return nil, fmt.Errorf("domain cannot be empty")
784 }
785 path := "/restart/" + url.PathEscape(restartDomain)
786 if withAttachments {
787 path += "?attachments=true"
788 }
789 resp, err := c.requestWithRetry(ctx, http.MethodPost, path, nil)
790 if err != nil {
791 return nil, err
792 }
793
794 var result RestartResult
795 if err := parseResponse(resp, &result); err != nil {
796 return nil, err
797 }
798
799 return &result, nil
800}
801
802// Tags API
803
804// ListTags returns available tags for a repository.
805func (c *Client) ListTags(ctx context.Context, repository string) ([]string, error) {
806 if repository == "" {
807 return nil, fmt.Errorf("repository cannot be empty")
808 }
809 resp, err := c.request(ctx, http.MethodGet, "/tags/"+url.PathEscape(repository), nil)
810 if err != nil {
811 return nil, err
812 }
813 var result struct {
814 Repository string `json:"repository"`
815 Tags []string `json:"tags"`
816 }
817 if err := parseResponse(resp, &result); err != nil {
818 return nil, err
819 }
820 return result.Tags, nil
821}
822
823// Logs API
824
825// GetProcessLogs returns Gordon process logs.
826func (c *Client) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
827 path := fmt.Sprintf("/logs?lines=%d", lines)
828 resp, err := c.request(ctx, http.MethodGet, path, nil)
829 if err != nil {
830 return nil, err
831 }
832
833 var result struct {
834 Lines []string `json:"lines"`
835 }
836 if err := parseResponse(resp, &result); err != nil {
837 return nil, err
838 }
839
840 return result.Lines, nil
841}
842
843// GetContainerLogs returns container logs for a specific domain.
844func (c *Client) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
845 path := fmt.Sprintf("/logs/%s?lines=%d", url.PathEscape(logDomain), lines)
846 resp, err := c.request(ctx, http.MethodGet, path, nil)
847 if err != nil {
848 return nil, err
849 }
850
851 var result struct {
852 Domain string `json:"domain"`
853 Lines []string `json:"lines"`
854 }
855 if err := parseResponse(resp, &result); err != nil {
856 return nil, err
857 }
858
859 return result.Lines, nil
860}
861
862// StreamProcessLogs returns a channel that streams Gordon process log lines via SSE.
863// The caller is responsible for reading from the channel until it's closed.
864func (c *Client) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
865 path := fmt.Sprintf("/logs?lines=%d&follow=true", lines)
866 return c.streamLogs(ctx, path)
867}
868
869// StreamContainerLogs returns a channel that streams container log lines via SSE.
870// The caller is responsible for reading from the channel until it's closed.
871func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
872 path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", url.PathEscape(logDomain), lines)
873 return c.streamLogs(ctx, path)
874}
875
876// Attachments Config API
877
878// GetAllAttachmentsConfig returns all configured attachments.
879func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
880 resp, err := c.request(ctx, http.MethodGet, "/attachments", nil)
881 if err != nil {
882 return nil, err
883 }
884
885 var result struct {
886 Attachments map[string][]string `json:"attachments"`
887 }
888 if err := parseResponse(resp, &result); err != nil {
889 return nil, err
890 }
891
892 return result.Attachments, nil
893}
894
895// GetAttachmentsConfig returns attachments for a specific domain/group from config.
896func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
897 if domainOrGroup == "" {
898 return nil, fmt.Errorf("domain or group is required")
899 }
900 path := "/attachments/" + url.PathEscape(domainOrGroup)
901 resp, err := c.request(ctx, http.MethodGet, path, nil)
902 if err != nil {
903 return nil, err
904 }
905
906 var result struct {
907 Target string `json:"target"`
908 Images []string `json:"images"`
909 }
910 if err := parseResponse(resp, &result); err != nil {
911 return nil, err
912 }
913
914 return result.Images, nil
915}
916
917// FindAttachmentTargetsByImage returns all attachment targets associated with the given image name.
918func (c *Client) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
919 resp, err := c.request(ctx, http.MethodGet, "/attachments/by-image/"+url.PathEscape(imageName), nil)
920 if err != nil {
921 return nil, err
922 }
923
924 var result dto.AttachmentTargetsByImageResponse
925 if err := parseResponse(resp, &result); err != nil {
926 return nil, err
927 }
928
929 return result.Targets, nil
930}
931
932// AddAttachment adds an attachment to a domain/group.
933func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
934 if domainOrGroup == "" {
935 return fmt.Errorf("domain or group is required")
936 }
937 if image == "" {
938 return fmt.Errorf("image is required")
939 }
940 path := "/attachments/" + url.PathEscape(domainOrGroup)
941 resp, err := c.request(ctx, http.MethodPost, path, struct {
942 Image string `json:"image"`
943 }{Image: image})
944 if err != nil {
945 return err
946 }
947 return parseResponse(resp, nil)
948}
949
950// RemoveAttachment removes an attachment from a domain/group.
951func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
952 if domainOrGroup == "" {
953 return fmt.Errorf("domain or group is required")
954 }
955 if image == "" {
956 return fmt.Errorf("image is required")
957 }
958 path := "/attachments/" + url.PathEscape(domainOrGroup) + "/" + url.PathEscape(image)
959 resp, err := c.request(ctx, http.MethodDelete, path, nil)
960 if err != nil {
961 return err
962 }
963 return parseResponse(resp, nil)
964}
965
966func (c *Client) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
967 resp, err := c.request(ctx, http.MethodGet, "/autoroute/allowed-domains", nil)
968 if err != nil {
969 return nil, err
970 }
971
972 var result dto.AutoRouteAllowedDomainsResponse
973 if err := parseResponse(resp, &result); err != nil {
974 return nil, err
975 }
976
977 return result.Domains, nil
978}
979
980func (c *Client) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
981 if strings.TrimSpace(pattern) == "" {
982 return fmt.Errorf("pattern must not be empty")
983 }
984 resp, err := c.request(ctx, http.MethodPost, "/autoroute/allowed-domains", dto.AutoRouteAllowedDomainRequest{Pattern: pattern})
985 if err != nil {
986 return err
987 }
988 return parseResponse(resp, nil)
989}
990
991func (c *Client) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
992 if strings.TrimSpace(pattern) == "" {
993 return fmt.Errorf("pattern must not be empty")
994 }
995 resp, err := c.request(ctx, http.MethodDelete, "/autoroute/allowed-domains/"+url.PathEscape(pattern), nil)
996 if err != nil {
997 return err
998 }
999 return parseResponse(resp, nil)
1000}
1001
1002// streamLogs handles SSE streaming for log endpoints.
1003func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
1004 url := c.baseURL + "/admin" + path
1005
1006 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
1007 if err != nil {
1008 return nil, fmt.Errorf("failed to create request: %w", err)
1009 }
1010
1011 req.Header.Set("Accept", "text/event-stream")
1012 if c.token != "" {
1013 req.Header.Set("Authorization", "Bearer "+c.token)
1014 }
1015
1016 // Use the same transport as the main client (honoring TLS config and
1017 // custom transports) but without a timeout so streaming doesn't get cut off.
1018 streamClient := &http.Client{
1019 Transport: c.httpClient.Transport,
1020 }
1021 resp, err := streamClient.Do(req)
1022 if err != nil {
1023 return nil, fmt.Errorf("failed to connect: %w", err)
1024 }
1025
1026 // Observe token rotation on streaming responses, same as request().
1027 c.observeTokenRotation(resp)
1028
1029 if resp.StatusCode >= 400 {
1030 body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
1031 resp.Body.Close()
1032 return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
1033 }
1034
1035 ch := make(chan string, 100)
1036
1037 go func() {
1038 defer close(ch)
1039 defer resp.Body.Close()
1040
1041 buf := make([]byte, 4096)
1042 var lineBuffer strings.Builder
1043
1044 for {
1045 select {
1046 case <-ctx.Done():
1047 return
1048 default:
1049 }
1050
1051 n, err := resp.Body.Read(buf)
1052 if n > 0 {
1053 lineBuffer.Write(buf[:n])
1054 }
1055 if err != nil {
1056 return // EOF or context cancellation — clean exit
1057 }
1058
1059 // Process complete SSE events
1060 for {
1061 data := lineBuffer.String()
1062 idx := strings.Index(data, "\n\n")
1063 if idx == -1 {
1064 break
1065 }
1066
1067 event := data[:idx]
1068 lineBuffer.Reset()
1069 lineBuffer.WriteString(data[idx+2:])
1070
1071 // Parse SSE data lines
1072 for _, line := range strings.Split(event, "\n") {
1073 if strings.HasPrefix(line, "data: ") {
1074 logLine := strings.TrimPrefix(line, "data: ")
1075 select {
1076 case ch <- logLine:
1077 case <-ctx.Done():
1078 return
1079 }
1080 }
1081 }
1082 }
1083 }
1084 }()
1085
1086 return ch, nil
1087}
1088
1089// VerifyAuth checks if authentication session is valid.
1090func (c *Client) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) {
1091 resp, err := c.request(ctx, http.MethodGet, "/auth/verify", nil)
1092 if err != nil {
1093 return nil, err
1094 }
1095
1096 var result dto.AuthVerifyResponse
1097 if err := parseResponse(resp, &result); err != nil {
1098 return nil, err
1099 }
1100
1101 return &result, nil
1102}
1103
1104// ListVolumes lists all volumes via the admin API.
1105func (c *Client) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
1106 resp, err := c.request(ctx, http.MethodGet, "/volumes", nil)
1107 if err != nil {
1108 return nil, err
1109 }
1110
1111 var volumes []dto.Volume
1112 if err := parseResponse(resp, &volumes); err != nil {
1113 return nil, err
1114 }
1115
1116 return volumes, nil
1117}
1118
1119// PruneVolumes prunes orphaned volumes via the admin API.
1120func (c *Client) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
1121 resp, err := c.request(ctx, http.MethodPost, "/volumes/prune", req)
1122 if err != nil {
1123 return nil, err
1124 }
1125
1126 var result dto.VolumePruneResponse
1127 if err := parseResponse(resp, &result); err != nil {
1128 return nil, err
1129 }
1130
1131 return &result, nil
1132}