v2.31.1 f37fe67

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		Cause string   `json:"cause"`
378		Hint  string   `json:"hint"`
379		Logs  []string `json:"logs"`
380	}
381	if err := json.Unmarshal(body, &errResp); err == nil {
382		structured = errResp.Cause != "" || errResp.Hint != "" || len(errResp.Logs) > 0
383		if errResp.Error != "" {
384			msg = errResp.Error
385		}
386	}
387	return &HTTPError{
388		StatusCode: resp.StatusCode,
389		Status:     resp.Status,
390		Body:       msg,
391		Cause:      errResp.Cause,
392		Hint:       errResp.Hint,
393		Logs:       errResp.Logs,
394		Structured: structured,
395	}
396}
397
398func isRetryableRequestError(err error) bool {
399	var transportErr *requestTransportError
400	return errors.As(err, &transportErr) &&
401		!errors.Is(err, context.Canceled) &&
402		!errors.Is(err, context.DeadlineExceeded)
403}
404
405func isIdempotentMethod(method string) bool {
406	switch method {
407	case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace,
408		http.MethodPut, http.MethodDelete:
409		return true
410	default:
411		return false
412	}
413}
414
415func isRetryableStatus(status int) bool {
416	// Gateway errors can be transient; requestWithRetry separately ensures the
417	// method is safe to replay before attempting another request.
418	return status == 502 || status == 503 || status == 504
419}
420
421func retryDelay(attempt int) time.Duration {
422	if attempt <= 1 {
423		return retryBaseDelay
424	}
425	return retryBaseDelay * time.Duration(1<<(attempt-1))
426}
427
428// requestWithRetry performs an HTTP request and retries transient failures
429// only when the HTTP method is idempotent. A transient failure from a
430// non-idempotent request is returned as OutcomeUnknownError without replaying it.
431func (c *Client) requestWithRetry(ctx context.Context, method, path string, body any) (*http.Response, error) {
432	var lastErr error
433	idempotent := isIdempotentMethod(method)
434
435	for attempt := 1; attempt <= retryMaxAttempts; attempt++ {
436		resp, err := c.request(ctx, method, path, body)
437		if err != nil {
438			if !idempotent {
439				var transportErr *requestTransportError
440				if errors.As(err, &transportErr) {
441					return nil, &OutcomeUnknownError{Method: method, Path: path, Err: err}
442				}
443				return nil, err
444			}
445			lastErr = err
446			if attempt == retryMaxAttempts || !isRetryableRequestError(err) {
447				return nil, err
448			}
449		} else {
450			if !isRetryableStatus(resp.StatusCode) {
451				return resp, nil
452			}
453
454			respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
455			_ = resp.Body.Close()
456			lastErr = parseErrorResponse(resp, respBody)
457			if !idempotent {
458				return nil, &OutcomeUnknownError{Method: method, Path: path, Err: lastErr}
459			}
460			if attempt == retryMaxAttempts {
461				return nil, lastErr
462			}
463		}
464
465		delay := retryDelay(attempt)
466		timer := time.NewTimer(delay)
467		select {
468		case <-ctx.Done():
469			timer.Stop()
470			return nil, ctx.Err()
471		case <-timer.C:
472		}
473	}
474
475	if lastErr != nil {
476		return nil, lastErr
477	}
478	return nil, fmt.Errorf("request failed after retries")
479}
480
481// Routes API
482
483// Type aliases for API types using shared DTO package.
484type RouteInfo = dto.RouteInfo
485type Attachment = dto.Attachment
486
487// ListRoutes returns all configured routes.
488func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
489	resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
490	if err != nil {
491		return nil, err
492	}
493
494	var result struct {
495		Routes []domain.Route `json:"routes"`
496	}
497	if err := parseResponse(resp, &result); err != nil {
498		return nil, err
499	}
500
501	return result.Routes, nil
502}
503
504// ListRoutesWithDetails returns routes with network and attachment info.
505func (c *Client) ListRoutesWithDetails(ctx context.Context) ([]RouteInfo, error) {
506	resp, err := c.request(ctx, http.MethodGet, "/routes?detailed=true", nil)
507	if err != nil {
508		return nil, err
509	}
510
511	var result struct {
512		Routes []RouteInfo `json:"routes"`
513	}
514	if err := parseResponse(resp, &result); err != nil {
515		return nil, err
516	}
517
518	return result.Routes, nil
519}
520
521// ListNetworks returns Gordon-managed networks.
522func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
523	resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
524	if err != nil {
525		return nil, err
526	}
527
528	var result struct {
529		Networks []*domain.NetworkInfo `json:"networks"`
530	}
531	if err := parseResponse(resp, &result); err != nil {
532		return nil, err
533	}
534
535	return result.Networks, nil
536}
537
538// ListAttachments returns attachments for a domain.
539func (c *Client) ListAttachments(ctx context.Context, routeDomain string) ([]Attachment, error) {
540	if routeDomain == "" {
541		return nil, fmt.Errorf("domain is required")
542	}
543	path := "/routes/" + url.PathEscape(routeDomain) + "/attachments"
544	resp, err := c.request(ctx, http.MethodGet, path, nil)
545	if err != nil {
546		return nil, err
547	}
548
549	var result struct {
550		Attachments []Attachment `json:"attachments"`
551	}
552	if err := parseResponse(resp, &result); err != nil {
553		return nil, err
554	}
555
556	return result.Attachments, nil
557}
558
559// GetRoute returns a specific route by domain.
560func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
561	resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
562	if err != nil {
563		return nil, err
564	}
565
566	var route domain.Route
567	if err := parseResponse(resp, &route); err != nil {
568		var httpErr *HTTPError
569		if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
570			return nil, domain.ErrRouteNotFound
571		}
572		return nil, err
573	}
574
575	return &route, nil
576}
577
578// GetRouteCleanupPreview returns retained cleanup state for a route that may no longer be configured.
579func (c *Client) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
580	resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain)+"/cleanup", nil)
581	if err != nil {
582		return nil, err
583	}
584
585	var report domain.CleanupReport
586	if err := parseResponse(resp, &report); err != nil {
587		return nil, err
588	}
589
590	return &report, nil
591}
592
593// FindRoutesByImage returns all routes associated with the given image name.
594func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
595	resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil)
596	if err != nil {
597		return nil, err
598	}
599
600	var result struct {
601		Image  string         `json:"image"`
602		Routes []domain.Route `json:"routes"`
603	}
604	if err := parseResponse(resp, &result); err != nil {
605		return nil, err
606	}
607
608	return result.Routes, nil
609}
610
611// AddRoute adds a new route.
612func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
613	resp, err := c.request(ctx, http.MethodPost, "/routes", route)
614	if err != nil {
615		return err
616	}
617	return parseResponse(resp, nil)
618}
619
620// UpdateRoute updates an existing route.
621func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
622	resp, err := c.request(ctx, http.MethodPut, "/routes/"+url.PathEscape(route.Domain), route)
623	if err != nil {
624		return err
625	}
626	return parseResponse(resp, nil)
627}
628
629// RemoveRoute removes a route by domain.
630func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
631	_, err := c.RemoveRouteWithCleanup(ctx, routeDomain)
632	return err
633}
634
635// RemoveRouteWithCleanup removes a route and returns the server cleanup report.
636func (c *Client) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) {
637	resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)
638	if err != nil {
639		return nil, fmt.Errorf("delete route %s failed: %w", routeDomain, err)
640	}
641	var result dto.RouteDeleteResponse
642	if err := parseResponse(resp, &result); err != nil {
643		return nil, fmt.Errorf("parse delete route %s response: %w", routeDomain, err)
644	}
645	return &result, nil
646}
647
648func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
649	resp, err := c.request(ctx, http.MethodPost, "/bootstrap", req)
650	if err != nil {
651		return nil, err
652	}
653	var result dto.BootstrapResponse
654	if err := parseResponse(resp, &result); err != nil {
655		return nil, err
656	}
657	return &result, nil
658}
659
660// Secrets API
661
662// AttachmentSecrets represents secrets for an attachment container.
663type AttachmentSecrets struct {
664	Service string   `json:"service"`
665	Keys    []string `json:"keys"`
666}
667
668// SecretsListResult contains domain secrets and any attachment secrets.
669type SecretsListResult struct {
670	Domain      string              `json:"domain"`
671	Keys        []string            `json:"keys"`
672	Attachments []AttachmentSecrets `json:"attachments,omitempty"`
673}
674
675// ListSecrets returns the list of secret keys for a domain.
676func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
677	result, err := c.ListSecretsWithAttachments(ctx, secretDomain)
678	if err != nil {
679		return nil, err
680	}
681	return result.Keys, nil
682}
683
684// ListSecretsWithAttachments returns domain secrets and attachment secrets.
685func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) {
686	resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
687	if err != nil {
688		return nil, err
689	}
690
691	var result SecretsListResult
692	if err := parseResponse(resp, &result); err != nil {
693		return nil, err
694	}
695
696	return &result, nil
697}
698
699// SetSecrets sets secrets for a domain.
700func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
701	resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets)
702	if err != nil {
703		return err
704	}
705	return parseResponse(resp, nil)
706}
707
708// DeleteSecret removes a secret from a domain.
709func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {
710	resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil)
711	if err != nil {
712		return err
713	}
714	return parseResponse(resp, nil)
715}
716
717// SetAttachmentSecrets sets secrets for an attachment container.
718func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error {
719	path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service)
720	resp, err := c.request(ctx, http.MethodPost, path, secrets)
721	if err != nil {
722		return err
723	}
724	return parseResponse(resp, nil)
725}
726
727// DeleteAttachmentSecret removes a secret from an attachment container.
728func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error {
729	path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key)
730	resp, err := c.request(ctx, http.MethodDelete, path, nil)
731	if err != nil {
732		return err
733	}
734	return parseResponse(resp, nil)
735}
736
737// Status API
738
739// Status represents the Gordon server status.
740type Status struct {
741	Routes           int               `json:"routes"`
742	RegistryDomain   string            `json:"registry_domain"`
743	RegistryPort     int               `json:"registry_port"`
744	ServerPort       int               `json:"server_port"`
745	AutoRoute        bool              `json:"auto_route"`
746	NetworkIsolation bool              `json:"network_isolation"`
747	ContainerStatus  map[string]string `json:"container_status"`
748}
749
750// GetTLSStatus returns the public TLS/ACME status.
751func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
752	resp, err := c.request(ctx, http.MethodGet, "/tls/status", nil)
753	if err != nil {
754		return nil, err
755	}
756
757	var status dto.TLSStatusResponse
758	if err := parseResponse(resp, &status); err != nil {
759		return nil, err
760	}
761
762	return &status, nil
763}
764
765// GetTrafficStatus returns the traffic plane status.
766func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
767	resp, err := c.request(ctx, http.MethodGet, "/traffic/status", nil)
768	if err != nil {
769		return nil, fmt.Errorf("request traffic status: %w", err)
770	}
771
772	var status dto.TrafficStatusResponse
773	if err := parseResponse(resp, &status); err != nil {
774		return nil, fmt.Errorf("parse traffic status response: %w", err)
775	}
776
777	return &status, nil
778}
779
780// GetStatus returns the Gordon server status.
781func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
782	resp, err := c.requestWithRetry(ctx, http.MethodGet, "/status", nil)
783	if err != nil {
784		return nil, err
785	}
786
787	var status Status
788	if err := parseResponse(resp, &status); err != nil {
789		return nil, err
790	}
791
792	return &status, nil
793}
794
795// RouteHealth represents the health status of a route.
796type RouteHealth struct {
797	ContainerStatus string `json:"container_status"`
798	HTTPStatus      int    `json:"http_status"`
799	ResponseTimeMs  int64  `json:"response_time_ms"`
800	Healthy         bool   `json:"healthy"`
801	Error           string `json:"error"`
802}
803
804// Backups API
805
806// Images API
807
808// ListImages returns runtime images and registry tags from the admin API.
809func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
810	resp, err := c.request(ctx, http.MethodGet, "/images", nil)
811	if err != nil {
812		return nil, err
813	}
814
815	var result dto.ImagesResponse
816	if err := parseResponse(resp, &result); err != nil {
817		return nil, err
818	}
819
820	return result.Images, nil
821}
822
823// PruneImages prunes runtime and registry images.
824func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
825	if req.KeepLast != nil && *req.KeepLast < 0 {
826		return nil, fmt.Errorf("keep_last must be >= 0")
827	}
828
829	resp, err := c.request(ctx, http.MethodPost, "/images/prune", req)
830	if err != nil {
831		return nil, err
832	}
833
834	var result dto.ImagePruneResponse
835	if err := parseResponse(resp, &result); err != nil {
836		return nil, err
837	}
838
839	return &result, nil
840}
841
842// ListBackups returns backups globally or for a domain.
843func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
844	path := "/backups"
845	if backupDomain != "" {
846		path += "/" + url.PathEscape(backupDomain)
847	}
848
849	resp, err := c.request(ctx, http.MethodGet, path, nil)
850	if err != nil {
851		return nil, err
852	}
853
854	var result dto.BackupsResponse
855	if err := parseResponse(resp, &result); err != nil {
856		return nil, err
857	}
858
859	return result.Backups, nil
860}
861
862// BackupStatus returns aggregate backup status.
863func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
864	resp, err := c.request(ctx, http.MethodGet, "/backups/status", nil)
865	if err != nil {
866		return nil, err
867	}
868
869	var result dto.BackupsResponse
870	if err := parseResponse(resp, &result); err != nil {
871		return nil, err
872	}
873
874	return result.Backups, nil
875}
876
877// RunBackup triggers a backup for a domain.
878func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
879	if backupDomain == "" {
880		return nil, fmt.Errorf("domain cannot be empty")
881	}
882
883	resp, err := c.request(ctx, http.MethodPost, "/backups/"+url.PathEscape(backupDomain), dto.BackupRunRequest{DB: dbName})
884	if err != nil {
885		return nil, err
886	}
887
888	var result dto.BackupRunResponse
889	if err := parseResponse(resp, &result); err != nil {
890		return nil, err
891	}
892
893	return &result, nil
894}
895
896// DetectDatabases detects supported databases for a domain.
897func (c *Client) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
898	if backupDomain == "" {
899		return nil, fmt.Errorf("domain cannot be empty")
900	}
901
902	resp, err := c.request(ctx, http.MethodGet, "/backups/"+url.PathEscape(backupDomain)+"/detect", nil)
903	if err != nil {
904		return nil, err
905	}
906
907	var result dto.BackupDetectResponse
908	if err := parseResponse(resp, &result); err != nil {
909		return nil, err
910	}
911
912	return result.Databases, nil
913}
914
915// ListVolumeBackups returns volume backups globally or for a domain.
916func (c *Client) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
917	path := "/backups/volumes"
918	if backupDomain != "" {
919		path += "/" + url.PathEscape(backupDomain)
920	}
921
922	resp, err := c.request(ctx, http.MethodGet, path, nil)
923	if err != nil {
924		return nil, err
925	}
926
927	var result dto.VolumeBackupsResponse
928	if err := parseResponse(resp, &result); err != nil {
929		return nil, err
930	}
931	return result.Backups, nil
932}
933
934// VolumeBackupStatus returns aggregate volume backup status.
935func (c *Client) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
936	resp, err := c.request(ctx, http.MethodGet, "/backups/volumes/status", nil)
937	if err != nil {
938		return nil, err
939	}
940
941	var result dto.VolumeBackupsResponse
942	if err := parseResponse(resp, &result); err != nil {
943		return nil, err
944	}
945	return result.Backups, nil
946}
947
948// RunVolumeBackups triggers volume backups.
949func (c *Client) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
950	path := "/backups/volumes"
951	if backupDomain != "" {
952		path += "/" + url.PathEscape(backupDomain)
953	}
954
955	resp, err := c.request(ctx, http.MethodPost, path, dto.VolumeBackupRunRequest{Volume: volumeName})
956	if err != nil {
957		return nil, err
958	}
959
960	var result dto.VolumeBackupRunResponse
961	if err := parseResponse(resp, &result); err != nil {
962		return nil, err
963	}
964	if resp.StatusCode == http.StatusPartialContent {
965		if result.Error == "" {
966			result.Error = "volume backup run partially failed"
967		}
968		return &result, fmt.Errorf("volume backup run partially failed: %s", result.Error)
969	}
970	return &result, nil
971}
972
973// GetHealth returns health status for all routes with HTTP probing.
974func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
975	resp, err := c.request(ctx, http.MethodGet, "/health", nil)
976	if err != nil {
977		return nil, err
978	}
979
980	var result struct {
981		Health map[string]*RouteHealth `json:"health"`
982	}
983	if err := parseResponse(resp, &result); err != nil {
984		return nil, err
985	}
986
987	return result.Health, nil
988}
989
990// Reload triggers a configuration reload.
991func (c *Client) Reload(ctx context.Context) error {
992	resp, err := c.requestWithRetry(ctx, http.MethodPost, "/reload", nil)
993	if err != nil {
994		return err
995	}
996	return parseResponse(resp, nil)
997}
998
999// Config API
1000
1001// Config represents the Gordon configuration.
1002type Config struct {
1003	Server struct {
1004		Port           int    `json:"port"`
1005		RegistryPort   int    `json:"registry_port"`
1006		RegistryDomain string `json:"registry_domain"`
1007		DataDir        string `json:"data_dir,omitempty"`
1008	} `json:"server"`
1009	AutoRoute struct {
1010		Enabled bool `json:"enabled"`
1011	} `json:"auto_route"`
1012	NetworkIsolation struct {
1013		Enabled bool   `json:"enabled"`
1014		Prefix  string `json:"prefix"`
1015	} `json:"network_isolation"`
1016	Volumes struct {
1017		AutoCreate bool   `json:"auto_create"`
1018		Prefix     string `json:"prefix"`
1019		Preserve   bool   `json:"preserve"`
1020	} `json:"volumes"`
1021	Routes         []domain.Route  `json:"routes"`
1022	ExternalRoutes []ExternalRoute `json:"external_routes"`
1023}
1024
1025// ExternalRoute represents a redacted external route config entry.
1026type ExternalRoute struct {
1027	Domain string `json:"domain"`
1028	Target string `json:"target,omitempty"`
1029}
1030
1031// GetConfig returns the Gordon configuration.
1032func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
1033	resp, err := c.request(ctx, http.MethodGet, "/config", nil)
1034	if err != nil {
1035		return nil, err
1036	}
1037
1038	var config Config
1039	if err := parseResponse(resp, &config); err != nil {
1040		return nil, err
1041	}
1042
1043	return &config, nil
1044}
1045
1046// Ping checks if the remote Gordon instance is reachable.
1047func (c *Client) Ping(ctx context.Context) error {
1048	_, err := c.GetStatus(ctx)
1049	return err
1050}
1051
1052// Deploy API
1053
1054// DeployResult contains the result of a deployment.
1055type DeployResult struct {
1056	Status      string `json:"status"`
1057	ContainerID string `json:"container_id"`
1058	Domain      string `json:"domain"`
1059}
1060
1061// Deploy triggers a deployment for the specified domain.
1062func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) {
1063	resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil)
1064	if err != nil {
1065		return nil, err
1066	}
1067
1068	var result DeployResult
1069	if err := parseResponse(resp, &result); err != nil {
1070		return nil, err
1071	}
1072
1073	return &result, nil
1074}
1075
1076// DeployIntent tells the server that a CLI-managed push is about to happen,
1077// suppressing event-based deploys for this image.
1078func (c *Client) DeployIntent(ctx context.Context, imageName string) error {
1079	imageName = strings.TrimSpace(imageName)
1080	if imageName == "" {
1081		return fmt.Errorf("image name cannot be empty")
1082	}
1083	resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy-intent/"+url.PathEscape(imageName), nil)
1084	if err != nil {
1085		return err
1086	}
1087	return parseResponse(resp, nil)
1088}
1089
1090// Restart API
1091
1092// RestartResult contains the result of a restart.
1093type RestartResult struct {
1094	Status string `json:"status"`
1095	Domain string `json:"domain"`
1096}
1097
1098// Restart triggers a container restart for the specified domain.
1099func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*RestartResult, error) {
1100	if restartDomain == "" {
1101		return nil, fmt.Errorf("domain cannot be empty")
1102	}
1103	path := "/restart/" + url.PathEscape(restartDomain)
1104	if withAttachments {
1105		path += "?attachments=true"
1106	}
1107	resp, err := c.requestWithRetry(ctx, http.MethodPost, path, nil)
1108	if err != nil {
1109		return nil, err
1110	}
1111
1112	var result RestartResult
1113	if err := parseResponse(resp, &result); err != nil {
1114		return nil, err
1115	}
1116
1117	return &result, nil
1118}
1119
1120// Tags API
1121
1122// ListTags returns available tags for a repository.
1123func (c *Client) ListTags(ctx context.Context, repository string) ([]string, error) {
1124	if repository == "" {
1125		return nil, fmt.Errorf("repository cannot be empty")
1126	}
1127	resp, err := c.request(ctx, http.MethodGet, "/tags/"+url.PathEscape(repository), nil)
1128	if err != nil {
1129		return nil, err
1130	}
1131	var result struct {
1132		Repository string   `json:"repository"`
1133		Tags       []string `json:"tags"`
1134	}
1135	if err := parseResponse(resp, &result); err != nil {
1136		return nil, err
1137	}
1138	return result.Tags, nil
1139}
1140
1141// Logs API
1142
1143// GetProcessLogs returns Gordon process logs.
1144func (c *Client) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
1145	path := fmt.Sprintf("/logs?lines=%d", lines)
1146	resp, err := c.request(ctx, http.MethodGet, path, nil)
1147	if err != nil {
1148		return nil, err
1149	}
1150
1151	var result struct {
1152		Lines []string `json:"lines"`
1153	}
1154	if err := parseResponse(resp, &result); err != nil {
1155		return nil, err
1156	}
1157
1158	return result.Lines, nil
1159}
1160
1161// GetContainerLogs returns container logs for a specific domain.
1162func (c *Client) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
1163	path := fmt.Sprintf("/logs/%s?lines=%d", url.PathEscape(logDomain), lines)
1164	resp, err := c.request(ctx, http.MethodGet, path, nil)
1165	if err != nil {
1166		return nil, err
1167	}
1168
1169	var result struct {
1170		Domain string   `json:"domain"`
1171		Lines  []string `json:"lines"`
1172	}
1173	if err := parseResponse(resp, &result); err != nil {
1174		return nil, err
1175	}
1176
1177	return result.Lines, nil
1178}
1179
1180// StreamProcessLogs returns a channel that streams Gordon process log lines via SSE.
1181// The caller is responsible for reading from the channel until it's closed.
1182func (c *Client) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
1183	path := fmt.Sprintf("/logs?lines=%d&follow=true", lines)
1184	return c.streamLogs(ctx, path)
1185}
1186
1187// StreamContainerLogs returns a channel that streams container log lines via SSE.
1188// The caller is responsible for reading from the channel until it's closed.
1189func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
1190	path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", url.PathEscape(logDomain), lines)
1191	return c.streamLogs(ctx, path)
1192}
1193
1194// Attachments Config API
1195
1196// ListOrphanedAttachments returns running attachment containers no longer configured.
1197func (c *Client) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) {
1198	resp, err := c.request(ctx, http.MethodGet, "/attachments/orphans", nil)
1199	if err != nil {
1200		return nil, err
1201	}
1202	var result struct {
1203		Attachments []domain.CleanupAttachment `json:"attachments"`
1204	}
1205	if err := parseResponse(resp, &result); err != nil {
1206		return nil, err
1207	}
1208	return result.Attachments, nil
1209}
1210
1211// CleanupOrphanedAttachments optionally stops/removes orphaned attachment containers.
1212func (c *Client) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) {
1213	path := "/attachments/prune"
1214	params := url.Values{}
1215	if stop {
1216		params.Set("stop", "true")
1217	}
1218	if owner != "" {
1219		params.Set("owner", owner)
1220	}
1221	if encoded := params.Encode(); encoded != "" {
1222		path += "?" + encoded
1223	}
1224	resp, err := c.request(ctx, http.MethodPost, path, nil)
1225	if err != nil {
1226		return nil, fmt.Errorf("cleanup orphaned attachments failed: %w", err)
1227	}
1228	var result domain.CleanupReport
1229	if err := parseResponse(resp, &result); err != nil {
1230		return nil, fmt.Errorf("parse orphaned attachment cleanup response: %w", err)
1231	}
1232	return &result, nil
1233}
1234
1235// GetAllAttachmentsConfig returns all configured attachments.
1236func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
1237	resp, err := c.request(ctx, http.MethodGet, "/attachments", nil)
1238	if err != nil {
1239		return nil, err
1240	}
1241
1242	var result struct {
1243		Attachments map[string][]string `json:"attachments"`
1244	}
1245	if err := parseResponse(resp, &result); err != nil {
1246		return nil, err
1247	}
1248
1249	return result.Attachments, nil
1250}
1251
1252// GetAttachmentsConfig returns attachments for a specific domain/group from config.
1253func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
1254	if domainOrGroup == "" {
1255		return nil, fmt.Errorf("domain or group is required")
1256	}
1257	path := "/attachments/" + url.PathEscape(domainOrGroup)
1258	resp, err := c.request(ctx, http.MethodGet, path, nil)
1259	if err != nil {
1260		return nil, err
1261	}
1262
1263	var result struct {
1264		Target string   `json:"target"`
1265		Images []string `json:"images"`
1266	}
1267	if err := parseResponse(resp, &result); err != nil {
1268		return nil, err
1269	}
1270
1271	return result.Images, nil
1272}
1273
1274// FindAttachmentTargetsByImage returns all attachment targets associated with the given image name.
1275func (c *Client) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
1276	resp, err := c.request(ctx, http.MethodGet, "/attachments/by-image/"+url.PathEscape(imageName), nil)
1277	if err != nil {
1278		return nil, err
1279	}
1280
1281	var result dto.AttachmentTargetsByImageResponse
1282	if err := parseResponse(resp, &result); err != nil {
1283		return nil, err
1284	}
1285
1286	return result.Targets, nil
1287}
1288
1289// AddAttachment adds an attachment to a domain/group.
1290func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
1291	if domainOrGroup == "" {
1292		return fmt.Errorf("domain or group is required")
1293	}
1294	if image == "" {
1295		return fmt.Errorf("image is required")
1296	}
1297	path := "/attachments/" + url.PathEscape(domainOrGroup)
1298	resp, err := c.request(ctx, http.MethodPost, path, struct {
1299		Image string `json:"image"`
1300	}{Image: image})
1301	if err != nil {
1302		return err
1303	}
1304	return parseResponse(resp, nil)
1305}
1306
1307// RemoveAttachment removes an attachment from a domain/group.
1308func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
1309	if domainOrGroup == "" {
1310		return fmt.Errorf("domain or group is required")
1311	}
1312	if image == "" {
1313		return fmt.Errorf("image is required")
1314	}
1315	path := "/attachments/" + url.PathEscape(domainOrGroup) + "/" + url.PathEscape(image)
1316	resp, err := c.request(ctx, http.MethodDelete, path, nil)
1317	if err != nil {
1318		return err
1319	}
1320	return parseResponse(resp, nil)
1321}
1322
1323func (c *Client) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
1324	resp, err := c.request(ctx, http.MethodGet, "/autoroute/allowed-domains", nil)
1325	if err != nil {
1326		return nil, err
1327	}
1328
1329	var result dto.AutoRouteAllowedDomainsResponse
1330	if err := parseResponse(resp, &result); err != nil {
1331		return nil, err
1332	}
1333
1334	return result.Domains, nil
1335}
1336
1337func (c *Client) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1338	if strings.TrimSpace(pattern) == "" {
1339		return fmt.Errorf("pattern must not be empty")
1340	}
1341	resp, err := c.request(ctx, http.MethodPost, "/autoroute/allowed-domains", dto.AutoRouteAllowedDomainRequest{Pattern: pattern})
1342	if err != nil {
1343		return err
1344	}
1345	return parseResponse(resp, nil)
1346}
1347
1348func (c *Client) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1349	if strings.TrimSpace(pattern) == "" {
1350		return fmt.Errorf("pattern must not be empty")
1351	}
1352	resp, err := c.request(ctx, http.MethodDelete, "/autoroute/allowed-domains/"+url.PathEscape(pattern), nil)
1353	if err != nil {
1354		return err
1355	}
1356	return parseResponse(resp, nil)
1357}
1358
1359// openSSEStream opens an SSE connection to the given admin path and returns the response.
1360func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response, error) {
1361	streamURL := c.baseURL + "/admin" + path
1362
1363	req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
1364	if err != nil {
1365		return nil, fmt.Errorf("failed to create request: %w", err)
1366	}
1367
1368	req.Header.Set("Accept", "text/event-stream")
1369	bearer, err := c.bearerToken(ctx)
1370	if err != nil {
1371		return nil, err
1372	}
1373	if bearer != "" {
1374		req.Header.Set("Authorization", "Bearer "+bearer)
1375	}
1376
1377	// Use the same transport as the main client (honoring TLS config and
1378	// custom transports) but without a timeout so streaming doesn't get cut off.
1379	streamClient := &http.Client{
1380		Transport: c.httpClient.Transport,
1381	}
1382	resp, err := streamClient.Do(req)
1383	if err != nil {
1384		return nil, fmt.Errorf("failed to connect: %w", err)
1385	}
1386
1387	if resp.StatusCode >= 400 {
1388		body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
1389		resp.Body.Close()
1390		return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
1391	}
1392
1393	return resp, nil
1394}
1395
1396// streamLogs handles SSE streaming for log endpoints.
1397func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
1398	resp, err := c.openSSEStream(ctx, path)
1399	if err != nil {
1400		return nil, err
1401	}
1402
1403	ch := make(chan string, 100)
1404
1405	go func() {
1406		defer close(ch)
1407		defer resp.Body.Close()
1408
1409		buf := make([]byte, 4096)
1410		var lineBuffer strings.Builder
1411
1412		for {
1413			select {
1414			case <-ctx.Done():
1415				return
1416			default:
1417			}
1418
1419			n, err := resp.Body.Read(buf)
1420			if n > 0 {
1421				lineBuffer.Write(buf[:n])
1422			}
1423			if err != nil {
1424				return // EOF or context cancellation — clean exit
1425			}
1426
1427			// Process complete SSE events
1428			for {
1429				data := lineBuffer.String()
1430				event, remaining, ok := strings.Cut(data, "\n\n")
1431				if !ok {
1432					break
1433				}
1434
1435				lineBuffer.Reset()
1436				lineBuffer.WriteString(remaining)
1437
1438				// Parse SSE data lines
1439				for line := range strings.SplitSeq(event, "\n") {
1440					logLine, ok := strings.CutPrefix(line, "data: ")
1441					if ok {
1442						select {
1443						case ch <- logLine:
1444						case <-ctx.Done():
1445							return
1446						}
1447					}
1448				}
1449			}
1450		}
1451	}()
1452
1453	return ch, nil
1454}
1455
1456// VerifyAuth checks if authentication session is valid.
1457func (c *Client) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) {
1458	resp, err := c.request(ctx, http.MethodGet, "/auth/verify", nil)
1459	if err != nil {
1460		return nil, err
1461	}
1462
1463	var result dto.AuthVerifyResponse
1464	if err := parseResponse(resp, &result); err != nil {
1465		return nil, err
1466	}
1467
1468	return &result, nil
1469}
1470
1471// ListVolumes lists all volumes via the admin API.
1472func (c *Client) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
1473	resp, err := c.request(ctx, http.MethodGet, "/volumes", nil)
1474	if err != nil {
1475		return nil, err
1476	}
1477
1478	var volumes []dto.Volume
1479	if err := parseResponse(resp, &volumes); err != nil {
1480		return nil, err
1481	}
1482
1483	return volumes, nil
1484}
1485
1486// PruneVolumes prunes orphaned volumes via the admin API.
1487func (c *Client) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
1488	resp, err := c.request(ctx, http.MethodPost, "/volumes/prune", req)
1489	if err != nil {
1490		return nil, err
1491	}
1492
1493	var result dto.VolumePruneResponse
1494	if err := parseResponse(resp, &result); err != nil {
1495		return nil, err
1496	}
1497
1498	return &result, nil
1499}

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 7

Tags 106

↑↓ moveenter openesc close