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}