v2.4.2 7da87b2

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	"gordon/internal/domain"
16)
17
18// Client is an HTTP client for the Gordon admin API.
19type Client struct {
20	baseURL    string
21	token      string
22	httpClient *http.Client
23}
24
25// ClientOption configures the Client.
26type ClientOption func(*Client)
27
28// NewClient creates a new remote Gordon client.
29func NewClient(baseURL string, opts ...ClientOption) *Client {
30	// Normalize base URL
31	baseURL = strings.TrimSuffix(baseURL, "/")
32
33	c := &Client{
34		baseURL: baseURL,
35		httpClient: &http.Client{
36			Timeout: 30 * time.Second,
37		},
38	}
39
40	for _, opt := range opts {
41		opt(c)
42	}
43
44	return c
45}
46
47// WithToken sets the authentication token.
48func WithToken(token string) ClientOption {
49	return func(c *Client) {
50		c.token = token
51	}
52}
53
54// WithHTTPClient sets a custom HTTP client.
55func WithHTTPClient(client *http.Client) ClientOption {
56	return func(c *Client) {
57		c.httpClient = client
58	}
59}
60
61// WithTimeout sets the HTTP client timeout.
62func WithTimeout(timeout time.Duration) ClientOption {
63	return func(c *Client) {
64		c.httpClient.Timeout = timeout
65	}
66}
67
68// request performs an HTTP request to the admin API.
69func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
70	url := c.baseURL + "/admin" + path
71
72	var bodyReader io.Reader
73	if body != nil {
74		jsonBody, err := json.Marshal(body)
75		if err != nil {
76			return nil, fmt.Errorf("failed to marshal request body: %w", err)
77		}
78		bodyReader = bytes.NewReader(jsonBody)
79	}
80
81	req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
82	if err != nil {
83		return nil, fmt.Errorf("failed to create request: %w", err)
84	}
85
86	req.Header.Set("Content-Type", "application/json")
87	req.Header.Set("Accept", "application/json")
88
89	if c.token != "" {
90		req.Header.Set("Authorization", "Bearer "+c.token)
91	}
92
93	return c.httpClient.Do(req)
94}
95
96// parseResponse parses a JSON response into the given target.
97func parseResponse(resp *http.Response, target any) error {
98	defer resp.Body.Close()
99
100	if resp.StatusCode >= 400 {
101		body, _ := io.ReadAll(resp.Body)
102		var errResp struct {
103			Error string `json:"error"`
104		}
105		if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
106			return fmt.Errorf("%s: %s", resp.Status, errResp.Error)
107		}
108		return fmt.Errorf("%s: %s", resp.Status, string(body))
109	}
110
111	if target != nil {
112		if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
113			return fmt.Errorf("failed to decode response: %w", err)
114		}
115	}
116
117	return nil
118}
119
120// Routes API
121
122// ListRoutes returns all configured routes.
123func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
124	resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
125	if err != nil {
126		return nil, err
127	}
128
129	var result struct {
130		Routes []domain.Route `json:"routes"`
131	}
132	if err := parseResponse(resp, &result); err != nil {
133		return nil, err
134	}
135
136	return result.Routes, nil
137}
138
139// GetRoute returns a specific route by domain.
140func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
141	resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
142	if err != nil {
143		return nil, err
144	}
145
146	var route domain.Route
147	if err := parseResponse(resp, &route); err != nil {
148		return nil, err
149	}
150
151	return &route, nil
152}
153
154// AddRoute adds a new route.
155func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
156	resp, err := c.request(ctx, http.MethodPost, "/routes", route)
157	if err != nil {
158		return err
159	}
160	return parseResponse(resp, nil)
161}
162
163// UpdateRoute updates an existing route.
164func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
165	resp, err := c.request(ctx, http.MethodPut, "/routes/"+url.PathEscape(route.Domain), route)
166	if err != nil {
167		return err
168	}
169	return parseResponse(resp, nil)
170}
171
172// RemoveRoute removes a route by domain.
173func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
174	resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)
175	if err != nil {
176		return err
177	}
178	return parseResponse(resp, nil)
179}
180
181// Secrets API
182
183// ListSecrets returns the list of secret keys for a domain.
184func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
185	resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
186	if err != nil {
187		return nil, err
188	}
189
190	var result struct {
191		Domain string   `json:"domain"`
192		Keys   []string `json:"keys"`
193	}
194	if err := parseResponse(resp, &result); err != nil {
195		return nil, err
196	}
197
198	return result.Keys, nil
199}
200
201// SetSecrets sets secrets for a domain.
202func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
203	resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets)
204	if err != nil {
205		return err
206	}
207	return parseResponse(resp, nil)
208}
209
210// DeleteSecret removes a secret from a domain.
211func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {
212	resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil)
213	if err != nil {
214		return err
215	}
216	return parseResponse(resp, nil)
217}
218
219// Status API
220
221// Status represents the Gordon server status.
222type Status struct {
223	Routes           int               `json:"routes"`
224	RegistryDomain   string            `json:"registry_domain"`
225	RegistryPort     int               `json:"registry_port"`
226	ServerPort       int               `json:"server_port"`
227	AutoRoute        bool              `json:"auto_route"`
228	NetworkIsolation bool              `json:"network_isolation"`
229	ContainerStatus  map[string]string `json:"container_status"`
230}
231
232// GetStatus returns the Gordon server status.
233func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
234	resp, err := c.request(ctx, http.MethodGet, "/status", nil)
235	if err != nil {
236		return nil, err
237	}
238
239	var status Status
240	if err := parseResponse(resp, &status); err != nil {
241		return nil, err
242	}
243
244	return &status, nil
245}
246
247// RouteHealth represents the health status of a route.
248type RouteHealth struct {
249	ContainerStatus string `json:"container_status"`
250	HTTPStatus      int    `json:"http_status"`
251	ResponseTimeMs  int64  `json:"response_time_ms"`
252	Healthy         bool   `json:"healthy"`
253	Error           string `json:"error"`
254}
255
256// GetHealth returns health status for all routes with HTTP probing.
257func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
258	resp, err := c.request(ctx, http.MethodGet, "/health", nil)
259	if err != nil {
260		return nil, err
261	}
262
263	var result struct {
264		Health map[string]*RouteHealth `json:"health"`
265	}
266	if err := parseResponse(resp, &result); err != nil {
267		return nil, err
268	}
269
270	return result.Health, nil
271}
272
273// Reload triggers a configuration reload.
274func (c *Client) Reload(ctx context.Context) error {
275	resp, err := c.request(ctx, http.MethodPost, "/reload", nil)
276	if err != nil {
277		return err
278	}
279	return parseResponse(resp, nil)
280}
281
282// Config API
283
284// Config represents the Gordon configuration.
285type Config struct {
286	Server struct {
287		Port           int    `json:"port"`
288		RegistryPort   int    `json:"registry_port"`
289		RegistryDomain string `json:"registry_domain"`
290		DataDir        string `json:"data_dir"`
291	} `json:"server"`
292	AutoRoute struct {
293		Enabled bool `json:"enabled"`
294	} `json:"auto_route"`
295	NetworkIsolation struct {
296		Enabled bool   `json:"enabled"`
297		Prefix  string `json:"prefix"`
298	} `json:"network_isolation"`
299	Routes         []domain.Route    `json:"routes"`
300	ExternalRoutes map[string]string `json:"external_routes"`
301}
302
303// GetConfig returns the Gordon configuration.
304func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
305	resp, err := c.request(ctx, http.MethodGet, "/config", nil)
306	if err != nil {
307		return nil, err
308	}
309
310	var config Config
311	if err := parseResponse(resp, &config); err != nil {
312		return nil, err
313	}
314
315	return &config, nil
316}
317
318// Ping checks if the remote Gordon instance is reachable.
319func (c *Client) Ping(ctx context.Context) error {
320	_, err := c.GetStatus(ctx)
321	return err
322}
323
324// Deploy API
325
326// DeployResult contains the result of a deployment.
327type DeployResult struct {
328	Status      string `json:"status"`
329	ContainerID string `json:"container_id"`
330	Domain      string `json:"domain"`
331}
332
333// Deploy triggers a deployment for the specified domain.
334func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) {
335	resp, err := c.request(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil)
336	if err != nil {
337		return nil, err
338	}
339
340	var result DeployResult
341	if err := parseResponse(resp, &result); err != nil {
342		return nil, err
343	}
344
345	return &result, nil
346}
347
348// Logs API
349
350// GetProcessLogs returns Gordon process logs.
351func (c *Client) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
352	path := fmt.Sprintf("/logs?lines=%d", lines)
353	resp, err := c.request(ctx, http.MethodGet, path, nil)
354	if err != nil {
355		return nil, err
356	}
357
358	var result struct {
359		Lines []string `json:"lines"`
360	}
361	if err := parseResponse(resp, &result); err != nil {
362		return nil, err
363	}
364
365	return result.Lines, nil
366}
367
368// GetContainerLogs returns container logs for a specific domain.
369func (c *Client) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
370	path := fmt.Sprintf("/logs/%s?lines=%d", url.PathEscape(logDomain), lines)
371	resp, err := c.request(ctx, http.MethodGet, path, nil)
372	if err != nil {
373		return nil, err
374	}
375
376	var result struct {
377		Domain string   `json:"domain"`
378		Lines  []string `json:"lines"`
379	}
380	if err := parseResponse(resp, &result); err != nil {
381		return nil, err
382	}
383
384	return result.Lines, nil
385}
386
387// StreamProcessLogs returns a channel that streams Gordon process log lines via SSE.
388// The caller is responsible for reading from the channel until it's closed.
389func (c *Client) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
390	path := fmt.Sprintf("/logs?lines=%d&follow=true", lines)
391	return c.streamLogs(ctx, path)
392}
393
394// StreamContainerLogs returns a channel that streams container log lines via SSE.
395// The caller is responsible for reading from the channel until it's closed.
396func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
397	path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", url.PathEscape(logDomain), lines)
398	return c.streamLogs(ctx, path)
399}
400
401// streamLogs handles SSE streaming for log endpoints.
402func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
403	url := c.baseURL + "/admin" + path
404
405	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
406	if err != nil {
407		return nil, fmt.Errorf("failed to create request: %w", err)
408	}
409
410	req.Header.Set("Accept", "text/event-stream")
411	if c.token != "" {
412		req.Header.Set("Authorization", "Bearer "+c.token)
413	}
414
415	// Use a client without timeout for streaming
416	streamClient := &http.Client{}
417	resp, err := streamClient.Do(req)
418	if err != nil {
419		return nil, fmt.Errorf("failed to connect: %w", err)
420	}
421
422	if resp.StatusCode >= 400 {
423		body, _ := io.ReadAll(resp.Body)
424		resp.Body.Close()
425		return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
426	}
427
428	ch := make(chan string, 100)
429
430	go func() {
431		defer close(ch)
432		defer resp.Body.Close()
433
434		buf := make([]byte, 4096)
435		var lineBuffer strings.Builder
436
437		for {
438			select {
439			case <-ctx.Done():
440				return
441			default:
442			}
443
444			n, err := resp.Body.Read(buf)
445			if err != nil {
446				// EOF is expected when stream ends, other errors are ignored
447				return
448			}
449
450			lineBuffer.Write(buf[:n])
451
452			// Process complete SSE events
453			for {
454				data := lineBuffer.String()
455				idx := strings.Index(data, "\n\n")
456				if idx == -1 {
457					break
458				}
459
460				event := data[:idx]
461				lineBuffer.Reset()
462				lineBuffer.WriteString(data[idx+2:])
463
464				// Parse SSE data lines
465				for _, line := range strings.Split(event, "\n") {
466					if strings.HasPrefix(line, "data: ") {
467						logLine := strings.TrimPrefix(line, "data: ")
468						select {
469						case ch <- logLine:
470						case <-ctx.Done():
471							return
472						}
473					}
474				}
475			}
476		}
477	}()
478
479	return ch, nil
480}

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