detached d95d72c

feat(cli): add bidirectional local/remote support for all commands

Add local mode support for routes and secrets commands (previously
remote-only), and add remote mode support for deploy and logs commands
(previously local-only).

Changes:
- Create LogService for accessing Gordon process and container logs
- Add deploy and logs endpoints to admin API with SSE streaming
- Add LocalServices provider for CLI local operations
- Update routes/secrets commands to work locally without --remote
- Update deploy/logs commands to work remotely with --remote
- Add container logs support: gordon logs [domain]
- Update CLI documentation

This enables consistent command behavior across local and remote modes,
improving the developer experience for both local development and remote
server management.
commit
d95d72c9d99b43a0852b4f7b9ddf6596314b3b3e
author
brice · · 2026-01-17 06:55 UTC
parent
fc65046
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +1833 -150
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 +21 -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,30 @@ 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+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", deployDomain, result.ContainerID[:12])))
41+ return nil
42+ }
43+
44+ // Local deployment via signal
45+ domain, err := app.SendDeploySignal(deployDomain)
2846 if err != nil {
2947 return err
3048 }
31- fmt.Printf("Deploy signal sent for domain: %s\n", domain)
49+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain)))
3250 return nil
3351 },
3452 }
@@ -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,30 @@ 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+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", deployDomain, result.ContainerID[:12])))
41+ return nil
42+ }
43+
44+ // Local deployment via signal
45+ domain, err := app.SendDeploySignal(deployDomain)
28 if err != nil {46 if err != nil {
29 return err47 return err
30 }48 }
31- fmt.Printf("Deploy signal sent for domain: %s\n", domain)49+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain)))
32 return nil50 return nil
33 },51 },
34 }52 }
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 +158 -0
@@ -319,3 +319,161 @@ func (c *Client) Ping(ctx context.Context) error {
319319 _, err := c.GetStatus(ctx)
320320 return err
321321 }
322+
323+// Deploy API
324+
325+// DeployResult contains the result of a deployment.
326+type DeployResult struct {
327+ Status string `json:"status"`
328+ ContainerID string `json:"container_id"`
329+ Domain string `json:"domain"`
330+}
331+
332+// Deploy triggers a deployment for the specified domain.
333+func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) {
334+ resp, err := c.request(ctx, http.MethodPost, "/deploy/"+deployDomain, nil)
335+ if err != nil {
336+ return nil, err
337+ }
338+
339+ var result DeployResult
340+ if err := parseResponse(resp, &result); err != nil {
341+ return nil, err
342+ }
343+
344+ return &result, nil
345+}
346+
347+// Logs API
348+
349+// GetProcessLogs returns Gordon process logs.
350+func (c *Client) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
351+ path := fmt.Sprintf("/logs?lines=%d", lines)
352+ resp, err := c.request(ctx, http.MethodGet, path, nil)
353+ if err != nil {
354+ return nil, err
355+ }
356+
357+ var result struct {
358+ Lines []string `json:"lines"`
359+ }
360+ if err := parseResponse(resp, &result); err != nil {
361+ return nil, err
362+ }
363+
364+ return result.Lines, nil
365+}
366+
367+// GetContainerLogs returns container logs for a specific domain.
368+func (c *Client) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
369+ path := fmt.Sprintf("/logs/%s?lines=%d", logDomain, lines)
370+ resp, err := c.request(ctx, http.MethodGet, path, nil)
371+ if err != nil {
372+ return nil, err
373+ }
374+
375+ var result struct {
376+ Domain string `json:"domain"`
377+ Lines []string `json:"lines"`
378+ }
379+ if err := parseResponse(resp, &result); err != nil {
380+ return nil, err
381+ }
382+
383+ return result.Lines, nil
384+}
385+
386+// StreamProcessLogs returns a channel that streams Gordon process log lines via SSE.
387+// The caller is responsible for reading from the channel until it's closed.
388+func (c *Client) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
389+ path := fmt.Sprintf("/logs?lines=%d&follow=true", lines)
390+ return c.streamLogs(ctx, path)
391+}
392+
393+// StreamContainerLogs returns a channel that streams container log lines via SSE.
394+// The caller is responsible for reading from the channel until it's closed.
395+func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
396+ path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", logDomain, lines)
397+ return c.streamLogs(ctx, path)
398+}
399+
400+// streamLogs handles SSE streaming for log endpoints.
401+func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
402+ url := c.baseURL + "/admin" + path
403+
404+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
405+ if err != nil {
406+ return nil, fmt.Errorf("failed to create request: %w", err)
407+ }
408+
409+ req.Header.Set("Accept", "text/event-stream")
410+ if c.token != "" {
411+ req.Header.Set("Authorization", "Bearer "+c.token)
412+ }
413+
414+ // Use a client without timeout for streaming
415+ streamClient := &http.Client{}
416+ resp, err := streamClient.Do(req)
417+ if err != nil {
418+ return nil, fmt.Errorf("failed to connect: %w", err)
419+ }
420+
421+ if resp.StatusCode >= 400 {
422+ body, _ := io.ReadAll(resp.Body)
423+ resp.Body.Close()
424+ return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
425+ }
426+
427+ ch := make(chan string, 100)
428+
429+ go func() {
430+ defer close(ch)
431+ defer resp.Body.Close()
432+
433+ buf := make([]byte, 4096)
434+ var lineBuffer strings.Builder
435+
436+ for {
437+ select {
438+ case <-ctx.Done():
439+ return
440+ default:
441+ }
442+
443+ n, err := resp.Body.Read(buf)
444+ if err != nil {
445+ // EOF is expected when stream ends, other errors are ignored
446+ return
447+ }
448+
449+ lineBuffer.Write(buf[:n])
450+
451+ // Process complete SSE events
452+ for {
453+ data := lineBuffer.String()
454+ idx := strings.Index(data, "\n\n")
455+ if idx == -1 {
456+ break
457+ }
458+
459+ event := data[:idx]
460+ lineBuffer.Reset()
461+ lineBuffer.WriteString(data[idx+2:])
462+
463+ // Parse SSE data lines
464+ for _, line := range strings.Split(event, "\n") {
465+ if strings.HasPrefix(line, "data: ") {
466+ logLine := strings.TrimPrefix(line, "data: ")
467+ select {
468+ case ch <- logLine:
469+ case <-ctx.Done():
470+ return
471+ }
472+ }
473+ }
474+ }
475+ }
476+ }()
477+
478+ return ch, nil
479+}
@@ -319,3 +319,161 @@ func (c *Client) Ping(ctx context.Context) error {
319 _, err := c.GetStatus(ctx)319 _, err := c.GetStatus(ctx)
320 return err320 return err
321 }321 }
322+
323+// Deploy API
324+
325+// DeployResult contains the result of a deployment.
326+type DeployResult struct {
327+ Status string `json:"status"`
328+ ContainerID string `json:"container_id"`
329+ Domain string `json:"domain"`
330+}
331+
332+// Deploy triggers a deployment for the specified domain.
333+func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) {
334+ resp, err := c.request(ctx, http.MethodPost, "/deploy/"+deployDomain, nil)
335+ if err != nil {
336+ return nil, err
337+ }
338+
339+ var result DeployResult
340+ if err := parseResponse(resp, &result); err != nil {
341+ return nil, err
342+ }
343+
344+ return &result, nil
345+}
346+
347+// Logs API
348+
349+// GetProcessLogs returns Gordon process logs.
350+func (c *Client) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
351+ path := fmt.Sprintf("/logs?lines=%d", lines)
352+ resp, err := c.request(ctx, http.MethodGet, path, nil)
353+ if err != nil {
354+ return nil, err
355+ }
356+
357+ var result struct {
358+ Lines []string `json:"lines"`
359+ }
360+ if err := parseResponse(resp, &result); err != nil {
361+ return nil, err
362+ }
363+
364+ return result.Lines, nil
365+}
366+
367+// GetContainerLogs returns container logs for a specific domain.
368+func (c *Client) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
369+ path := fmt.Sprintf("/logs/%s?lines=%d", logDomain, lines)
370+ resp, err := c.request(ctx, http.MethodGet, path, nil)
371+ if err != nil {
372+ return nil, err
373+ }
374+
375+ var result struct {
376+ Domain string `json:"domain"`
377+ Lines []string `json:"lines"`
378+ }
379+ if err := parseResponse(resp, &result); err != nil {
380+ return nil, err
381+ }
382+
383+ return result.Lines, nil
384+}
385+
386+// StreamProcessLogs returns a channel that streams Gordon process log lines via SSE.
387+// The caller is responsible for reading from the channel until it's closed.
388+func (c *Client) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
389+ path := fmt.Sprintf("/logs?lines=%d&follow=true", lines)
390+ return c.streamLogs(ctx, path)
391+}
392+
393+// StreamContainerLogs returns a channel that streams container log lines via SSE.
394+// The caller is responsible for reading from the channel until it's closed.
395+func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
396+ path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", logDomain, lines)
397+ return c.streamLogs(ctx, path)
398+}
399+
400+// streamLogs handles SSE streaming for log endpoints.
401+func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
402+ url := c.baseURL + "/admin" + path
403+
404+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
405+ if err != nil {
406+ return nil, fmt.Errorf("failed to create request: %w", err)
407+ }
408+
409+ req.Header.Set("Accept", "text/event-stream")
410+ if c.token != "" {
411+ req.Header.Set("Authorization", "Bearer "+c.token)
412+ }
413+
414+ // Use a client without timeout for streaming
415+ streamClient := &http.Client{}
416+ resp, err := streamClient.Do(req)
417+ if err != nil {
418+ return nil, fmt.Errorf("failed to connect: %w", err)
419+ }
420+
421+ if resp.StatusCode >= 400 {
422+ body, _ := io.ReadAll(resp.Body)
423+ resp.Body.Close()
424+ return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
425+ }
426+
427+ ch := make(chan string, 100)
428+
429+ go func() {
430+ defer close(ch)
431+ defer resp.Body.Close()
432+
433+ buf := make([]byte, 4096)
434+ var lineBuffer strings.Builder
435+
436+ for {
437+ select {
438+ case <-ctx.Done():
439+ return
440+ default:
441+ }
442+
443+ n, err := resp.Body.Read(buf)
444+ if err != nil {
445+ // EOF is expected when stream ends, other errors are ignored
446+ return
447+ }
448+
449+ lineBuffer.Write(buf[:n])
450+
451+ // Process complete SSE events
452+ for {
453+ data := lineBuffer.String()
454+ idx := strings.Index(data, "\n\n")
455+ if idx == -1 {
456+ break
457+ }
458+
459+ event := data[:idx]
460+ lineBuffer.Reset()
461+ lineBuffer.WriteString(data[idx+2:])
462+
463+ // Parse SSE data lines
464+ for _, line := range strings.Split(event, "\n") {
465+ if strings.HasPrefix(line, "data: ") {
466+ logLine := strings.TrimPrefix(line, "data: ")
467+ select {
468+ case ch <- logLine:
469+ case <-ctx.Done():
470+ return
471+ }
472+ }
473+ }
474+ }
475+ }
476+ }()
477+
478+ return ch, nil
479+}
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 +118 -65
@@ -14,6 +14,9 @@ import (
1414 "github.com/spf13/cobra"
1515 )
1616
17+// configPath is set by the root command
18+var configPath string
19+
1720 // truncateImage shortens long image references for display.
1821 // For digests (image@sha256:...), shows first 12 chars of digest.
1922 // For tags, truncates to maxLen with ellipsis if needed.
@@ -93,66 +96,106 @@ func newRoutesListCmd() *cobra.Command {
9396 ctx := context.Background()
9497
9598 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
99+ if isRemote {
100+ return runRoutesListRemote(ctx, client)
100101 }
102+ return runRoutesListLocal(ctx, configPath)
103+ },
104+ }
105+}
101106
102- routes, err := client.ListRoutes(ctx)
103- if err != nil {
104- return fmt.Errorf("failed to list routes: %w", err)
105- }
107+// runRoutesListRemote lists routes from a remote Gordon instance.
108+func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
109+ routes, err := client.ListRoutes(ctx)
110+ if err != nil {
111+ return fmt.Errorf("failed to list routes: %w", err)
112+ }
106113
107- if len(routes) == 0 {
108- fmt.Println(styles.Theme.Muted.Render("No routes configured"))
109- return nil
110- }
114+ if len(routes) == 0 {
115+ fmt.Println(styles.Theme.Muted.Render("No routes configured"))
116+ return nil
117+ }
111118
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- }
119+ // Get health status for each route (includes container status and HTTP probe)
120+ health, _ := client.GetHealth(ctx)
121+ if health == nil {
122+ health = make(map[string]*remote.RouteHealth)
123+ }
117124
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]
125+ // Build table rows
126+ const imageColWidth = 35
127+ rows := make([][]string, len(routes))
128+ for i, route := range routes {
129+ routeHealth := health[route.Domain]
123130
124- // Container status column
125- containerStatus := "unknown"
126- if routeHealth != nil {
127- containerStatus = routeHealth.ContainerStatus
128- }
129- containerBadge := components.ContainerStatusBadge(containerStatus)
131+ // Container status column
132+ containerStatus := "unknown"
133+ if routeHealth != nil {
134+ containerStatus = routeHealth.ContainerStatus
135+ }
136+ containerBadge := components.ContainerStatusBadge(containerStatus)
130137
131- // HTTP status column
132- httpStatus := formatHTTPStatus(routeHealth)
138+ // HTTP status column
139+ httpStatus := formatHTTPStatus(routeHealth)
133140
134- displayImage := truncateImage(route.Image, imageColWidth)
135- rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}
136- }
141+ displayImage := truncateImage(route.Image, imageColWidth)
142+ rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}
143+ }
137144
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())
145+ // Render table
146+ table := components.NewTable(
147+ components.WithColumns([]components.TableColumn{
148+ {Title: "Domain", Width: 25},
149+ {Title: "Image", Width: imageColWidth},
150+ {Title: "Container", Width: 12},
151+ {Title: "HTTP", Width: 14},
152+ }),
153+ components.WithRows(rows),
154+ )
155+
156+ fmt.Println(styles.Theme.Title.Render("Routes"))
157+ fmt.Println()
158+ fmt.Println(table.View())
159+
160+ return nil
161+}
152162
153- return nil
154- },
163+// runRoutesListLocal lists routes from local configuration.
164+func runRoutesListLocal(ctx context.Context, cfgPath string) error {
165+ local, err := GetLocalServices(cfgPath)
166+ if err != nil {
167+ return fmt.Errorf("failed to initialize local services: %w", err)
168+ }
169+
170+ routes := local.GetConfigService().GetRoutes(ctx)
171+
172+ if len(routes) == 0 {
173+ fmt.Println(styles.Theme.Muted.Render("No routes configured"))
174+ return nil
155175 }
176+
177+ // Build table rows (no health info in local mode)
178+ const imageColWidth = 45
179+ rows := make([][]string, len(routes))
180+ for i, route := range routes {
181+ displayImage := truncateImage(route.Image, imageColWidth)
182+ rows[i] = []string{route.Domain, displayImage}
183+ }
184+
185+ // Render table
186+ table := components.NewTable(
187+ components.WithColumns([]components.TableColumn{
188+ {Title: "Domain", Width: 30},
189+ {Title: "Image", Width: imageColWidth},
190+ }),
191+ components.WithRows(rows),
192+ )
193+
194+ fmt.Println(styles.Theme.Title.Render("Routes (local)"))
195+ fmt.Println()
196+ fmt.Println(table.View())
197+
198+ return nil
156199 }
157200
158201 // newRoutesAddCmd creates the routes add command.
@@ -172,12 +215,6 @@ Examples:
172215 ctx := context.Background()
173216 routeDomain := args[0]
174217
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-
181218 if image == "" {
182219 fmt.Println(styles.RenderError("--image flag is required"))
183220 return nil
@@ -188,8 +225,19 @@ Examples:
188225 Image: image,
189226 }
190227
191- if err := client.AddRoute(ctx, route); err != nil {
192- return fmt.Errorf("failed to add route: %w", err)
228+ client, isRemote := GetRemoteClient()
229+ if isRemote {
230+ if err := client.AddRoute(ctx, route); err != nil {
231+ return fmt.Errorf("failed to add route: %w", err)
232+ }
233+ } else {
234+ local, err := GetLocalServices(configPath)
235+ if err != nil {
236+ return fmt.Errorf("failed to initialize local services: %w", err)
237+ }
238+ if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
239+ return fmt.Errorf("failed to add route: %w", err)
240+ }
193241 }
194242
195243 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))
@@ -220,12 +268,6 @@ Examples:
220268 ctx := context.Background()
221269 routeDomain := args[0]
222270
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-
229271 // Confirm unless --force
230272 if !force {
231273 confirmed, err := components.RunConfirm(
@@ -241,8 +283,19 @@ Examples:
241283 }
242284 }
243285
244- if err := client.RemoveRoute(ctx, routeDomain); err != nil {
245- return fmt.Errorf("failed to remove route: %w", err)
286+ client, isRemote := GetRemoteClient()
287+ if isRemote {
288+ if err := client.RemoveRoute(ctx, routeDomain); err != nil {
289+ return fmt.Errorf("failed to remove route: %w", err)
290+ }
291+ } else {
292+ local, err := GetLocalServices(configPath)
293+ if err != nil {
294+ return fmt.Errorf("failed to initialize local services: %w", err)
295+ }
296+ if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil {
297+ return fmt.Errorf("failed to remove route: %w", err)
298+ }
246299 }
247300
248301 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
@@ -14,6 +14,9 @@ import (
14 "github.com/spf13/cobra"14 "github.com/spf13/cobra"
15 )15 )
16 16
17+// configPath is set by the root command
18+var configPath string
19+
17 // truncateImage shortens long image references for display.20 // truncateImage shortens long image references for display.
18 // For digests (image@sha256:...), shows first 12 chars of digest.21 // For digests (image@sha256:...), shows first 12 chars of digest.
19 // For tags, truncates to maxLen with ellipsis if needed.22 // For tags, truncates to maxLen with ellipsis if needed.
@@ -93,66 +96,106 @@ func newRoutesListCmd() *cobra.Command {
93 ctx := context.Background()96 ctx := context.Background()
94 97
95 client, isRemote := GetRemoteClient()98 client, isRemote := GetRemoteClient()
96- if !isRemote {99+ if isRemote {
97- fmt.Println(styles.RenderError("routes command requires --remote flag or GORDON_REMOTE env var"))100+ 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 }101 }
102+ return runRoutesListLocal(ctx, configPath)
103+ },
104+ }
105+}
101 106
102- routes, err := client.ListRoutes(ctx)107+// runRoutesListRemote lists routes from a remote Gordon instance.
103- if err != nil {108+func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
104- return fmt.Errorf("failed to list routes: %w", err)109+ routes, err := client.ListRoutes(ctx)
105- }110+ if err != nil {
111+ return fmt.Errorf("failed to list routes: %w", err)
112+ }
106 113
107- if len(routes) == 0 {114+ if len(routes) == 0 {
108- fmt.Println(styles.Theme.Muted.Render("No routes configured"))115+ fmt.Println(styles.Theme.Muted.Render("No routes configured"))
109- return nil116+ return nil
110- }117+ }
111 118
112- // Get health status for each route (includes container status and HTTP probe)119+ // Get health status for each route (includes container status and HTTP probe)
113- health, _ := client.GetHealth(ctx)120+ health, _ := client.GetHealth(ctx)
114- if health == nil {121+ if health == nil {
115- health = make(map[string]*remote.RouteHealth)122+ health = make(map[string]*remote.RouteHealth)
116- }123+ }
117 124
118- // Build table rows125+ // Build table rows
119- const imageColWidth = 35126+ const imageColWidth = 35
120- rows := make([][]string, len(routes))127+ rows := make([][]string, len(routes))
121- for i, route := range routes {128+ for i, route := range routes {
122- routeHealth := health[route.Domain]129+ routeHealth := health[route.Domain]
123 130
124- // Container status column131+ // Container status column
125- containerStatus := "unknown"132+ containerStatus := "unknown"
126- if routeHealth != nil {133+ if routeHealth != nil {
127- containerStatus = routeHealth.ContainerStatus134+ containerStatus = routeHealth.ContainerStatus
128- }135+ }
129- containerBadge := components.ContainerStatusBadge(containerStatus)136+ containerBadge := components.ContainerStatusBadge(containerStatus)
130 137
131- // HTTP status column138+ // HTTP status column
132- httpStatus := formatHTTPStatus(routeHealth)139+ httpStatus := formatHTTPStatus(routeHealth)
133 140
134- displayImage := truncateImage(route.Image, imageColWidth)141+ displayImage := truncateImage(route.Image, imageColWidth)
135- rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}142+ rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}
136- }143+ }
137 144
138- // Render table145+ // Render table
139- table := components.NewTable(146+ table := components.NewTable(
140- components.WithColumns([]components.TableColumn{147+ components.WithColumns([]components.TableColumn{
141- {Title: "Domain", Width: 25},148+ {Title: "Domain", Width: 25},
142- {Title: "Image", Width: imageColWidth},149+ {Title: "Image", Width: imageColWidth},
143- {Title: "Container", Width: 12},150+ {Title: "Container", Width: 12},
144- {Title: "HTTP", Width: 14},151+ {Title: "HTTP", Width: 14},
145- }),152+ }),
146- components.WithRows(rows),153+ components.WithRows(rows),
147- )154+ )
148-155+
149- fmt.Println(styles.Theme.Title.Render("Routes"))156+ fmt.Println(styles.Theme.Title.Render("Routes"))
150- fmt.Println()157+ fmt.Println()
151- fmt.Println(table.View())158+ fmt.Println(table.View())
159+
160+ return nil
161+}
152 162
153- return nil163+// runRoutesListLocal lists routes from local configuration.
154- },164+func runRoutesListLocal(ctx context.Context, cfgPath string) error {
165+ local, err := GetLocalServices(cfgPath)
166+ if err != nil {
167+ return fmt.Errorf("failed to initialize local services: %w", err)
168+ }
169+
170+ routes := local.GetConfigService().GetRoutes(ctx)
171+
172+ if len(routes) == 0 {
173+ fmt.Println(styles.Theme.Muted.Render("No routes configured"))
174+ return nil
155 }175 }
176+
177+ // Build table rows (no health info in local mode)
178+ const imageColWidth = 45
179+ rows := make([][]string, len(routes))
180+ for i, route := range routes {
181+ displayImage := truncateImage(route.Image, imageColWidth)
182+ rows[i] = []string{route.Domain, displayImage}
183+ }
184+
185+ // Render table
186+ table := components.NewTable(
187+ components.WithColumns([]components.TableColumn{
188+ {Title: "Domain", Width: 30},
189+ {Title: "Image", Width: imageColWidth},
190+ }),
191+ components.WithRows(rows),
192+ )
193+
194+ fmt.Println(styles.Theme.Title.Render("Routes (local)"))
195+ fmt.Println()
196+ fmt.Println(table.View())
197+
198+ return nil
156 }199 }
157 200
158 // newRoutesAddCmd creates the routes add command.201 // newRoutesAddCmd creates the routes add command.
@@ -172,12 +215,6 @@ Examples:
172 ctx := context.Background()215 ctx := context.Background()
173 routeDomain := args[0]216 routeDomain := args[0]
174 217
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 == "" {218 if image == "" {
182 fmt.Println(styles.RenderError("--image flag is required"))219 fmt.Println(styles.RenderError("--image flag is required"))
183 return nil220 return nil
@@ -188,8 +225,19 @@ Examples:
188 Image: image,225 Image: image,
189 }226 }
190 227
191- if err := client.AddRoute(ctx, route); err != nil {228+ client, isRemote := GetRemoteClient()
192- return fmt.Errorf("failed to add route: %w", err)229+ if isRemote {
230+ if err := client.AddRoute(ctx, route); err != nil {
231+ return fmt.Errorf("failed to add route: %w", err)
232+ }
233+ } else {
234+ local, err := GetLocalServices(configPath)
235+ if err != nil {
236+ return fmt.Errorf("failed to initialize local services: %w", err)
237+ }
238+ if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
239+ return fmt.Errorf("failed to add route: %w", err)
240+ }
193 }241 }
194 242
195 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))243 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))
@@ -220,12 +268,6 @@ Examples:
220 ctx := context.Background()268 ctx := context.Background()
221 routeDomain := args[0]269 routeDomain := args[0]
222 270
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 --force271 // Confirm unless --force
230 if !force {272 if !force {
231 confirmed, err := components.RunConfirm(273 confirmed, err := components.RunConfirm(
@@ -241,8 +283,19 @@ Examples:
241 }283 }
242 }284 }
243 285
244- if err := client.RemoveRoute(ctx, routeDomain); err != nil {286+ client, isRemote := GetRemoteClient()
245- return fmt.Errorf("failed to remove route: %w", err)287+ if isRemote {
288+ if err := client.RemoveRoute(ctx, routeDomain); err != nil {
289+ return fmt.Errorf("failed to remove route: %w", err)
290+ }
291+ } else {
292+ local, err := GetLocalServices(configPath)
293+ if err != nil {
294+ return fmt.Errorf("failed to initialize local services: %w", err)
295+ }
296+ if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil {
297+ return fmt.Errorf("failed to remove route: %w", err)
298+ }
246 }299 }
247 300
248 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))301 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 +234 -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"
@@ -24,6 +26,7 @@ type Handler struct {
2426 containerSvc in.ContainerService
2527 healthSvc in.HealthService
2628 secretSvc in.SecretService
29+ logSvc in.LogService
2730 eventBus out.EventPublisher
2831 log zerowrap.Logger
2932 }
@@ -35,6 +38,7 @@ func NewHandler(
3538 containerSvc in.ContainerService,
3639 healthSvc in.HealthService,
3740 secretSvc in.SecretService,
41+ logSvc in.LogService,
3842 eventBus out.EventPublisher,
3943 log zerowrap.Logger,
4044 ) *Handler {
@@ -44,6 +48,7 @@ func NewHandler(
4448 containerSvc: containerSvc,
4549 healthSvc: healthSvc,
4650 secretSvc: secretSvc,
51+ logSvc: logSvc,
4752 eventBus: eventBus,
4853 log: log,
4954 }
@@ -77,6 +82,10 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
7782 h.handleRoutes(w, r, path)
7883 case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):
7984 h.handleSecrets(w, r, path)
85+ case path == "/deploy" || strings.HasPrefix(path, "/deploy/"):
86+ h.handleDeploy(w, r, path)
87+ case path == "/logs" || strings.HasPrefix(path, "/logs/"):
88+ h.handleLogs(w, r, path)
8089 case path == "/status":
8190 h.handleStatus(w, r)
8291 case path == "/health":
@@ -484,3 +493,228 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
484493
485494 h.sendJSON(w, http.StatusOK, config)
486495 }
496+
497+// handleDeploy handles /admin/deploy/:domain endpoint.
498+// POST triggers a deployment for the specified domain.
499+func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {
500+ if r.Method != http.MethodPost {
501+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
502+ return
503+ }
504+
505+ ctx := r.Context()
506+ log := zerowrap.FromCtx(ctx)
507+
508+ // Check write permission
509+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
510+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
511+ return
512+ }
513+
514+ // Parse domain from path
515+ deployDomain := strings.TrimPrefix(path, "/deploy/")
516+ if deployDomain == "" || deployDomain == "/deploy" {
517+ h.sendError(w, http.StatusBadRequest, "domain required in path")
518+ return
519+ }
520+
521+ // Get the route for this domain
522+ route, err := h.configSvc.GetRoute(ctx, deployDomain)
523+ if err != nil {
524+ h.sendError(w, http.StatusNotFound, "route not found")
525+ return
526+ }
527+
528+ // Deploy the container
529+ container, err := h.containerSvc.Deploy(ctx, *route)
530+ if err != nil {
531+ log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
532+ h.sendError(w, http.StatusInternalServerError, fmt.Sprintf("failed to deploy: %v", err))
533+ return
534+ }
535+
536+ log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
537+ h.sendJSON(w, http.StatusOK, map[string]any{
538+ "status": "deployed",
539+ "container_id": container.ID,
540+ "domain": deployDomain,
541+ })
542+}
543+
544+// handleLogs handles /admin/logs endpoints.
545+// GET /admin/logs - Gordon process logs
546+// GET /admin/logs/:domain - Container logs for a specific domain
547+func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
548+ if r.Method != http.MethodGet {
549+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
550+ return
551+ }
552+
553+ ctx := r.Context()
554+ log := zerowrap.FromCtx(ctx)
555+
556+ // Check read permission
557+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
558+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
559+ return
560+ }
561+
562+ // Check if LogService is available
563+ if h.logSvc == nil {
564+ h.sendError(w, http.StatusServiceUnavailable, "log service not available")
565+ return
566+ }
567+
568+ // Parse query parameters
569+ lines := 50 // default
570+ if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
571+ if n, err := strconv.Atoi(linesStr); err == nil && n > 0 {
572+ lines = n
573+ }
574+ }
575+ follow := r.URL.Query().Get("follow") == "true"
576+
577+ // Parse domain from path
578+ logDomain := strings.TrimPrefix(path, "/logs/")
579+ if logDomain == "/logs" {
580+ logDomain = ""
581+ }
582+
583+ if logDomain == "" {
584+ // Gordon process logs
585+ h.handleProcessLogs(w, r, lines, follow)
586+ } else {
587+ // Container logs
588+ h.handleContainerLogs(w, r, logDomain, lines, follow)
589+ }
590+
591+ // Prevent unused variable warning when follow is implemented
592+ _ = log
593+}
594+
595+// handleProcessLogs handles Gordon process logs.
596+func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) {
597+ ctx := r.Context()
598+ log := zerowrap.FromCtx(ctx)
599+
600+ if follow {
601+ // SSE streaming
602+ h.streamProcessLogs(w, r, lines)
603+ return
604+ }
605+
606+ // Return last N lines as JSON
607+ logLines, err := h.logSvc.GetProcessLogs(ctx, lines)
608+ if err != nil {
609+ log.Warn().Err(err).Msg("failed to get process logs")
610+ h.sendError(w, http.StatusInternalServerError, "failed to get logs")
611+ return
612+ }
613+
614+ h.sendJSON(w, http.StatusOK, map[string]any{
615+ "lines": logLines,
616+ })
617+}
618+
619+// handleContainerLogs handles container logs for a specific domain.
620+func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) {
621+ ctx := r.Context()
622+ log := zerowrap.FromCtx(ctx)
623+
624+ if follow {
625+ // SSE streaming
626+ h.streamContainerLogs(w, r, logDomain, lines)
627+ return
628+ }
629+
630+ // Return last N lines as JSON
631+ logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines)
632+ if err != nil {
633+ log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs")
634+ h.sendError(w, http.StatusInternalServerError, fmt.Sprintf("failed to get logs: %v", err))
635+ return
636+ }
637+
638+ h.sendJSON(w, http.StatusOK, map[string]any{
639+ "domain": logDomain,
640+ "lines": logLines,
641+ })
642+}
643+
644+// streamProcessLogs streams Gordon process logs via SSE.
645+func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) {
646+ ctx := r.Context()
647+ log := zerowrap.FromCtx(ctx)
648+
649+ // Set up SSE
650+ w.Header().Set("Content-Type", "text/event-stream")
651+ w.Header().Set("Cache-Control", "no-cache")
652+ w.Header().Set("Connection", "keep-alive")
653+ w.WriteHeader(http.StatusOK)
654+
655+ flusher, ok := w.(http.Flusher)
656+ if !ok {
657+ h.sendError(w, http.StatusInternalServerError, "streaming not supported")
658+ return
659+ }
660+
661+ ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
662+ if err != nil {
663+ log.Warn().Err(err).Msg("failed to follow process logs")
664+ _, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", err.Error())
665+ flusher.Flush()
666+ return
667+ }
668+
669+ for {
670+ select {
671+ case line, ok := <-ch:
672+ if !ok {
673+ return
674+ }
675+ _, _ = fmt.Fprintf(w, "data: %s\n\n", line)
676+ flusher.Flush()
677+ case <-ctx.Done():
678+ return
679+ }
680+ }
681+}
682+
683+// streamContainerLogs streams container logs via SSE.
684+func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) {
685+ ctx := r.Context()
686+ log := zerowrap.FromCtx(ctx)
687+
688+ // Set up SSE
689+ w.Header().Set("Content-Type", "text/event-stream")
690+ w.Header().Set("Cache-Control", "no-cache")
691+ w.Header().Set("Connection", "keep-alive")
692+ w.WriteHeader(http.StatusOK)
693+
694+ flusher, ok := w.(http.Flusher)
695+ if !ok {
696+ h.sendError(w, http.StatusInternalServerError, "streaming not supported")
697+ return
698+ }
699+
700+ ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
701+ if err != nil {
702+ log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs")
703+ _, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", err.Error())
704+ flusher.Flush()
705+ return
706+ }
707+
708+ for {
709+ select {
710+ case line, ok := <-ch:
711+ if !ok {
712+ return
713+ }
714+ _, _ = fmt.Fprintf(w, "data: %s\n\n", line)
715+ flusher.Flush()
716+ case <-ctx.Done():
717+ return
718+ }
719+ }
720+}
@@ -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"
@@ -24,6 +26,7 @@ type Handler struct {
24 containerSvc in.ContainerService26 containerSvc in.ContainerService
25 healthSvc in.HealthService27 healthSvc in.HealthService
26 secretSvc in.SecretService28 secretSvc in.SecretService
29+ logSvc in.LogService
27 eventBus out.EventPublisher30 eventBus out.EventPublisher
28 log zerowrap.Logger31 log zerowrap.Logger
29 }32 }
@@ -35,6 +38,7 @@ func NewHandler(
35 containerSvc in.ContainerService,38 containerSvc in.ContainerService,
36 healthSvc in.HealthService,39 healthSvc in.HealthService,
37 secretSvc in.SecretService,40 secretSvc in.SecretService,
41+ logSvc in.LogService,
38 eventBus out.EventPublisher,42 eventBus out.EventPublisher,
39 log zerowrap.Logger,43 log zerowrap.Logger,
40 ) *Handler {44 ) *Handler {
@@ -44,6 +48,7 @@ func NewHandler(
44 containerSvc: containerSvc,48 containerSvc: containerSvc,
45 healthSvc: healthSvc,49 healthSvc: healthSvc,
46 secretSvc: secretSvc,50 secretSvc: secretSvc,
51+ logSvc: logSvc,
47 eventBus: eventBus,52 eventBus: eventBus,
48 log: log,53 log: log,
49 }54 }
@@ -77,6 +82,10 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
77 h.handleRoutes(w, r, path)82 h.handleRoutes(w, r, path)
78 case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):83 case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):
79 h.handleSecrets(w, r, path)84 h.handleSecrets(w, r, path)
85+ case path == "/deploy" || strings.HasPrefix(path, "/deploy/"):
86+ h.handleDeploy(w, r, path)
87+ case path == "/logs" || strings.HasPrefix(path, "/logs/"):
88+ h.handleLogs(w, r, path)
80 case path == "/status":89 case path == "/status":
81 h.handleStatus(w, r)90 h.handleStatus(w, r)
82 case path == "/health":91 case path == "/health":
@@ -484,3 +493,228 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
484 493
485 h.sendJSON(w, http.StatusOK, config)494 h.sendJSON(w, http.StatusOK, config)
486 }495 }
496+
497+// handleDeploy handles /admin/deploy/:domain endpoint.
498+// POST triggers a deployment for the specified domain.
499+func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {
500+ if r.Method != http.MethodPost {
501+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
502+ return
503+ }
504+
505+ ctx := r.Context()
506+ log := zerowrap.FromCtx(ctx)
507+
508+ // Check write permission
509+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
510+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
511+ return
512+ }
513+
514+ // Parse domain from path
515+ deployDomain := strings.TrimPrefix(path, "/deploy/")
516+ if deployDomain == "" || deployDomain == "/deploy" {
517+ h.sendError(w, http.StatusBadRequest, "domain required in path")
518+ return
519+ }
520+
521+ // Get the route for this domain
522+ route, err := h.configSvc.GetRoute(ctx, deployDomain)
523+ if err != nil {
524+ h.sendError(w, http.StatusNotFound, "route not found")
525+ return
526+ }
527+
528+ // Deploy the container
529+ container, err := h.containerSvc.Deploy(ctx, *route)
530+ if err != nil {
531+ log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
532+ h.sendError(w, http.StatusInternalServerError, fmt.Sprintf("failed to deploy: %v", err))
533+ return
534+ }
535+
536+ log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
537+ h.sendJSON(w, http.StatusOK, map[string]any{
538+ "status": "deployed",
539+ "container_id": container.ID,
540+ "domain": deployDomain,
541+ })
542+}
543+
544+// handleLogs handles /admin/logs endpoints.
545+// GET /admin/logs - Gordon process logs
546+// GET /admin/logs/:domain - Container logs for a specific domain
547+func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
548+ if r.Method != http.MethodGet {
549+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
550+ return
551+ }
552+
553+ ctx := r.Context()
554+ log := zerowrap.FromCtx(ctx)
555+
556+ // Check read permission
557+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
558+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
559+ return
560+ }
561+
562+ // Check if LogService is available
563+ if h.logSvc == nil {
564+ h.sendError(w, http.StatusServiceUnavailable, "log service not available")
565+ return
566+ }
567+
568+ // Parse query parameters
569+ lines := 50 // default
570+ if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
571+ if n, err := strconv.Atoi(linesStr); err == nil && n > 0 {
572+ lines = n
573+ }
574+ }
575+ follow := r.URL.Query().Get("follow") == "true"
576+
577+ // Parse domain from path
578+ logDomain := strings.TrimPrefix(path, "/logs/")
579+ if logDomain == "/logs" {
580+ logDomain = ""
581+ }
582+
583+ if logDomain == "" {
584+ // Gordon process logs
585+ h.handleProcessLogs(w, r, lines, follow)
586+ } else {
587+ // Container logs
588+ h.handleContainerLogs(w, r, logDomain, lines, follow)
589+ }
590+
591+ // Prevent unused variable warning when follow is implemented
592+ _ = log
593+}
594+
595+// handleProcessLogs handles Gordon process logs.
596+func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) {
597+ ctx := r.Context()
598+ log := zerowrap.FromCtx(ctx)
599+
600+ if follow {
601+ // SSE streaming
602+ h.streamProcessLogs(w, r, lines)
603+ return
604+ }
605+
606+ // Return last N lines as JSON
607+ logLines, err := h.logSvc.GetProcessLogs(ctx, lines)
608+ if err != nil {
609+ log.Warn().Err(err).Msg("failed to get process logs")
610+ h.sendError(w, http.StatusInternalServerError, "failed to get logs")
611+ return
612+ }
613+
614+ h.sendJSON(w, http.StatusOK, map[string]any{
615+ "lines": logLines,
616+ })
617+}
618+
619+// handleContainerLogs handles container logs for a specific domain.
620+func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) {
621+ ctx := r.Context()
622+ log := zerowrap.FromCtx(ctx)
623+
624+ if follow {
625+ // SSE streaming
626+ h.streamContainerLogs(w, r, logDomain, lines)
627+ return
628+ }
629+
630+ // Return last N lines as JSON
631+ logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines)
632+ if err != nil {
633+ log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs")
634+ h.sendError(w, http.StatusInternalServerError, fmt.Sprintf("failed to get logs: %v", err))
635+ return
636+ }
637+
638+ h.sendJSON(w, http.StatusOK, map[string]any{
639+ "domain": logDomain,
640+ "lines": logLines,
641+ })
642+}
643+
644+// streamProcessLogs streams Gordon process logs via SSE.
645+func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) {
646+ ctx := r.Context()
647+ log := zerowrap.FromCtx(ctx)
648+
649+ // Set up SSE
650+ w.Header().Set("Content-Type", "text/event-stream")
651+ w.Header().Set("Cache-Control", "no-cache")
652+ w.Header().Set("Connection", "keep-alive")
653+ w.WriteHeader(http.StatusOK)
654+
655+ flusher, ok := w.(http.Flusher)
656+ if !ok {
657+ h.sendError(w, http.StatusInternalServerError, "streaming not supported")
658+ return
659+ }
660+
661+ ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
662+ if err != nil {
663+ log.Warn().Err(err).Msg("failed to follow process logs")
664+ _, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", err.Error())
665+ flusher.Flush()
666+ return
667+ }
668+
669+ for {
670+ select {
671+ case line, ok := <-ch:
672+ if !ok {
673+ return
674+ }
675+ _, _ = fmt.Fprintf(w, "data: %s\n\n", line)
676+ flusher.Flush()
677+ case <-ctx.Done():
678+ return
679+ }
680+ }
681+}
682+
683+// streamContainerLogs streams container logs via SSE.
684+func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) {
685+ ctx := r.Context()
686+ log := zerowrap.FromCtx(ctx)
687+
688+ // Set up SSE
689+ w.Header().Set("Content-Type", "text/event-stream")
690+ w.Header().Set("Cache-Control", "no-cache")
691+ w.Header().Set("Connection", "keep-alive")
692+ w.WriteHeader(http.StatusOK)
693+
694+ flusher, ok := w.(http.Flusher)
695+ if !ok {
696+ h.sendError(w, http.StatusInternalServerError, "streaming not supported")
697+ return
698+ }
699+
700+ ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
701+ if err != nil {
702+ log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs")
703+ _, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", err.Error())
704+ flusher.Flush()
705+ return
706+ }
707+
708+ for {
709+ select {
710+ case line, ok := <-ch:
711+ if !ok {
712+ return
713+ }
714+ _, _ = fmt.Fprintf(w, "data: %s\n\n", line)
715+ flusher.Flush()
716+ case <-ctx.Done():
717+ return
718+ }
719+ }
720+}
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 +357 -0
@@ -0,0 +1,357 @@
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+// GetContainerLogs returns the last N lines of container logs for a domain.
144+func (s *Service) GetContainerLogs(ctx context.Context, domain string, lines int) ([]string, error) {
145+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
146+ zerowrap.FieldLayer: "usecase",
147+ zerowrap.FieldUseCase: "GetContainerLogs",
148+ "domain": domain,
149+ "lines": lines,
150+ })
151+ log := zerowrap.FromCtx(ctx)
152+
153+ // Get container by domain
154+ container, ok := s.containerSvc.Get(ctx, domain)
155+ if !ok || container == nil {
156+ return nil, fmt.Errorf("container not found for domain: %s", domain)
157+ }
158+
159+ // Get logs from container runtime (non-follow mode)
160+ reader, err := s.runtime.GetContainerLogs(ctx, container.ID, false)
161+ if err != nil {
162+ return nil, log.WrapErr(err, "failed to get container logs")
163+ }
164+ defer reader.Close()
165+
166+ // Parse Docker log stream and get lines
167+ logLines, err := parseDockerLogs(reader)
168+ if err != nil {
169+ return nil, log.WrapErr(err, "failed to parse container logs")
170+ }
171+
172+ // Return last N lines
173+ if len(logLines) > lines {
174+ logLines = logLines[len(logLines)-lines:]
175+ }
176+
177+ return logLines, nil
178+}
179+
180+// FollowContainerLogs returns a channel that streams container log lines.
181+func (s *Service) FollowContainerLogs(ctx context.Context, domain string, initialLines int) (<-chan string, error) {
182+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
183+ zerowrap.FieldLayer: "usecase",
184+ zerowrap.FieldUseCase: "FollowContainerLogs",
185+ "domain": domain,
186+ "initial_lines": initialLines,
187+ })
188+ log := zerowrap.FromCtx(ctx)
189+
190+ // Get container by domain
191+ container, ok := s.containerSvc.Get(ctx, domain)
192+ if !ok || container == nil {
193+ return nil, fmt.Errorf("container not found for domain: %s", domain)
194+ }
195+
196+ // Get logs from container runtime (follow mode)
197+ reader, err := s.runtime.GetContainerLogs(ctx, container.ID, true)
198+ if err != nil {
199+ return nil, log.WrapErr(err, "failed to get container logs")
200+ }
201+
202+ ch := make(chan string, 100)
203+
204+ go func() {
205+ defer close(ch)
206+ defer reader.Close()
207+
208+ // Stream Docker logs
209+ err := streamDockerLogs(ctx, reader, ch)
210+ if err != nil && ctx.Err() == nil {
211+ log.Warn().Err(err).Msg("error streaming container logs")
212+ }
213+ }()
214+
215+ return ch, nil
216+}
217+
218+// tailLines reads the last N lines from a file using a ring buffer.
219+func tailLines(file *os.File, n int) ([]string, error) {
220+ if n <= 0 {
221+ return []string{}, nil
222+ }
223+
224+ // Seek to beginning
225+ if _, err := file.Seek(0, io.SeekStart); err != nil {
226+ return nil, err
227+ }
228+
229+ scanner := bufio.NewScanner(file)
230+ buffer := make([]string, n)
231+ index := 0
232+ total := 0
233+
234+ for scanner.Scan() {
235+ buffer[index] = scanner.Text()
236+ index = (index + 1) % n
237+ total++
238+ }
239+
240+ if err := scanner.Err(); err != nil {
241+ return nil, err
242+ }
243+
244+ if total == 0 {
245+ return []string{}, nil
246+ }
247+
248+ // Build result in correct order
249+ var result []string
250+ if total <= n {
251+ result = make([]string, total)
252+ for i := 0; i < total; i++ {
253+ result[i] = buffer[i]
254+ }
255+ } else {
256+ result = make([]string, n)
257+ for i := 0; i < n; i++ {
258+ result[i] = buffer[(index+i)%n]
259+ }
260+ }
261+
262+ return result, nil
263+}
264+
265+// parseDockerLogs parses Docker multiplexed log stream and returns lines.
266+// Docker logs have an 8-byte header: [stream type (1), padding (3), size (4)]
267+func parseDockerLogs(reader io.Reader) ([]string, error) {
268+ var lines []string
269+ header := make([]byte, 8)
270+
271+ for {
272+ // Read header
273+ _, err := io.ReadFull(reader, header)
274+ if err != nil {
275+ if err == io.EOF {
276+ break
277+ }
278+ return lines, nil // Return what we have
279+ }
280+
281+ // Get payload size from header (big endian)
282+ size := binary.BigEndian.Uint32(header[4:8])
283+ if size == 0 {
284+ continue
285+ }
286+
287+ // Read payload
288+ payload := make([]byte, size)
289+ _, err = io.ReadFull(reader, payload)
290+ if err != nil {
291+ if err == io.EOF {
292+ break
293+ }
294+ return lines, nil
295+ }
296+
297+ // Split by newlines and add to result
298+ content := strings.TrimRight(string(payload), "\n\r")
299+ for _, line := range strings.Split(content, "\n") {
300+ if line != "" {
301+ lines = append(lines, line)
302+ }
303+ }
304+ }
305+
306+ return lines, nil
307+}
308+
309+// streamDockerLogs streams Docker multiplexed log stream to a channel.
310+func streamDockerLogs(ctx context.Context, reader io.Reader, ch chan<- string) error {
311+ header := make([]byte, 8)
312+
313+ for {
314+ select {
315+ case <-ctx.Done():
316+ return ctx.Err()
317+ default:
318+ }
319+
320+ // Read header
321+ _, err := io.ReadFull(reader, header)
322+ if err != nil {
323+ if err == io.EOF {
324+ return nil
325+ }
326+ return err
327+ }
328+
329+ // Get payload size
330+ size := binary.BigEndian.Uint32(header[4:8])
331+ if size == 0 {
332+ continue
333+ }
334+
335+ // Read payload
336+ payload := make([]byte, size)
337+ _, err = io.ReadFull(reader, payload)
338+ if err != nil {
339+ if err == io.EOF {
340+ return nil
341+ }
342+ return err
343+ }
344+
345+ // Split by newlines and send to channel
346+ content := strings.TrimRight(string(payload), "\n\r")
347+ for _, line := range strings.Split(content, "\n") {
348+ if line != "" {
349+ select {
350+ case ch <- line:
351+ case <-ctx.Done():
352+ return ctx.Err()
353+ }
354+ }
355+ }
356+ }
357+}
@@ -0,0 +1,357 @@
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+// GetContainerLogs returns the last N lines of container logs for a domain.
144+func (s *Service) GetContainerLogs(ctx context.Context, domain string, lines int) ([]string, error) {
145+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
146+ zerowrap.FieldLayer: "usecase",
147+ zerowrap.FieldUseCase: "GetContainerLogs",
148+ "domain": domain,
149+ "lines": lines,
150+ })
151+ log := zerowrap.FromCtx(ctx)
152+
153+ // Get container by domain
154+ container, ok := s.containerSvc.Get(ctx, domain)
155+ if !ok || container == nil {
156+ return nil, fmt.Errorf("container not found for domain: %s", domain)
157+ }
158+
159+ // Get logs from container runtime (non-follow mode)
160+ reader, err := s.runtime.GetContainerLogs(ctx, container.ID, false)
161+ if err != nil {
162+ return nil, log.WrapErr(err, "failed to get container logs")
163+ }
164+ defer reader.Close()
165+
166+ // Parse Docker log stream and get lines
167+ logLines, err := parseDockerLogs(reader)
168+ if err != nil {
169+ return nil, log.WrapErr(err, "failed to parse container logs")
170+ }
171+
172+ // Return last N lines
173+ if len(logLines) > lines {
174+ logLines = logLines[len(logLines)-lines:]
175+ }
176+
177+ return logLines, nil
178+}
179+
180+// FollowContainerLogs returns a channel that streams container log lines.
181+func (s *Service) FollowContainerLogs(ctx context.Context, domain string, initialLines int) (<-chan string, error) {
182+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
183+ zerowrap.FieldLayer: "usecase",
184+ zerowrap.FieldUseCase: "FollowContainerLogs",
185+ "domain": domain,
186+ "initial_lines": initialLines,
187+ })
188+ log := zerowrap.FromCtx(ctx)
189+
190+ // Get container by domain
191+ container, ok := s.containerSvc.Get(ctx, domain)
192+ if !ok || container == nil {
193+ return nil, fmt.Errorf("container not found for domain: %s", domain)
194+ }
195+
196+ // Get logs from container runtime (follow mode)
197+ reader, err := s.runtime.GetContainerLogs(ctx, container.ID, true)
198+ if err != nil {
199+ return nil, log.WrapErr(err, "failed to get container logs")
200+ }
201+
202+ ch := make(chan string, 100)
203+
204+ go func() {
205+ defer close(ch)
206+ defer reader.Close()
207+
208+ // Stream Docker logs
209+ err := streamDockerLogs(ctx, reader, ch)
210+ if err != nil && ctx.Err() == nil {
211+ log.Warn().Err(err).Msg("error streaming container logs")
212+ }
213+ }()
214+
215+ return ch, nil
216+}
217+
218+// tailLines reads the last N lines from a file using a ring buffer.
219+func tailLines(file *os.File, n int) ([]string, error) {
220+ if n <= 0 {
221+ return []string{}, nil
222+ }
223+
224+ // Seek to beginning
225+ if _, err := file.Seek(0, io.SeekStart); err != nil {
226+ return nil, err
227+ }
228+
229+ scanner := bufio.NewScanner(file)
230+ buffer := make([]string, n)
231+ index := 0
232+ total := 0
233+
234+ for scanner.Scan() {
235+ buffer[index] = scanner.Text()
236+ index = (index + 1) % n
237+ total++
238+ }
239+
240+ if err := scanner.Err(); err != nil {
241+ return nil, err
242+ }
243+
244+ if total == 0 {
245+ return []string{}, nil
246+ }
247+
248+ // Build result in correct order
249+ var result []string
250+ if total <= n {
251+ result = make([]string, total)
252+ for i := 0; i < total; i++ {
253+ result[i] = buffer[i]
254+ }
255+ } else {
256+ result = make([]string, n)
257+ for i := 0; i < n; i++ {
258+ result[i] = buffer[(index+i)%n]
259+ }
260+ }
261+
262+ return result, nil
263+}
264+
265+// parseDockerLogs parses Docker multiplexed log stream and returns lines.
266+// Docker logs have an 8-byte header: [stream type (1), padding (3), size (4)]
267+func parseDockerLogs(reader io.Reader) ([]string, error) {
268+ var lines []string
269+ header := make([]byte, 8)
270+
271+ for {
272+ // Read header
273+ _, err := io.ReadFull(reader, header)
274+ if err != nil {
275+ if err == io.EOF {
276+ break
277+ }
278+ return lines, nil // Return what we have
279+ }
280+
281+ // Get payload size from header (big endian)
282+ size := binary.BigEndian.Uint32(header[4:8])
283+ if size == 0 {
284+ continue
285+ }
286+
287+ // Read payload
288+ payload := make([]byte, size)
289+ _, err = io.ReadFull(reader, payload)
290+ if err != nil {
291+ if err == io.EOF {
292+ break
293+ }
294+ return lines, nil
295+ }
296+
297+ // Split by newlines and add to result
298+ content := strings.TrimRight(string(payload), "\n\r")
299+ for _, line := range strings.Split(content, "\n") {
300+ if line != "" {
301+ lines = append(lines, line)
302+ }
303+ }
304+ }
305+
306+ return lines, nil
307+}
308+
309+// streamDockerLogs streams Docker multiplexed log stream to a channel.
310+func streamDockerLogs(ctx context.Context, reader io.Reader, ch chan<- string) error {
311+ header := make([]byte, 8)
312+
313+ for {
314+ select {
315+ case <-ctx.Done():
316+ return ctx.Err()
317+ default:
318+ }
319+
320+ // Read header
321+ _, err := io.ReadFull(reader, header)
322+ if err != nil {
323+ if err == io.EOF {
324+ return nil
325+ }
326+ return err
327+ }
328+
329+ // Get payload size
330+ size := binary.BigEndian.Uint32(header[4:8])
331+ if size == 0 {
332+ continue
333+ }
334+
335+ // Read payload
336+ payload := make([]byte, size)
337+ _, err = io.ReadFull(reader, payload)
338+ if err != nil {
339+ if err == io.EOF {
340+ return nil
341+ }
342+ return err
343+ }
344+
345+ // Split by newlines and send to channel
346+ content := strings.TrimRight(string(payload), "\n\r")
347+ for _, line := range strings.Split(content, "\n") {
348+ if line != "" {
349+ select {
350+ case ch <- line:
351+ case <-ctx.Done():
352+ return ctx.Err()
353+ }
354+ }
355+ }
356+ }
357+}
A internal/usecase/logs/service_test.go +257 -0
@@ -0,0 +1,257 @@
1+package logs
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "gordon/internal/boundaries/in/mocks"
16+ outMocks "gordon/internal/boundaries/out/mocks"
17+ "gordon/internal/domain"
18+)
19+
20+func TestService_GetProcessLogs(t *testing.T) {
21+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
22+
23+ t.Run("returns lines from log file", func(t *testing.T) {
24+ // Create temp log file with content
25+ tmpDir := t.TempDir()
26+ logPath := filepath.Join(tmpDir, "test.log")
27+ content := "line1\nline2\nline3\nline4\nline5\n"
28+ err := os.WriteFile(logPath, []byte(content), 0644)
29+ require.NoError(t, err)
30+
31+ containerSvc := mocks.NewMockContainerService(t)
32+ runtime := outMocks.NewMockContainerRuntime(t)
33+
34+ svc := NewService(logPath, containerSvc, runtime, log)
35+
36+ lines, err := svc.GetProcessLogs(context.Background(), 3)
37+ require.NoError(t, err)
38+ assert.Len(t, lines, 3)
39+ assert.Equal(t, []string{"line3", "line4", "line5"}, lines)
40+ })
41+
42+ t.Run("returns empty slice for non-existent file", func(t *testing.T) {
43+ containerSvc := mocks.NewMockContainerService(t)
44+ runtime := outMocks.NewMockContainerRuntime(t)
45+
46+ svc := NewService("/nonexistent/file.log", containerSvc, runtime, log)
47+
48+ lines, err := svc.GetProcessLogs(context.Background(), 10)
49+ require.NoError(t, err)
50+ assert.Empty(t, lines)
51+ })
52+
53+ t.Run("returns error when log file path not configured", func(t *testing.T) {
54+ containerSvc := mocks.NewMockContainerService(t)
55+ runtime := outMocks.NewMockContainerRuntime(t)
56+
57+ svc := NewService("", containerSvc, runtime, log)
58+
59+ _, err := svc.GetProcessLogs(context.Background(), 10)
60+ assert.Error(t, err)
61+ assert.Contains(t, err.Error(), "log file path not configured")
62+ })
63+
64+ t.Run("returns all lines when fewer than requested", func(t *testing.T) {
65+ tmpDir := t.TempDir()
66+ logPath := filepath.Join(tmpDir, "test.log")
67+ content := "line1\nline2\n"
68+ err := os.WriteFile(logPath, []byte(content), 0644)
69+ require.NoError(t, err)
70+
71+ containerSvc := mocks.NewMockContainerService(t)
72+ runtime := outMocks.NewMockContainerRuntime(t)
73+
74+ svc := NewService(logPath, containerSvc, runtime, log)
75+
76+ lines, err := svc.GetProcessLogs(context.Background(), 10)
77+ require.NoError(t, err)
78+ assert.Equal(t, []string{"line1", "line2"}, lines)
79+ })
80+}
81+
82+func TestService_FollowProcessLogs(t *testing.T) {
83+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
84+
85+ t.Run("streams lines and respects context cancellation", func(t *testing.T) {
86+ tmpDir := t.TempDir()
87+ logPath := filepath.Join(tmpDir, "test.log")
88+ content := "initial1\ninitial2\n"
89+ err := os.WriteFile(logPath, []byte(content), 0644)
90+ require.NoError(t, err)
91+
92+ containerSvc := mocks.NewMockContainerService(t)
93+ runtime := outMocks.NewMockContainerRuntime(t)
94+
95+ svc := NewService(logPath, containerSvc, runtime, log)
96+
97+ ctx, cancel := context.WithCancel(context.Background())
98+ defer cancel()
99+
100+ ch, err := svc.FollowProcessLogs(ctx, 2)
101+ require.NoError(t, err)
102+
103+ // Read initial lines
104+ var received []string
105+ for i := 0; i < 2; i++ {
106+ select {
107+ case line := <-ch:
108+ received = append(received, line)
109+ case <-time.After(time.Second):
110+ t.Fatal("timeout waiting for initial lines")
111+ }
112+ }
113+ assert.Equal(t, []string{"initial1", "initial2"}, received)
114+
115+ // Cancel context to stop following
116+ cancel()
117+
118+ // Channel should be closed
119+ select {
120+ case <-ch:
121+ // May receive one more line before closure, that's OK
122+ case <-time.After(500 * time.Millisecond):
123+ // OK, channel may already be closed
124+ }
125+ })
126+}
127+
128+func TestService_GetContainerLogs(t *testing.T) {
129+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
130+
131+ t.Run("returns error when container not found", func(t *testing.T) {
132+ containerSvc := mocks.NewMockContainerService(t)
133+ runtime := outMocks.NewMockContainerRuntime(t)
134+
135+ containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
136+
137+ svc := NewService("/tmp/test.log", containerSvc, runtime, log)
138+
139+ _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10)
140+ assert.Error(t, err)
141+ assert.Contains(t, err.Error(), "container not found")
142+ })
143+
144+ t.Run("calls runtime with correct container ID", func(t *testing.T) {
145+ containerSvc := mocks.NewMockContainerService(t)
146+ runtime := outMocks.NewMockContainerRuntime(t)
147+
148+ container := &domain.Container{
149+ ID: "abc123",
150+ Name: "app.local",
151+ Status: "running",
152+ }
153+ containerSvc.EXPECT().Get(mock.Anything, "app.local").Return(container, true)
154+
155+ // Create a mock reader that returns empty content
156+ runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil)
157+
158+ svc := NewService("/tmp/test.log", containerSvc, runtime, log)
159+
160+ lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10)
161+ require.NoError(t, err)
162+ assert.Empty(t, lines)
163+ })
164+}
165+
166+func TestService_FollowContainerLogs(t *testing.T) {
167+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
168+
169+ t.Run("returns error when container not found", func(t *testing.T) {
170+ containerSvc := mocks.NewMockContainerService(t)
171+ runtime := outMocks.NewMockContainerRuntime(t)
172+
173+ containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
174+
175+ svc := NewService("/tmp/test.log", containerSvc, runtime, log)
176+
177+ _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10)
178+ assert.Error(t, err)
179+ assert.Contains(t, err.Error(), "container not found")
180+ })
181+}
182+
183+func TestTailLines(t *testing.T) {
184+ t.Run("returns correct last N lines", func(t *testing.T) {
185+ tmpDir := t.TempDir()
186+ path := filepath.Join(tmpDir, "test.log")
187+ content := "a\nb\nc\nd\ne\n"
188+ err := os.WriteFile(path, []byte(content), 0644)
189+ require.NoError(t, err)
190+
191+ file, err := os.Open(path)
192+ require.NoError(t, err)
193+ defer file.Close()
194+
195+ lines, err := tailLines(file, 3)
196+ require.NoError(t, err)
197+ assert.Equal(t, []string{"c", "d", "e"}, lines)
198+ })
199+
200+ t.Run("returns all lines when n exceeds line count", func(t *testing.T) {
201+ tmpDir := t.TempDir()
202+ path := filepath.Join(tmpDir, "test.log")
203+ content := "x\ny\n"
204+ err := os.WriteFile(path, []byte(content), 0644)
205+ require.NoError(t, err)
206+
207+ file, err := os.Open(path)
208+ require.NoError(t, err)
209+ defer file.Close()
210+
211+ lines, err := tailLines(file, 10)
212+ require.NoError(t, err)
213+ assert.Equal(t, []string{"x", "y"}, lines)
214+ })
215+
216+ t.Run("returns empty slice for empty file", func(t *testing.T) {
217+ tmpDir := t.TempDir()
218+ path := filepath.Join(tmpDir, "test.log")
219+ err := os.WriteFile(path, []byte(""), 0644)
220+ require.NoError(t, err)
221+
222+ file, err := os.Open(path)
223+ require.NoError(t, err)
224+ defer file.Close()
225+
226+ lines, err := tailLines(file, 10)
227+ require.NoError(t, err)
228+ assert.Empty(t, lines)
229+ })
230+
231+ t.Run("returns empty slice when n is zero", func(t *testing.T) {
232+ tmpDir := t.TempDir()
233+ path := filepath.Join(tmpDir, "test.log")
234+ content := "a\nb\n"
235+ err := os.WriteFile(path, []byte(content), 0644)
236+ require.NoError(t, err)
237+
238+ file, err := os.Open(path)
239+ require.NoError(t, err)
240+ defer file.Close()
241+
242+ lines, err := tailLines(file, 0)
243+ require.NoError(t, err)
244+ assert.Empty(t, lines)
245+ })
246+}
247+
248+// mockReader implements io.ReadCloser for testing.
249+type mockReader struct{}
250+
251+func (m *mockReader) Read(p []byte) (n int, err error) {
252+ return 0, os.ErrNotExist // Return EOF immediately
253+}
254+
255+func (m *mockReader) Close() error {
256+ return nil
257+}
@@ -0,0 +1,257 @@
1+package logs
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "gordon/internal/boundaries/in/mocks"
16+ outMocks "gordon/internal/boundaries/out/mocks"
17+ "gordon/internal/domain"
18+)
19+
20+func TestService_GetProcessLogs(t *testing.T) {
21+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
22+
23+ t.Run("returns lines from log file", func(t *testing.T) {
24+ // Create temp log file with content
25+ tmpDir := t.TempDir()
26+ logPath := filepath.Join(tmpDir, "test.log")
27+ content := "line1\nline2\nline3\nline4\nline5\n"
28+ err := os.WriteFile(logPath, []byte(content), 0644)
29+ require.NoError(t, err)
30+
31+ containerSvc := mocks.NewMockContainerService(t)
32+ runtime := outMocks.NewMockContainerRuntime(t)
33+
34+ svc := NewService(logPath, containerSvc, runtime, log)
35+
36+ lines, err := svc.GetProcessLogs(context.Background(), 3)
37+ require.NoError(t, err)
38+ assert.Len(t, lines, 3)
39+ assert.Equal(t, []string{"line3", "line4", "line5"}, lines)
40+ })
41+
42+ t.Run("returns empty slice for non-existent file", func(t *testing.T) {
43+ containerSvc := mocks.NewMockContainerService(t)
44+ runtime := outMocks.NewMockContainerRuntime(t)
45+
46+ svc := NewService("/nonexistent/file.log", containerSvc, runtime, log)
47+
48+ lines, err := svc.GetProcessLogs(context.Background(), 10)
49+ require.NoError(t, err)
50+ assert.Empty(t, lines)
51+ })
52+
53+ t.Run("returns error when log file path not configured", func(t *testing.T) {
54+ containerSvc := mocks.NewMockContainerService(t)
55+ runtime := outMocks.NewMockContainerRuntime(t)
56+
57+ svc := NewService("", containerSvc, runtime, log)
58+
59+ _, err := svc.GetProcessLogs(context.Background(), 10)
60+ assert.Error(t, err)
61+ assert.Contains(t, err.Error(), "log file path not configured")
62+ })
63+
64+ t.Run("returns all lines when fewer than requested", func(t *testing.T) {
65+ tmpDir := t.TempDir()
66+ logPath := filepath.Join(tmpDir, "test.log")
67+ content := "line1\nline2\n"
68+ err := os.WriteFile(logPath, []byte(content), 0644)
69+ require.NoError(t, err)
70+
71+ containerSvc := mocks.NewMockContainerService(t)
72+ runtime := outMocks.NewMockContainerRuntime(t)
73+
74+ svc := NewService(logPath, containerSvc, runtime, log)
75+
76+ lines, err := svc.GetProcessLogs(context.Background(), 10)
77+ require.NoError(t, err)
78+ assert.Equal(t, []string{"line1", "line2"}, lines)
79+ })
80+}
81+
82+func TestService_FollowProcessLogs(t *testing.T) {
83+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
84+
85+ t.Run("streams lines and respects context cancellation", func(t *testing.T) {
86+ tmpDir := t.TempDir()
87+ logPath := filepath.Join(tmpDir, "test.log")
88+ content := "initial1\ninitial2\n"
89+ err := os.WriteFile(logPath, []byte(content), 0644)
90+ require.NoError(t, err)
91+
92+ containerSvc := mocks.NewMockContainerService(t)
93+ runtime := outMocks.NewMockContainerRuntime(t)
94+
95+ svc := NewService(logPath, containerSvc, runtime, log)
96+
97+ ctx, cancel := context.WithCancel(context.Background())
98+ defer cancel()
99+
100+ ch, err := svc.FollowProcessLogs(ctx, 2)
101+ require.NoError(t, err)
102+
103+ // Read initial lines
104+ var received []string
105+ for i := 0; i < 2; i++ {
106+ select {
107+ case line := <-ch:
108+ received = append(received, line)
109+ case <-time.After(time.Second):
110+ t.Fatal("timeout waiting for initial lines")
111+ }
112+ }
113+ assert.Equal(t, []string{"initial1", "initial2"}, received)
114+
115+ // Cancel context to stop following
116+ cancel()
117+
118+ // Channel should be closed
119+ select {
120+ case <-ch:
121+ // May receive one more line before closure, that's OK
122+ case <-time.After(500 * time.Millisecond):
123+ // OK, channel may already be closed
124+ }
125+ })
126+}
127+
128+func TestService_GetContainerLogs(t *testing.T) {
129+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
130+
131+ t.Run("returns error when container not found", func(t *testing.T) {
132+ containerSvc := mocks.NewMockContainerService(t)
133+ runtime := outMocks.NewMockContainerRuntime(t)
134+
135+ containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
136+
137+ svc := NewService("/tmp/test.log", containerSvc, runtime, log)
138+
139+ _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10)
140+ assert.Error(t, err)
141+ assert.Contains(t, err.Error(), "container not found")
142+ })
143+
144+ t.Run("calls runtime with correct container ID", func(t *testing.T) {
145+ containerSvc := mocks.NewMockContainerService(t)
146+ runtime := outMocks.NewMockContainerRuntime(t)
147+
148+ container := &domain.Container{
149+ ID: "abc123",
150+ Name: "app.local",
151+ Status: "running",
152+ }
153+ containerSvc.EXPECT().Get(mock.Anything, "app.local").Return(container, true)
154+
155+ // Create a mock reader that returns empty content
156+ runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil)
157+
158+ svc := NewService("/tmp/test.log", containerSvc, runtime, log)
159+
160+ lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10)
161+ require.NoError(t, err)
162+ assert.Empty(t, lines)
163+ })
164+}
165+
166+func TestService_FollowContainerLogs(t *testing.T) {
167+ log := zerowrap.New(zerowrap.Config{Level: "warn"})
168+
169+ t.Run("returns error when container not found", func(t *testing.T) {
170+ containerSvc := mocks.NewMockContainerService(t)
171+ runtime := outMocks.NewMockContainerRuntime(t)
172+
173+ containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
174+
175+ svc := NewService("/tmp/test.log", containerSvc, runtime, log)
176+
177+ _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10)
178+ assert.Error(t, err)
179+ assert.Contains(t, err.Error(), "container not found")
180+ })
181+}
182+
183+func TestTailLines(t *testing.T) {
184+ t.Run("returns correct last N lines", func(t *testing.T) {
185+ tmpDir := t.TempDir()
186+ path := filepath.Join(tmpDir, "test.log")
187+ content := "a\nb\nc\nd\ne\n"
188+ err := os.WriteFile(path, []byte(content), 0644)
189+ require.NoError(t, err)
190+
191+ file, err := os.Open(path)
192+ require.NoError(t, err)
193+ defer file.Close()
194+
195+ lines, err := tailLines(file, 3)
196+ require.NoError(t, err)
197+ assert.Equal(t, []string{"c", "d", "e"}, lines)
198+ })
199+
200+ t.Run("returns all lines when n exceeds line count", func(t *testing.T) {
201+ tmpDir := t.TempDir()
202+ path := filepath.Join(tmpDir, "test.log")
203+ content := "x\ny\n"
204+ err := os.WriteFile(path, []byte(content), 0644)
205+ require.NoError(t, err)
206+
207+ file, err := os.Open(path)
208+ require.NoError(t, err)
209+ defer file.Close()
210+
211+ lines, err := tailLines(file, 10)
212+ require.NoError(t, err)
213+ assert.Equal(t, []string{"x", "y"}, lines)
214+ })
215+
216+ t.Run("returns empty slice for empty file", func(t *testing.T) {
217+ tmpDir := t.TempDir()
218+ path := filepath.Join(tmpDir, "test.log")
219+ err := os.WriteFile(path, []byte(""), 0644)
220+ require.NoError(t, err)
221+
222+ file, err := os.Open(path)
223+ require.NoError(t, err)
224+ defer file.Close()
225+
226+ lines, err := tailLines(file, 10)
227+ require.NoError(t, err)
228+ assert.Empty(t, lines)
229+ })
230+
231+ t.Run("returns empty slice when n is zero", func(t *testing.T) {
232+ tmpDir := t.TempDir()
233+ path := filepath.Join(tmpDir, "test.log")
234+ content := "a\nb\n"
235+ err := os.WriteFile(path, []byte(content), 0644)
236+ require.NoError(t, err)
237+
238+ file, err := os.Open(path)
239+ require.NoError(t, err)
240+ defer file.Close()
241+
242+ lines, err := tailLines(file, 0)
243+ require.NoError(t, err)
244+ assert.Empty(t, lines)
245+ })
246+}
247+
248+// mockReader implements io.ReadCloser for testing.
249+type mockReader struct{}
250+
251+func (m *mockReader) Read(p []byte) (n int, err error) {
252+ return 0, os.ErrNotExist // Return EOF immediately
253+}
254+
255+func (m *mockReader) Close() error {
256+ return nil
257+}

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