v2.31.0-dev4 f8fb41e

client.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close