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}