v2.30.0-pre 7341e0e

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

Keyboard shortcuts

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

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

Branches 8

Tags 106

↑↓ moveenter openesc close