v2.31.0 e02f654

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

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