v2.11.0 dca6537

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	"encoding/json"
8	"fmt"
9	"io"
10	"net/http"
11	"net/url"
12	"strings"
13	"time"
14
15	"github.com/bnema/gordon/internal/adapters/dto"
16	"github.com/bnema/gordon/internal/domain"
17)
18
19// Client is an HTTP client for the Gordon admin API.
20type Client struct {
21	baseURL    string
22	token      string
23	httpClient *http.Client
24}
25
26// ClientOption configures the Client.
27type ClientOption func(*Client)
28
29// NewClient creates a new remote Gordon client.
30func NewClient(baseURL string, opts ...ClientOption) *Client {
31	// Normalize base URL
32	baseURL = strings.TrimSuffix(baseURL, "/")
33
34	c := &Client{
35		baseURL: baseURL,
36		httpClient: &http.Client{
37			Timeout: 2 * time.Minute,
38		},
39	}
40
41	for _, opt := range opts {
42		opt(c)
43	}
44
45	return c
46}
47
48// WithToken sets the authentication token.
49func WithToken(token string) ClientOption {
50	return func(c *Client) {
51		c.token = token
52	}
53}
54
55// WithHTTPClient sets a custom HTTP client.
56func WithHTTPClient(client *http.Client) ClientOption {
57	return func(c *Client) {
58		c.httpClient = client
59	}
60}
61
62// WithTimeout sets the HTTP client timeout.
63func WithTimeout(timeout time.Duration) ClientOption {
64	return func(c *Client) {
65		c.httpClient.Timeout = timeout
66	}
67}
68
69// request performs an HTTP request to the admin API.
70func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
71	url := c.baseURL + "/admin" + path
72
73	var bodyReader io.Reader
74	if body != nil {
75		jsonBody, err := json.Marshal(body)
76		if err != nil {
77			return nil, fmt.Errorf("failed to marshal request body: %w", err)
78		}
79		bodyReader = bytes.NewReader(jsonBody)
80	}
81
82	req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
83	if err != nil {
84		return nil, fmt.Errorf("failed to create request: %w", err)
85	}
86
87	req.Header.Set("Content-Type", "application/json")
88	req.Header.Set("Accept", "application/json")
89
90	if c.token != "" {
91		req.Header.Set("Authorization", "Bearer "+c.token)
92	}
93
94	return c.httpClient.Do(req)
95}
96
97// parseResponse parses a JSON response into the given target.
98func parseResponse(resp *http.Response, target any) error {
99	defer resp.Body.Close()
100
101	if resp.StatusCode >= 400 {
102		body, _ := io.ReadAll(resp.Body)
103		var errResp struct {
104			Error string `json:"error"`
105		}
106		if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
107			return fmt.Errorf("%s: %s", resp.Status, errResp.Error)
108		}
109		return fmt.Errorf("%s: %s", resp.Status, string(body))
110	}
111
112	if target != nil {
113		if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
114			return fmt.Errorf("failed to decode response: %w", err)
115		}
116	}
117
118	return nil
119}
120
121// Routes API
122
123// Type aliases for API types using shared DTO package.
124type RouteInfo = dto.RouteInfo
125type Attachment = dto.Attachment
126
127// ListRoutes returns all configured routes.
128func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
129	resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
130	if err != nil {
131		return nil, err
132	}
133
134	var result struct {
135		Routes []domain.Route `json:"routes"`
136	}
137	if err := parseResponse(resp, &result); err != nil {
138		return nil, err
139	}
140
141	return result.Routes, nil
142}
143
144// ListRoutesWithDetails returns routes with network and attachment info.
145func (c *Client) ListRoutesWithDetails(ctx context.Context) ([]RouteInfo, error) {
146	resp, err := c.request(ctx, http.MethodGet, "/routes?detailed=true", nil)
147	if err != nil {
148		return nil, err
149	}
150
151	var result struct {
152		Routes []RouteInfo `json:"routes"`
153	}
154	if err := parseResponse(resp, &result); err != nil {
155		return nil, err
156	}
157
158	return result.Routes, nil
159}
160
161// ListNetworks returns Gordon-managed networks.
162func (c *Client) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
163	resp, err := c.request(ctx, http.MethodGet, "/networks", nil)
164	if err != nil {
165		return nil, err
166	}
167
168	var result struct {
169		Networks []*domain.NetworkInfo `json:"networks"`
170	}
171	if err := parseResponse(resp, &result); err != nil {
172		return nil, err
173	}
174
175	return result.Networks, nil
176}
177
178// ListAttachments returns attachments for a domain.
179func (c *Client) ListAttachments(ctx context.Context, routeDomain string) ([]Attachment, error) {
180	if routeDomain == "" {
181		return nil, fmt.Errorf("domain is required")
182	}
183	path := "/routes/" + url.PathEscape(routeDomain) + "/attachments"
184	resp, err := c.request(ctx, http.MethodGet, path, nil)
185	if err != nil {
186		return nil, err
187	}
188
189	var result struct {
190		Attachments []Attachment `json:"attachments"`
191	}
192	if err := parseResponse(resp, &result); err != nil {
193		return nil, err
194	}
195
196	return result.Attachments, nil
197}
198
199// GetRoute returns a specific route by domain.
200func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
201	resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
202	if err != nil {
203		return nil, err
204	}
205
206	var route domain.Route
207	if err := parseResponse(resp, &route); err != nil {
208		return nil, err
209	}
210
211	return &route, nil
212}
213
214// AddRoute adds a new route.
215func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
216	resp, err := c.request(ctx, http.MethodPost, "/routes", route)
217	if err != nil {
218		return err
219	}
220	return parseResponse(resp, nil)
221}
222
223// UpdateRoute updates an existing route.
224func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
225	resp, err := c.request(ctx, http.MethodPut, "/routes/"+url.PathEscape(route.Domain), route)
226	if err != nil {
227		return err
228	}
229	return parseResponse(resp, nil)
230}
231
232// RemoveRoute removes a route by domain.
233func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
234	resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)
235	if err != nil {
236		return err
237	}
238	return parseResponse(resp, nil)
239}
240
241// Secrets API
242
243// AttachmentSecrets represents secrets for an attachment container.
244type AttachmentSecrets struct {
245	Service string   `json:"service"`
246	Keys    []string `json:"keys"`
247}
248
249// SecretsListResult contains domain secrets and any attachment secrets.
250type SecretsListResult struct {
251	Domain      string              `json:"domain"`
252	Keys        []string            `json:"keys"`
253	Attachments []AttachmentSecrets `json:"attachments,omitempty"`
254}
255
256// ListSecrets returns the list of secret keys for a domain.
257func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
258	result, err := c.ListSecretsWithAttachments(ctx, secretDomain)
259	if err != nil {
260		return nil, err
261	}
262	return result.Keys, nil
263}
264
265// ListSecretsWithAttachments returns domain secrets and attachment secrets.
266func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) {
267	resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
268	if err != nil {
269		return nil, err
270	}
271
272	var result SecretsListResult
273	if err := parseResponse(resp, &result); err != nil {
274		return nil, err
275	}
276
277	return &result, nil
278}
279
280// SetSecrets sets secrets for a domain.
281func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
282	resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets)
283	if err != nil {
284		return err
285	}
286	return parseResponse(resp, nil)
287}
288
289// DeleteSecret removes a secret from a domain.
290func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {
291	resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil)
292	if err != nil {
293		return err
294	}
295	return parseResponse(resp, nil)
296}
297
298// SetAttachmentSecrets sets secrets for an attachment container.
299func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error {
300	path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service)
301	resp, err := c.request(ctx, http.MethodPost, path, secrets)
302	if err != nil {
303		return err
304	}
305	return parseResponse(resp, nil)
306}
307
308// DeleteAttachmentSecret removes a secret from an attachment container.
309func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error {
310	path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key)
311	resp, err := c.request(ctx, http.MethodDelete, path, nil)
312	if err != nil {
313		return err
314	}
315	return parseResponse(resp, nil)
316}
317
318// Status API
319
320// Status represents the Gordon server status.
321type Status struct {
322	Routes           int               `json:"routes"`
323	RegistryDomain   string            `json:"registry_domain"`
324	RegistryPort     int               `json:"registry_port"`
325	ServerPort       int               `json:"server_port"`
326	AutoRoute        bool              `json:"auto_route"`
327	NetworkIsolation bool              `json:"network_isolation"`
328	ContainerStatus  map[string]string `json:"container_status"`
329}
330
331// GetStatus returns the Gordon server status.
332func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
333	resp, err := c.request(ctx, http.MethodGet, "/status", nil)
334	if err != nil {
335		return nil, err
336	}
337
338	var status Status
339	if err := parseResponse(resp, &status); err != nil {
340		return nil, err
341	}
342
343	return &status, nil
344}
345
346// RouteHealth represents the health status of a route.
347type RouteHealth struct {
348	ContainerStatus string `json:"container_status"`
349	HTTPStatus      int    `json:"http_status"`
350	ResponseTimeMs  int64  `json:"response_time_ms"`
351	Healthy         bool   `json:"healthy"`
352	Error           string `json:"error"`
353}
354
355// GetHealth returns health status for all routes with HTTP probing.
356func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
357	resp, err := c.request(ctx, http.MethodGet, "/health", nil)
358	if err != nil {
359		return nil, err
360	}
361
362	var result struct {
363		Health map[string]*RouteHealth `json:"health"`
364	}
365	if err := parseResponse(resp, &result); err != nil {
366		return nil, err
367	}
368
369	return result.Health, nil
370}
371
372// Reload triggers a configuration reload.
373func (c *Client) Reload(ctx context.Context) error {
374	resp, err := c.request(ctx, http.MethodPost, "/reload", nil)
375	if err != nil {
376		return err
377	}
378	return parseResponse(resp, nil)
379}
380
381// Config API
382
383// Config represents the Gordon configuration.
384type Config struct {
385	Server struct {
386		Port           int    `json:"port"`
387		RegistryPort   int    `json:"registry_port"`
388		RegistryDomain string `json:"registry_domain"`
389		DataDir        string `json:"data_dir"`
390	} `json:"server"`
391	AutoRoute struct {
392		Enabled bool `json:"enabled"`
393	} `json:"auto_route"`
394	NetworkIsolation struct {
395		Enabled bool   `json:"enabled"`
396		Prefix  string `json:"prefix"`
397	} `json:"network_isolation"`
398	Routes         []domain.Route    `json:"routes"`
399	ExternalRoutes map[string]string `json:"external_routes"`
400}
401
402// GetConfig returns the Gordon configuration.
403func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
404	resp, err := c.request(ctx, http.MethodGet, "/config", nil)
405	if err != nil {
406		return nil, err
407	}
408
409	var config Config
410	if err := parseResponse(resp, &config); err != nil {
411		return nil, err
412	}
413
414	return &config, nil
415}
416
417// Ping checks if the remote Gordon instance is reachable.
418func (c *Client) Ping(ctx context.Context) error {
419	_, err := c.GetStatus(ctx)
420	return err
421}
422
423// Deploy API
424
425// DeployResult contains the result of a deployment.
426type DeployResult struct {
427	Status      string `json:"status"`
428	ContainerID string `json:"container_id"`
429	Domain      string `json:"domain"`
430}
431
432// Deploy triggers a deployment for the specified domain.
433func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) {
434	resp, err := c.request(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil)
435	if err != nil {
436		return nil, err
437	}
438
439	var result DeployResult
440	if err := parseResponse(resp, &result); err != nil {
441		return nil, err
442	}
443
444	return &result, nil
445}
446
447// Restart API
448
449// RestartResult contains the result of a restart.
450type RestartResult struct {
451	Status string `json:"status"`
452	Domain string `json:"domain"`
453}
454
455// Restart triggers a container restart for the specified domain.
456func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*RestartResult, error) {
457	if restartDomain == "" {
458		return nil, fmt.Errorf("domain cannot be empty")
459	}
460	path := "/restart/" + url.PathEscape(restartDomain)
461	if withAttachments {
462		path += "?attachments=true"
463	}
464	resp, err := c.request(ctx, http.MethodPost, path, nil)
465	if err != nil {
466		return nil, err
467	}
468
469	var result RestartResult
470	if err := parseResponse(resp, &result); err != nil {
471		return nil, err
472	}
473
474	return &result, nil
475}
476
477// Tags API
478
479// ListTags returns available tags for a repository.
480func (c *Client) ListTags(ctx context.Context, repository string) ([]string, error) {
481	if repository == "" {
482		return nil, fmt.Errorf("repository cannot be empty")
483	}
484	resp, err := c.request(ctx, http.MethodGet, "/tags/"+url.PathEscape(repository), nil)
485	if err != nil {
486		return nil, err
487	}
488	var result struct {
489		Repository string   `json:"repository"`
490		Tags       []string `json:"tags"`
491	}
492	if err := parseResponse(resp, &result); err != nil {
493		return nil, err
494	}
495	return result.Tags, nil
496}
497
498// Logs API
499
500// GetProcessLogs returns Gordon process logs.
501func (c *Client) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
502	path := fmt.Sprintf("/logs?lines=%d", lines)
503	resp, err := c.request(ctx, http.MethodGet, path, nil)
504	if err != nil {
505		return nil, err
506	}
507
508	var result struct {
509		Lines []string `json:"lines"`
510	}
511	if err := parseResponse(resp, &result); err != nil {
512		return nil, err
513	}
514
515	return result.Lines, nil
516}
517
518// GetContainerLogs returns container logs for a specific domain.
519func (c *Client) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
520	path := fmt.Sprintf("/logs/%s?lines=%d", url.PathEscape(logDomain), lines)
521	resp, err := c.request(ctx, http.MethodGet, path, nil)
522	if err != nil {
523		return nil, err
524	}
525
526	var result struct {
527		Domain string   `json:"domain"`
528		Lines  []string `json:"lines"`
529	}
530	if err := parseResponse(resp, &result); err != nil {
531		return nil, err
532	}
533
534	return result.Lines, nil
535}
536
537// StreamProcessLogs returns a channel that streams Gordon process log lines via SSE.
538// The caller is responsible for reading from the channel until it's closed.
539func (c *Client) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
540	path := fmt.Sprintf("/logs?lines=%d&follow=true", lines)
541	return c.streamLogs(ctx, path)
542}
543
544// StreamContainerLogs returns a channel that streams container log lines via SSE.
545// The caller is responsible for reading from the channel until it's closed.
546func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
547	path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", url.PathEscape(logDomain), lines)
548	return c.streamLogs(ctx, path)
549}
550
551// Attachments Config API
552
553// GetAllAttachmentsConfig returns all configured attachments.
554func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
555	resp, err := c.request(ctx, http.MethodGet, "/attachments", nil)
556	if err != nil {
557		return nil, err
558	}
559
560	var result struct {
561		Attachments map[string][]string `json:"attachments"`
562	}
563	if err := parseResponse(resp, &result); err != nil {
564		return nil, err
565	}
566
567	return result.Attachments, nil
568}
569
570// GetAttachmentsConfig returns attachments for a specific domain/group from config.
571func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
572	if domainOrGroup == "" {
573		return nil, fmt.Errorf("domain or group is required")
574	}
575	path := "/attachments/" + url.PathEscape(domainOrGroup)
576	resp, err := c.request(ctx, http.MethodGet, path, nil)
577	if err != nil {
578		return nil, err
579	}
580
581	var result struct {
582		Target string   `json:"target"`
583		Images []string `json:"images"`
584	}
585	if err := parseResponse(resp, &result); err != nil {
586		return nil, err
587	}
588
589	return result.Images, nil
590}
591
592// AddAttachment adds an attachment to a domain/group.
593func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
594	if domainOrGroup == "" {
595		return fmt.Errorf("domain or group is required")
596	}
597	if image == "" {
598		return fmt.Errorf("image is required")
599	}
600	path := "/attachments/" + url.PathEscape(domainOrGroup)
601	resp, err := c.request(ctx, http.MethodPost, path, struct {
602		Image string `json:"image"`
603	}{Image: image})
604	if err != nil {
605		return err
606	}
607	return parseResponse(resp, nil)
608}
609
610// RemoveAttachment removes an attachment from a domain/group.
611func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
612	if domainOrGroup == "" {
613		return fmt.Errorf("domain or group is required")
614	}
615	if image == "" {
616		return fmt.Errorf("image is required")
617	}
618	path := "/attachments/" + url.PathEscape(domainOrGroup) + "/" + url.PathEscape(image)
619	resp, err := c.request(ctx, http.MethodDelete, path, nil)
620	if err != nil {
621		return err
622	}
623	return parseResponse(resp, nil)
624}
625
626// streamLogs handles SSE streaming for log endpoints.
627func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
628	url := c.baseURL + "/admin" + path
629
630	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
631	if err != nil {
632		return nil, fmt.Errorf("failed to create request: %w", err)
633	}
634
635	req.Header.Set("Accept", "text/event-stream")
636	if c.token != "" {
637		req.Header.Set("Authorization", "Bearer "+c.token)
638	}
639
640	// Use a client without timeout for streaming
641	streamClient := &http.Client{}
642	resp, err := streamClient.Do(req)
643	if err != nil {
644		return nil, fmt.Errorf("failed to connect: %w", err)
645	}
646
647	if resp.StatusCode >= 400 {
648		body, _ := io.ReadAll(resp.Body)
649		resp.Body.Close()
650		return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
651	}
652
653	ch := make(chan string, 100)
654
655	go func() {
656		defer close(ch)
657		defer resp.Body.Close()
658
659		buf := make([]byte, 4096)
660		var lineBuffer strings.Builder
661
662		for {
663			select {
664			case <-ctx.Done():
665				return
666			default:
667			}
668
669			n, err := resp.Body.Read(buf)
670			if n > 0 {
671				lineBuffer.Write(buf[:n])
672			}
673			if err != nil {
674				return // EOF or context cancellation — clean exit
675			}
676
677			// Process complete SSE events
678			for {
679				data := lineBuffer.String()
680				idx := strings.Index(data, "\n\n")
681				if idx == -1 {
682					break
683				}
684
685				event := data[:idx]
686				lineBuffer.Reset()
687				lineBuffer.WriteString(data[idx+2:])
688
689				// Parse SSE data lines
690				for _, line := range strings.Split(event, "\n") {
691					if strings.HasPrefix(line, "data: ") {
692						logLine := strings.TrimPrefix(line, "data: ")
693						select {
694						case ch <- logLine:
695						case <-ctx.Done():
696							return
697						}
698					}
699				}
700			}
701		}
702	}()
703
704	return ch, nil
705}
706
707// VerifyAuth checks if authentication session is valid.
708func (c *Client) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) {
709	resp, err := c.request(ctx, http.MethodGet, "/auth/verify", nil)
710	if err != nil {
711		return nil, err
712	}
713
714	var result dto.AuthVerifyResponse
715	if err := parseResponse(resp, &result); err != nil {
716		return nil, err
717	}
718
719	return &result, nil
720}

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