v2.16.0 ebd2ab3

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

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