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
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}

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