detached 090e7ba

Merge pull request #23 from bnema/feature/bidirectional-cli-commands

feat(cli): add bidirectional local/remote support for all commands
commit
090e7ba8718652e607c43a00512e70b5c6f4e2c5
author
bnema · · 2026-01-17 07:24 UTC
committer
GitHub · · 2026-01-17 07:24 UTC
merge of
770d3a7 + 0b67829 · diff against the first parent
signature
unsigned
15 files · +1879 -156
M .mockery.yaml +1 -0
@@ -31,3 +31,4 @@ packages:
3131 HealthService:
3232 HTTPProber:
3333 SecretService:
34+ LogService:
@@ -31,3 +31,4 @@ packages:
31 HealthService:31 HealthService:
32 HTTPProber:32 HTTPProber:
33 SecretService:33 SecretService:
34+ LogService:
M docs/cli/commands.md +60 -23
@@ -9,11 +9,11 @@ Gordon provides a command-line interface for server management, deployment, and
99 | `gordon serve` | Start the Gordon server |
1010 | `gordon reload` | Reload configuration and sync containers |
1111 | `gordon deploy` | Manually deploy or redeploy a specific route |
12-| `gordon logs` | Display Gordon process logs |
12+| `gordon logs` | Display Gordon process or container logs |
1313 | `gordon version` | Print version information |
1414 | `gordon auth` | Manage registry authentication |
1515 | `gordon routes` | Manage routes (local or remote) |
16-| `gordon secrets` | Manage secrets (remote only) |
16+| `gordon secrets` | Manage secrets (local or remote) |
1717 | `gordon remotes` | Manage saved remote Gordon instances |
1818
1919 ## Quick Reference
@@ -30,9 +30,11 @@ gordon reload
3030 gordon deploy myapp.example.com
3131
3232 # View logs
33-gordon logs
34-gordon logs -f
35-gordon logs -n 100
33+gordon logs # Gordon process logs
34+gordon logs -f # Follow process logs
35+gordon logs -n 100 # Last 100 lines
36+gordon logs myapp.local # Container logs for myapp.local
37+gordon logs myapp.local -f # Follow container logs
3638
3739 # Check version
3840 gordon version
@@ -225,7 +227,7 @@ Manually deploy or redeploy a specific route.
225227 ### Synopsis
226228
227229 ```bash
228-gordon deploy <domain>
230+gordon deploy <domain> [options]
229231 ```
230232
231233 ### Arguments
@@ -234,9 +236,20 @@ gordon deploy <domain>
234236 |----------|-------------|
235237 | `<domain>` | The domain name of the route to deploy (required) |
236238
239+### Options
240+
241+| Option | Description |
242+|--------|-------------|
243+| `--remote` | Remote Gordon URL |
244+| `--token` | Authentication token for remote |
245+
237246 ### Description
238247
239-Sends `SIGUSR2` to the Gordon process with the specified domain, triggering:
248+**Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain.
249+
250+**Remote mode:** Calls the remote Gordon Admin API to trigger deployment.
251+
252+Both modes trigger:
240253
241254 - Fresh image pull (always pulls latest, ignoring cache)
242255 - Container redeployment for the specified route
@@ -244,8 +257,12 @@ Sends `SIGUSR2` to the Gordon process with the specified domain, triggering:
244257 ### Examples
245258
246259 ```bash
260+# Local deployment
247261 gordon deploy myapp.example.com
248262 gordon deploy api.example.com
263+
264+# Remote deployment
265+gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
249266 ```
250267
251268 ### Use Cases
@@ -253,19 +270,26 @@ gordon deploy api.example.com
253270 - Recover from a failed deployment
254271 - Force redeploy without pushing a new image
255272 - Manual deployment when automatic deploy didn't trigger
273+- Trigger deployments on remote Gordon instances from CI/CD
256274
257275 ---
258276
259277 ## gordon logs
260278
261-Display Gordon process logs.
279+Display Gordon process logs or container logs.
262280
263281 ### Synopsis
264282
265283 ```bash
266-gordon logs [options]
284+gordon logs [domain] [options]
267285 ```
268286
287+### Arguments
288+
289+| Argument | Description |
290+|----------|-------------|
291+| `[domain]` | Optional. Container domain to view logs for. Without this, shows Gordon process logs. |
292+
269293 ### Options
270294
271295 | Option | Short | Default | Description |
@@ -273,14 +297,26 @@ gordon logs [options]
273297 | `--config` | `-c` | Auto | Path to config file |
274298 | `--follow` | `-f` | false | Follow log output (like `tail -f`) |
275299 | `--lines` | `-n` | 50 | Number of lines to show |
300+| `--remote` | | | Remote Gordon URL |
301+| `--token` | | | Authentication token for remote |
276302
277303 ### Examples
278304
279305 ```bash
306+# Gordon process logs
280307 gordon logs # Last 50 lines
281308 gordon logs -f # Follow logs
282309 gordon logs -n 100 # Last 100 lines
283310 gordon logs -f -n 200 # Follow, starting from last 200 lines
311+
312+# Container logs
313+gordon logs myapp.local # Last 50 lines from container
314+gordon logs myapp.local -f # Follow container logs
315+gordon logs myapp.local -n 100 # Last 100 lines from container
316+
317+# Remote mode
318+gordon logs --remote https://gordon.mydomain.com --token $TOKEN
319+gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN
284320 ```
285321
286322 ### Log Locations
@@ -294,6 +330,10 @@ tail -f ~/.gordon/logs/gordon.log
294330
295331 # With systemd
296332 journalctl --user -u gordon -f
333+
334+# Container logs via docker (local alternative)
335+docker logs --tail 50 myapp.local
336+docker logs -f myapp.local
297337 ```
298338
299339 ---
@@ -603,31 +643,28 @@ gordon routes deploy myapp.example.com
603643
604644 ## gordon secrets
605645
606-Manage secrets on remote Gordon instances. Requires remote targeting.
646+Manage secrets on local or remote Gordon instances.
607647
608648 ### Subcommands
609649
610650 | Subcommand | Description |
611651 |------------|-------------|
612-| `list` | List all secrets |
613-| `get` | Get a secret value |
652+| `list` | List all secrets for a domain |
614653 | `set` | Set a secret value |
615-| `delete` | Delete a secret |
654+| `remove` | Remove a secret |
616655
617656 ### Examples
618657
619658 ```bash
620-# List secrets
621-gordon secrets list --remote https://gordon.mydomain.com --token $TOKEN
622-
623-# Set a secret
624-gordon secrets set DATABASE_URL "postgres://..." --remote ... --token ...
625-
626-# Get a secret
627-gordon secrets get DATABASE_URL --remote ... --token ...
659+# Local mode (reads/writes secrets directly)
660+gordon secrets list myapp.local
661+gordon secrets set myapp.local DATABASE_URL "postgres://..."
662+gordon secrets remove myapp.local DATABASE_URL
628663
629-# Delete a secret
630-gordon secrets delete DATABASE_URL --remote ... --token ...
664+# Remote mode
665+gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TOKEN
666+gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote ... --token ...
667+gordon secrets remove myapp.local DATABASE_URL --remote ... --token ...
631668 ```
632669
633670 ---
@@ -9,11 +9,11 @@ Gordon provides a command-line interface for server management, deployment, and
9 | `gordon serve` | Start the Gordon server |9 | `gordon serve` | Start the Gordon server |
10 | `gordon reload` | Reload configuration and sync containers |10 | `gordon reload` | Reload configuration and sync containers |
11 | `gordon deploy` | Manually deploy or redeploy a specific route |11 | `gordon deploy` | Manually deploy or redeploy a specific route |
12-| `gordon logs` | Display Gordon process logs |12+| `gordon logs` | Display Gordon process or container logs |
13 | `gordon version` | Print version information |13 | `gordon version` | Print version information |
14 | `gordon auth` | Manage registry authentication |14 | `gordon auth` | Manage registry authentication |
15 | `gordon routes` | Manage routes (local or remote) |15 | `gordon routes` | Manage routes (local or remote) |
16-| `gordon secrets` | Manage secrets (remote only) |16+| `gordon secrets` | Manage secrets (local or remote) |
17 | `gordon remotes` | Manage saved remote Gordon instances |17 | `gordon remotes` | Manage saved remote Gordon instances |
18 18
19 ## Quick Reference19 ## Quick Reference
@@ -30,9 +30,11 @@ gordon reload
30 gordon deploy myapp.example.com30 gordon deploy myapp.example.com
31 31
32 # View logs32 # View logs
33-gordon logs33+gordon logs # Gordon process logs
34-gordon logs -f34+gordon logs -f # Follow process logs
35-gordon logs -n 10035+gordon logs -n 100 # Last 100 lines
36+gordon logs myapp.local # Container logs for myapp.local
37+gordon logs myapp.local -f # Follow container logs
36 38
37 # Check version39 # Check version
38 gordon version40 gordon version
@@ -225,7 +227,7 @@ Manually deploy or redeploy a specific route.
225 ### Synopsis227 ### Synopsis
226 228
227 ```bash229 ```bash
228-gordon deploy <domain>230+gordon deploy <domain> [options]
229 ```231 ```
230 232
231 ### Arguments233 ### Arguments
@@ -234,9 +236,20 @@ gordon deploy <domain>
234 |----------|-------------|236 |----------|-------------|
235 | `<domain>` | The domain name of the route to deploy (required) |237 | `<domain>` | The domain name of the route to deploy (required) |
236 238
239+### Options
240+
241+| Option | Description |
242+|--------|-------------|
243+| `--remote` | Remote Gordon URL |
244+| `--token` | Authentication token for remote |
245+
237 ### Description246 ### Description
238 247
239-Sends `SIGUSR2` to the Gordon process with the specified domain, triggering:248+**Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain.
249+
250+**Remote mode:** Calls the remote Gordon Admin API to trigger deployment.
251+
252+Both modes trigger:
240 253
241 - Fresh image pull (always pulls latest, ignoring cache)254 - Fresh image pull (always pulls latest, ignoring cache)
242 - Container redeployment for the specified route255 - Container redeployment for the specified route
@@ -244,8 +257,12 @@ Sends `SIGUSR2` to the Gordon process with the specified domain, triggering:
244 ### Examples257 ### Examples
245 258
246 ```bash259 ```bash
260+# Local deployment
247 gordon deploy myapp.example.com261 gordon deploy myapp.example.com
248 gordon deploy api.example.com262 gordon deploy api.example.com
263+
264+# Remote deployment
265+gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
249 ```266 ```
250 267
251 ### Use Cases268 ### Use Cases
@@ -253,19 +270,26 @@ gordon deploy api.example.com
253 - Recover from a failed deployment270 - Recover from a failed deployment
254 - Force redeploy without pushing a new image271 - Force redeploy without pushing a new image
255 - Manual deployment when automatic deploy didn't trigger272 - Manual deployment when automatic deploy didn't trigger
273+- Trigger deployments on remote Gordon instances from CI/CD
256 274
257 ---275 ---
258 276
259 ## gordon logs277 ## gordon logs
260 278
261-Display Gordon process logs.279+Display Gordon process logs or container logs.
262 280
263 ### Synopsis281 ### Synopsis
264 282
265 ```bash283 ```bash
266-gordon logs [options]284+gordon logs [domain] [options]
267 ```285 ```
268 286
287+### Arguments
288+
289+| Argument | Description |
290+|----------|-------------|
291+| `[domain]` | Optional. Container domain to view logs for. Without this, shows Gordon process logs. |
292+
269 ### Options293 ### Options
270 294
271 | Option | Short | Default | Description |295 | Option | Short | Default | Description |
@@ -273,14 +297,26 @@ gordon logs [options]
273 | `--config` | `-c` | Auto | Path to config file |297 | `--config` | `-c` | Auto | Path to config file |
274 | `--follow` | `-f` | false | Follow log output (like `tail -f`) |298 | `--follow` | `-f` | false | Follow log output (like `tail -f`) |
275 | `--lines` | `-n` | 50 | Number of lines to show |299 | `--lines` | `-n` | 50 | Number of lines to show |
300+| `--remote` | | | Remote Gordon URL |
301+| `--token` | | | Authentication token for remote |
276 302
277 ### Examples303 ### Examples
278 304
279 ```bash305 ```bash
306+# Gordon process logs
280 gordon logs # Last 50 lines307 gordon logs # Last 50 lines
281 gordon logs -f # Follow logs308 gordon logs -f # Follow logs
282 gordon logs -n 100 # Last 100 lines309 gordon logs -n 100 # Last 100 lines
283 gordon logs -f -n 200 # Follow, starting from last 200 lines310 gordon logs -f -n 200 # Follow, starting from last 200 lines
311+
312+# Container logs
313+gordon logs myapp.local # Last 50 lines from container
314+gordon logs myapp.local -f # Follow container logs
315+gordon logs myapp.local -n 100 # Last 100 lines from container
316+
317+# Remote mode
318+gordon logs --remote https://gordon.mydomain.com --token $TOKEN
319+gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN
284 ```320 ```
285 321
286 ### Log Locations322 ### Log Locations
@@ -294,6 +330,10 @@ tail -f ~/.gordon/logs/gordon.log
294 330
295 # With systemd331 # With systemd
296 journalctl --user -u gordon -f332 journalctl --user -u gordon -f
333+
334+# Container logs via docker (local alternative)
335+docker logs --tail 50 myapp.local
336+docker logs -f myapp.local
297 ```337 ```
298 338
299 ---339 ---
@@ -603,31 +643,28 @@ gordon routes deploy myapp.example.com
603 643
604 ## gordon secrets644 ## gordon secrets
605 645
606-Manage secrets on remote Gordon instances. Requires remote targeting.646+Manage secrets on local or remote Gordon instances.
607 647
608 ### Subcommands648 ### Subcommands
609 649
610 | Subcommand | Description |650 | Subcommand | Description |
611 |------------|-------------|651 |------------|-------------|
612-| `list` | List all secrets |652+| `list` | List all secrets for a domain |
613-| `get` | Get a secret value |
614 | `set` | Set a secret value |653 | `set` | Set a secret value |
615-| `delete` | Delete a secret |654+| `remove` | Remove a secret |
616 655
617 ### Examples656 ### Examples
618 657
619 ```bash658 ```bash
620-# List secrets659+# Local mode (reads/writes secrets directly)
621-gordon secrets list --remote https://gordon.mydomain.com --token $TOKEN660+gordon secrets list myapp.local
622-661+gordon secrets set myapp.local DATABASE_URL "postgres://..."
623-# Set a secret662+gordon secrets remove myapp.local DATABASE_URL
624-gordon secrets set DATABASE_URL "postgres://..." --remote ... --token ...
625-
626-# Get a secret
627-gordon secrets get DATABASE_URL --remote ... --token ...
628 663
629-# Delete a secret664+# Remote mode
630-gordon secrets delete DATABASE_URL --remote ... --token ...665+gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TOKEN
666+gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote ... --token ...
667+gordon secrets remove myapp.local DATABASE_URL --remote ... --token ...
631 ```668 ```
632 669
633 ---670 ---
M internal/adapters/in/cli/deploy.go +25 -3
@@ -1,10 +1,12 @@
11 package cli
22
33 import (
4+ "context"
45 "fmt"
56
67 "github.com/spf13/cobra"
78
9+ "gordon/internal/adapters/in/cli/ui/styles"
810 "gordon/internal/app"
911 )
1012
@@ -21,14 +23,34 @@ even if a container is already running.
2123
2224 Examples:
2325 gordon deploy myapp.example.com
24- gordon deploy api.example.com`,
26+ gordon deploy api.example.com
27+ gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN`,
2528 Args: cobra.ExactArgs(1),
2629 RunE: func(cmd *cobra.Command, args []string) error {
27- domain, err := app.SendDeploySignal(args[0])
30+ ctx := context.Background()
31+ deployDomain := args[0]
32+
33+ client, isRemote := GetRemoteClient()
34+ if isRemote {
35+ // Remote deployment via admin API
36+ result, err := client.Deploy(ctx, deployDomain)
37+ if err != nil {
38+ return fmt.Errorf("failed to deploy: %w", err)
39+ }
40+ containerID := result.ContainerID
41+ if len(containerID) > 12 {
42+ containerID = containerID[:12]
43+ }
44+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", deployDomain, containerID)))
45+ return nil
46+ }
47+
48+ // Local deployment via signal
49+ domain, err := app.SendDeploySignal(deployDomain)
2850 if err != nil {
2951 return err
3052 }
31- fmt.Printf("Deploy signal sent for domain: %s\n", domain)
53+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain)))
3254 return nil
3355 },
3456 }
@@ -1,10 +1,12 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 6
6 "github.com/spf13/cobra"7 "github.com/spf13/cobra"
7 8
9+ "gordon/internal/adapters/in/cli/ui/styles"
8 "gordon/internal/app"10 "gordon/internal/app"
9 )11 )
10 12
@@ -21,14 +23,34 @@ even if a container is already running.
21 23
22 Examples:24 Examples:
23 gordon deploy myapp.example.com25 gordon deploy myapp.example.com
24- gordon deploy api.example.com`,26+ gordon deploy api.example.com
27+ gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN`,
25 Args: cobra.ExactArgs(1),28 Args: cobra.ExactArgs(1),
26 RunE: func(cmd *cobra.Command, args []string) error {29 RunE: func(cmd *cobra.Command, args []string) error {
27- domain, err := app.SendDeploySignal(args[0])30+ ctx := context.Background()
31+ deployDomain := args[0]
32+
33+ client, isRemote := GetRemoteClient()
34+ if isRemote {
35+ // Remote deployment via admin API
36+ result, err := client.Deploy(ctx, deployDomain)
37+ if err != nil {
38+ return fmt.Errorf("failed to deploy: %w", err)
39+ }
40+ containerID := result.ContainerID
41+ if len(containerID) > 12 {
42+ containerID = containerID[:12]
43+ }
44+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", deployDomain, containerID)))
45+ return nil
46+ }
47+
48+ // Local deployment via signal
49+ domain, err := app.SendDeploySignal(deployDomain)
28 if err != nil {50 if err != nil {
29 return err51 return err
30 }52 }
31- fmt.Printf("Deploy signal sent for domain: %s\n", domain)53+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain)))
32 return nil54 return nil
33 },55 },
34 }56 }
A internal/adapters/in/cli/local.go +97 -0
@@ -0,0 +1,97 @@
1+// Package cli implements the CLI adapter for Gordon.
2+package cli
3+
4+import (
5+ "context"
6+ "fmt"
7+ "path/filepath"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/spf13/viper"
11+
12+ "gordon/internal/adapters/out/domainsecrets"
13+ "gordon/internal/app"
14+ "gordon/internal/boundaries/in"
15+ "gordon/internal/usecase/config"
16+ secretsSvc "gordon/internal/usecase/secrets"
17+)
18+
19+// LocalServices provides direct access to local services for CLI operations.
20+type LocalServices struct {
21+ configSvc in.ConfigService
22+ secretSvc in.SecretService
23+ dataDir string
24+}
25+
26+// GetConfigService returns the config service.
27+func (l *LocalServices) GetConfigService() in.ConfigService {
28+ return l.configSvc
29+}
30+
31+// GetSecretService returns the secret service.
32+func (l *LocalServices) GetSecretService() in.SecretService {
33+ return l.secretSvc
34+}
35+
36+// GetDataDir returns the data directory.
37+func (l *LocalServices) GetDataDir() string {
38+ return l.dataDir
39+}
40+
41+// GetLocalServices creates local services for CLI operations.
42+// It loads the config and initializes services without starting the server.
43+func GetLocalServices(configPath string) (*LocalServices, error) {
44+ // Set up viper with defaults
45+ v := viper.New()
46+ v.SetDefault("server.port", 80)
47+ v.SetDefault("server.registry_port", 5000)
48+ v.SetDefault("server.data_dir", app.DefaultDataDir())
49+
50+ // Configure viper with config file paths
51+ app.ConfigureViper(v, configPath)
52+
53+ // Read config file
54+ if err := v.ReadInConfig(); err != nil {
55+ if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
56+ return nil, fmt.Errorf("failed to read config file: %w", err)
57+ }
58+ // Config file not found is OK for some operations
59+ }
60+
61+ // Create a minimal logger for CLI operations
62+ log := zerowrap.New(zerowrap.Config{
63+ Level: "warn",
64+ Format: "console",
65+ })
66+
67+ // Create config service (without event bus for CLI operations)
68+ configSvc := config.NewService(v, nil)
69+ ctx := context.Background()
70+ if err := configSvc.Load(ctx); err != nil {
71+ return nil, fmt.Errorf("failed to load configuration: %w", err)
72+ }
73+
74+ // Determine data directory and env directory
75+ dataDir := v.GetString("server.data_dir")
76+ if dataDir == "" {
77+ dataDir = app.DefaultDataDir()
78+ }
79+
80+ envDir := v.GetString("env.dir")
81+ if envDir == "" {
82+ envDir = filepath.Join(dataDir, "env")
83+ }
84+
85+ // Create domain secret store and service
86+ domainSecretStore, err := domainsecrets.NewFileStore(envDir, log)
87+ if err != nil {
88+ return nil, fmt.Errorf("failed to create domain secret store: %w", err)
89+ }
90+ secretSvc := secretsSvc.NewService(domainSecretStore, log)
91+
92+ return &LocalServices{
93+ configSvc: configSvc,
94+ secretSvc: secretSvc,
95+ dataDir: dataDir,
96+ }, nil
97+}
@@ -0,0 +1,97 @@
1+// Package cli implements the CLI adapter for Gordon.
2+package cli
3+
4+import (
5+ "context"
6+ "fmt"
7+ "path/filepath"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/spf13/viper"
11+
12+ "gordon/internal/adapters/out/domainsecrets"
13+ "gordon/internal/app"
14+ "gordon/internal/boundaries/in"
15+ "gordon/internal/usecase/config"
16+ secretsSvc "gordon/internal/usecase/secrets"
17+)
18+
19+// LocalServices provides direct access to local services for CLI operations.
20+type LocalServices struct {
21+ configSvc in.ConfigService
22+ secretSvc in.SecretService
23+ dataDir string
24+}
25+
26+// GetConfigService returns the config service.
27+func (l *LocalServices) GetConfigService() in.ConfigService {
28+ return l.configSvc
29+}
30+
31+// GetSecretService returns the secret service.
32+func (l *LocalServices) GetSecretService() in.SecretService {
33+ return l.secretSvc
34+}
35+
36+// GetDataDir returns the data directory.
37+func (l *LocalServices) GetDataDir() string {
38+ return l.dataDir
39+}
40+
41+// GetLocalServices creates local services for CLI operations.
42+// It loads the config and initializes services without starting the server.
43+func GetLocalServices(configPath string) (*LocalServices, error) {
44+ // Set up viper with defaults
45+ v := viper.New()
46+ v.SetDefault("server.port", 80)
47+ v.SetDefault("server.registry_port", 5000)
48+ v.SetDefault("server.data_dir", app.DefaultDataDir())
49+
50+ // Configure viper with config file paths
51+ app.ConfigureViper(v, configPath)
52+
53+ // Read config file
54+ if err := v.ReadInConfig(); err != nil {
55+ if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
56+ return nil, fmt.Errorf("failed to read config file: %w", err)
57+ }
58+ // Config file not found is OK for some operations
59+ }
60+
61+ // Create a minimal logger for CLI operations
62+ log := zerowrap.New(zerowrap.Config{
63+ Level: "warn",
64+ Format: "console",
65+ })
66+
67+ // Create config service (without event bus for CLI operations)
68+ configSvc := config.NewService(v, nil)
69+ ctx := context.Background()
70+ if err := configSvc.Load(ctx); err != nil {
71+ return nil, fmt.Errorf("failed to load configuration: %w", err)
72+ }
73+
74+ // Determine data directory and env directory
75+ dataDir := v.GetString("server.data_dir")
76+ if dataDir == "" {
77+ dataDir = app.DefaultDataDir()
78+ }
79+
80+ envDir := v.GetString("env.dir")
81+ if envDir == "" {
82+ envDir = filepath.Join(dataDir, "env")
83+ }
84+
85+ // Create domain secret store and service
86+ domainSecretStore, err := domainsecrets.NewFileStore(envDir, log)
87+ if err != nil {
88+ return nil, fmt.Errorf("failed to create domain secret store: %w", err)
89+ }
90+ secretSvc := secretsSvc.NewService(domainSecretStore, log)
91+
92+ return &LocalServices{
93+ configSvc: configSvc,
94+ secretSvc: secretSvc,
95+ dataDir: dataDir,
96+ }, nil
97+}
M internal/adapters/in/cli/remote/client.go +165 -6
@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "io"
1010 "net/http"
11+ "net/url"
1112 "strings"
1213 "time"
1314
@@ -137,7 +138,7 @@ func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
137138
138139 // GetRoute returns a specific route by domain.
139140 func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
140- resp, err := c.request(ctx, http.MethodGet, "/routes/"+routeDomain, nil)
141+ resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
141142 if err != nil {
142143 return nil, err
143144 }
@@ -161,7 +162,7 @@ func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
161162
162163 // UpdateRoute updates an existing route.
163164 func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
164- resp, err := c.request(ctx, http.MethodPut, "/routes/"+route.Domain, route)
165+ resp, err := c.request(ctx, http.MethodPut, "/routes/"+url.PathEscape(route.Domain), route)
165166 if err != nil {
166167 return err
167168 }
@@ -170,7 +171,7 @@ func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
170171
171172 // RemoveRoute removes a route by domain.
172173 func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
173- resp, err := c.request(ctx, http.MethodDelete, "/routes/"+routeDomain, nil)
174+ resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)
174175 if err != nil {
175176 return err
176177 }
@@ -181,7 +182,7 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
181182
182183 // ListSecrets returns the list of secret keys for a domain.
183184 func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
184- resp, err := c.request(ctx, http.MethodGet, "/secrets/"+secretDomain, nil)
185+ resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
185186 if err != nil {
186187 return nil, err
187188 }
@@ -199,7 +200,7 @@ func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string
199200
200201 // SetSecrets sets secrets for a domain.
201202 func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
202- resp, err := c.request(ctx, http.MethodPost, "/secrets/"+secretDomain, secrets)
203+ resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets)
203204 if err != nil {
204205 return err
205206 }
@@ -208,7 +209,7 @@ func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets ma
208209
209210 // DeleteSecret removes a secret from a domain.
210211 func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {
211- resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+secretDomain+"/"+key, nil)
212+ resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil)
212213 if err != nil {
213214 return err
214215 }
@@ -319,3 +320,161 @@ func (c *Client) Ping(ctx context.Context) error {
319320 _, err := c.GetStatus(ctx)
320321 return err
321322 }
323+
324+// Deploy API
325+
326+// DeployResult contains the result of a deployment.
327+type 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.
334+func (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.
351+func (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.
369+func (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.
389+func (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.
396+func (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.
402+func (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+}
@@ -8,6 +8,7 @@ import (
8 "fmt"8 "fmt"
9 "io"9 "io"
10 "net/http"10 "net/http"
11+ "net/url"
11 "strings"12 "strings"
12 "time"13 "time"
13 14
@@ -137,7 +138,7 @@ func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
137 138
138 // GetRoute returns a specific route by domain.139 // GetRoute returns a specific route by domain.
139 func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {140 func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
140- resp, err := c.request(ctx, http.MethodGet, "/routes/"+routeDomain, nil)141+ resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil)
141 if err != nil {142 if err != nil {
142 return nil, err143 return nil, err
143 }144 }
@@ -161,7 +162,7 @@ func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
161 162
162 // UpdateRoute updates an existing route.163 // UpdateRoute updates an existing route.
163 func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {164 func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
164- resp, err := c.request(ctx, http.MethodPut, "/routes/"+route.Domain, route)165+ resp, err := c.request(ctx, http.MethodPut, "/routes/"+url.PathEscape(route.Domain), route)
165 if err != nil {166 if err != nil {
166 return err167 return err
167 }168 }
@@ -170,7 +171,7 @@ func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
170 171
171 // RemoveRoute removes a route by domain.172 // RemoveRoute removes a route by domain.
172 func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {173 func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
173- resp, err := c.request(ctx, http.MethodDelete, "/routes/"+routeDomain, nil)174+ resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)
174 if err != nil {175 if err != nil {
175 return err176 return err
176 }177 }
@@ -181,7 +182,7 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
181 182
182 // ListSecrets returns the list of secret keys for a domain.183 // ListSecrets returns the list of secret keys for a domain.
183 func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {184 func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
184- resp, err := c.request(ctx, http.MethodGet, "/secrets/"+secretDomain, nil)185+ resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
185 if err != nil {186 if err != nil {
186 return nil, err187 return nil, err
187 }188 }
@@ -199,7 +200,7 @@ func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string
199 200
200 // SetSecrets sets secrets for a domain.201 // SetSecrets sets secrets for a domain.
201 func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {202 func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
202- resp, err := c.request(ctx, http.MethodPost, "/secrets/"+secretDomain, secrets)203+ resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets)
203 if err != nil {204 if err != nil {
204 return err205 return err
205 }206 }
@@ -208,7 +209,7 @@ func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets ma
208 209
209 // DeleteSecret removes a secret from a domain.210 // DeleteSecret removes a secret from a domain.
210 func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {211 func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {
211- resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+secretDomain+"/"+key, nil)212+ resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil)
212 if err != nil {213 if err != nil {
213 return err214 return err
214 }215 }
@@ -319,3 +320,161 @@ func (c *Client) Ping(ctx context.Context) error {
319 _, err := c.GetStatus(ctx)320 _, err := c.GetStatus(ctx)
320 return err321 return err
321 }322 }
323+
324+// Deploy API
325+
326+// DeployResult contains the result of a deployment.
327+type 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.
334+func (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.
351+func (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.
369+func (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.
389+func (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.
396+func (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.
402+func (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+}
M internal/adapters/in/cli/root.go +114 -10
@@ -3,10 +3,13 @@
33 package cli
44
55 import (
6- "gordon/internal/adapters/in/cli/remote"
7- "gordon/internal/app"
6+ "context"
7+ "fmt"
88
99 "github.com/spf13/cobra"
10+
11+ "gordon/internal/adapters/in/cli/remote"
12+ "gordon/internal/app"
1013 )
1114
1215 var (
@@ -113,26 +116,127 @@ func runReload() error {
113116 func newLogsCmd() *cobra.Command {
114117 var follow bool
115118 var lines int
116- var configPath string
119+ var logsConfigPath string
117120
118121 cmd := &cobra.Command{
119- Use: "logs",
120- Short: "Show Gordon process logs",
121- Long: `Shows logs from the Gordon process. By default reads from the log file
122-configured in config.toml. If file logging is not enabled, falls back to
123-journalctl (if running as a systemd service).`,
122+ Use: "logs [domain]",
123+ Short: "Show logs (Gordon process or container)",
124+ Long: `Shows logs from the Gordon process or a specific container.
125+
126+Without a domain argument, shows Gordon process logs.
127+With a domain argument, shows container logs for that domain.
128+
129+Examples:
130+ gordon logs # Gordon process logs
131+ gordon logs -f # Follow process logs
132+ gordon logs myapp.local # Container logs for myapp.local
133+ gordon logs myapp.local -f # Follow container logs
134+
135+Remote mode:
136+ gordon logs --remote https://gordon.mydomain.com --token $TOKEN
137+ gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`,
138+ Args: cobra.MaximumNArgs(1),
124139 RunE: func(cmd *cobra.Command, args []string) error {
125- return app.ShowLogs(configPath, follow, lines)
140+ logDomain := ""
141+ if len(args) > 0 {
142+ logDomain = args[0]
143+ }
144+ return runLogs(logsConfigPath, logDomain, follow, lines)
126145 },
127146 }
128147
129- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
148+ cmd.Flags().StringVarP(&logsConfigPath, "config", "c", "", "Path to config file")
130149 cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
131150 cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
132151
133152 return cmd
134153 }
135154
155+// runLogs handles the logs command logic.
156+func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {
157+ client, isRemote := GetRemoteClient()
158+ if isRemote {
159+ return runLogsRemote(client, logDomain, follow, lines)
160+ }
161+ return runLogsLocal(logsConfigPath, logDomain, follow, lines)
162+}
163+
164+// runLogsRemote fetches logs from a remote Gordon instance.
165+func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error {
166+ ctx := context.Background()
167+
168+ if follow {
169+ return streamLogsRemote(ctx, client, logDomain, lines)
170+ }
171+
172+ if logDomain == "" {
173+ // Process logs
174+ logLines, err := client.GetProcessLogs(ctx, lines)
175+ if err != nil {
176+ return fmt.Errorf("failed to get process logs: %w", err)
177+ }
178+ for _, line := range logLines {
179+ fmt.Println(line)
180+ }
181+ } else {
182+ // Container logs
183+ logLines, err := client.GetContainerLogs(ctx, logDomain, lines)
184+ if err != nil {
185+ return fmt.Errorf("failed to get container logs: %w", err)
186+ }
187+ for _, line := range logLines {
188+ fmt.Println(line)
189+ }
190+ }
191+ return nil
192+}
193+
194+// streamLogsRemote streams logs from a remote Gordon instance.
195+func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int) error {
196+ var ch <-chan string
197+ var err error
198+
199+ if logDomain == "" {
200+ ch, err = client.StreamProcessLogs(ctx, lines)
201+ } else {
202+ ch, err = client.StreamContainerLogs(ctx, logDomain, lines)
203+ }
204+ if err != nil {
205+ return fmt.Errorf("failed to stream logs: %w", err)
206+ }
207+
208+ for line := range ch {
209+ fmt.Println(line)
210+ }
211+ return nil
212+}
213+
214+// runLogsLocal shows logs from a local Gordon instance.
215+func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
216+ if logDomain == "" {
217+ // Process logs - use existing app.ShowLogs
218+ return app.ShowLogs(logsConfigPath, follow, lines)
219+ }
220+
221+ // Container logs - use local services
222+ return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines)
223+}
224+
225+// showContainerLogsLocal shows container logs using local Docker access.
226+func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
227+ // For local container logs, we need Docker access which requires
228+ // the runtime to be initialized. For now, suggest using remote mode
229+ // or direct docker logs command.
230+ fmt.Printf("Container logs for %s\n", logDomain)
231+ fmt.Println("To view container logs locally, use:")
232+ fmt.Printf(" docker logs --tail %d %s\n", lines, logDomain)
233+ if follow {
234+ fmt.Printf(" docker logs -f --tail %d %s\n", lines, logDomain)
235+ }
236+ fmt.Println("\nOr use remote mode to access logs via the admin API.")
237+ return nil
238+}
239+
136240 // SetVersionInfo sets the version information for the CLI.
137241 func SetVersionInfo(version, commit, date string) {
138242 Version = version
@@ -3,10 +3,13 @@
3 package cli3 package cli
4 4
5 import (5 import (
6- "gordon/internal/adapters/in/cli/remote"6+ "context"
7- "gordon/internal/app"7+ "fmt"
8 8
9 "github.com/spf13/cobra"9 "github.com/spf13/cobra"
10+
11+ "gordon/internal/adapters/in/cli/remote"
12+ "gordon/internal/app"
10 )13 )
11 14
12 var (15 var (
@@ -113,26 +116,127 @@ func runReload() error {
113 func newLogsCmd() *cobra.Command {116 func newLogsCmd() *cobra.Command {
114 var follow bool117 var follow bool
115 var lines int118 var lines int
116- var configPath string119+ var logsConfigPath string
117 120
118 cmd := &cobra.Command{121 cmd := &cobra.Command{
119- Use: "logs",122+ Use: "logs [domain]",
120- Short: "Show Gordon process logs",123+ Short: "Show logs (Gordon process or container)",
121- Long: `Shows logs from the Gordon process. By default reads from the log file124+ Long: `Shows logs from the Gordon process or a specific container.
122-configured in config.toml. If file logging is not enabled, falls back to125+
123-journalctl (if running as a systemd service).`,126+Without a domain argument, shows Gordon process logs.
127+With a domain argument, shows container logs for that domain.
128+
129+Examples:
130+ gordon logs # Gordon process logs
131+ gordon logs -f # Follow process logs
132+ gordon logs myapp.local # Container logs for myapp.local
133+ gordon logs myapp.local -f # Follow container logs
134+
135+Remote mode:
136+ gordon logs --remote https://gordon.mydomain.com --token $TOKEN
137+ gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`,
138+ Args: cobra.MaximumNArgs(1),
124 RunE: func(cmd *cobra.Command, args []string) error {139 RunE: func(cmd *cobra.Command, args []string) error {
125- return app.ShowLogs(configPath, follow, lines)140+ logDomain := ""
141+ if len(args) > 0 {
142+ logDomain = args[0]
143+ }
144+ return runLogs(logsConfigPath, logDomain, follow, lines)
126 },145 },
127 }146 }
128 147
129- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")148+ cmd.Flags().StringVarP(&logsConfigPath, "config", "c", "", "Path to config file")
130 cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")149 cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
131 cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")150 cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
132 151
133 return cmd152 return cmd
134 }153 }
135 154
155+// runLogs handles the logs command logic.
156+func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {
157+ client, isRemote := GetRemoteClient()
158+ if isRemote {
159+ return runLogsRemote(client, logDomain, follow, lines)
160+ }
161+ return runLogsLocal(logsConfigPath, logDomain, follow, lines)
162+}
163+
164+// runLogsRemote fetches logs from a remote Gordon instance.
165+func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error {
166+ ctx := context.Background()
167+
168+ if follow {
169+ return streamLogsRemote(ctx, client, logDomain, lines)
170+ }
171+
172+ if logDomain == "" {
173+ // Process logs
174+ logLines, err := client.GetProcessLogs(ctx, lines)
175+ if err != nil {
176+ return fmt.Errorf("failed to get process logs: %w", err)
177+ }
178+ for _, line := range logLines {
179+ fmt.Println(line)
180+ }
181+ } else {
182+ // Container logs
183+ logLines, err := client.GetContainerLogs(ctx, logDomain, lines)
184+ if err != nil {
185+ return fmt.Errorf("failed to get container logs: %w", err)
186+ }
187+ for _, line := range logLines {
188+ fmt.Println(line)
189+ }
190+ }
191+ return nil
192+}
193+
194+// streamLogsRemote streams logs from a remote Gordon instance.
195+func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int) error {
196+ var ch <-chan string
197+ var err error
198+
199+ if logDomain == "" {
200+ ch, err = client.StreamProcessLogs(ctx, lines)
201+ } else {
202+ ch, err = client.StreamContainerLogs(ctx, logDomain, lines)
203+ }
204+ if err != nil {
205+ return fmt.Errorf("failed to stream logs: %w", err)
206+ }
207+
208+ for line := range ch {
209+ fmt.Println(line)
210+ }
211+ return nil
212+}
213+
214+// runLogsLocal shows logs from a local Gordon instance.
215+func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
216+ if logDomain == "" {
217+ // Process logs - use existing app.ShowLogs
218+ return app.ShowLogs(logsConfigPath, follow, lines)
219+ }
220+
221+ // Container logs - use local services
222+ return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines)
223+}
224+
225+// showContainerLogsLocal shows container logs using local Docker access.
226+func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
227+ // For local container logs, we need Docker access which requires
228+ // the runtime to be initialized. For now, suggest using remote mode
229+ // or direct docker logs command.
230+ fmt.Printf("Container logs for %s\n", logDomain)
231+ fmt.Println("To view container logs locally, use:")
232+ fmt.Printf(" docker logs --tail %d %s\n", lines, logDomain)
233+ if follow {
234+ fmt.Printf(" docker logs -f --tail %d %s\n", lines, logDomain)
235+ }
236+ fmt.Println("\nOr use remote mode to access logs via the admin API.")
237+ return nil
238+}
239+
136 // SetVersionInfo sets the version information for the CLI.240 // SetVersionInfo sets the version information for the CLI.
137 func SetVersionInfo(version, commit, date string) {241 func SetVersionInfo(version, commit, date string) {
138 Version = version242 Version = version
M internal/adapters/in/cli/routes.go +119 -65
@@ -14,6 +14,10 @@ import (
1414 "github.com/spf13/cobra"
1515 )
1616
17+// configPath for local operations. If empty, config is auto-discovered
18+// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
19+var configPath string
20+
1721 // truncateImage shortens long image references for display.
1822 // For digests (image@sha256:...), shows first 12 chars of digest.
1923 // For tags, truncates to maxLen with ellipsis if needed.
@@ -93,66 +97,106 @@ func newRoutesListCmd() *cobra.Command {
9397 ctx := context.Background()
9498
9599 client, isRemote := GetRemoteClient()
96- if !isRemote {
97- fmt.Println(styles.RenderError("routes command requires --remote flag or GORDON_REMOTE env var"))
98- fmt.Println(styles.Theme.Muted.Render("Local route management is not yet supported. Use the config file directly."))
99- return nil
100+ if isRemote {
101+ return runRoutesListRemote(ctx, client)
100102 }
103+ return runRoutesListLocal(ctx, configPath)
104+ },
105+ }
106+}
101107
102- routes, err := client.ListRoutes(ctx)
103- if err != nil {
104- return fmt.Errorf("failed to list routes: %w", err)
105- }
108+// runRoutesListRemote lists routes from a remote Gordon instance.
109+func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
110+ routes, err := client.ListRoutes(ctx)
111+ if err != nil {
112+ return fmt.Errorf("failed to list routes: %w", err)
113+ }
106114
107- if len(routes) == 0 {
108- fmt.Println(styles.Theme.Muted.Render("No routes configured"))
109- return nil
110- }
115+ if len(routes) == 0 {
116+ fmt.Println(styles.Theme.Muted.Render("No routes configured"))
117+ return nil
118+ }
111119
112- // Get health status for each route (includes container status and HTTP probe)
113- health, _ := client.GetHealth(ctx)
114- if health == nil {
115- health = make(map[string]*remote.RouteHealth)
116- }
120+ // Get health status for each route (includes container status and HTTP probe)
121+ health, _ := client.GetHealth(ctx)
122+ if health == nil {
123+ health = make(map[string]*remote.RouteHealth)
124+ }
117125
118- // Build table rows
119- const imageColWidth = 35
120- rows := make([][]string, len(routes))
121- for i, route := range routes {
122- routeHealth := health[route.Domain]
126+ // Build table rows
127+ const imageColWidth = 35
128+ rows := make([][]string, len(routes))
129+ for i, route := range routes {
130+ routeHealth := health[route.Domain]
123131
124- // Container status column
125- containerStatus := "unknown"
126- if routeHealth != nil {
127- containerStatus = routeHealth.ContainerStatus
128- }
129- containerBadge := components.ContainerStatusBadge(containerStatus)
132+ // Container status column
133+ containerStatus := "unknown"
134+ if routeHealth != nil {
135+ containerStatus = routeHealth.ContainerStatus
136+ }
137+ containerBadge := components.ContainerStatusBadge(containerStatus)
130138
131- // HTTP status column
132- httpStatus := formatHTTPStatus(routeHealth)
139+ // HTTP status column
140+ httpStatus := formatHTTPStatus(routeHealth)
133141
134- displayImage := truncateImage(route.Image, imageColWidth)
135- rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}
136- }
142+ displayImage := truncateImage(route.Image, imageColWidth)
143+ rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}
144+ }
137145
138- // Render table
139- table := components.NewTable(
140- components.WithColumns([]components.TableColumn{
141- {Title: "Domain", Width: 25},
142- {Title: "Image", Width: imageColWidth},
143- {Title: "Container", Width: 12},
144- {Title: "HTTP", Width: 14},
145- }),
146- components.WithRows(rows),
147- )
148-
149- fmt.Println(styles.Theme.Title.Render("Routes"))
150- fmt.Println()
151- fmt.Println(table.View())
146+ // Render table
147+ table := components.NewTable(
148+ components.WithColumns([]components.TableColumn{
149+ {Title: "Domain", Width: 25},
150+ {Title: "Image", Width: imageColWidth},
151+ {Title: "Container", Width: 12},
152+ {Title: "HTTP", Width: 14},
153+ }),
154+ components.WithRows(rows),
155+ )
156+
157+ fmt.Println(styles.Theme.Title.Render("Routes"))
158+ fmt.Println()
159+ fmt.Println(table.View())
160+
161+ return nil
162+}
152163
153- return nil
154- },
164+// runRoutesListLocal lists routes from local configuration.
165+func runRoutesListLocal(ctx context.Context, cfgPath string) error {
166+ local, err := GetLocalServices(cfgPath)
167+ if err != nil {
168+ return fmt.Errorf("failed to initialize local services: %w", err)
169+ }
170+
171+ routes := local.GetConfigService().GetRoutes(ctx)
172+
173+ if len(routes) == 0 {
174+ fmt.Println(styles.Theme.Muted.Render("No routes configured"))
175+ return nil
155176 }
177+
178+ // Build table rows (no health info in local mode)
179+ const imageColWidth = 45
180+ rows := make([][]string, len(routes))
181+ for i, route := range routes {
182+ displayImage := truncateImage(route.Image, imageColWidth)
183+ rows[i] = []string{route.Domain, displayImage}
184+ }
185+
186+ // Render table
187+ table := components.NewTable(
188+ components.WithColumns([]components.TableColumn{
189+ {Title: "Domain", Width: 30},
190+ {Title: "Image", Width: imageColWidth},
191+ }),
192+ components.WithRows(rows),
193+ )
194+
195+ fmt.Println(styles.Theme.Title.Render("Routes (local)"))
196+ fmt.Println()
197+ fmt.Println(table.View())
198+
199+ return nil
156200 }
157201
158202 // newRoutesAddCmd creates the routes add command.
@@ -172,12 +216,6 @@ Examples:
172216 ctx := context.Background()
173217 routeDomain := args[0]
174218
175- client, isRemote := GetRemoteClient()
176- if !isRemote {
177- fmt.Println(styles.RenderError("routes command requires --remote flag or GORDON_REMOTE env var"))
178- return nil
179- }
180-
181219 if image == "" {
182220 fmt.Println(styles.RenderError("--image flag is required"))
183221 return nil
@@ -188,8 +226,19 @@ Examples:
188226 Image: image,
189227 }
190228
191- if err := client.AddRoute(ctx, route); err != nil {
192- return fmt.Errorf("failed to add route: %w", err)
229+ client, isRemote := GetRemoteClient()
230+ if isRemote {
231+ if err := client.AddRoute(ctx, route); err != nil {
232+ return fmt.Errorf("failed to add route: %w", err)
233+ }
234+ } else {
235+ local, err := GetLocalServices(configPath)
236+ if err != nil {
237+ return fmt.Errorf("failed to initialize local services: %w", err)
238+ }
239+ if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
240+ return fmt.Errorf("failed to add route: %w", err)
241+ }
193242 }
194243
195244 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))
@@ -220,12 +269,6 @@ Examples:
220269 ctx := context.Background()
221270 routeDomain := args[0]
222271
223- client, isRemote := GetRemoteClient()
224- if !isRemote {
225- fmt.Println(styles.RenderError("routes command requires --remote flag or GORDON_REMOTE env var"))
226- return nil
227- }
228-
229272 // Confirm unless --force
230273 if !force {
231274 confirmed, err := components.RunConfirm(
@@ -241,8 +284,19 @@ Examples:
241284 }
242285 }
243286
244- if err := client.RemoveRoute(ctx, routeDomain); err != nil {
245- return fmt.Errorf("failed to remove route: %w", err)
287+ client, isRemote := GetRemoteClient()
288+ if isRemote {
289+ if err := client.RemoveRoute(ctx, routeDomain); err != nil {
290+ return fmt.Errorf("failed to remove route: %w", err)
291+ }
292+ } else {
293+ local, err := GetLocalServices(configPath)
294+ if err != nil {
295+ return fmt.Errorf("failed to initialize local services: %w", err)
296+ }
297+ if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil {
298+ return fmt.Errorf("failed to remove route: %w", err)
299+ }
246300 }
247301
248302 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
@@ -14,6 +14,10 @@ import (
14 "github.com/spf13/cobra"14 "github.com/spf13/cobra"
15 )15 )
16 16
17+// configPath for local operations. If empty, config is auto-discovered
18+// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
19+var configPath string
20+
17 // truncateImage shortens long image references for display.21 // truncateImage shortens long image references for display.
18 // For digests (image@sha256:...), shows first 12 chars of digest.22 // For digests (image@sha256:...), shows first 12 chars of digest.
19 // For tags, truncates to maxLen with ellipsis if needed.23 // For tags, truncates to maxLen with ellipsis if needed.
@@ -93,66 +97,106 @@ func newRoutesListCmd() *cobra.Command {
93 ctx := context.Background()97 ctx := context.Background()
94 98
95 client, isRemote := GetRemoteClient()99 client, isRemote := GetRemoteClient()
96- if !isRemote {100+ if isRemote {
97- fmt.Println(styles.RenderError("routes command requires --remote flag or GORDON_REMOTE env var"))101+ return runRoutesListRemote(ctx, client)
98- fmt.Println(styles.Theme.Muted.Render("Local route management is not yet supported. Use the config file directly."))
99- return nil
100 }102 }
103+ return runRoutesListLocal(ctx, configPath)
104+ },
105+ }
106+}
101 107
102- routes, err := client.ListRoutes(ctx)108+// runRoutesListRemote lists routes from a remote Gordon instance.
103- if err != nil {109+func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
104- return fmt.Errorf("failed to list routes: %w", err)110+ routes, err := client.ListRoutes(ctx)
105- }111+ if err != nil {
112+ return fmt.Errorf("failed to list routes: %w", err)
113+ }
106 114
107- if len(routes) == 0 {115+ if len(routes) == 0 {
108- fmt.Println(styles.Theme.Muted.Render("No routes configured"))116+ fmt.Println(styles.Theme.Muted.Render("No routes configured"))
109- return nil117+ return nil
110- }118+ }
111 119
112- // Get health status for each route (includes container status and HTTP probe)120+ // Get health status for each route (includes container status and HTTP probe)
113- health, _ := client.GetHealth(ctx)121+ health, _ := client.GetHealth(ctx)
114- if health == nil {122+ if health == nil {
115- health = make(map[string]*remote.RouteHealth)123+ health = make(map[string]*remote.RouteHealth)
116- }124+ }
117 125
118- // Build table rows126+ // Build table rows
119- const imageColWidth = 35127+ const imageColWidth = 35
120- rows := make([][]string, len(routes))128+ rows := make([][]string, len(routes))
121- for i, route := range routes {129+ for i, route := range routes {
122- routeHealth := health[route.Domain]130+ routeHealth := health[route.Domain]
123 131
124- // Container status column132+ // Container status column
125- containerStatus := "unknown"133+ containerStatus := "unknown"
126- if routeHealth != nil {134+ if routeHealth != nil {
127- containerStatus = routeHealth.ContainerStatus135+ containerStatus = routeHealth.ContainerStatus
128- }136+ }
129- containerBadge := components.ContainerStatusBadge(containerStatus)137+ containerBadge := components.ContainerStatusBadge(containerStatus)
130 138
131- // HTTP status column139+ // HTTP status column
132- httpStatus := formatHTTPStatus(routeHealth)140+ httpStatus := formatHTTPStatus(routeHealth)
133 141
134- displayImage := truncateImage(route.Image, imageColWidth)142+ displayImage := truncateImage(route.Image, imageColWidth)
135- rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}143+ rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}
136- }144+ }
137 145
138- // Render table146+ // Render table
139- table := components.NewTable(147+ table := components.NewTable(
140- components.WithColumns([]components.TableColumn{148+ components.WithColumns([]components.TableColumn{
141- {Title: "Domain", Width: 25},149+ {Title: "Domain", Width: 25},
142- {Title: "Image", Width: imageColWidth},150+ {Title: "Image", Width: imageColWidth},
143- {Title: "Container", Width: 12},151+ {Title: "Container", Width: 12},
144- {Title: "HTTP", Width: 14},152+ {Title: "HTTP", Width: 14},
145- }),153+ }),
146- components.WithRows(rows),154+ components.WithRows(rows),
147- )155+ )
148-156+
149- fmt.Println(styles.Theme.Title.Render("Routes"))157+ fmt.Println(styles.Theme.Title.Render("Routes"))
150- fmt.Println()158+ fmt.Println()
151- fmt.Println(table.View())159+ fmt.Println(table.View())
160+
161+ return nil
162+}
152 163
153- return nil164+// runRoutesListLocal lists routes from local configuration.
154- },165+func runRoutesListLocal(ctx context.Context, cfgPath string) error {
166+ local, err := GetLocalServices(cfgPath)
167+ if err != nil {
168+ return fmt.Errorf("failed to initialize local services: %w", err)
169+ }
170+
171+ routes := local.GetConfigService().GetRoutes(ctx)
172+
173+ if len(routes) == 0 {
174+ fmt.Println(styles.Theme.Muted.Render("No routes configured"))
175+ return nil
155 }176 }
177+
178+ // Build table rows (no health info in local mode)
179+ const imageColWidth = 45
180+ rows := make([][]string, len(routes))
181+ for i, route := range routes {
182+ displayImage := truncateImage(route.Image, imageColWidth)
183+ rows[i] = []string{route.Domain, displayImage}
184+ }
185+
186+ // Render table
187+ table := components.NewTable(
188+ components.WithColumns([]components.TableColumn{
189+ {Title: "Domain", Width: 30},
190+ {Title: "Image", Width: imageColWidth},
191+ }),
192+ components.WithRows(rows),
193+ )
194+
195+ fmt.Println(styles.Theme.Title.Render("Routes (local)"))
196+ fmt.Println()
197+ fmt.Println(table.View())
198+
199+ return nil
156 }200 }
157 201
158 // newRoutesAddCmd creates the routes add command.202 // newRoutesAddCmd creates the routes add command.
@@ -172,12 +216,6 @@ Examples:
172 ctx := context.Background()216 ctx := context.Background()
173 routeDomain := args[0]217 routeDomain := args[0]
174 218
175- client, isRemote := GetRemoteClient()
176- if !isRemote {
177- fmt.Println(styles.RenderError("routes command requires --remote flag or GORDON_REMOTE env var"))
178- return nil
179- }
180-
181 if image == "" {219 if image == "" {
182 fmt.Println(styles.RenderError("--image flag is required"))220 fmt.Println(styles.RenderError("--image flag is required"))
183 return nil221 return nil
@@ -188,8 +226,19 @@ Examples:
188 Image: image,226 Image: image,
189 }227 }
190 228
191- if err := client.AddRoute(ctx, route); err != nil {229+ client, isRemote := GetRemoteClient()
192- return fmt.Errorf("failed to add route: %w", err)230+ if isRemote {
231+ if err := client.AddRoute(ctx, route); err != nil {
232+ return fmt.Errorf("failed to add route: %w", err)
233+ }
234+ } else {
235+ local, err := GetLocalServices(configPath)
236+ if err != nil {
237+ return fmt.Errorf("failed to initialize local services: %w", err)
238+ }
239+ if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
240+ return fmt.Errorf("failed to add route: %w", err)
241+ }
193 }242 }
194 243
195 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))244 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))
@@ -220,12 +269,6 @@ Examples:
220 ctx := context.Background()269 ctx := context.Background()
221 routeDomain := args[0]270 routeDomain := args[0]
222 271
223- client, isRemote := GetRemoteClient()
224- if !isRemote {
225- fmt.Println(styles.RenderError("routes command requires --remote flag or GORDON_REMOTE env var"))
226- return nil
227- }
228-
229 // Confirm unless --force272 // Confirm unless --force
230 if !force {273 if !force {
231 confirmed, err := components.RunConfirm(274 confirmed, err := components.RunConfirm(
@@ -241,8 +284,19 @@ Examples:
241 }284 }
242 }285 }
243 286
244- if err := client.RemoveRoute(ctx, routeDomain); err != nil {287+ client, isRemote := GetRemoteClient()
245- return fmt.Errorf("failed to remove route: %w", err)288+ if isRemote {
289+ if err := client.RemoveRoute(ctx, routeDomain); err != nil {
290+ return fmt.Errorf("failed to remove route: %w", err)
291+ }
292+ } else {
293+ local, err := GetLocalServices(configPath)
294+ if err != nil {
295+ return fmt.Errorf("failed to initialize local services: %w", err)
296+ }
297+ if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil {
298+ return fmt.Errorf("failed to remove route: %w", err)
299+ }
246 }300 }
247 301
248 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))302 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
M internal/adapters/in/cli/secrets.go +48 -28
@@ -46,26 +46,36 @@ Examples:
4646 Args: cobra.ExactArgs(1),
4747 RunE: func(cmd *cobra.Command, args []string) error {
4848 ctx := context.Background()
49- domain := args[0]
49+ secretDomain := args[0]
50+
51+ var keys []string
52+ var err error
5053
5154 client, isRemote := GetRemoteClient()
52- if !isRemote {
53- fmt.Println(styles.RenderError("secrets command requires --remote flag or GORDON_REMOTE env var"))
54- fmt.Println(styles.Theme.Muted.Render("Local secret management is not yet supported."))
55- return nil
55+ if isRemote {
56+ keys, err = client.ListSecrets(ctx, secretDomain)
57+ } else {
58+ local, localErr := GetLocalServices(configPath)
59+ if localErr != nil {
60+ return fmt.Errorf("failed to initialize local services: %w", localErr)
61+ }
62+ keys, err = local.GetSecretService().ListKeys(ctx, secretDomain)
5663 }
5764
58- keys, err := client.ListSecrets(ctx, domain)
5965 if err != nil {
6066 return fmt.Errorf("failed to list secrets: %w", err)
6167 }
6268
6369 if len(keys) == 0 {
64- fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", domain)))
70+ fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))
6571 return nil
6672 }
6773
68- fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Secrets for %s", domain)))
74+ title := fmt.Sprintf("Secrets for %s", secretDomain)
75+ if !isRemote {
76+ title = fmt.Sprintf("Secrets for %s (local)", secretDomain)
77+ }
78+ fmt.Println(styles.Theme.Title.Render(title))
6979 fmt.Println()
7080
7181 // Build table rows
@@ -106,15 +116,9 @@ Examples:
106116 Args: cobra.MinimumNArgs(2),
107117 RunE: func(cmd *cobra.Command, args []string) error {
108118 ctx := context.Background()
109- domain := args[0]
119+ secretDomain := args[0]
110120 pairs := args[1:]
111121
112- client, isRemote := GetRemoteClient()
113- if !isRemote {
114- fmt.Println(styles.RenderError("secrets command requires --remote flag or GORDON_REMOTE env var"))
115- return nil
116- }
117-
118122 // Parse KEY=value pairs
119123 secrets := make(map[string]string)
120124 for _, pair := range pairs {
@@ -126,8 +130,19 @@ Examples:
126130 secrets[parts[0]] = parts[1]
127131 }
128132
129- if err := client.SetSecrets(ctx, domain, secrets); err != nil {
130- return fmt.Errorf("failed to set secrets: %w", err)
133+ client, isRemote := GetRemoteClient()
134+ if isRemote {
135+ if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil {
136+ return fmt.Errorf("failed to set secrets: %w", err)
137+ }
138+ } else {
139+ local, err := GetLocalServices(configPath)
140+ if err != nil {
141+ return fmt.Errorf("failed to initialize local services: %w", err)
142+ }
143+ if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil {
144+ return fmt.Errorf("failed to set secrets: %w", err)
145+ }
131146 }
132147
133148 if len(secrets) == 1 {
@@ -135,7 +150,7 @@ Examples:
135150 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s", key)))
136151 }
137152 } else {
138- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), domain)))
153+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), secretDomain)))
139154 }
140155
141156 return nil
@@ -158,19 +173,13 @@ Examples:
158173 Args: cobra.ExactArgs(2),
159174 RunE: func(cmd *cobra.Command, args []string) error {
160175 ctx := context.Background()
161- domain := args[0]
176+ secretDomain := args[0]
162177 key := args[1]
163178
164- client, isRemote := GetRemoteClient()
165- if !isRemote {
166- fmt.Println(styles.RenderError("secrets command requires --remote flag or GORDON_REMOTE env var"))
167- return nil
168- }
169-
170179 // Confirm unless --force
171180 if !force {
172181 confirmed, err := components.RunConfirm(
173- fmt.Sprintf("Remove secret '%s' from %s?", key, domain),
182+ fmt.Sprintf("Remove secret '%s' from %s?", key, secretDomain),
174183 )
175184 if err != nil {
176185 return err
@@ -181,8 +190,19 @@ Examples:
181190 }
182191 }
183192
184- if err := client.DeleteSecret(ctx, domain, key); err != nil {
185- return fmt.Errorf("failed to remove secret: %w", err)
193+ client, isRemote := GetRemoteClient()
194+ if isRemote {
195+ if err := client.DeleteSecret(ctx, secretDomain, key); err != nil {
196+ return fmt.Errorf("failed to remove secret: %w", err)
197+ }
198+ } else {
199+ local, err := GetLocalServices(configPath)
200+ if err != nil {
201+ return fmt.Errorf("failed to initialize local services: %w", err)
202+ }
203+ if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil {
204+ return fmt.Errorf("failed to remove secret: %w", err)
205+ }
186206 }
187207
188208 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s", key)))
@@ -46,26 +46,36 @@ Examples:
46 Args: cobra.ExactArgs(1),46 Args: cobra.ExactArgs(1),
47 RunE: func(cmd *cobra.Command, args []string) error {47 RunE: func(cmd *cobra.Command, args []string) error {
48 ctx := context.Background()48 ctx := context.Background()
49- domain := args[0]49+ secretDomain := args[0]
50+
51+ var keys []string
52+ var err error
50 53
51 client, isRemote := GetRemoteClient()54 client, isRemote := GetRemoteClient()
52- if !isRemote {55+ if isRemote {
53- fmt.Println(styles.RenderError("secrets command requires --remote flag or GORDON_REMOTE env var"))56+ keys, err = client.ListSecrets(ctx, secretDomain)
54- fmt.Println(styles.Theme.Muted.Render("Local secret management is not yet supported."))57+ } else {
55- return nil58+ local, localErr := GetLocalServices(configPath)
59+ if localErr != nil {
60+ return fmt.Errorf("failed to initialize local services: %w", localErr)
61+ }
62+ keys, err = local.GetSecretService().ListKeys(ctx, secretDomain)
56 }63 }
57 64
58- keys, err := client.ListSecrets(ctx, domain)
59 if err != nil {65 if err != nil {
60 return fmt.Errorf("failed to list secrets: %w", err)66 return fmt.Errorf("failed to list secrets: %w", err)
61 }67 }
62 68
63 if len(keys) == 0 {69 if len(keys) == 0 {
64- fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", domain)))70+ fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))
65 return nil71 return nil
66 }72 }
67 73
68- fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Secrets for %s", domain)))74+ title := fmt.Sprintf("Secrets for %s", secretDomain)
75+ if !isRemote {
76+ title = fmt.Sprintf("Secrets for %s (local)", secretDomain)
77+ }
78+ fmt.Println(styles.Theme.Title.Render(title))
69 fmt.Println()79 fmt.Println()
70 80
71 // Build table rows81 // Build table rows
@@ -106,15 +116,9 @@ Examples:
106 Args: cobra.MinimumNArgs(2),116 Args: cobra.MinimumNArgs(2),
107 RunE: func(cmd *cobra.Command, args []string) error {117 RunE: func(cmd *cobra.Command, args []string) error {
108 ctx := context.Background()118 ctx := context.Background()
109- domain := args[0]119+ secretDomain := args[0]
110 pairs := args[1:]120 pairs := args[1:]
111 121
112- client, isRemote := GetRemoteClient()
113- if !isRemote {
114- fmt.Println(styles.RenderError("secrets command requires --remote flag or GORDON_REMOTE env var"))
115- return nil
116- }
117-
118 // Parse KEY=value pairs122 // Parse KEY=value pairs
119 secrets := make(map[string]string)123 secrets := make(map[string]string)
120 for _, pair := range pairs {124 for _, pair := range pairs {
@@ -126,8 +130,19 @@ Examples:
126 secrets[parts[0]] = parts[1]130 secrets[parts[0]] = parts[1]
127 }131 }
128 132
129- if err := client.SetSecrets(ctx, domain, secrets); err != nil {133+ client, isRemote := GetRemoteClient()
130- return fmt.Errorf("failed to set secrets: %w", err)134+ if isRemote {
135+ if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil {
136+ return fmt.Errorf("failed to set secrets: %w", err)
137+ }
138+ } else {
139+ local, err := GetLocalServices(configPath)
140+ if err != nil {
141+ return fmt.Errorf("failed to initialize local services: %w", err)
142+ }
143+ if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil {
144+ return fmt.Errorf("failed to set secrets: %w", err)
145+ }
131 }146 }
132 147
133 if len(secrets) == 1 {148 if len(secrets) == 1 {
@@ -135,7 +150,7 @@ Examples:
135 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s", key)))150 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s", key)))
136 }151 }
137 } else {152 } else {
138- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), domain)))153+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), secretDomain)))
139 }154 }
140 155
141 return nil156 return nil
@@ -158,19 +173,13 @@ Examples:
158 Args: cobra.ExactArgs(2),173 Args: cobra.ExactArgs(2),
159 RunE: func(cmd *cobra.Command, args []string) error {174 RunE: func(cmd *cobra.Command, args []string) error {
160 ctx := context.Background()175 ctx := context.Background()
161- domain := args[0]176+ secretDomain := args[0]
162 key := args[1]177 key := args[1]
163 178
164- client, isRemote := GetRemoteClient()
165- if !isRemote {
166- fmt.Println(styles.RenderError("secrets command requires --remote flag or GORDON_REMOTE env var"))
167- return nil
168- }
169-
170 // Confirm unless --force179 // Confirm unless --force
171 if !force {180 if !force {
172 confirmed, err := components.RunConfirm(181 confirmed, err := components.RunConfirm(
173- fmt.Sprintf("Remove secret '%s' from %s?", key, domain),182+ fmt.Sprintf("Remove secret '%s' from %s?", key, secretDomain),
174 )183 )
175 if err != nil {184 if err != nil {
176 return err185 return err
@@ -181,8 +190,19 @@ Examples:
181 }190 }
182 }191 }
183 192
184- if err := client.DeleteSecret(ctx, domain, key); err != nil {193+ client, isRemote := GetRemoteClient()
185- return fmt.Errorf("failed to remove secret: %w", err)194+ if isRemote {
195+ if err := client.DeleteSecret(ctx, secretDomain, key); err != nil {
196+ return fmt.Errorf("failed to remove secret: %w", err)
197+ }
198+ } else {
199+ local, err := GetLocalServices(configPath)
200+ if err != nil {
201+ return fmt.Errorf("failed to initialize local services: %w", err)
202+ }
203+ if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil {
204+ return fmt.Errorf("failed to remove secret: %w", err)
205+ }
186 }206 }
187 207
188 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s", key)))208 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s", key)))
M internal/adapters/in/http/admin/handler.go +242 -0
@@ -4,7 +4,9 @@ package admin
44 import (
55 "encoding/json"
66 "errors"
7+ "fmt"
78 "net/http"
9+ "strconv"
810 "strings"
911
1012 "github.com/bnema/zerowrap"
@@ -17,6 +19,9 @@ import (
1719 // maxAdminRequestSize is the maximum allowed size for admin API request bodies.
1820 const maxAdminRequestSize = 1 << 20 // 1MB
1921
22+// maxLogLines is the maximum allowed number of log lines that can be requested.
23+const maxLogLines = 10000
24+
2025 // Handler implements the HTTP handler for the admin API.
2126 type Handler struct {
2227 configSvc in.ConfigService
@@ -24,6 +29,7 @@ type Handler struct {
2429 containerSvc in.ContainerService
2530 healthSvc in.HealthService
2631 secretSvc in.SecretService
32+ logSvc in.LogService
2733 eventBus out.EventPublisher
2834 log zerowrap.Logger
2935 }
@@ -35,6 +41,7 @@ func NewHandler(
3541 containerSvc in.ContainerService,
3642 healthSvc in.HealthService,
3743 secretSvc in.SecretService,
44+ logSvc in.LogService,
3845 eventBus out.EventPublisher,
3946 log zerowrap.Logger,
4047 ) *Handler {
@@ -44,6 +51,7 @@ func NewHandler(
4451 containerSvc: containerSvc,
4552 healthSvc: healthSvc,
4653 secretSvc: secretSvc,
54+ logSvc: logSvc,
4755 eventBus: eventBus,
4856 log: log,
4957 }
@@ -77,6 +85,10 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
7785 h.handleRoutes(w, r, path)
7886 case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):
7987 h.handleSecrets(w, r, path)
88+ case path == "/deploy" || strings.HasPrefix(path, "/deploy/"):
89+ h.handleDeploy(w, r, path)
90+ case path == "/logs" || strings.HasPrefix(path, "/logs/"):
91+ h.handleLogs(w, r, path)
8092 case path == "/status":
8193 h.handleStatus(w, r)
8294 case path == "/health":
@@ -484,3 +496,233 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
484496
485497 h.sendJSON(w, http.StatusOK, config)
486498 }
499+
500+// handleDeploy handles /admin/deploy/:domain endpoint.
501+// POST triggers a deployment for the specified domain.
502+func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {
503+ if r.Method != http.MethodPost {
504+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
505+ return
506+ }
507+
508+ ctx := r.Context()
509+ log := zerowrap.FromCtx(ctx)
510+
511+ // Check write permission
512+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
513+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
514+ return
515+ }
516+
517+ // Parse domain from path
518+ deployDomain := strings.TrimPrefix(path, "/deploy/")
519+ if deployDomain == "" || deployDomain == "/deploy" {
520+ h.sendError(w, http.StatusBadRequest, "domain required in path")
521+ return
522+ }
523+
524+ // Get the route for this domain
525+ route, err := h.configSvc.GetRoute(ctx, deployDomain)
526+ if err != nil {
527+ h.sendError(w, http.StatusNotFound, "route not found")
528+ return
529+ }
530+
531+ // Deploy the container
532+ container, err := h.containerSvc.Deploy(ctx, *route)
533+ if err != nil {
534+ log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
535+ h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
536+ return
537+ }
538+
539+ log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
540+ h.sendJSON(w, http.StatusOK, map[string]any{
541+ "status": "deployed",
542+ "container_id": container.ID,
543+ "domain": deployDomain,
544+ })
545+}
546+
547+// handleLogs handles /admin/logs endpoints.
548+// GET /admin/logs - Gordon process logs
549+// GET /admin/logs/:domain - Container logs for a specific domain
550+func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
551+ if r.Method != http.MethodGet {
552+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
553+ return
554+ }
555+
556+ ctx := r.Context()
557+ log := zerowrap.FromCtx(ctx)
558+
559+ // Check read permission
560+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
561+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
562+ return
563+ }
564+
565+ // Check if LogService is available
566+ if h.logSvc == nil {
567+ h.sendError(w, http.StatusServiceUnavailable, "log service not available")
568+ return
569+ }
570+
571+ // Parse query parameters
572+ lines := 50 // default
573+ if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
574+ if n, err := strconv.Atoi(linesStr); err == nil && n > 0 {
575+ lines = n
576+ }
577+ }
578+ if lines > maxLogLines {
579+ lines = maxLogLines
580+ }
581+ follow := r.URL.Query().Get("follow") == "true"
582+
583+ // Parse domain from path
584+ logDomain := strings.TrimPrefix(path, "/logs/")
585+ if logDomain == "/logs" {
586+ logDomain = ""
587+ }
588+
589+ if logDomain == "" {
590+ // Gordon process logs
591+ h.handleProcessLogs(w, r, lines, follow)
592+ } else {
593+ // Container logs
594+ h.handleContainerLogs(w, r, logDomain, lines, follow)
595+ }
596+
597+ // Prevent unused variable warning when follow is implemented
598+ _ = log
599+}
600+
601+// handleProcessLogs handles Gordon process logs.
602+func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) {
603+ ctx := r.Context()
604+ log := zerowrap.FromCtx(ctx)
605+
606+ if follow {
607+ // SSE streaming
608+ h.streamProcessLogs(w, r, lines)
609+ return
610+ }
611+
612+ // Return last N lines as JSON
613+ logLines, err := h.logSvc.GetProcessLogs(ctx, lines)
614+ if err != nil {
615+ log.Warn().Err(err).Msg("failed to get process logs")
616+ h.sendError(w, http.StatusInternalServerError, "failed to get logs")
617+ return
618+ }
619+
620+ h.sendJSON(w, http.StatusOK, map[string]any{
621+ "lines": logLines,
622+ })
623+}
624+
625+// handleContainerLogs handles container logs for a specific domain.
626+func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) {
627+ ctx := r.Context()
628+ log := zerowrap.FromCtx(ctx)
629+
630+ if follow {
631+ // SSE streaming
632+ h.streamContainerLogs(w, r, logDomain, lines)
633+ return
634+ }
635+
636+ // Return last N lines as JSON
637+ logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines)
638+ if err != nil {
639+ log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs")
640+ h.sendError(w, http.StatusInternalServerError, fmt.Sprintf("failed to get logs: %v", err))
641+ return
642+ }
643+
644+ h.sendJSON(w, http.StatusOK, map[string]any{
645+ "domain": logDomain,
646+ "lines": logLines,
647+ })
648+}
649+
650+// streamProcessLogs streams Gordon process logs via SSE.
651+func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) {
652+ ctx := r.Context()
653+ log := zerowrap.FromCtx(ctx)
654+
655+ // Check for flusher support before setting up SSE
656+ flusher, ok := w.(http.Flusher)
657+ if !ok {
658+ h.sendError(w, http.StatusInternalServerError, "streaming not supported")
659+ return
660+ }
661+
662+ // Set up SSE
663+ w.Header().Set("Content-Type", "text/event-stream")
664+ w.Header().Set("Cache-Control", "no-cache")
665+ w.Header().Set("Connection", "keep-alive")
666+ w.WriteHeader(http.StatusOK)
667+
668+ ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
669+ if err != nil {
670+ log.Warn().Err(err).Msg("failed to follow process logs")
671+ _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n")
672+ flusher.Flush()
673+ return
674+ }
675+
676+ for {
677+ select {
678+ case line, ok := <-ch:
679+ if !ok {
680+ return
681+ }
682+ _, _ = fmt.Fprintf(w, "data: %s\n\n", line)
683+ flusher.Flush()
684+ case <-ctx.Done():
685+ return
686+ }
687+ }
688+}
689+
690+// streamContainerLogs streams container logs via SSE.
691+func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) {
692+ ctx := r.Context()
693+ log := zerowrap.FromCtx(ctx)
694+
695+ // Check for flusher support before setting up SSE
696+ flusher, ok := w.(http.Flusher)
697+ if !ok {
698+ h.sendError(w, http.StatusInternalServerError, "streaming not supported")
699+ return
700+ }
701+
702+ // Set up SSE
703+ w.Header().Set("Content-Type", "text/event-stream")
704+ w.Header().Set("Cache-Control", "no-cache")
705+ w.Header().Set("Connection", "keep-alive")
706+ w.WriteHeader(http.StatusOK)
707+
708+ ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
709+ if err != nil {
710+ log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs")
711+ _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n")
712+ flusher.Flush()
713+ return
714+ }
715+
716+ for {
717+ select {
718+ case line, ok := <-ch:
719+ if !ok {
720+ return
721+ }
722+ _, _ = fmt.Fprintf(w, "data: %s\n\n", line)
723+ flusher.Flush()
724+ case <-ctx.Done():
725+ return
726+ }
727+ }
728+}
@@ -4,7 +4,9 @@ package admin
4 import (4 import (
5 "encoding/json"5 "encoding/json"
6 "errors"6 "errors"
7+ "fmt"
7 "net/http"8 "net/http"
9+ "strconv"
8 "strings"10 "strings"
9 11
10 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
@@ -17,6 +19,9 @@ import (
17 // maxAdminRequestSize is the maximum allowed size for admin API request bodies.19 // maxAdminRequestSize is the maximum allowed size for admin API request bodies.
18 const maxAdminRequestSize = 1 << 20 // 1MB20 const maxAdminRequestSize = 1 << 20 // 1MB
19 21
22+// maxLogLines is the maximum allowed number of log lines that can be requested.
23+const maxLogLines = 10000
24+
20 // Handler implements the HTTP handler for the admin API.25 // Handler implements the HTTP handler for the admin API.
21 type Handler struct {26 type Handler struct {
22 configSvc in.ConfigService27 configSvc in.ConfigService
@@ -24,6 +29,7 @@ type Handler struct {
24 containerSvc in.ContainerService29 containerSvc in.ContainerService
25 healthSvc in.HealthService30 healthSvc in.HealthService
26 secretSvc in.SecretService31 secretSvc in.SecretService
32+ logSvc in.LogService
27 eventBus out.EventPublisher33 eventBus out.EventPublisher
28 log zerowrap.Logger34 log zerowrap.Logger
29 }35 }
@@ -35,6 +41,7 @@ func NewHandler(
35 containerSvc in.ContainerService,41 containerSvc in.ContainerService,
36 healthSvc in.HealthService,42 healthSvc in.HealthService,
37 secretSvc in.SecretService,43 secretSvc in.SecretService,
44+ logSvc in.LogService,
38 eventBus out.EventPublisher,45 eventBus out.EventPublisher,
39 log zerowrap.Logger,46 log zerowrap.Logger,
40 ) *Handler {47 ) *Handler {
@@ -44,6 +51,7 @@ func NewHandler(
44 containerSvc: containerSvc,51 containerSvc: containerSvc,
45 healthSvc: healthSvc,52 healthSvc: healthSvc,
46 secretSvc: secretSvc,53 secretSvc: secretSvc,
54+ logSvc: logSvc,
47 eventBus: eventBus,55 eventBus: eventBus,
48 log: log,56 log: log,
49 }57 }
@@ -77,6 +85,10 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
77 h.handleRoutes(w, r, path)85 h.handleRoutes(w, r, path)
78 case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):86 case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):
79 h.handleSecrets(w, r, path)87 h.handleSecrets(w, r, path)
88+ case path == "/deploy" || strings.HasPrefix(path, "/deploy/"):
89+ h.handleDeploy(w, r, path)
90+ case path == "/logs" || strings.HasPrefix(path, "/logs/"):
91+ h.handleLogs(w, r, path)
80 case path == "/status":92 case path == "/status":
81 h.handleStatus(w, r)93 h.handleStatus(w, r)
82 case path == "/health":94 case path == "/health":
@@ -484,3 +496,233 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
484 496
485 h.sendJSON(w, http.StatusOK, config)497 h.sendJSON(w, http.StatusOK, config)
486 }498 }
499+
500+// handleDeploy handles /admin/deploy/:domain endpoint.
501+// POST triggers a deployment for the specified domain.
502+func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {
503+ if r.Method != http.MethodPost {
504+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
505+ return
506+ }
507+
508+ ctx := r.Context()
509+ log := zerowrap.FromCtx(ctx)
510+
511+ // Check write permission
512+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
513+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
514+ return
515+ }
516+
517+ // Parse domain from path
518+ deployDomain := strings.TrimPrefix(path, "/deploy/")
519+ if deployDomain == "" || deployDomain == "/deploy" {
520+ h.sendError(w, http.StatusBadRequest, "domain required in path")
521+ return
522+ }
523+
524+ // Get the route for this domain
525+ route, err := h.configSvc.GetRoute(ctx, deployDomain)
526+ if err != nil {
527+ h.sendError(w, http.StatusNotFound, "route not found")
528+ return
529+ }
530+
531+ // Deploy the container
532+ container, err := h.containerSvc.Deploy(ctx, *route)
533+ if err != nil {
534+ log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
535+ h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
536+ return
537+ }
538+
539+ log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
540+ h.sendJSON(w, http.StatusOK, map[string]any{
541+ "status": "deployed",
542+ "container_id": container.ID,
543+ "domain": deployDomain,
544+ })
545+}
546+
547+// handleLogs handles /admin/logs endpoints.
548+// GET /admin/logs - Gordon process logs
549+// GET /admin/logs/:domain - Container logs for a specific domain
550+func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
551+ if r.Method != http.MethodGet {
552+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
553+ return
554+ }
555+
556+ ctx := r.Context()
557+ log := zerowrap.FromCtx(ctx)
558+
559+ // Check read permission
560+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
561+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
562+ return
563+ }
564+
565+ // Check if LogService is available
566+ if h.logSvc == nil {
567+ h.sendError(w, http.StatusServiceUnavailable, "log service not available")
568+ return
569+ }
570+
571+ // Parse query parameters
572+ lines := 50 // default
573+ if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
574+ if n, err := strconv.Atoi(linesStr); err == nil && n > 0 {
575+ lines = n
576+ }
577+ }
578+ if lines > maxLogLines {
579+ lines = maxLogLines
580+ }
581+ follow := r.URL.Query().Get("follow") == "true"
582+
583+ // Parse domain from path
584+ logDomain := strings.TrimPrefix(path, "/logs/")
585+ if logDomain == "/logs" {
586+ logDomain = ""
587+ }
588+
589+ if logDomain == "" {
590+ // Gordon process logs
591+ h.handleProcessLogs(w, r, lines, follow)
592+ } else {
593+ // Container logs
594+ h.handleContainerLogs(w, r, logDomain, lines, follow)
595+ }
596+
597+ // Prevent unused variable warning when follow is implemented
598+ _ = log
599+}
600+
601+// handleProcessLogs handles Gordon process logs.
602+func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) {
603+ ctx := r.Context()
604+ log := zerowrap.FromCtx(ctx)
605+
606+ if follow {
607+ // SSE streaming
608+ h.streamProcessLogs(w, r, lines)
609+ return
610+ }
611+
612+ // Return last N lines as JSON
613+ logLines, err := h.logSvc.GetProcessLogs(ctx, lines)
614+ if err != nil {
615+ log.Warn().Err(err).Msg("failed to get process logs")
616+ h.sendError(w, http.StatusInternalServerError, "failed to get logs")
617+ return
618+ }
619+
620+ h.sendJSON(w, http.StatusOK, map[string]any{
621+ "lines": logLines,
622+ })
623+}
624+
625+// handleContainerLogs handles container logs for a specific domain.
626+func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) {
627+ ctx := r.Context()
628+ log := zerowrap.FromCtx(ctx)
629+
630+ if follow {
631+ // SSE streaming
632+ h.streamContainerLogs(w, r, logDomain, lines)
633+ return
634+ }
635+
636+ // Return last N lines as JSON
637+ logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines)
638+ if err != nil {
639+ log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs")
640+ h.sendError(w, http.StatusInternalServerError, fmt.Sprintf("failed to get logs: %v", err))
641+ return
642+ }
643+
644+ h.sendJSON(w, http.StatusOK, map[string]any{
645+ "domain": logDomain,
646+ "lines": logLines,
647+ })
648+}
649+
650+// streamProcessLogs streams Gordon process logs via SSE.
651+func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) {
652+ ctx := r.Context()
653+ log := zerowrap.FromCtx(ctx)
654+
655+ // Check for flusher support before setting up SSE
656+ flusher, ok := w.(http.Flusher)
657+ if !ok {
658+ h.sendError(w, http.StatusInternalServerError, "streaming not supported")
659+ return
660+ }
661+
662+ // Set up SSE
663+ w.Header().Set("Content-Type", "text/event-stream")
664+ w.Header().Set("Cache-Control", "no-cache")
665+ w.Header().Set("Connection", "keep-alive")
666+ w.WriteHeader(http.StatusOK)
667+
668+ ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
669+ if err != nil {
670+ log.Warn().Err(err).Msg("failed to follow process logs")
671+ _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n")
672+ flusher.Flush()
673+ return
674+ }
675+
676+ for {
677+ select {
678+ case line, ok := <-ch:
679+ if !ok {
680+ return
681+ }
682+ _, _ = fmt.Fprintf(w, "data: %s\n\n", line)
683+ flusher.Flush()
684+ case <-ctx.Done():
685+ return
686+ }
687+ }
688+}
689+
690+// streamContainerLogs streams container logs via SSE.
691+func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) {
692+ ctx := r.Context()
693+ log := zerowrap.FromCtx(ctx)
694+
695+ // Check for flusher support before setting up SSE
696+ flusher, ok := w.(http.Flusher)
697+ if !ok {
698+ h.sendError(w, http.StatusInternalServerError, "streaming not supported")
699+ return
700+ }
701+
702+ // Set up SSE
703+ w.Header().Set("Content-Type", "text/event-stream")
704+ w.Header().Set("Cache-Control", "no-cache")
705+ w.Header().Set("Connection", "keep-alive")
706+ w.WriteHeader(http.StatusOK)
707+
708+ ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
709+ if err != nil {
710+ log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs")
711+ _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n")
712+ flusher.Flush()
713+ return
714+ }
715+
716+ for {
717+ select {
718+ case line, ok := <-ch:
719+ if !ok {
720+ return
721+ }
722+ _, _ = fmt.Fprintf(w, "data: %s\n\n", line)
723+ flusher.Flush()
724+ case <-ctx.Done():
725+ return
726+ }
727+ }
728+}
M internal/adapters/in/http/admin/handler_test.go +20 -20
@@ -33,7 +33,7 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
3333 containerSvc := inmocks.NewMockContainerService(t)
3434 secretSvc := inmocks.NewMockSecretService(t)
3535
36- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
36+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
3737
3838 tests := []struct {
3939 name string
@@ -95,7 +95,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
9595 containerSvc := inmocks.NewMockContainerService(t)
9696 secretSvc := inmocks.NewMockSecretService(t)
9797
98- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
98+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
9999
100100 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
101101
@@ -154,7 +154,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
154154 containerSvc := inmocks.NewMockContainerService(t)
155155 secretSvc := inmocks.NewMockSecretService(t)
156156
157- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
157+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
158158
159159 routeJSON := `{"image": "myapp:v2"}`
160160
@@ -198,7 +198,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
198198 containerSvc := inmocks.NewMockContainerService(t)
199199 secretSvc := inmocks.NewMockSecretService(t)
200200
201- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
201+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
202202
203203 tests := []struct {
204204 name string
@@ -242,7 +242,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
242242 containerSvc := inmocks.NewMockContainerService(t)
243243 secretSvc := inmocks.NewMockSecretService(t)
244244
245- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
245+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
246246
247247 tests := []struct {
248248 name string
@@ -299,7 +299,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
299299 containerSvc := inmocks.NewMockContainerService(t)
300300 secretSvc := inmocks.NewMockSecretService(t)
301301
302- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
302+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
303303
304304 secretsJSON := `{"API_KEY": "secret123"}`
305305
@@ -348,7 +348,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
348348 containerSvc := inmocks.NewMockContainerService(t)
349349 secretSvc := inmocks.NewMockSecretService(t)
350350
351- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
351+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
352352
353353 tests := []struct {
354354 name string
@@ -392,7 +392,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
392392 containerSvc := inmocks.NewMockContainerService(t)
393393 secretSvc := inmocks.NewMockSecretService(t)
394394
395- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
395+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
396396
397397 tests := []struct {
398398 name string
@@ -446,7 +446,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
446446 containerSvc := inmocks.NewMockContainerService(t)
447447 secretSvc := inmocks.NewMockSecretService(t)
448448
449- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
449+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
450450
451451 tests := []struct {
452452 name string
@@ -503,7 +503,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
503503 containerSvc := inmocks.NewMockContainerService(t)
504504 secretSvc := inmocks.NewMockSecretService(t)
505505
506- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
506+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
507507
508508 tests := []struct {
509509 name string
@@ -557,7 +557,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
557557 containerSvc := inmocks.NewMockContainerService(t)
558558 secretSvc := inmocks.NewMockSecretService(t)
559559
560- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
560+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
561561
562562 expectedRoutes := []domain.Route{
563563 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -588,7 +588,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
588588 containerSvc := inmocks.NewMockContainerService(t)
589589 secretSvc := inmocks.NewMockSecretService(t)
590590
591- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
591+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
592592
593593 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
594594 Domain: "app.example.com", Image: "app:latest",
@@ -615,7 +615,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
615615 containerSvc := inmocks.NewMockContainerService(t)
616616 secretSvc := inmocks.NewMockSecretService(t)
617617
618- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
618+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
619619
620620 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
621621
@@ -658,7 +658,7 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
658658 containerSvc := inmocks.NewMockContainerService(t)
659659 secretSvc := inmocks.NewMockSecretService(t)
660660
661- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
661+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
662662
663663 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
664664
@@ -679,7 +679,7 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
679679 containerSvc := inmocks.NewMockContainerService(t)
680680 secretSvc := inmocks.NewMockSecretService(t)
681681
682- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
682+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
683683
684684 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
685685 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -696,7 +696,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
696696 containerSvc := inmocks.NewMockContainerService(t)
697697 secretSvc := inmocks.NewMockSecretService(t)
698698
699- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
699+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
700700
701701 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
702702 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -713,7 +713,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
713713 containerSvc := inmocks.NewMockContainerService(t)
714714 secretSvc := inmocks.NewMockSecretService(t)
715715
716- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
716+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
717717
718718 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
719719 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -730,7 +730,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
730730 containerSvc := inmocks.NewMockContainerService(t)
731731 secretSvc := inmocks.NewMockSecretService(t)
732732
733- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
733+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
734734
735735 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
736736 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -747,7 +747,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
747747 containerSvc := inmocks.NewMockContainerService(t)
748748 secretSvc := inmocks.NewMockSecretService(t)
749749
750- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
750+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
751751
752752 tests := []struct {
753753 method string
@@ -780,7 +780,7 @@ func TestHandler_NotFound(t *testing.T) {
780780 containerSvc := inmocks.NewMockContainerService(t)
781781 secretSvc := inmocks.NewMockSecretService(t)
782782
783- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())
783+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
784784
785785 req := httptest.NewRequest("GET", "/admin/unknown", nil)
786786 req = req.WithContext(ctxWithScopes("admin:*:*"))
@@ -33,7 +33,7 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
33 containerSvc := inmocks.NewMockContainerService(t)33 containerSvc := inmocks.NewMockContainerService(t)
34 secretSvc := inmocks.NewMockSecretService(t)34 secretSvc := inmocks.NewMockSecretService(t)
35 35
36- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())36+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
37 37
38 tests := []struct {38 tests := []struct {
39 name string39 name string
@@ -95,7 +95,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
95 containerSvc := inmocks.NewMockContainerService(t)95 containerSvc := inmocks.NewMockContainerService(t)
96 secretSvc := inmocks.NewMockSecretService(t)96 secretSvc := inmocks.NewMockSecretService(t)
97 97
98- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())98+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
99 99
100 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`100 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
101 101
@@ -154,7 +154,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
154 containerSvc := inmocks.NewMockContainerService(t)154 containerSvc := inmocks.NewMockContainerService(t)
155 secretSvc := inmocks.NewMockSecretService(t)155 secretSvc := inmocks.NewMockSecretService(t)
156 156
157- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())157+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
158 158
159 routeJSON := `{"image": "myapp:v2"}`159 routeJSON := `{"image": "myapp:v2"}`
160 160
@@ -198,7 +198,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
198 containerSvc := inmocks.NewMockContainerService(t)198 containerSvc := inmocks.NewMockContainerService(t)
199 secretSvc := inmocks.NewMockSecretService(t)199 secretSvc := inmocks.NewMockSecretService(t)
200 200
201- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())201+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
202 202
203 tests := []struct {203 tests := []struct {
204 name string204 name string
@@ -242,7 +242,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
242 containerSvc := inmocks.NewMockContainerService(t)242 containerSvc := inmocks.NewMockContainerService(t)
243 secretSvc := inmocks.NewMockSecretService(t)243 secretSvc := inmocks.NewMockSecretService(t)
244 244
245- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())245+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
246 246
247 tests := []struct {247 tests := []struct {
248 name string248 name string
@@ -299,7 +299,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
299 containerSvc := inmocks.NewMockContainerService(t)299 containerSvc := inmocks.NewMockContainerService(t)
300 secretSvc := inmocks.NewMockSecretService(t)300 secretSvc := inmocks.NewMockSecretService(t)
301 301
302- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())302+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
303 303
304 secretsJSON := `{"API_KEY": "secret123"}`304 secretsJSON := `{"API_KEY": "secret123"}`
305 305
@@ -348,7 +348,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
348 containerSvc := inmocks.NewMockContainerService(t)348 containerSvc := inmocks.NewMockContainerService(t)
349 secretSvc := inmocks.NewMockSecretService(t)349 secretSvc := inmocks.NewMockSecretService(t)
350 350
351- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())351+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
352 352
353 tests := []struct {353 tests := []struct {
354 name string354 name string
@@ -392,7 +392,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
392 containerSvc := inmocks.NewMockContainerService(t)392 containerSvc := inmocks.NewMockContainerService(t)
393 secretSvc := inmocks.NewMockSecretService(t)393 secretSvc := inmocks.NewMockSecretService(t)
394 394
395- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())395+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
396 396
397 tests := []struct {397 tests := []struct {
398 name string398 name string
@@ -446,7 +446,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
446 containerSvc := inmocks.NewMockContainerService(t)446 containerSvc := inmocks.NewMockContainerService(t)
447 secretSvc := inmocks.NewMockSecretService(t)447 secretSvc := inmocks.NewMockSecretService(t)
448 448
449- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())449+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
450 450
451 tests := []struct {451 tests := []struct {
452 name string452 name string
@@ -503,7 +503,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
503 containerSvc := inmocks.NewMockContainerService(t)503 containerSvc := inmocks.NewMockContainerService(t)
504 secretSvc := inmocks.NewMockSecretService(t)504 secretSvc := inmocks.NewMockSecretService(t)
505 505
506- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())506+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
507 507
508 tests := []struct {508 tests := []struct {
509 name string509 name string
@@ -557,7 +557,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
557 containerSvc := inmocks.NewMockContainerService(t)557 containerSvc := inmocks.NewMockContainerService(t)
558 secretSvc := inmocks.NewMockSecretService(t)558 secretSvc := inmocks.NewMockSecretService(t)
559 559
560- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())560+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
561 561
562 expectedRoutes := []domain.Route{562 expectedRoutes := []domain.Route{
563 {Domain: "app1.example.com", Image: "app1:latest"},563 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -588,7 +588,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
588 containerSvc := inmocks.NewMockContainerService(t)588 containerSvc := inmocks.NewMockContainerService(t)
589 secretSvc := inmocks.NewMockSecretService(t)589 secretSvc := inmocks.NewMockSecretService(t)
590 590
591- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())591+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
592 592
593 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{593 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
594 Domain: "app.example.com", Image: "app:latest",594 Domain: "app.example.com", Image: "app:latest",
@@ -615,7 +615,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
615 containerSvc := inmocks.NewMockContainerService(t)615 containerSvc := inmocks.NewMockContainerService(t)
616 secretSvc := inmocks.NewMockSecretService(t)616 secretSvc := inmocks.NewMockSecretService(t)
617 617
618- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())618+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
619 619
620 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)620 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
621 621
@@ -658,7 +658,7 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
658 containerSvc := inmocks.NewMockContainerService(t)658 containerSvc := inmocks.NewMockContainerService(t)
659 secretSvc := inmocks.NewMockSecretService(t)659 secretSvc := inmocks.NewMockSecretService(t)
660 660
661- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())661+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
662 662
663 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)663 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
664 664
@@ -679,7 +679,7 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
679 containerSvc := inmocks.NewMockContainerService(t)679 containerSvc := inmocks.NewMockContainerService(t)
680 secretSvc := inmocks.NewMockSecretService(t)680 secretSvc := inmocks.NewMockSecretService(t)
681 681
682- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())682+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
683 683
684 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))684 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
685 req = req.WithContext(ctxWithScopes("admin:routes:write"))685 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -696,7 +696,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
696 containerSvc := inmocks.NewMockContainerService(t)696 containerSvc := inmocks.NewMockContainerService(t)
697 secretSvc := inmocks.NewMockSecretService(t)697 secretSvc := inmocks.NewMockSecretService(t)
698 698
699- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())699+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
700 700
701 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)701 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
702 req = req.WithContext(ctxWithScopes("admin:routes:write"))702 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -713,7 +713,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
713 containerSvc := inmocks.NewMockContainerService(t)713 containerSvc := inmocks.NewMockContainerService(t)
714 secretSvc := inmocks.NewMockSecretService(t)714 secretSvc := inmocks.NewMockSecretService(t)
715 715
716- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())716+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
717 717
718 req := httptest.NewRequest("GET", "/admin/secrets/", nil)718 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
719 req = req.WithContext(ctxWithScopes("admin:secrets:read"))719 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -730,7 +730,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
730 containerSvc := inmocks.NewMockContainerService(t)730 containerSvc := inmocks.NewMockContainerService(t)
731 secretSvc := inmocks.NewMockSecretService(t)731 secretSvc := inmocks.NewMockSecretService(t)
732 732
733- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())733+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
734 734
735 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)735 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
736 req = req.WithContext(ctxWithScopes("admin:secrets:write"))736 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -747,7 +747,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
747 containerSvc := inmocks.NewMockContainerService(t)747 containerSvc := inmocks.NewMockContainerService(t)
748 secretSvc := inmocks.NewMockSecretService(t)748 secretSvc := inmocks.NewMockSecretService(t)
749 749
750- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())750+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
751 751
752 tests := []struct {752 tests := []struct {
753 method string753 method string
@@ -780,7 +780,7 @@ func TestHandler_NotFound(t *testing.T) {
780 containerSvc := inmocks.NewMockContainerService(t)780 containerSvc := inmocks.NewMockContainerService(t)
781 secretSvc := inmocks.NewMockSecretService(t)781 secretSvc := inmocks.NewMockSecretService(t)
782 782
783- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger())783+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
784 784
785 req := httptest.NewRequest("GET", "/admin/unknown", nil)785 req := httptest.NewRequest("GET", "/admin/unknown", nil)
786 req = req.WithContext(ctxWithScopes("admin:*:*"))786 req = req.WithContext(ctxWithScopes("admin:*:*"))
M internal/app/run.go +6 -1
@@ -48,6 +48,7 @@ import (
4848 "gordon/internal/usecase/config"
4949 "gordon/internal/usecase/container"
5050 "gordon/internal/usecase/health"
51+ "gordon/internal/usecase/logs"
5152 "gordon/internal/usecase/proxy"
5253 registrySvc "gordon/internal/usecase/registry"
5354 secretsSvc "gordon/internal/usecase/secrets"
@@ -311,8 +312,12 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
311312 prober := httpprober.New()
312313 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
313314
315+ // Create log service for accessing logs via admin API
316+ logFilePath := cfg.Logging.File.Path
317+ logSvc := logs.NewService(logFilePath, svc.containerSvc, svc.runtime, log)
318+
314319 // Create admin handler for admin API
315- svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, svc.eventBus, log)
320+ svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.eventBus, log)
316321
317322 return svc, nil
318323 }
@@ -48,6 +48,7 @@ import (
48 "gordon/internal/usecase/config"48 "gordon/internal/usecase/config"
49 "gordon/internal/usecase/container"49 "gordon/internal/usecase/container"
50 "gordon/internal/usecase/health"50 "gordon/internal/usecase/health"
51+ "gordon/internal/usecase/logs"
51 "gordon/internal/usecase/proxy"52 "gordon/internal/usecase/proxy"
52 registrySvc "gordon/internal/usecase/registry"53 registrySvc "gordon/internal/usecase/registry"
53 secretsSvc "gordon/internal/usecase/secrets"54 secretsSvc "gordon/internal/usecase/secrets"
@@ -311,8 +312,12 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
311 prober := httpprober.New()312 prober := httpprober.New()
312 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)313 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
313 314
315+ // Create log service for accessing logs via admin API
316+ logFilePath := cfg.Logging.File.Path
317+ logSvc := logs.NewService(logFilePath, svc.containerSvc, svc.runtime, log)
318+
314 // Create admin handler for admin API319 // Create admin handler for admin API
315- svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, svc.eventBus, log)320+ svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.eventBus, log)
316 321
317 return svc, nil322 return svc, nil
318 }323 }
A internal/boundaries/in/logs.go +20 -0
@@ -0,0 +1,20 @@
1+package in
2+
3+import "context"
4+
5+// LogService defines the contract for accessing logs.
6+type LogService interface {
7+ // GetProcessLogs returns the last N lines of Gordon process logs.
8+ GetProcessLogs(ctx context.Context, lines int) ([]string, error)
9+
10+ // FollowProcessLogs returns a channel that streams Gordon process log lines.
11+ // The channel is closed when the context is canceled.
12+ FollowProcessLogs(ctx context.Context, initialLines int) (<-chan string, error)
13+
14+ // GetContainerLogs returns the last N lines of container logs for a domain.
15+ GetContainerLogs(ctx context.Context, domain string, lines int) ([]string, error)
16+
17+ // FollowContainerLogs returns a channel that streams container log lines.
18+ // The channel is closed when the context is canceled.
19+ FollowContainerLogs(ctx context.Context, domain string, initialLines int) (<-chan string, error)
20+}
@@ -0,0 +1,20 @@
1+package in
2+
3+import "context"
4+
5+// LogService defines the contract for accessing logs.
6+type LogService interface {
7+ // GetProcessLogs returns the last N lines of Gordon process logs.
8+ GetProcessLogs(ctx context.Context, lines int) ([]string, error)
9+
10+ // FollowProcessLogs returns a channel that streams Gordon process log lines.
11+ // The channel is closed when the context is canceled.
12+ FollowProcessLogs(ctx context.Context, initialLines int) (<-chan string, error)
13+
14+ // GetContainerLogs returns the last N lines of container logs for a domain.
15+ GetContainerLogs(ctx context.Context, domain string, lines int) ([]string, error)
16+
17+ // FollowContainerLogs returns a channel that streams container log lines.
18+ // The channel is closed when the context is canceled.
19+ FollowContainerLogs(ctx context.Context, domain string, initialLines int) (<-chan string, error)
20+}
A internal/boundaries/in/mocks/mock_log_service.go +322 -0
@@ -0,0 +1,322 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockLogService creates a new instance of MockLogService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockLogService(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockLogService {
19+ mock := &MockLogService{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockLogService is an autogenerated mock type for the LogService type
28+type MockLogService struct {
29+ mock.Mock
30+}
31+
32+type MockLogService_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockLogService) EXPECT() *MockLogService_Expecter {
37+ return &MockLogService_Expecter{mock: &_m.Mock}
38+}
39+
40+// FollowContainerLogs provides a mock function for the type MockLogService
41+func (_mock *MockLogService) FollowContainerLogs(ctx context.Context, domain string, initialLines int) (<-chan string, error) {
42+ ret := _mock.Called(ctx, domain, initialLines)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for FollowContainerLogs")
46+ }
47+
48+ var r0 <-chan string
49+ var r1 error
50+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) (<-chan string, error)); ok {
51+ return returnFunc(ctx, domain, initialLines)
52+ }
53+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) <-chan string); ok {
54+ r0 = returnFunc(ctx, domain, initialLines)
55+ } else {
56+ if ret.Get(0) != nil {
57+ r0 = ret.Get(0).(<-chan string)
58+ }
59+ }
60+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok {
61+ r1 = returnFunc(ctx, domain, initialLines)
62+ } else {
63+ r1 = ret.Error(1)
64+ }
65+ return r0, r1
66+}
67+
68+// MockLogService_FollowContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FollowContainerLogs'
69+type MockLogService_FollowContainerLogs_Call struct {
70+ *mock.Call
71+}
72+
73+// FollowContainerLogs is a helper method to define mock.On call
74+// - ctx context.Context
75+// - domain string
76+// - initialLines int
77+func (_e *MockLogService_Expecter) FollowContainerLogs(ctx interface{}, domain interface{}, initialLines interface{}) *MockLogService_FollowContainerLogs_Call {
78+ return &MockLogService_FollowContainerLogs_Call{Call: _e.mock.On("FollowContainerLogs", ctx, domain, initialLines)}
79+}
80+
81+func (_c *MockLogService_FollowContainerLogs_Call) Run(run func(ctx context.Context, domain string, initialLines int)) *MockLogService_FollowContainerLogs_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 string
88+ if args[1] != nil {
89+ arg1 = args[1].(string)
90+ }
91+ var arg2 int
92+ if args[2] != nil {
93+ arg2 = args[2].(int)
94+ }
95+ run(
96+ arg0,
97+ arg1,
98+ arg2,
99+ )
100+ })
101+ return _c
102+}
103+
104+func (_c *MockLogService_FollowContainerLogs_Call) Return(stringCh <-chan string, err error) *MockLogService_FollowContainerLogs_Call {
105+ _c.Call.Return(stringCh, err)
106+ return _c
107+}
108+
109+func (_c *MockLogService_FollowContainerLogs_Call) RunAndReturn(run func(ctx context.Context, domain string, initialLines int) (<-chan string, error)) *MockLogService_FollowContainerLogs_Call {
110+ _c.Call.Return(run)
111+ return _c
112+}
113+
114+// FollowProcessLogs provides a mock function for the type MockLogService
115+func (_mock *MockLogService) FollowProcessLogs(ctx context.Context, initialLines int) (<-chan string, error) {
116+ ret := _mock.Called(ctx, initialLines)
117+
118+ if len(ret) == 0 {
119+ panic("no return value specified for FollowProcessLogs")
120+ }
121+
122+ var r0 <-chan string
123+ var r1 error
124+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) (<-chan string, error)); ok {
125+ return returnFunc(ctx, initialLines)
126+ }
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) <-chan string); ok {
128+ r0 = returnFunc(ctx, initialLines)
129+ } else {
130+ if ret.Get(0) != nil {
131+ r0 = ret.Get(0).(<-chan string)
132+ }
133+ }
134+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
135+ r1 = returnFunc(ctx, initialLines)
136+ } else {
137+ r1 = ret.Error(1)
138+ }
139+ return r0, r1
140+}
141+
142+// MockLogService_FollowProcessLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FollowProcessLogs'
143+type MockLogService_FollowProcessLogs_Call struct {
144+ *mock.Call
145+}
146+
147+// FollowProcessLogs is a helper method to define mock.On call
148+// - ctx context.Context
149+// - initialLines int
150+func (_e *MockLogService_Expecter) FollowProcessLogs(ctx interface{}, initialLines interface{}) *MockLogService_FollowProcessLogs_Call {
151+ return &MockLogService_FollowProcessLogs_Call{Call: _e.mock.On("FollowProcessLogs", ctx, initialLines)}
152+}
153+
154+func (_c *MockLogService_FollowProcessLogs_Call) Run(run func(ctx context.Context, initialLines int)) *MockLogService_FollowProcessLogs_Call {
155+ _c.Call.Run(func(args mock.Arguments) {
156+ var arg0 context.Context
157+ if args[0] != nil {
158+ arg0 = args[0].(context.Context)
159+ }
160+ var arg1 int
161+ if args[1] != nil {
162+ arg1 = args[1].(int)
163+ }
164+ run(
165+ arg0,
166+ arg1,
167+ )
168+ })
169+ return _c
170+}
171+
172+func (_c *MockLogService_FollowProcessLogs_Call) Return(stringCh <-chan string, err error) *MockLogService_FollowProcessLogs_Call {
173+ _c.Call.Return(stringCh, err)
174+ return _c
175+}
176+
177+func (_c *MockLogService_FollowProcessLogs_Call) RunAndReturn(run func(ctx context.Context, initialLines int) (<-chan string, error)) *MockLogService_FollowProcessLogs_Call {
178+ _c.Call.Return(run)
179+ return _c
180+}
181+
182+// GetContainerLogs provides a mock function for the type MockLogService
183+func (_mock *MockLogService) GetContainerLogs(ctx context.Context, domain string, lines int) ([]string, error) {
184+ ret := _mock.Called(ctx, domain, lines)
185+
186+ if len(ret) == 0 {
187+ panic("no return value specified for GetContainerLogs")
188+ }
189+
190+ var r0 []string
191+ var r1 error
192+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) ([]string, error)); ok {
193+ return returnFunc(ctx, domain, lines)
194+ }
195+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) []string); ok {
196+ r0 = returnFunc(ctx, domain, lines)
197+ } else {
198+ if ret.Get(0) != nil {
199+ r0 = ret.Get(0).([]string)
200+ }
201+ }
202+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok {
203+ r1 = returnFunc(ctx, domain, lines)
204+ } else {
205+ r1 = ret.Error(1)
206+ }
207+ return r0, r1
208+}
209+
210+// MockLogService_GetContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetContainerLogs'
211+type MockLogService_GetContainerLogs_Call struct {
212+ *mock.Call
213+}
214+
215+// GetContainerLogs is a helper method to define mock.On call
216+// - ctx context.Context
217+// - domain string
218+// - lines int
219+func (_e *MockLogService_Expecter) GetContainerLogs(ctx interface{}, domain interface{}, lines interface{}) *MockLogService_GetContainerLogs_Call {
220+ return &MockLogService_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, domain, lines)}
221+}
222+
223+func (_c *MockLogService_GetContainerLogs_Call) Run(run func(ctx context.Context, domain string, lines int)) *MockLogService_GetContainerLogs_Call {
224+ _c.Call.Run(func(args mock.Arguments) {
225+ var arg0 context.Context
226+ if args[0] != nil {
227+ arg0 = args[0].(context.Context)
228+ }
229+ var arg1 string
230+ if args[1] != nil {
231+ arg1 = args[1].(string)
232+ }
233+ var arg2 int
234+ if args[2] != nil {
235+ arg2 = args[2].(int)
236+ }
237+ run(
238+ arg0,
239+ arg1,
240+ arg2,
241+ )
242+ })
243+ return _c
244+}
245+
246+func (_c *MockLogService_GetContainerLogs_Call) Return(strings []string, err error) *MockLogService_GetContainerLogs_Call {
247+ _c.Call.Return(strings, err)
248+ return _c
249+}
250+
251+func (_c *MockLogService_GetContainerLogs_Call) RunAndReturn(run func(ctx context.Context, domain string, lines int) ([]string, error)) *MockLogService_GetContainerLogs_Call {
252+ _c.Call.Return(run)
253+ return _c
254+}
255+
256+// GetProcessLogs provides a mock function for the type MockLogService
257+func (_mock *MockLogService) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
258+ ret := _mock.Called(ctx, lines)
259+
260+ if len(ret) == 0 {
261+ panic("no return value specified for GetProcessLogs")
262+ }
263+
264+ var r0 []string
265+ var r1 error
266+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]string, error)); ok {
267+ return returnFunc(ctx, lines)
268+ }
269+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []string); ok {
270+ r0 = returnFunc(ctx, lines)
271+ } else {
272+ if ret.Get(0) != nil {
273+ r0 = ret.Get(0).([]string)
274+ }
275+ }
276+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
277+ r1 = returnFunc(ctx, lines)
278+ } else {
279+ r1 = ret.Error(1)
280+ }
281+ return r0, r1
282+}
283+
284+// MockLogService_GetProcessLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProcessLogs'
285+type MockLogService_GetProcessLogs_Call struct {
286+ *mock.Call
287+}
288+
289+// GetProcessLogs is a helper method to define mock.On call
290+// - ctx context.Context
291+// - lines int
292+func (_e *MockLogService_Expecter) GetProcessLogs(ctx interface{}, lines interface{}) *MockLogService_GetProcessLogs_Call {
293+ return &MockLogService_GetProcessLogs_Call{Call: _e.mock.On("GetProcessLogs", ctx, lines)}
294+}
295+
296+func (_c *MockLogService_GetProcessLogs_Call) Run(run func(ctx context.Context, lines int)) *MockLogService_GetProcessLogs_Call {
297+ _c.Call.Run(func(args mock.Arguments) {
298+ var arg0 context.Context
299+ if args[0] != nil {
300+ arg0 = args[0].(context.Context)
301+ }
302+ var arg1 int
303+ if args[1] != nil {
304+ arg1 = args[1].(int)
305+ }
306+ run(
307+ arg0,
308+ arg1,
309+ )
310+ })
311+ return _c
312+}
313+
314+func (_c *MockLogService_GetProcessLogs_Call) Return(strings []string, err error) *MockLogService_GetProcessLogs_Call {
315+ _c.Call.Return(strings, err)
316+ return _c
317+}
318+
319+func (_c *MockLogService_GetProcessLogs_Call) RunAndReturn(run func(ctx context.Context, lines int) ([]string, error)) *MockLogService_GetProcessLogs_Call {
320+ _c.Call.Return(run)
321+ return _c
322+}
@@ -0,0 +1,322 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockLogService creates a new instance of MockLogService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockLogService(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockLogService {
19+ mock := &MockLogService{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockLogService is an autogenerated mock type for the LogService type
28+type MockLogService struct {
29+ mock.Mock
30+}
31+
32+type MockLogService_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockLogService) EXPECT() *MockLogService_Expecter {
37+ return &MockLogService_Expecter{mock: &_m.Mock}
38+}
39+
40+// FollowContainerLogs provides a mock function for the type MockLogService
41+func (_mock *MockLogService) FollowContainerLogs(ctx context.Context, domain string, initialLines int) (<-chan string, error) {
42+ ret := _mock.Called(ctx, domain, initialLines)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for FollowContainerLogs")
46+ }
47+
48+ var r0 <-chan string
49+ var r1 error
50+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) (<-chan string, error)); ok {
51+ return returnFunc(ctx, domain, initialLines)
52+ }
53+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) <-chan string); ok {
54+ r0 = returnFunc(ctx, domain, initialLines)
55+ } else {
56+ if ret.Get(0) != nil {
57+ r0 = ret.Get(0).(<-chan string)
58+ }
59+ }
60+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok {
61+ r1 = returnFunc(ctx, domain, initialLines)
62+ } else {
63+ r1 = ret.Error(1)
64+ }
65+ return r0, r1
66+}
67+
68+// MockLogService_FollowContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FollowContainerLogs'
69+type MockLogService_FollowContainerLogs_Call struct {
70+ *mock.Call
71+}
72+
73+// FollowContainerLogs is a helper method to define mock.On call
74+// - ctx context.Context
75+// - domain string
76+// - initialLines int
77+func (_e *MockLogService_Expecter) FollowContainerLogs(ctx interface{}, domain interface{}, initialLines interface{}) *MockLogService_FollowContainerLogs_Call {
78+ return &MockLogService_FollowContainerLogs_Call{Call: _e.mock.On("FollowContainerLogs", ctx, domain, initialLines)}
79+}
80+
81+func (_c *MockLogService_FollowContainerLogs_Call) Run(run func(ctx context.Context, domain string, initialLines int)) *MockLogService_FollowContainerLogs_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 string
88+ if args[1] != nil {
89+ arg1 = args[1].(string)
90+ }
91+ var arg2 int
92+ if args[2] != nil {
93+ arg2 = args[2].(int)
94+ }
95+ run(
96+ arg0,
97+ arg1,
98+ arg2,
99+ )
100+ })
101+ return _c
102+}
103+
104+func (_c *MockLogService_FollowContainerLogs_Call) Return(stringCh <-chan string, err error) *MockLogService_FollowContainerLogs_Call {
105+ _c.Call.Return(stringCh, err)
106+ return _c
107+}
108+
109+func (_c *MockLogService_FollowContainerLogs_Call) RunAndReturn(run func(ctx context.Context, domain string, initialLines int) (<-chan string, error)) *MockLogService_FollowContainerLogs_Call {
110+ _c.Call.Return(run)
111+ return _c
112+}
113+
114+// FollowProcessLogs provides a mock function for the type MockLogService
115+func (_mock *MockLogService) FollowProcessLogs(ctx context.Context, initialLines int) (<-chan string, error) {
116+ ret := _mock.Called(ctx, initialLines)
117+
118+ if len(ret) == 0 {
119+ panic("no return value specified for FollowProcessLogs")
120+ }
121+
122+ var r0 <-chan string
123+ var r1 error
124+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) (<-chan string, error)); ok {
125+ return returnFunc(ctx, initialLines)
126+ }
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) <-chan string); ok {
128+ r0 = returnFunc(ctx, initialLines)
129+ } else {
130+ if ret.Get(0) != nil {
131+ r0 = ret.Get(0).(<-chan string)
132+ }
133+ }
134+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
135+ r1 = returnFunc(ctx, initialLines)
136+ } else {
137+ r1 = ret.Error(1)
138+ }
139+ return r0, r1
140+}
141+
142+// MockLogService_FollowProcessLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FollowProcessLogs'
143+type MockLogService_FollowProcessLogs_Call struct {
144+ *mock.Call
145+}
146+
147+// FollowProcessLogs is a helper method to define mock.On call
148+// - ctx context.Context
149+// - initialLines int
150+func (_e *MockLogService_Expecter) FollowProcessLogs(ctx interface{}, initialLines interface{}) *MockLogService_FollowProcessLogs_Call {
151+ return &MockLogService_FollowProcessLogs_Call{Call: _e.mock.On("FollowProcessLogs", ctx, initialLines)}
152+}
153+
154+func (_c *MockLogService_FollowProcessLogs_Call) Run(run func(ctx context.Context, initialLines int)) *MockLogService_FollowProcessLogs_Call {
155+ _c.Call.Run(func(args mock.Arguments) {
156+ var arg0 context.Context
157+ if args[0] != nil {
158+ arg0 = args[0].(context.Context)
159+ }
160+ var arg1 int
161+ if args[1] != nil {
162+ arg1 = args[1].(int)
163+ }
164+ run(
165+ arg0,
166+ arg1,
167+ )
168+ })
169+ return _c
170+}
171+
172+func (_c *MockLogService_FollowProcessLogs_Call) Return(stringCh <-chan string, err error) *MockLogService_FollowProcessLogs_Call {
173+ _c.Call.Return(stringCh, err)
174+ return _c
175+}
176+
177+func (_c *MockLogService_FollowProcessLogs_Call) RunAndReturn(run func(ctx context.Context, initialLines int) (<-chan string, error)) *MockLogService_FollowProcessLogs_Call {
178+ _c.Call.Return(run)
179+ return _c
180+}
181+
182+// GetContainerLogs provides a mock function for the type MockLogService
183+func (_mock *MockLogService) GetContainerLogs(ctx context.Context, domain string, lines int) ([]string, error) {
184+ ret := _mock.Called(ctx, domain, lines)
185+
186+ if len(ret) == 0 {
187+ panic("no return value specified for GetContainerLogs")
188+ }
189+
190+ var r0 []string
191+ var r1 error
192+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) ([]string, error)); ok {
193+ return returnFunc(ctx, domain, lines)
194+ }
195+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) []string); ok {
196+ r0 = returnFunc(ctx, domain, lines)
197+ } else {
198+ if ret.Get(0) != nil {
199+ r0 = ret.Get(0).([]string)
200+ }
201+ }
202+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok {
203+ r1 = returnFunc(ctx, domain, lines)
204+ } else {
205+ r1 = ret.Error(1)
206+ }
207+ return r0, r1
208+}
209+
210+// MockLogService_GetContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetContainerLogs'
211+type MockLogService_GetContainerLogs_Call struct {
212+ *mock.Call
213+}
214+
215+// GetContainerLogs is a helper method to define mock.On call
216+// - ctx context.Context
217+// - domain string
218+// - lines int
219+func (_e *MockLogService_Expecter) GetContainerLogs(ctx interface{}, domain interface{}, lines interface{}) *MockLogService_GetContainerLogs_Call {
220+ return &MockLogService_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, domain, lines)}
221+}
222+
223+func (_c *MockLogService_GetContainerLogs_Call) Run(run func(ctx context.Context, domain string, lines int)) *MockLogService_GetContainerLogs_Call {
224+ _c.Call.Run(func(args mock.Arguments) {
225+ var arg0 context.Context
226+ if args[0] != nil {
227+ arg0 = args[0].(context.Context)
228+ }
229+ var arg1 string
230+ if args[1] != nil {
231+ arg1 = args[1].(string)
232+ }
233+ var arg2 int
234+ if args[2] != nil {
235+ arg2 = args[2].(int)
236+ }
237+ run(
238+ arg0,
239+ arg1,
240+ arg2,
241+ )
242+ })
243+ return _c
244+}
245+
246+func (_c *MockLogService_GetContainerLogs_Call) Return(strings []string, err error) *MockLogService_GetContainerLogs_Call {
247+ _c.Call.Return(strings, err)
248+ return _c
249+}
250+
251+func (_c *MockLogService_GetContainerLogs_Call) RunAndReturn(run func(ctx context.Context, domain string, lines int) ([]string, error)) *MockLogService_GetContainerLogs_Call {
252+ _c.Call.Return(run)
253+ return _c
254+}
255+
256+// GetProcessLogs provides a mock function for the type MockLogService
257+func (_mock *MockLogService) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
258+ ret := _mock.Called(ctx, lines)
259+
260+ if len(ret) == 0 {
261+ panic("no return value specified for GetProcessLogs")
262+ }
263+
264+ var r0 []string
265+ var r1 error
266+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]string, error)); ok {
267+ return returnFunc(ctx, lines)
268+ }
269+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []string); ok {
270+ r0 = returnFunc(ctx, lines)
271+ } else {
272+ if ret.Get(0) != nil {
273+ r0 = ret.Get(0).([]string)
274+ }
275+ }
276+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
277+ r1 = returnFunc(ctx, lines)
278+ } else {
279+ r1 = ret.Error(1)
280+ }
281+ return r0, r1
282+}
283+
284+// MockLogService_GetProcessLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProcessLogs'
285+type MockLogService_GetProcessLogs_Call struct {
286+ *mock.Call
287+}
288+
289+// GetProcessLogs is a helper method to define mock.On call
290+// - ctx context.Context
291+// - lines int
292+func (_e *MockLogService_Expecter) GetProcessLogs(ctx interface{}, lines interface{}) *MockLogService_GetProcessLogs_Call {
293+ return &MockLogService_GetProcessLogs_Call{Call: _e.mock.On("GetProcessLogs", ctx, lines)}
294+}
295+
296+func (_c *MockLogService_GetProcessLogs_Call) Run(run func(ctx context.Context, lines int)) *MockLogService_GetProcessLogs_Call {
297+ _c.Call.Run(func(args mock.Arguments) {
298+ var arg0 context.Context
299+ if args[0] != nil {
300+ arg0 = args[0].(context.Context)
301+ }
302+ var arg1 int
303+ if args[1] != nil {
304+ arg1 = args[1].(int)
305+ }
306+ run(
307+ arg0,
308+ arg1,
309+ )
310+ })
311+ return _c
312+}
313+
314+func (_c *MockLogService_GetProcessLogs_Call) Return(strings []string, err error) *MockLogService_GetProcessLogs_Call {
315+ _c.Call.Return(strings, err)
316+ return _c
317+}
318+
319+func (_c *MockLogService_GetProcessLogs_Call) RunAndReturn(run func(ctx context.Context, lines int) ([]string, error)) *MockLogService_GetProcessLogs_Call {
320+ _c.Call.Return(run)
321+ return _c
322+}
A internal/usecase/logs/service.go +382 -0
@@ -0,0 +1,382 @@
1+// Package logs implements the log access use case.
2+package logs
3+
4+import (
5+ "bufio"
6+ "context"
7+ "encoding/binary"
8+ "fmt"
9+ "io"
10+ "os"
11+ "strings"
12+ "time"
13+
14+ "github.com/bnema/zerowrap"
15+
16+ "gordon/internal/boundaries/in"
17+ "gordon/internal/boundaries/out"
18+)
19+
20+// Service implements the LogService interface.
21+type Service struct {
22+ logFilePath string
23+ containerSvc in.ContainerService
24+ runtime out.ContainerRuntime
25+ log zerowrap.Logger
26+}
27+
28+// NewService creates a new log service.
29+func NewService(
30+ logFilePath string,
31+ containerSvc in.ContainerService,
32+ runtime out.ContainerRuntime,
33+ log zerowrap.Logger,
34+) *Service {
35+ return &Service{
36+ logFilePath: logFilePath,
37+ containerSvc: containerSvc,
38+ runtime: runtime,
39+ log: log,
40+ }
41+}
42+
43+// GetProcessLogs returns the last N lines of Gordon process logs.
44+func (s *Service) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
45+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
46+ zerowrap.FieldLayer: "usecase",
47+ zerowrap.FieldUseCase: "GetProcessLogs",
48+ "lines": lines,
49+ })
50+ log := zerowrap.FromCtx(ctx)
51+
52+ if s.logFilePath == "" {
53+ return nil, fmt.Errorf("log file path not configured")
54+ }
55+
56+ file, err := os.Open(s.logFilePath)
57+ if err != nil {
58+ if os.IsNotExist(err) {
59+ return []string{}, nil
60+ }
61+ return nil, log.WrapErr(err, "failed to open log file")
62+ }
63+ defer file.Close()
64+
65+ return tailLines(file, lines)
66+}
67+
68+// FollowProcessLogs returns a channel that streams Gordon process log lines.
69+func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-chan string, error) {
70+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
71+ zerowrap.FieldLayer: "usecase",
72+ zerowrap.FieldUseCase: "FollowProcessLogs",
73+ "initial_lines": initialLines,
74+ })
75+ log := zerowrap.FromCtx(ctx)
76+
77+ if s.logFilePath == "" {
78+ return nil, fmt.Errorf("log file path not configured")
79+ }
80+
81+ file, err := os.Open(s.logFilePath)
82+ if err != nil {
83+ return nil, log.WrapErr(err, "failed to open log file")
84+ }
85+
86+ ch := make(chan string, 100)
87+
88+ go func() {
89+ defer close(ch)
90+ defer file.Close()
91+
92+ // First, get initial lines
93+ if initialLines > 0 {
94+ lines, err := tailLines(file, initialLines)
95+ if err != nil {
96+ log.Warn().Err(err).Msg("failed to read initial lines")
97+ } else {
98+ for _, line := range lines {
99+ select {
100+ case ch <- line:
101+ case <-ctx.Done():
102+ return
103+ }
104+ }
105+ }
106+ }
107+
108+ // Seek to end for following
109+ if _, err := file.Seek(0, io.SeekEnd); err != nil {
110+ log.Warn().Err(err).Msg("failed to seek to end")
111+ return
112+ }
113+
114+ // Follow new lines
115+ reader := bufio.NewReader(file)
116+ for {
117+ select {
118+ case <-ctx.Done():
119+ return
120+ default:
121+ line, err := reader.ReadString('\n')
122+ if err != nil {
123+ if err == io.EOF {
124+ time.Sleep(100 * time.Millisecond)
125+ continue
126+ }
127+ log.Warn().Err(err).Msg("error reading log file")
128+ return
129+ }
130+ line = strings.TrimRight(line, "\n\r")
131+ select {
132+ case ch <- line:
133+ case <-ctx.Done():
134+ return
135+ }
136+ }
137+ }
138+ }()
139+
140+ return ch, nil
141+}
142+
143+// validateDomain checks that domain is safe for use in logs and error messages.
144+func validateDomain(domain string) error {
145+ if domain == "" {
146+ return fmt.Errorf("domain cannot be empty")
147+ }
148+ if len(domain) > 253 {
149+ return fmt.Errorf("domain too long")
150+ }
151+ if strings.Contains(domain, "..") {
152+ return fmt.Errorf("invalid domain")
153+ }
154+ if strings.ContainsAny(domain, "\x00\n\r") {
155+ return fmt.Errorf("invalid domain")
156+ }
157+ return nil
158+}
159+
160+// GetContainerLogs returns the last N lines of container logs for a domain.
161+func (s *Service) GetContainerLogs(ctx context.Context, domain string, lines int) ([]string, error) {
162+ if err := validateDomain(domain); err != nil {
163+ return nil, err
164+ }
165+
166+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
167+ zerowrap.FieldLayer: "usecase",
168+ zerowrap.FieldUseCase: "GetContainerLogs",
169+ "domain": domain,
170+ "lines": lines,
171+ })
172+ log := zerowrap.FromCtx(ctx)
173+
174+ // Get container by domain
175+ container, ok := s.containerSvc.Get(ctx, domain)
176+ if !ok || container == nil {
177+ return nil, fmt.Errorf("container not found for domain: %s", domain)
178+ }
179+
180+ // Get logs from container runtime (non-follow mode)
181+ reader, err := s.runtime.GetContainerLogs(ctx, container.ID, false)
182+ if err != nil {
183+ return nil, log.WrapErr(err, "failed to get container logs")
184+ }
185+ defer reader.Close()
186+
187+ // Parse Docker log stream and get lines
188+ logLines, err := parseDockerLogs(reader)
189+ if err != nil {
190+ return nil, log.WrapErr(err, "failed to parse container logs")
191+ }
192+
193+ // Return last N lines
194+ if len(logLines) > lines {
195+ logLines = logLines[len(logLines)-lines:]
196+ }
197+
198+ return logLines, nil
199+}
200+
201+// FollowContainerLogs returns a channel that streams container log lines.
202+func (s *Service) FollowContainerLogs(ctx context.Context, domain string, initialLines int) (<-chan string, error) {
203+ if err := validateDomain(domain); err != nil {
204+ return nil, err
205+ }
206+
207+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
208+ zerowrap.FieldLayer: "usecase",
209+ zerowrap.FieldUseCase: "FollowContainerLogs",
210+ "domain": domain,
211+ "initial_lines": initialLines,
212+ })
213+ log := zerowrap.FromCtx(ctx)
214+
215+ // Get container by domain
216+ container, ok := s.containerSvc.Get(ctx, domain)
217+ if !ok || container == nil {
218+ return nil, fmt.Errorf("container not found for domain: %s", domain)
219+ }
220+
221+ // Get logs from container runtime (follow mode)
222+ reader, err := s.runtime.GetContainerLogs(ctx, container.ID, true)
223+ if err != nil {
224+ return nil, log.WrapErr(err, "failed to get container logs")
225+ }
226+
227+ ch := make(chan string, 100)
228+
229+ go func() {
230+ defer close(ch)
231+ defer reader.Close()
232+
233+ // Stream Docker logs
234+ err := streamDockerLogs(ctx, reader, ch)
235+ if err != nil && ctx.Err() == nil {
236+ log.Warn().Err(err).Msg("error streaming container logs")
237+ }
238+ }()
239+
240+ return ch, nil
241+}
242+
243+// tailLines reads the last N lines from a file using a ring buffer.
244+func tailLines(file *os.File, n int) ([]string, error) {
245+ if n <= 0 {
246+ return []string{}, nil
247+ }
248+
249+ // Seek to beginning
250+ if _, err := file.Seek(0, io.SeekStart); err != nil {
251+ return nil, err
252+ }
253+
254+ scanner := bufio.NewScanner(file)
255+ buffer := make([]string, n)
256+ index := 0
257+ total := 0
258+
259+ for scanner.Scan() {
260+ buffer[index] = scanner.Text()
261+ index = (index + 1) % n
262+ total++
263+ }
264+
265+ if err := scanner.Err(); err != nil {
266+ return nil, err
267+ }
268+
269+ if total == 0 {
270+ return []string{}, nil
271+ }
272+
273+ // Build result in correct order
274+ var result []string
275+ if total <= n {
276+ result = make([]string, total)
277+ for i := 0; i < total; i++ {
278+ result[i] = buffer[i]
279+ }
280+ } else {
281+ result = make([]string, n)
282+ for i := 0; i < n; i++ {
283+ result[i] = buffer[(index+i)%n]
284+ }
285+ }
286+
287+ return result, nil
288+}
289+
290+// parseDockerLogs parses Docker multiplexed log stream and returns lines.
291+// Docker logs have an 8-byte header: [stream type (1), padding (3), size (4)]
292+func parseDockerLogs(reader io.Reader) ([]string, error) {
293+ var lines []string
294+ header := make([]byte, 8)
295+
296+ for {
297+ // Read header
298+ _, err := io.ReadFull(reader, header)
299+ if err != nil {
300+ if err == io.EOF {
301+ break
302+ }
303+ return lines, nil // Return what we have
304+ }
305+
306+ // Get payload size from header (big endian)
307+ size := binary.BigEndian.Uint32(header[4:8])
308+ if size == 0 {
309+ continue
310+ }
311+
312+ // Read payload
313+ payload := make([]byte, size)
314+ _, err = io.ReadFull(reader, payload)
315+ if err != nil {
316+ if err == io.EOF {
317+ break
318+ }
319+ return lines, nil
320+ }
321+
322+ // Split by newlines and add to result
323+ content := strings.TrimRight(string(payload), "\n\r")
324+ for _, line := range strings.Split(content, "\n") {
325+ if line != "" {
326+ lines = append(lines, line)
327+ }
328+ }
329+ }
330+
331+ return lines, nil
332+}
333+
334+// streamDockerLogs streams Docker multiplexed log stream to a channel.
335+func streamDockerLogs(ctx context.Context, reader io.Reader, ch chan<- string) error {
336+ header := make([]byte, 8)
337+
338+ for {
339+ select {
340+ case <-ctx.Done():
341+ return ctx.Err()
342+ default:
343+ }
344+
345+ // Read header
346+ _, err := io.ReadFull(reader, header)
347+ if err != nil {
348+ if err == io.EOF {
349+ return nil
350+ }
351+ return err
352+ }
353+
354+ // Get payload size
355+ size := binary.BigEndian.Uint32(header[4:8])
356+ if size == 0 {
357+ continue
358+ }
359+
360+ // Read payload
361+ payload := make([]byte, size)
362+ _, err = io.ReadFull(reader, payload)
363+ if err != nil {
364+ if err == io.EOF {
365+ return nil
366+ }
367+ return err
368+ }
369+
370+ // Split by newlines and send to channel
371+ content := strings.TrimRight(string(payload), "\n\r")
372+ for _, line := range strings.Split(content, "\n") {
373+ if line != "" {
374+ select {
375+ case ch <- line:
376+ case <-ctx.Done():
377+ return ctx.Err()
378+ }
379+ }
380+ }
381+ }
382+}
@@ -0,0 +1,382 @@
1+// Package logs implements the log access use case.
2+package logs
3+
4+import (
5+ "bufio"
6+ "context"
7+ "encoding/binary"
8+ "fmt"
9+ "io"
10+ "os"
11+ "strings"
12+ "time"
13+
14+ "github.com/bnema/zerowrap"
15+
16+ "gordon/internal/boundaries/in"
17+ "gordon/internal/boundaries/out"
18+)
19+
20+// Service implements the LogService interface.
21+type Service struct {
22+ logFilePath string
23+ containerSvc in.ContainerService
24+ runtime out.ContainerRuntime
25+ log zerowrap.Logger
26+}
27+
28+// NewService creates a new log service.
29+func NewService(
30+ logFilePath string,
31+ containerSvc in.ContainerService,
32+ runtime out.ContainerRuntime,
33+ log zerowrap.Logger,
34+) *Service {
35+ return &Service{
36+ logFilePath: logFilePath,
37+ containerSvc: containerSvc,
38+ runtime: runtime,
39+ log: log,
40+ }
41+}
42+
43+// GetProcessLogs returns the last N lines of Gordon process logs.
44+func (s *Service) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
45+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
46+ zerowrap.FieldLayer: "usecase",
47+ zerowrap.FieldUseCase: "GetProcessLogs",
48+ "lines": lines,
49+ })
50+ log := zerowrap.FromCtx(ctx)
51+
52+ if s.logFilePath == "" {
53+ return nil, fmt.Errorf("log file path not configured")
54+ }
55+
56+ file, err := os.Open(s.logFilePath)
57+ if err != nil {
58+ if os.IsNotExist(err) {
59+ return []string{}, nil
60+ }
61+ return nil, log.WrapErr(err, "failed to open log file")
62+ }
63+ defer file.Close()
64+
65+ return tailLines(file, lines)
66+}
67+
68+// FollowProcessLogs returns a channel that streams Gordon process log lines.
69+func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-chan string, error) {
70+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
71+ zerowrap.FieldLayer: "usecase",
72+ zerowrap.FieldUseCase: "FollowProcessLogs",
73+ "initial_lines": initialLines,
74+ })
75+ log := zerowrap.FromCtx(ctx)
76+
77+ if s.logFilePath == "" {
78+ return nil, fmt.Errorf("log file path not configured")
79+ }
80+
81+ file, err := os.Open(s.logFilePath)
82+ if err != nil {
83+ return nil, log.WrapErr(err, "failed to open log file")
84+ }
85+
86+ ch := make(chan string, 100)
87+
88+ go func() {
89+ defer close(ch)
90+ defer file.Close()
91+
92+ // First, get initial lines
93+ if initialLines > 0 {
94+ lines, err := tailLines(file, initialLines)
95+ if err != nil {
96+ log.Warn().Err(err).Msg("failed to read initial lines")
97+ } else {
98+ for _, line := range lines {
99+ select {
100+ case ch <- line:
101+ case <-ctx.Done():
102+ return
103+ }
104+ }
105+ }
106+ }
107+
108+ // Seek to end for following
109+ if _, err := file.Seek(0, io.SeekEnd); err != nil {
110+ log.Warn().Err(err).Msg("failed to seek to end")
111+ return
112+ }
113+
114+ // Follow new lines
115+ reader := bufio.NewReader(file)
116+ for {
117+ select {
118+ case <-ctx.Done():
119+ return
120+ default:
121+ line, err := reader.ReadString('\n')
122+ if err != nil {
123+ if err == io.EOF {
124+ time.Sleep(100 * time.Millisecond)
125+ continue
126+ }
127+ log.Warn().Err(err).Msg("error reading log file")
128+ return
129+ }
130+ line = strings.TrimRight(line, "\n\r")
131+ select {
132+ case ch <- line:
133+ case <-ctx.Done():
134+ return
135+ }
136+ }
137+ }
138+ }()
139+
140+ return ch, nil
141+}
142+
143+// validateDomain checks that domain is safe for use in logs and error messages.
144+func validateDomain(domain string) error {
145+ if domain == "" {
146+ return fmt.Errorf("domain cannot be empty")
147+ }
148+ if len(domain) > 253 {
149+ return fmt.Errorf("domain too long")
150+ }
151+ if strings.Contains(domain, "..") {
152+ return fmt.Errorf("invalid domain")
153+ }
154+ if strings.ContainsAny(domain, "\x00\n\r") {
155+ return fmt.Errorf("invalid domain")
156+ }
157+ return nil
158+}
159+
160+// GetContainerLogs returns the last N lines of container logs for a domain.
161+func (s *Service) GetContainerLogs(ctx context.Context, domain string, lines int) ([]string, error) {
162+ if err := validateDomain(domain); err != nil {
163+ return nil, err
164+ }
165+
166+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
167+ zerowrap.FieldLayer: "usecase",
168+ zerowrap.FieldUseCase: "GetContainerLogs",
169+ "domain": domain,
170+ "lines": lines,
171+ })
172+ log := zerowrap.FromCtx(ctx)
173+
174+ // Get container by domain
175+ container, ok := s.containerSvc.Get(ctx, domain)
176+ if !ok || container == nil {
177+ return nil, fmt.Errorf("container not found for domain: %s", domain)
178+ }
179+
180+ // Get logs from container runtime (non-follow mode)
181+ reader, err := s.runtime.GetContainerLogs(ctx, container.ID, false)
182+ if err != nil {
183+ return nil, log.WrapErr(err, "failed to get container logs")
184+ }
185+ defer reader.Close()
186+
187+ // Parse Docker log stream and get lines
188+ logLines, err := parseDockerLogs(reader)
189+ if err != nil {
190+ return nil, log.WrapErr(err, "failed to parse container logs")
191+ }
192+
193+ // Return last N lines
194+ if len(logLines) > lines {
195+ logLines = logLines[len(logLines)-lines:]
196+ }
197+
198+ return logLines, nil
199+}
200+
201+// FollowContainerLogs returns a channel that streams container log lines.
202+func (s *Service) FollowContainerLogs(ctx context.Context, domain string, initialLines int) (<-chan string, error) {
203+ if err := validateDomain(domain); err != nil {
204+ return nil, err
205+ }
206+
207+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
208+ zerowrap.FieldLayer: "usecase",
209+ zerowrap.FieldUseCase: "FollowContainerLogs",
210+ "domain": domain,
211+ "initial_lines": initialLines,
212+ })
213+ log := zerowrap.FromCtx(ctx)
214+
215+ // Get container by domain
216+ container, ok := s.containerSvc.Get(ctx, domain)
217+ if !ok || container == nil {
218+ return nil, fmt.Errorf("container not found for domain: %s", domain)
219+ }
220+
221+ // Get logs from container runtime (follow mode)
222+ reader, err := s.runtime.GetContainerLogs(ctx, container.ID, true)
223+ if err != nil {
224+ return nil, log.WrapErr(err, "failed to get container logs")
225+ }
226+
227+ ch := make(chan string, 100)
228+
229+ go func() {
230+ defer close(ch)
231+ defer reader.Close()
232+
233+ // Stream Docker logs
234+ err := streamDockerLogs(ctx, reader, ch)
235+ if err != nil && ctx.Err() == nil {
236+ log.Warn().Err(err).Msg("error streaming container logs")
237+ }
238+ }()
239+
240+ return ch, nil
241+}
242+
243+// tailLines reads the last N lines from a file using a ring buffer.
244+func tailLines(file *os.File, n int) ([]string, error) {
245+ if n <= 0 {
246+ return []string{}, nil
247+ }
248+
249+ // Seek to beginning
250+ if _, err := file.Seek(0, io.SeekStart); err != nil {
251+ return nil, err
252+ }
253+
254+ scanner := bufio.NewScanner(file)
255+ buffer := make([]string, n)
256+ index := 0
257+ total := 0
258+
259+ for scanner.Scan() {
260+ buffer[index] = scanner.Text()
261+ index = (index + 1) % n
262+ total++
263+ }
264+
265+ if err := scanner.Err(); err != nil {
266+ return nil, err
267+ }
268+
269+ if total == 0 {
270+ return []string{}, nil
271+ }
272+
273+ // Build result in correct order
274+ var result []string
275+ if total <= n {
276+ result = make([]string, total)
277+ for i := 0; i < total; i++ {
278+ result[i] = buffer[i]
279+ }
280+ } else {
281+ result = make([]string, n)
282+ for i := 0; i < n; i++ {
283+ result[i] = buffer[(index+i)%n]
284+ }
285+ }
286+
287+ return result, nil
288+}
289+
290+// parseDockerLogs parses Docker multiplexed log stream and returns lines.
291+// Docker logs have an 8-byte header: [stream type (1), padding (3), size (4)]
292+func parseDockerLogs(reader io.Reader) ([]string, error) {
293+ var lines []string
294+ header := make([]byte, 8)
295+
296+ for {
297+ // Read header
298+ _, err := io.ReadFull(reader, header)
299+ if err != nil {
300+ if err == io.EOF {
301+ break
302+ }
303+ return lines, nil // Return what we have
304+ }
305+
306+ // Get payload size from header (big endian)
307+ size := binary.BigEndian.Uint32(header[4:8])
308+ if size == 0 {
309+ continue
310+ }
311+
312+ // Read payload
313+ payload := make([]byte, size)
314+ _, err = io.ReadFull(reader, payload)
315+ if err != nil {
316+ if err == io.EOF {
317+ break
318+ }
319+ return lines, nil
320+ }
321+
322+ // Split by newlines and add to result
323+ content := strings.TrimRight(string(payload), "\n\r")
324+ for _, line := range strings.Split(content, "\n") {
325+ if line != "" {
326+ lines = append(lines, line)
327+ }
328+ }
329+ }
330+
331+ return lines, nil
332+}
333+
334+// streamDockerLogs streams Docker multiplexed log stream to a channel.
335+func streamDockerLogs(ctx context.Context, reader io.Reader, ch chan<- string) error {
336+ header := make([]byte, 8)
337+
338+ for {
339+ select {
340+ case <-ctx.Done():
341+ return ctx.Err()
342+ default:
343+ }
344+
345+ // Read header
346+ _, err := io.ReadFull(reader, header)
347+ if err != nil {
348+ if err == io.EOF {
349+ return nil
350+ }
351+ return err
352+ }
353+
354+ // Get payload size
355+ size := binary.BigEndian.Uint32(header[4:8])
356+ if size == 0 {
357+ continue
358+ }
359+
360+ // Read payload
361+ payload := make([]byte, size)
362+ _, err = io.ReadFull(reader, payload)
363+ if err != nil {
364+ if err == io.EOF {
365+ return nil
366+ }
367+ return err
368+ }
369+
370+ // Split by newlines and send to channel
371+ content := strings.TrimRight(string(payload), "\n\r")
372+ for _, line := range strings.Split(content, "\n") {
373+ if line != "" {
374+ select {
375+ case ch <- line:
376+ case <-ctx.Done():
377+ return ctx.Err()
378+ }
379+ }
380+ }
381+ }
382+}
A internal/usecase/logs/service_test.go +258 -0
@@ -0,0 +1,258 @@
1+package logs
2+
3+import (
4+ "context"
5+ "io"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "gordon/internal/boundaries/in/mocks"
17+ outMocks "gordon/internal/boundaries/out/mocks"
18+ "gordon/internal/domain"
19+)
20+
21+func TestService_GetProcessLogs(t *testing.T) {
22+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
23+
24+ t.Run("returns lines from log file", func(t *testing.T) {
25+ // Create temp log file with content
26+ tmpDir := t.TempDir()
27+ logPath := filepath.Join(tmpDir, "test.log")
28+ content := "line1\nline2\nline3\nline4\nline5\n"
29+ err := os.WriteFile(logPath, []byte(content), 0644)
30+ require.NoError(t, err)
31+
32+ containerSvc := mocks.NewMockContainerService(t)
33+ runtime := outMocks.NewMockContainerRuntime(t)
34+
35+ svc := NewService(logPath, containerSvc, runtime, log)
36+
37+ lines, err := svc.GetProcessLogs(context.Background(), 3)
38+ require.NoError(t, err)
39+ assert.Len(t, lines, 3)
40+ assert.Equal(t, []string{"line3", "line4", "line5"}, lines)
41+ })
42+
43+ t.Run("returns empty slice for non-existent file", func(t *testing.T) {
44+ containerSvc := mocks.NewMockContainerService(t)
45+ runtime := outMocks.NewMockContainerRuntime(t)
46+
47+ svc := NewService("/nonexistent/file.log", containerSvc, runtime, log)
48+
49+ lines, err := svc.GetProcessLogs(context.Background(), 10)
50+ require.NoError(t, err)
51+ assert.Empty(t, lines)
52+ })
53+
54+ t.Run("returns error when log file path not configured", func(t *testing.T) {
55+ containerSvc := mocks.NewMockContainerService(t)
56+ runtime := outMocks.NewMockContainerRuntime(t)
57+
58+ svc := NewService("", containerSvc, runtime, log)
59+
60+ _, err := svc.GetProcessLogs(context.Background(), 10)
61+ assert.Error(t, err)
62+ assert.Contains(t, err.Error(), "log file path not configured")
63+ })
64+
65+ t.Run("returns all lines when fewer than requested", func(t *testing.T) {
66+ tmpDir := t.TempDir()
67+ logPath := filepath.Join(tmpDir, "test.log")
68+ content := "line1\nline2\n"
69+ err := os.WriteFile(logPath, []byte(content), 0644)
70+ require.NoError(t, err)
71+
72+ containerSvc := mocks.NewMockContainerService(t)
73+ runtime := outMocks.NewMockContainerRuntime(t)
74+
75+ svc := NewService(logPath, containerSvc, runtime, log)
76+
77+ lines, err := svc.GetProcessLogs(context.Background(), 10)
78+ require.NoError(t, err)
79+ assert.Equal(t, []string{"line1", "line2"}, lines)
80+ })
81+}
82+
83+func TestService_FollowProcessLogs(t *testing.T) {
84+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
85+
86+ t.Run("streams lines and respects context cancellation", func(t *testing.T) {
87+ tmpDir := t.TempDir()
88+ logPath := filepath.Join(tmpDir, "test.log")
89+ content := "initial1\ninitial2\n"
90+ err := os.WriteFile(logPath, []byte(content), 0644)
91+ require.NoError(t, err)
92+
93+ containerSvc := mocks.NewMockContainerService(t)
94+ runtime := outMocks.NewMockContainerRuntime(t)
95+
96+ svc := NewService(logPath, containerSvc, runtime, log)
97+
98+ ctx, cancel := context.WithCancel(context.Background())
99+ defer cancel()
100+
101+ ch, err := svc.FollowProcessLogs(ctx, 2)
102+ require.NoError(t, err)
103+
104+ // Read initial lines
105+ var received []string
106+ for i := 0; i < 2; i++ {
107+ select {
108+ case line := <-ch:
109+ received = append(received, line)
110+ case <-time.After(time.Second):
111+ t.Fatal("timeout waiting for initial lines")
112+ }
113+ }
114+ assert.Equal(t, []string{"initial1", "initial2"}, received)
115+
116+ // Cancel context to stop following
117+ cancel()
118+
119+ // Channel should be closed
120+ select {
121+ case <-ch:
122+ // May receive one more line before closure, that's OK
123+ case <-time.After(500 * time.Millisecond):
124+ // OK, channel may already be closed
125+ }
126+ })
127+}
128+
129+func TestService_GetContainerLogs(t *testing.T) {
130+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
131+
132+ t.Run("returns error when container not found", func(t *testing.T) {
133+ containerSvc := mocks.NewMockContainerService(t)
134+ runtime := outMocks.NewMockContainerRuntime(t)
135+
136+ containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
137+
138+ svc := NewService("/tmp/test.log", containerSvc, runtime, log)
139+
140+ _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10)
141+ assert.Error(t, err)
142+ assert.Contains(t, err.Error(), "container not found")
143+ })
144+
145+ t.Run("calls runtime with correct container ID", func(t *testing.T) {
146+ containerSvc := mocks.NewMockContainerService(t)
147+ runtime := outMocks.NewMockContainerRuntime(t)
148+
149+ container := &domain.Container{
150+ ID: "abc123",
151+ Name: "app.local",
152+ Status: "running",
153+ }
154+ containerSvc.EXPECT().Get(mock.Anything, "app.local").Return(container, true)
155+
156+ // Create a mock reader that returns empty content
157+ runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil)
158+
159+ svc := NewService("/tmp/test.log", containerSvc, runtime, log)
160+
161+ lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10)
162+ require.NoError(t, err)
163+ assert.Empty(t, lines)
164+ })
165+}
166+
167+func TestService_FollowContainerLogs(t *testing.T) {
168+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
169+
170+ t.Run("returns error when container not found", func(t *testing.T) {
171+ containerSvc := mocks.NewMockContainerService(t)
172+ runtime := outMocks.NewMockContainerRuntime(t)
173+
174+ containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
175+
176+ svc := NewService("/tmp/test.log", containerSvc, runtime, log)
177+
178+ _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10)
179+ assert.Error(t, err)
180+ assert.Contains(t, err.Error(), "container not found")
181+ })
182+}
183+
184+func TestTailLines(t *testing.T) {
185+ t.Run("returns correct last N lines", func(t *testing.T) {
186+ tmpDir := t.TempDir()
187+ path := filepath.Join(tmpDir, "test.log")
188+ content := "a\nb\nc\nd\ne\n"
189+ err := os.WriteFile(path, []byte(content), 0644)
190+ require.NoError(t, err)
191+
192+ file, err := os.Open(path)
193+ require.NoError(t, err)
194+ defer file.Close()
195+
196+ lines, err := tailLines(file, 3)
197+ require.NoError(t, err)
198+ assert.Equal(t, []string{"c", "d", "e"}, lines)
199+ })
200+
201+ t.Run("returns all lines when n exceeds line count", func(t *testing.T) {
202+ tmpDir := t.TempDir()
203+ path := filepath.Join(tmpDir, "test.log")
204+ content := "x\ny\n"
205+ err := os.WriteFile(path, []byte(content), 0644)
206+ require.NoError(t, err)
207+
208+ file, err := os.Open(path)
209+ require.NoError(t, err)
210+ defer file.Close()
211+
212+ lines, err := tailLines(file, 10)
213+ require.NoError(t, err)
214+ assert.Equal(t, []string{"x", "y"}, lines)
215+ })
216+
217+ t.Run("returns empty slice for empty file", func(t *testing.T) {
218+ tmpDir := t.TempDir()
219+ path := filepath.Join(tmpDir, "test.log")
220+ err := os.WriteFile(path, []byte(""), 0644)
221+ require.NoError(t, err)
222+
223+ file, err := os.Open(path)
224+ require.NoError(t, err)
225+ defer file.Close()
226+
227+ lines, err := tailLines(file, 10)
228+ require.NoError(t, err)
229+ assert.Empty(t, lines)
230+ })
231+
232+ t.Run("returns empty slice when n is zero", func(t *testing.T) {
233+ tmpDir := t.TempDir()
234+ path := filepath.Join(tmpDir, "test.log")
235+ content := "a\nb\n"
236+ err := os.WriteFile(path, []byte(content), 0644)
237+ require.NoError(t, err)
238+
239+ file, err := os.Open(path)
240+ require.NoError(t, err)
241+ defer file.Close()
242+
243+ lines, err := tailLines(file, 0)
244+ require.NoError(t, err)
245+ assert.Empty(t, lines)
246+ })
247+}
248+
249+// mockReader implements io.ReadCloser for testing.
250+type mockReader struct{}
251+
252+func (m *mockReader) Read(p []byte) (n int, err error) {
253+ return 0, io.EOF
254+}
255+
256+func (m *mockReader) Close() error {
257+ return nil
258+}
@@ -0,0 +1,258 @@
1+package logs
2+
3+import (
4+ "context"
5+ "io"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "gordon/internal/boundaries/in/mocks"
17+ outMocks "gordon/internal/boundaries/out/mocks"
18+ "gordon/internal/domain"
19+)
20+
21+func TestService_GetProcessLogs(t *testing.T) {
22+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
23+
24+ t.Run("returns lines from log file", func(t *testing.T) {
25+ // Create temp log file with content
26+ tmpDir := t.TempDir()
27+ logPath := filepath.Join(tmpDir, "test.log")
28+ content := "line1\nline2\nline3\nline4\nline5\n"
29+ err := os.WriteFile(logPath, []byte(content), 0644)
30+ require.NoError(t, err)
31+
32+ containerSvc := mocks.NewMockContainerService(t)
33+ runtime := outMocks.NewMockContainerRuntime(t)
34+
35+ svc := NewService(logPath, containerSvc, runtime, log)
36+
37+ lines, err := svc.GetProcessLogs(context.Background(), 3)
38+ require.NoError(t, err)
39+ assert.Len(t, lines, 3)
40+ assert.Equal(t, []string{"line3", "line4", "line5"}, lines)
41+ })
42+
43+ t.Run("returns empty slice for non-existent file", func(t *testing.T) {
44+ containerSvc := mocks.NewMockContainerService(t)
45+ runtime := outMocks.NewMockContainerRuntime(t)
46+
47+ svc := NewService("/nonexistent/file.log", containerSvc, runtime, log)
48+
49+ lines, err := svc.GetProcessLogs(context.Background(), 10)
50+ require.NoError(t, err)
51+ assert.Empty(t, lines)
52+ })
53+
54+ t.Run("returns error when log file path not configured", func(t *testing.T) {
55+ containerSvc := mocks.NewMockContainerService(t)
56+ runtime := outMocks.NewMockContainerRuntime(t)
57+
58+ svc := NewService("", containerSvc, runtime, log)
59+
60+ _, err := svc.GetProcessLogs(context.Background(), 10)
61+ assert.Error(t, err)
62+ assert.Contains(t, err.Error(), "log file path not configured")
63+ })
64+
65+ t.Run("returns all lines when fewer than requested", func(t *testing.T) {
66+ tmpDir := t.TempDir()
67+ logPath := filepath.Join(tmpDir, "test.log")
68+ content := "line1\nline2\n"
69+ err := os.WriteFile(logPath, []byte(content), 0644)
70+ require.NoError(t, err)
71+
72+ containerSvc := mocks.NewMockContainerService(t)
73+ runtime := outMocks.NewMockContainerRuntime(t)
74+
75+ svc := NewService(logPath, containerSvc, runtime, log)
76+
77+ lines, err := svc.GetProcessLogs(context.Background(), 10)
78+ require.NoError(t, err)
79+ assert.Equal(t, []string{"line1", "line2"}, lines)
80+ })
81+}
82+
83+func TestService_FollowProcessLogs(t *testing.T) {
84+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
85+
86+ t.Run("streams lines and respects context cancellation", func(t *testing.T) {
87+ tmpDir := t.TempDir()
88+ logPath := filepath.Join(tmpDir, "test.log")
89+ content := "initial1\ninitial2\n"
90+ err := os.WriteFile(logPath, []byte(content), 0644)
91+ require.NoError(t, err)
92+
93+ containerSvc := mocks.NewMockContainerService(t)
94+ runtime := outMocks.NewMockContainerRuntime(t)
95+
96+ svc := NewService(logPath, containerSvc, runtime, log)
97+
98+ ctx, cancel := context.WithCancel(context.Background())
99+ defer cancel()
100+
101+ ch, err := svc.FollowProcessLogs(ctx, 2)
102+ require.NoError(t, err)
103+
104+ // Read initial lines
105+ var received []string
106+ for i := 0; i < 2; i++ {
107+ select {
108+ case line := <-ch:
109+ received = append(received, line)
110+ case <-time.After(time.Second):
111+ t.Fatal("timeout waiting for initial lines")
112+ }
113+ }
114+ assert.Equal(t, []string{"initial1", "initial2"}, received)
115+
116+ // Cancel context to stop following
117+ cancel()
118+
119+ // Channel should be closed
120+ select {
121+ case <-ch:
122+ // May receive one more line before closure, that's OK
123+ case <-time.After(500 * time.Millisecond):
124+ // OK, channel may already be closed
125+ }
126+ })
127+}
128+
129+func TestService_GetContainerLogs(t *testing.T) {
130+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
131+
132+ t.Run("returns error when container not found", func(t *testing.T) {
133+ containerSvc := mocks.NewMockContainerService(t)
134+ runtime := outMocks.NewMockContainerRuntime(t)
135+
136+ containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
137+
138+ svc := NewService("/tmp/test.log", containerSvc, runtime, log)
139+
140+ _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10)
141+ assert.Error(t, err)
142+ assert.Contains(t, err.Error(), "container not found")
143+ })
144+
145+ t.Run("calls runtime with correct container ID", func(t *testing.T) {
146+ containerSvc := mocks.NewMockContainerService(t)
147+ runtime := outMocks.NewMockContainerRuntime(t)
148+
149+ container := &domain.Container{
150+ ID: "abc123",
151+ Name: "app.local",
152+ Status: "running",
153+ }
154+ containerSvc.EXPECT().Get(mock.Anything, "app.local").Return(container, true)
155+
156+ // Create a mock reader that returns empty content
157+ runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil)
158+
159+ svc := NewService("/tmp/test.log", containerSvc, runtime, log)
160+
161+ lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10)
162+ require.NoError(t, err)
163+ assert.Empty(t, lines)
164+ })
165+}
166+
167+func TestService_FollowContainerLogs(t *testing.T) {
168+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
169+
170+ t.Run("returns error when container not found", func(t *testing.T) {
171+ containerSvc := mocks.NewMockContainerService(t)
172+ runtime := outMocks.NewMockContainerRuntime(t)
173+
174+ containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
175+
176+ svc := NewService("/tmp/test.log", containerSvc, runtime, log)
177+
178+ _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10)
179+ assert.Error(t, err)
180+ assert.Contains(t, err.Error(), "container not found")
181+ })
182+}
183+
184+func TestTailLines(t *testing.T) {
185+ t.Run("returns correct last N lines", func(t *testing.T) {
186+ tmpDir := t.TempDir()
187+ path := filepath.Join(tmpDir, "test.log")
188+ content := "a\nb\nc\nd\ne\n"
189+ err := os.WriteFile(path, []byte(content), 0644)
190+ require.NoError(t, err)
191+
192+ file, err := os.Open(path)
193+ require.NoError(t, err)
194+ defer file.Close()
195+
196+ lines, err := tailLines(file, 3)
197+ require.NoError(t, err)
198+ assert.Equal(t, []string{"c", "d", "e"}, lines)
199+ })
200+
201+ t.Run("returns all lines when n exceeds line count", func(t *testing.T) {
202+ tmpDir := t.TempDir()
203+ path := filepath.Join(tmpDir, "test.log")
204+ content := "x\ny\n"
205+ err := os.WriteFile(path, []byte(content), 0644)
206+ require.NoError(t, err)
207+
208+ file, err := os.Open(path)
209+ require.NoError(t, err)
210+ defer file.Close()
211+
212+ lines, err := tailLines(file, 10)
213+ require.NoError(t, err)
214+ assert.Equal(t, []string{"x", "y"}, lines)
215+ })
216+
217+ t.Run("returns empty slice for empty file", func(t *testing.T) {
218+ tmpDir := t.TempDir()
219+ path := filepath.Join(tmpDir, "test.log")
220+ err := os.WriteFile(path, []byte(""), 0644)
221+ require.NoError(t, err)
222+
223+ file, err := os.Open(path)
224+ require.NoError(t, err)
225+ defer file.Close()
226+
227+ lines, err := tailLines(file, 10)
228+ require.NoError(t, err)
229+ assert.Empty(t, lines)
230+ })
231+
232+ t.Run("returns empty slice when n is zero", func(t *testing.T) {
233+ tmpDir := t.TempDir()
234+ path := filepath.Join(tmpDir, "test.log")
235+ content := "a\nb\n"
236+ err := os.WriteFile(path, []byte(content), 0644)
237+ require.NoError(t, err)
238+
239+ file, err := os.Open(path)
240+ require.NoError(t, err)
241+ defer file.Close()
242+
243+ lines, err := tailLines(file, 0)
244+ require.NoError(t, err)
245+ assert.Empty(t, lines)
246+ })
247+}
248+
249+// mockReader implements io.ReadCloser for testing.
250+type mockReader struct{}
251+
252+func (m *mockReader) Read(p []byte) (n int, err error) {
253+ return 0, io.EOF
254+}
255+
256+func (m *mockReader) Close() error {
257+ return nil
258+}

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