detached ec8c5b9

Merge pull request #175 from bnema/feat/routes-list-status

feat(cli): split routes inventory from route status
commit
ec8c5b97c4ba6e9772884d1aab9b4d1669fc70e3
author
bnema · · 2026-04-16 03:59 UTC
committer
GitHub · · 2026-04-16 03:59 UTC
merge of
9ef6929 + bb8a06b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0-dev8
9 files · +1757 -211
M docs/cli/index.md +15 -0
@@ -116,6 +116,7 @@ gordon auth internal
116116
117117 # Routes
118118 gordon routes list
119+gordon routes status
119120 gordon routes add myapp.example.com myapp:latest
120121 gordon routes remove myapp.example.com
121122 gordon routes deploy myapp.example.com
@@ -167,6 +168,20 @@ The CLI can target remote Gordon instances using client config, an active remote
167168 or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides
168169 when you want to bypass your saved configuration.
169170
171+`gordon routes list` and `gordon routes status` are the exceptions: when neither `--remote`
172+nor `GORDON_REMOTE` is set, they show local routes first, then every saved remote. Set either
173+one to force a single target.
174+
175+```bash
176+# Aggregate routes views
177+gordon routes list
178+gordon routes status
179+
180+# Single-target override
181+gordon routes list --remote prod
182+GORDON_REMOTE=prod gordon routes status
183+```
184+
170185 **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.
171186 If remote CLI gets a `404` during `/auth/token` exchange, the server likely still sets only `server.registry_domain` and needs `server.gordon_domain` configured.
172187
@@ -116,6 +116,7 @@ gordon auth internal
116 116
117 # Routes117 # Routes
118 gordon routes list118 gordon routes list
119+gordon routes status
119 gordon routes add myapp.example.com myapp:latest120 gordon routes add myapp.example.com myapp:latest
120 gordon routes remove myapp.example.com121 gordon routes remove myapp.example.com
121 gordon routes deploy myapp.example.com122 gordon routes deploy myapp.example.com
@@ -167,6 +168,20 @@ The CLI can target remote Gordon instances using client config, an active remote
167 or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides168 or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides
168 when you want to bypass your saved configuration.169 when you want to bypass your saved configuration.
169 170
171+`gordon routes list` and `gordon routes status` are the exceptions: when neither `--remote`
172+nor `GORDON_REMOTE` is set, they show local routes first, then every saved remote. Set either
173+one to force a single target.
174+
175+```bash
176+# Aggregate routes views
177+gordon routes list
178+gordon routes status
179+
180+# Single-target override
181+gordon routes list --remote prod
182+GORDON_REMOTE=prod gordon routes status
183+```
184+
170 **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.185 **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.
171 If remote CLI gets a `404` during `/auth/token` exchange, the server likely still sets only `server.registry_domain` and needs `server.gordon_domain` configured.186 If remote CLI gets a `404` during `/auth/token` exchange, the server likely still sets only `server.registry_domain` and needs `server.gordon_domain` configured.
172 187
M docs/cli/remotes.md +24 -11
@@ -162,12 +162,14 @@ gordon remotes use <name>
162162
163163 ### Description
164164
165-When a remote is active, it's used automatically for all remote-capable commands without needing to specify `--remote` and `--token`:
165+When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`.
166+`gordon routes list` and `gordon routes status` are the exception: they aggregate local + saved remotes unless you set `--remote` or `GORDON_REMOTE`.
166167
167168 ```bash
168169 gordon remotes use prod
169-gordon routes list # Uses prod remote automatically
170170 gordon secrets list app.com # Uses prod remote automatically
171+gordon routes list # Aggregates local + saved remotes
172+gordon routes status # Aggregates local + saved remotes
171173 ```
172174
173175 ### Example
@@ -242,6 +244,8 @@ token = "eyJ..."
242244
243245 When multiple sources specify remote or token, the CLI uses this priority:
244246
247+For `routes list` and `routes status`, `--remote` and `GORDON_REMOTE` are the explicit single-target selectors. Without either one, those commands aggregate local + saved remotes even when an active remote exists.
248+
245249 **Remote URL:**
246250 1. `--remote` flag
247251 2. `GORDON_REMOTE` environment variable
@@ -263,8 +267,13 @@ When multiple sources specify remote or token, the CLI uses this priority:
263267 This allows overriding specific values while keeping defaults:
264268
265269 ```bash
266-# Active remote is prod, but use different token
267-gordon routes list --token $TEMPORARY_TOKEN
270+# Aggregate routes views
271+gordon routes list
272+gordon routes status
273+
274+# Force one target
275+GORDON_REMOTE=prod gordon routes list
276+GORDON_REMOTE=prod gordon routes status
268277 ```
269278
270279 ---
@@ -281,12 +290,12 @@ gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN
281290
282291 # Work with prod
283292 gordon remotes use prod
284-gordon routes list
285293 gordon secrets list myapp.example.com
294+gordon routes list --remote prod
286295
287296 # Switch to staging
288297 gordon remotes use staging
289-gordon routes list
298+GORDON_REMOTE=staging gordon routes status
290299 ```
291300
292301 ### CI/CD Pipeline
@@ -306,13 +315,17 @@ steps:
306315 ### Compare Environments
307316
308317 ```bash
309-# Compare routes across environments
318+# Aggregate route inventory/status across local + saved remotes
319+gordon routes list
320+gordon routes status
321+
322+# Compare one target at a time
310323 gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN
311-gordon routes list --remote https://gordon.staging.example.com --token $STAGING_TOKEN
324+GORDON_REMOTE=staging gordon routes status
312325
313-# Or switch between active remotes
314-gordon remotes use prod && gordon routes list
315-gordon remotes use staging && gordon routes list
326+# Or switch between active remotes for other commands
327+gordon remotes use prod && gordon secrets list myapp.example.com
328+gordon remotes use staging && gordon secrets list myapp.example.com
316329 ```
317330
318331 ### Private Admin + Wildcard App Domains
@@ -162,12 +162,14 @@ gordon remotes use <name>
162 162
163 ### Description163 ### Description
164 164
165-When a remote is active, it's used automatically for all remote-capable commands without needing to specify `--remote` and `--token`:165+When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`.
166+`gordon routes list` and `gordon routes status` are the exception: they aggregate local + saved remotes unless you set `--remote` or `GORDON_REMOTE`.
166 167
167 ```bash168 ```bash
168 gordon remotes use prod169 gordon remotes use prod
169-gordon routes list # Uses prod remote automatically
170 gordon secrets list app.com # Uses prod remote automatically170 gordon secrets list app.com # Uses prod remote automatically
171+gordon routes list # Aggregates local + saved remotes
172+gordon routes status # Aggregates local + saved remotes
171 ```173 ```
172 174
173 ### Example175 ### Example
@@ -242,6 +244,8 @@ token = "eyJ..."
242 244
243 When multiple sources specify remote or token, the CLI uses this priority:245 When multiple sources specify remote or token, the CLI uses this priority:
244 246
247+For `routes list` and `routes status`, `--remote` and `GORDON_REMOTE` are the explicit single-target selectors. Without either one, those commands aggregate local + saved remotes even when an active remote exists.
248+
245 **Remote URL:**249 **Remote URL:**
246 1. `--remote` flag250 1. `--remote` flag
247 2. `GORDON_REMOTE` environment variable251 2. `GORDON_REMOTE` environment variable
@@ -263,8 +267,13 @@ When multiple sources specify remote or token, the CLI uses this priority:
263 This allows overriding specific values while keeping defaults:267 This allows overriding specific values while keeping defaults:
264 268
265 ```bash269 ```bash
266-# Active remote is prod, but use different token270+# Aggregate routes views
267-gordon routes list --token $TEMPORARY_TOKEN271+gordon routes list
272+gordon routes status
273+
274+# Force one target
275+GORDON_REMOTE=prod gordon routes list
276+GORDON_REMOTE=prod gordon routes status
268 ```277 ```
269 278
270 ---279 ---
@@ -281,12 +290,12 @@ gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN
281 290
282 # Work with prod291 # Work with prod
283 gordon remotes use prod292 gordon remotes use prod
284-gordon routes list
285 gordon secrets list myapp.example.com293 gordon secrets list myapp.example.com
294+gordon routes list --remote prod
286 295
287 # Switch to staging296 # Switch to staging
288 gordon remotes use staging297 gordon remotes use staging
289-gordon routes list298+GORDON_REMOTE=staging gordon routes status
290 ```299 ```
291 300
292 ### CI/CD Pipeline301 ### CI/CD Pipeline
@@ -306,13 +315,17 @@ steps:
306 ### Compare Environments315 ### Compare Environments
307 316
308 ```bash317 ```bash
309-# Compare routes across environments318+# Aggregate route inventory/status across local + saved remotes
319+gordon routes list
320+gordon routes status
321+
322+# Compare one target at a time
310 gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN323 gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN
311-gordon routes list --remote https://gordon.staging.example.com --token $STAGING_TOKEN324+GORDON_REMOTE=staging gordon routes status
312 325
313-# Or switch between active remotes326+# Or switch between active remotes for other commands
314-gordon remotes use prod && gordon routes list327+gordon remotes use prod && gordon secrets list myapp.example.com
315-gordon remotes use staging && gordon routes list328+gordon remotes use staging && gordon secrets list myapp.example.com
316 ```329 ```
317 330
318 ### Private Admin + Wildcard App Domains331 ### Private Admin + Wildcard App Domains
M docs/cli/routes.md +119 -16
@@ -2,8 +2,10 @@
22
33 Manage routes on local or remote Gordon instances.
44
5-Remote targeting uses client config or an active remote by default.
6-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
5+Most remote-capable commands use client config or an active remote by default.
6+`gordon routes list` and `gordon routes status` are different: when neither
7+`--remote` nor `GORDON_REMOTE` is set, they show local routes first, then each
8+saved remote under its own heading. See [CLI Overview](./index.md).
79
810 ## gordon routes
911
@@ -11,7 +13,8 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
1113
1214 | Subcommand | Description |
1315 |------------|-------------|
14-| `list` | List all routes |
16+| `list` | List routes by domain and image |
17+| `status` | Show detailed route status |
1518 | `show` | Show details for a single route |
1619 | `add` | Create or update a route |
1720 | `remove` | Remove a route |
@@ -21,22 +24,34 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
2124
2225 ## gordon routes list
2326
24-List all configured routes.
27+List routes by domain and image only.
28+
29+When no target is selected, Gordon shows local routes first, then each saved
30+remote under its own heading. Use `--remote` or `GORDON_REMOTE` to show one
31+target only.
2532
2633 ```bash
2734 gordon routes list
2835 gordon routes list --json
2936 gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
37+GORDON_REMOTE=prod gordon routes list
3038 ```
3139
3240 ### Output
3341
34-```
35-Domain Image Status
36---------------------------------------------------------------------------------
37-app.example.com myapp:latest running
38-api.example.com myapi:v2.1.0 running
39-admin.example.com admin-panel:latest stopped
42+```text
43+Routes
44+
45+Local
46+ app.example.com myapp:latest
47+ api.example.com myapi:v2.1.0
48+
49+Remote: hetzner-vps
50+ gordon.example.com gordon-webapp:latest
51+
52+Remote: igor
53+ grafana.supri.xyz grafana
54+ test.supri.xyz hello-test
4055 ```
4156
4257 ### JSON Output
@@ -48,18 +63,106 @@ gordon routes list --json
4863 ```json
4964 [
5065 {
51- "domain": "app.example.com",
52- "image": "myapp:latest",
53- "status": "running"
66+ "kind": "local",
67+ "name": "local",
68+ "routes": [
69+ {
70+ "domain": "app.example.com",
71+ "image": "myapp:latest"
72+ }
73+ ]
5474 },
5575 {
56- "domain": "api.example.com",
57- "image": "myapi:v2.1.0",
58- "status": "running"
76+ "kind": "remote",
77+ "name": "igor",
78+ "url": "https://gordon.supri.xyz",
79+ "routes": [
80+ {
81+ "domain": "grafana.supri.xyz",
82+ "image": "grafana"
83+ }
84+ ]
85+ }
86+]
87+```
88+
89+Single-target mode still returns a one-element array. Sections can also include
90+an `error` field when a target is unavailable.
91+
92+### Options
93+
94+| Option | Description |
95+|--------|-------------|
96+| `--json` | Output routes as JSON |
97+| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
98+| `--token` | Authentication token for remote |
99+
100+---
101+
102+## gordon routes status
103+
104+Show detailed route status for each target.
105+
106+`routes status` uses the same target selection rules as `routes list`. When no
107+target is selected, Gordon shows local status first, then each saved remote
108+under its own heading.
109+
110+```bash
111+gordon routes status
112+gordon routes status --json
113+gordon routes status --remote https://gordon.mydomain.com --token $TOKEN
114+GORDON_REMOTE=prod gordon routes status
115+```
116+
117+### Output
118+
119+```text
120+Route Status
121+
122+Local
123+ <rich tree for local routes>
124+
125+Remote: hetzner-vps
126+ <rich tree for that remote>
127+
128+Remote: igor
129+ <rich tree for that remote>
130+```
131+
132+The rich view keeps network grouping, container status, HTTP probe status, and
133+attachments within each target.
134+
135+### JSON Output
136+
137+```json
138+[
139+ {
140+ "kind": "local",
141+ "name": "local",
142+ "routes": [
143+ {
144+ "domain": "app.example.com",
145+ "image": "myapp:latest",
146+ "container_id": "abcd1234",
147+ "container_status": "running",
148+ "http_status": 200,
149+ "network": "gordon-shared",
150+ "attachments": [
151+ {
152+ "name": "postgres",
153+ "image": "postgres:18",
154+ "status": "running"
155+ }
156+ ]
157+ }
158+ ]
59159 }
60160 ]
61161 ```
62162
163+Single-target mode still returns a one-element array. Sections can also include
164+an `error` field when a target is unavailable.
165+
63166 ### Options
64167
65168 | Option | Description |
@@ -2,8 +2,10 @@
2 2
3 Manage routes on local or remote Gordon instances.3 Manage routes on local or remote Gordon instances.
4 4
5-Remote targeting uses client config or an active remote by default.5+Most remote-capable commands use client config or an active remote by default.
6-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).6+`gordon routes list` and `gordon routes status` are different: when neither
7+`--remote` nor `GORDON_REMOTE` is set, they show local routes first, then each
8+saved remote under its own heading. See [CLI Overview](./index.md).
7 9
8 ## gordon routes10 ## gordon routes
9 11
@@ -11,7 +13,8 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
11 13
12 | Subcommand | Description |14 | Subcommand | Description |
13 |------------|-------------|15 |------------|-------------|
14-| `list` | List all routes |16+| `list` | List routes by domain and image |
17+| `status` | Show detailed route status |
15 | `show` | Show details for a single route |18 | `show` | Show details for a single route |
16 | `add` | Create or update a route |19 | `add` | Create or update a route |
17 | `remove` | Remove a route |20 | `remove` | Remove a route |
@@ -21,22 +24,34 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
21 24
22 ## gordon routes list25 ## gordon routes list
23 26
24-List all configured routes.27+List routes by domain and image only.
28+
29+When no target is selected, Gordon shows local routes first, then each saved
30+remote under its own heading. Use `--remote` or `GORDON_REMOTE` to show one
31+target only.
25 32
26 ```bash33 ```bash
27 gordon routes list34 gordon routes list
28 gordon routes list --json35 gordon routes list --json
29 gordon routes list --remote https://gordon.mydomain.com --token $TOKEN36 gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
37+GORDON_REMOTE=prod gordon routes list
30 ```38 ```
31 39
32 ### Output40 ### Output
33 41
34-```42+```text
35-Domain Image Status43+Routes
36---------------------------------------------------------------------------------44+
37-app.example.com myapp:latest running45+Local
38-api.example.com myapi:v2.1.0 running46+ app.example.com myapp:latest
39-admin.example.com admin-panel:latest stopped47+ api.example.com myapi:v2.1.0
48+
49+Remote: hetzner-vps
50+ gordon.example.com gordon-webapp:latest
51+
52+Remote: igor
53+ grafana.supri.xyz grafana
54+ test.supri.xyz hello-test
40 ```55 ```
41 56
42 ### JSON Output57 ### JSON Output
@@ -48,18 +63,106 @@ gordon routes list --json
48 ```json63 ```json
49 [64 [
50 {65 {
51- "domain": "app.example.com",66+ "kind": "local",
52- "image": "myapp:latest",67+ "name": "local",
53- "status": "running"68+ "routes": [
69+ {
70+ "domain": "app.example.com",
71+ "image": "myapp:latest"
72+ }
73+ ]
54 },74 },
55 {75 {
56- "domain": "api.example.com",76+ "kind": "remote",
57- "image": "myapi:v2.1.0",77+ "name": "igor",
58- "status": "running"78+ "url": "https://gordon.supri.xyz",
79+ "routes": [
80+ {
81+ "domain": "grafana.supri.xyz",
82+ "image": "grafana"
83+ }
84+ ]
85+ }
86+]
87+```
88+
89+Single-target mode still returns a one-element array. Sections can also include
90+an `error` field when a target is unavailable.
91+
92+### Options
93+
94+| Option | Description |
95+|--------|-------------|
96+| `--json` | Output routes as JSON |
97+| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
98+| `--token` | Authentication token for remote |
99+
100+---
101+
102+## gordon routes status
103+
104+Show detailed route status for each target.
105+
106+`routes status` uses the same target selection rules as `routes list`. When no
107+target is selected, Gordon shows local status first, then each saved remote
108+under its own heading.
109+
110+```bash
111+gordon routes status
112+gordon routes status --json
113+gordon routes status --remote https://gordon.mydomain.com --token $TOKEN
114+GORDON_REMOTE=prod gordon routes status
115+```
116+
117+### Output
118+
119+```text
120+Route Status
121+
122+Local
123+ <rich tree for local routes>
124+
125+Remote: hetzner-vps
126+ <rich tree for that remote>
127+
128+Remote: igor
129+ <rich tree for that remote>
130+```
131+
132+The rich view keeps network grouping, container status, HTTP probe status, and
133+attachments within each target.
134+
135+### JSON Output
136+
137+```json
138+[
139+ {
140+ "kind": "local",
141+ "name": "local",
142+ "routes": [
143+ {
144+ "domain": "app.example.com",
145+ "image": "myapp:latest",
146+ "container_id": "abcd1234",
147+ "container_status": "running",
148+ "http_status": 200,
149+ "network": "gordon-shared",
150+ "attachments": [
151+ {
152+ "name": "postgres",
153+ "image": "postgres:18",
154+ "status": "running"
155+ }
156+ ]
157+ }
158+ ]
59 }159 }
60 ]160 ]
61 ```161 ```
62 162
163+Single-target mode still returns a one-element array. Sections can also include
164+an `error` field when a target is unavailable.
165+
63 ### Options166 ### Options
64 167
65 | Option | Description |168 | Option | Description |
M internal/adapters/in/cli/controlplane_resolver.go +5 -1
@@ -28,7 +28,11 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
2828 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil
2929 }
3030
31- kernel, err := app.NewKernel(configPath)
31+ return resolveLocalControlPlane(configPath)
32+}
33+
34+func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {
35+ kernel, err := app.NewKernelQuiet(configPath)
3236 if err != nil {
3337 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
3438 }
@@ -28,7 +28,11 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
28 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil28 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil
29 }29 }
30 30
31- kernel, err := app.NewKernel(configPath)31+ return resolveLocalControlPlane(configPath)
32+}
33+
34+func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {
35+ kernel, err := app.NewKernelQuiet(configPath)
32 if err != nil {36 if err != nil {
33 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)37 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
34 }38 }
M internal/adapters/in/cli/json_parity_test.go +1 -0
@@ -13,6 +13,7 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
1313 builder func() *cobra.Command
1414 }{
1515 {"routes list", newRoutesListCmd},
16+ {"routes status", newRoutesStatusCmd},
1617 {"attachments list", newAttachmentsListCmd},
1718 {"secrets list", newSecretsListCmd},
1819 {"images list", newImagesListCmd},
@@ -13,6 +13,7 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
13 builder func() *cobra.Command13 builder func() *cobra.Command
14 }{14 }{
15 {"routes list", newRoutesListCmd},15 {"routes list", newRoutesListCmd},
16+ {"routes status", newRoutesStatusCmd},
16 {"attachments list", newAttachmentsListCmd},17 {"attachments list", newAttachmentsListCmd},
17 {"secrets list", newSecretsListCmd},18 {"secrets list", newSecretsListCmd},
18 {"images list", newImagesListCmd},19 {"images list", newImagesListCmd},
M internal/adapters/in/cli/json_test.go +59 -0
@@ -266,6 +266,65 @@ func TestRoutesList_JSONShape_RoundTripsLocalPayload(t *testing.T) {
266266 assert.Equal(t, payload[0], got[0])
267267 }
268268
269+func TestRoutesStatus_JSONFlag_Accepted(t *testing.T) {
270+ cmd := newRoutesStatusCmd()
271+ f := cmd.Flags().Lookup("json")
272+ assert.NotNil(t, f)
273+ if f != nil {
274+ assert.Equal(t, "false", f.DefValue)
275+ }
276+}
277+
278+func TestRoutesList_JSONShape_RoundTripsSections(t *testing.T) {
279+ payload := []routeListSection{{
280+ Kind: "local",
281+ Name: "local",
282+ Routes: []routeListItem{{
283+ Domain: "app.local",
284+ Image: "myapp:latest",
285+ }},
286+ }}
287+
288+ encoded, err := json.Marshal(payload)
289+ require.NoError(t, err)
290+
291+ var got []routeListSection
292+ require.NoError(t, json.Unmarshal(encoded, &got))
293+ require.Len(t, got, 1)
294+ assert.Equal(t, "local", got[0].Kind)
295+ assert.Equal(t, "app.local", got[0].Routes[0].Domain)
296+}
297+
298+func TestRoutesStatus_JSONShape_RoundTripsSections(t *testing.T) {
299+ payload := []routeStatusSection{{
300+ Kind: "remote",
301+ Name: "igor",
302+ URL: "https://gordon.supri.xyz",
303+ Routes: []routeStatusItem{{
304+ Domain: "grafana.supri.xyz",
305+ Image: "grafana",
306+ ContainerStatus: "running",
307+ HTTPStatus: 200,
308+ Network: "gordon-shared",
309+ Attachments: []routeStatusAttachment{{
310+ Name: "prometheus",
311+ Image: "prometheus:v5",
312+ Status: "running",
313+ }},
314+ }},
315+ }}
316+
317+ encoded, err := json.Marshal(payload)
318+ require.NoError(t, err)
319+
320+ var got []routeStatusSection
321+ require.NoError(t, json.Unmarshal(encoded, &got))
322+ require.Len(t, got, 1)
323+ assert.Equal(t, "igor", got[0].Name)
324+ assert.Equal(t, 200, got[0].Routes[0].HTTPStatus)
325+ assert.Equal(t, "prometheus", got[0].Routes[0].Attachments[0].Name)
326+}
327+
269328 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {
270329 var out bytes.Buffer
271330 err := writeJSON(&out, map[string]any{"ok": true})
@@ -266,6 +266,65 @@ func TestRoutesList_JSONShape_RoundTripsLocalPayload(t *testing.T) {
266 assert.Equal(t, payload[0], got[0])266 assert.Equal(t, payload[0], got[0])
267 }267 }
268 268
269+func TestRoutesStatus_JSONFlag_Accepted(t *testing.T) {
270+ cmd := newRoutesStatusCmd()
271+ f := cmd.Flags().Lookup("json")
272+ assert.NotNil(t, f)
273+ if f != nil {
274+ assert.Equal(t, "false", f.DefValue)
275+ }
276+}
277+
278+func TestRoutesList_JSONShape_RoundTripsSections(t *testing.T) {
279+ payload := []routeListSection{{
280+ Kind: "local",
281+ Name: "local",
282+ Routes: []routeListItem{{
283+ Domain: "app.local",
284+ Image: "myapp:latest",
285+ }},
286+ }}
287+
288+ encoded, err := json.Marshal(payload)
289+ require.NoError(t, err)
290+
291+ var got []routeListSection
292+ require.NoError(t, json.Unmarshal(encoded, &got))
293+ require.Len(t, got, 1)
294+ assert.Equal(t, "local", got[0].Kind)
295+ assert.Equal(t, "app.local", got[0].Routes[0].Domain)
296+}
297+
298+func TestRoutesStatus_JSONShape_RoundTripsSections(t *testing.T) {
299+ payload := []routeStatusSection{{
300+ Kind: "remote",
301+ Name: "igor",
302+ URL: "https://gordon.supri.xyz",
303+ Routes: []routeStatusItem{{
304+ Domain: "grafana.supri.xyz",
305+ Image: "grafana",
306+ ContainerStatus: "running",
307+ HTTPStatus: 200,
308+ Network: "gordon-shared",
309+ Attachments: []routeStatusAttachment{{
310+ Name: "prometheus",
311+ Image: "prometheus:v5",
312+ Status: "running",
313+ }},
314+ }},
315+ }}
316+
317+ encoded, err := json.Marshal(payload)
318+ require.NoError(t, err)
319+
320+ var got []routeStatusSection
321+ require.NoError(t, json.Unmarshal(encoded, &got))
322+ require.Len(t, got, 1)
323+ assert.Equal(t, "igor", got[0].Name)
324+ assert.Equal(t, 200, got[0].Routes[0].HTTPStatus)
325+ assert.Equal(t, "prometheus", got[0].Routes[0].Attachments[0].Name)
326+}
327+
269 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {328 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {
270 var out bytes.Buffer329 var out bytes.Buffer
271 err := writeJSON(&out, map[string]any{"ok": true})330 err := writeJSON(&out, map[string]any{"ok": true})
M internal/adapters/in/cli/routes.go +813 -182
@@ -5,8 +5,11 @@ import (
55 "errors"
66 "fmt"
77 "io"
8+ "os"
89 "sort"
10+ "strconv"
911 "strings"
12+ "sync"
1013
1114 "github.com/spf13/cobra"
1215
@@ -86,6 +89,58 @@ type networkGroup struct {
8689 routes []remote.RouteInfo
8790 }
8891
92+type routeListItem struct {
93+ Domain string `json:"domain"`
94+ Image string `json:"image"`
95+}
96+
97+type routeListSection struct {
98+ Kind string `json:"kind"`
99+ Name string `json:"name"`
100+ URL string `json:"url,omitempty"`
101+ Error string `json:"error,omitempty"`
102+ Routes []routeListItem `json:"routes,omitempty"`
103+}
104+
105+type routeStatusAttachment struct {
106+ Name string `json:"name"`
107+ Image string `json:"image"`
108+ Status string `json:"status"`
109+}
110+
111+type routeStatusItem struct {
112+ Domain string `json:"domain"`
113+ Image string `json:"image"`
114+ ContainerID string `json:"container_id,omitempty"`
115+ ContainerStatus string `json:"container_status"`
116+ HTTPStatus int `json:"http_status"`
117+ HealthError string `json:"health_error,omitempty"`
118+ Network string `json:"network"`
119+ Attachments []routeStatusAttachment `json:"attachments,omitempty"`
120+}
121+
122+type routeStatusSection struct {
123+ Kind string `json:"kind"`
124+ Name string `json:"name"`
125+ URL string `json:"url,omitempty"`
126+ Error string `json:"error,omitempty"`
127+ Routes []routeStatusItem `json:"routes,omitempty"`
128+}
129+
130+type routesListDeps struct {
131+ explicitRemote func() (*remote.ResolvedRemote, bool, error)
132+ loadLocal func(context.Context, string) (routeListSection, error)
133+ listRemotes func() (map[string]remote.RemoteEntry, string, error)
134+ loadRemote func(context.Context, string, remote.RemoteEntry) (routeListSection, error)
135+}
136+
137+type routesStatusDeps struct {
138+ explicitRemote func() (*remote.ResolvedRemote, bool, error)
139+ loadLocal func(context.Context, string) (routeStatusSection, error)
140+ listRemotes func() (map[string]remote.RemoteEntry, string, error)
141+ loadRemote func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error)
142+}
143+
89144 // groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
90145 // and solo routes. Both are sorted alphabetically by domain.
91146 func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
@@ -136,12 +191,7 @@ the local Gordon configuration.`,
136191 }
137192
138193 cmd.AddCommand(newRoutesListCmd())
139-
140- // "status" is an alias for "list"
141- statusCmd := newRoutesListCmd()
142- statusCmd.Use = "status"
143- statusCmd.Short = "Show status of all routes (alias for list)"
144- cmd.AddCommand(statusCmd)
194+ cmd.AddCommand(newRoutesStatusCmd())
145195
146196 cmd.AddCommand(newRoutesShowCmd())
147197 cmd.AddCommand(newRoutesAddCmd())
@@ -158,13 +208,16 @@ func newRoutesListCmd() *cobra.Command {
158208 Use: "list",
159209 Short: "List all routes",
160210 RunE: func(cmd *cobra.Command, args []string) error {
161- ctx := cmd.Context()
211+ sections, err := collectRoutesListSections(cmd.Context(), configPath, routesListDeps{})
212+ if err != nil {
213+ return err
214+ }
162215
163- client, isRemote := GetRemoteClient()
164- if isRemote {
165- return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout())
216+ if jsonOut {
217+ return writeJSON(cmd.OutOrStdout(), sections)
166218 }
167- return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout())
219+
220+ return renderRoutesListSections(cmd.OutOrStdout(), sections)
168221 },
169222 }
170223
@@ -173,28 +226,23 @@ func newRoutesListCmd() *cobra.Command {
173226 return cmd
174227 }
175228
176-func newRoutesShowCmd() *cobra.Command {
229+func newRoutesStatusCmd() *cobra.Command {
177230 var jsonOut bool
178231
179232 cmd := &cobra.Command{
180- Use: "show <domain>",
181- Short: "Show details for a single route",
182- Long: `Display detailed information about a specific route including its image,
183-container status, and health.
184-
185-Examples:
186- gordon routes show app.mydomain.com
187- gordon routes show app.mydomain.com --json
188- gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
189- Args: cobra.ExactArgs(1),
233+ Use: "status",
234+ Short: "Show detailed route status",
190235 RunE: func(cmd *cobra.Command, args []string) error {
191- ctx := cmd.Context()
192- handle, err := resolveControlPlane(configPath)
236+ sections, err := collectRoutesStatusSections(cmd.Context(), configPath, routesStatusDeps{})
193237 if err != nil {
194238 return err
195239 }
196- defer handle.close()
197- return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
240+
241+ if jsonOut {
242+ return writeJSON(cmd.OutOrStdout(), sections)
243+ }
244+
245+ return renderRoutesStatusSections(cmd.OutOrStdout(), sections)
198246 },
199247 }
200248
@@ -203,248 +251,831 @@ Examples:
203251 return cmd
204252 }
205253
206-func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
207- route, err := cp.GetRoute(ctx, routeDomain)
208- if err != nil {
209- if errors.Is(err, domain.ErrRouteNotFound) {
210- return fmt.Errorf("route %q not found", routeDomain)
254+func collectRoutesListSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
255+ if deps.explicitRemote == nil {
256+ deps.explicitRemote = resolveRoutesExplicitRemote
257+ }
258+ if deps.loadLocal == nil {
259+ deps.loadLocal = loadRoutesListLocalSection
260+ }
261+ if deps.listRemotes == nil {
262+ deps.listRemotes = remote.ListRemotes
263+ }
264+ if deps.loadRemote == nil {
265+ deps.loadRemote = loadRoutesListRemoteSection
266+ }
267+
268+ if resolved, ok, err := deps.explicitRemote(); err != nil {
269+ return nil, err
270+ } else if ok {
271+ section := loadRoutesListExplicitRemoteSection(ctx, deps, resolved)
272+ return []routeListSection{section}, nil
273+ }
274+ if target, ok := resolveRoutesExplicitTarget(); ok {
275+ return []routeListSection{{
276+ Kind: "remote",
277+ Name: target,
278+ Error: unresolvedRoutesTargetError(target),
279+ }}, nil
280+ }
281+
282+ return collectRoutesListAggregateSections(ctx, cfgPath, deps)
283+}
284+
285+func loadRoutesListExplicitRemoteSection(ctx context.Context, deps routesListDeps, resolved *remote.ResolvedRemote) routeListSection {
286+ section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
287+ if err != nil && section.Error == "" {
288+ section.Error = err.Error()
289+ }
290+ return normalizeRouteListRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
291+}
292+
293+func collectRoutesListAggregateSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
294+
295+ type localResult struct {
296+ section routeListSection
297+ err error
298+ }
299+ type remotesResult struct {
300+ entries map[string]remote.RemoteEntry
301+ err error
302+ }
303+
304+ localCh := make(chan localResult, 1)
305+ remotesCh := make(chan remotesResult, 1)
306+
307+ go func() {
308+ section, err := deps.loadLocal(ctx, cfgPath)
309+ localCh <- localResult{section: section, err: err}
310+ }()
311+ go func() {
312+ entries, _, err := deps.listRemotes()
313+ remotesCh <- remotesResult{entries: entries, err: err}
314+ }()
315+
316+ localRes := <-localCh
317+ remotes := <-remotesCh
318+ local := localRes.section
319+ if err := localRes.err; err != nil {
320+ if local.Error == "" {
321+ local.Error = err.Error()
211322 }
212- return fmt.Errorf("failed to get route: %w", err)
213323 }
214324
215- health, _ := cp.GetHealth(ctx)
216- var routeHealth *remote.RouteHealth
217- if health != nil {
218- routeHealth = health[routeDomain]
325+ sections := make([]routeListSection, 0, 1)
326+ sections = append(sections, normalizeRouteListLocalSection(local))
327+
328+ if remotes.err != nil {
329+ sections = append(sections, routeListSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
330+ return sections, nil
219331 }
220332
221- containerStatus := "unknown"
222- httpStatus := 0
223- if routeHealth != nil {
224- containerStatus = routeHealth.ContainerStatus
225- httpStatus = routeHealth.HTTPStatus
333+ names := sortedRemoteNames(remotes.entries)
334+ remoteSections := make([]routeListSection, len(names))
335+ var wg sync.WaitGroup
336+ for i, name := range names {
337+ i, name := i, name
338+ entry := remotes.entries[name]
339+ wg.Add(1)
340+ go func() {
341+ defer wg.Done()
342+ section, err := deps.loadRemote(ctx, name, entry)
343+ if err != nil && section.Error == "" {
344+ section.Error = err.Error()
345+ }
346+ remoteSections[i] = normalizeRouteListRemoteSection(section, name, entry)
347+ }()
226348 }
349+ wg.Wait()
227350
228- if jsonOut {
229- payload := map[string]any{
230- "domain": route.Domain,
231- "image": route.Image,
232- "container_status": containerStatus,
233- "http_status": httpStatus,
351+ sections = append(sections, remoteSections...)
352+ return sections, nil
353+}
354+
355+func normalizeRouteListLocalSection(section routeListSection) routeListSection {
356+ if section.Kind == "" {
357+ section.Kind = "local"
358+ }
359+ if section.Name == "" {
360+ section.Name = "local"
361+ }
362+ return section
363+}
364+
365+func normalizeRouteListRemoteSection(section routeListSection, name string, entry remote.RemoteEntry) routeListSection {
366+ if section.Kind == "" {
367+ section.Kind = "remote"
368+ }
369+ if section.Name == "" {
370+ section.Name = name
371+ }
372+ if section.URL == "" {
373+ section.URL = entry.URL
374+ }
375+ return section
376+}
377+
378+func collectRoutesStatusSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
379+ if deps.explicitRemote == nil {
380+ deps.explicitRemote = resolveRoutesExplicitRemote
381+ }
382+ if deps.loadLocal == nil {
383+ deps.loadLocal = loadRoutesStatusLocalSection
384+ }
385+ if deps.listRemotes == nil {
386+ deps.listRemotes = remote.ListRemotes
387+ }
388+ if deps.loadRemote == nil {
389+ deps.loadRemote = loadRoutesStatusRemoteSection
390+ }
391+
392+ if resolved, ok, err := deps.explicitRemote(); err != nil {
393+ return nil, err
394+ } else if ok {
395+ section := loadRoutesStatusExplicitRemoteSection(ctx, deps, resolved)
396+ return []routeStatusSection{section}, nil
397+ }
398+ if target, ok := resolveRoutesExplicitTarget(); ok {
399+ return []routeStatusSection{{
400+ Kind: "remote",
401+ Name: target,
402+ Error: unresolvedRoutesTargetError(target),
403+ }}, nil
404+ }
405+
406+ return collectRoutesStatusAggregateSections(ctx, cfgPath, deps)
407+}
408+
409+func loadRoutesStatusExplicitRemoteSection(ctx context.Context, deps routesStatusDeps, resolved *remote.ResolvedRemote) routeStatusSection {
410+ section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
411+ if err != nil && section.Error == "" {
412+ section.Error = err.Error()
413+ }
414+ return normalizeRouteStatusRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
415+}
416+
417+func collectRoutesStatusAggregateSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
418+
419+ type localResult struct {
420+ section routeStatusSection
421+ err error
422+ }
423+ type remotesResult struct {
424+ entries map[string]remote.RemoteEntry
425+ err error
426+ }
427+
428+ localCh := make(chan localResult, 1)
429+ remotesCh := make(chan remotesResult, 1)
430+
431+ go func() {
432+ section, err := deps.loadLocal(ctx, cfgPath)
433+ localCh <- localResult{section: section, err: err}
434+ }()
435+ go func() {
436+ entries, _, err := deps.listRemotes()
437+ remotesCh <- remotesResult{entries: entries, err: err}
438+ }()
439+
440+ localRes := <-localCh
441+ remotes := <-remotesCh
442+ local := localRes.section
443+ if err := localRes.err; err != nil {
444+ if local.Error == "" {
445+ local.Error = err.Error()
234446 }
235- return writeJSON(out, payload)
236447 }
237448
238- if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil {
449+ sections := make([]routeStatusSection, 0, 1)
450+ sections = append(sections, normalizeRouteStatusLocalSection(local))
451+
452+ if remotes.err != nil {
453+ sections = append(sections, routeStatusSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
454+ return sections, nil
455+ }
456+
457+ names := sortedRemoteNames(remotes.entries)
458+ remoteSections := make([]routeStatusSection, len(names))
459+ var wg sync.WaitGroup
460+ for i, name := range names {
461+ i, name := i, name
462+ entry := remotes.entries[name]
463+ wg.Add(1)
464+ go func() {
465+ defer wg.Done()
466+ section, err := deps.loadRemote(ctx, name, entry)
467+ if err != nil && section.Error == "" {
468+ section.Error = err.Error()
469+ }
470+ remoteSections[i] = normalizeRouteStatusRemoteSection(section, name, entry)
471+ }()
472+ }
473+ wg.Wait()
474+
475+ sections = append(sections, remoteSections...)
476+ return sections, nil
477+}
478+
479+func normalizeRouteStatusLocalSection(section routeStatusSection) routeStatusSection {
480+ if section.Kind == "" {
481+ section.Kind = "local"
482+ }
483+ if section.Name == "" {
484+ section.Name = "local"
485+ }
486+ return section
487+}
488+
489+func normalizeRouteStatusRemoteSection(section routeStatusSection, name string, entry remote.RemoteEntry) routeStatusSection {
490+ if section.Kind == "" {
491+ section.Kind = "remote"
492+ }
493+ if section.Name == "" {
494+ section.Name = name
495+ }
496+ if section.URL == "" {
497+ section.URL = entry.URL
498+ }
499+ return section
500+}
501+
502+func renderRoutesListSections(out io.Writer, sections []routeListSection) error {
503+ sections = renderedRouteListSections(sections)
504+
505+ if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
239506 return err
240507 }
241508 if err := cliWriteLine(out, ""); err != nil {
242509 return err
243510 }
244- if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil {
511+
512+ for i, section := range sections {
513+ if i > 0 {
514+ if err := cliWriteLine(out, ""); err != nil {
515+ return err
516+ }
517+ }
518+ if err := renderRoutesListSection(out, section); err != nil {
519+ return err
520+ }
521+ }
522+
523+ return nil
524+}
525+
526+func renderRoutesListSection(out io.Writer, section routeListSection) error {
527+ if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
245528 return err
246529 }
247- if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil {
530+
531+ if len(section.Routes) > 0 {
532+ const imageColWidth = 45
533+ rows := make([][]string, 0, len(section.Routes))
534+ for _, route := range section.Routes {
535+ rows = append(rows, []string{route.Domain, truncateImage(route.Image, imageColWidth)})
536+ }
537+
538+ table := components.NewTable(
539+ components.WithColumns([]components.TableColumn{
540+ {Title: "Domain", Width: 30},
541+ {Title: "Image", Width: imageColWidth},
542+ }),
543+ components.WithRows(rows),
544+ )
545+
546+ if err := cliWriteLine(out, table.View()); err != nil {
547+ return err
548+ }
549+ }
550+
551+ if section.Error != "" {
552+ return cliWriteLine(out, cliRenderWarning(section.Error))
553+ }
554+
555+ if len(section.Routes) == 0 {
556+ return cliWriteLine(out, cliRenderMuted("No routes configured"))
557+ }
558+
559+ return nil
560+}
561+
562+func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error {
563+ sections = renderedRouteStatusSections(sections)
564+
565+ if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil {
248566 return err
249567 }
250- if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
568+ if err := cliWriteLine(out, ""); err != nil {
251569 return err
252570 }
253- if httpStatus > 0 {
254- if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
571+
572+ for i, section := range sections {
573+ if i > 0 {
574+ if err := cliWriteLine(out, ""); err != nil {
575+ return err
576+ }
577+ }
578+ if err := renderRoutesStatusSection(out, section); err != nil {
579+ return err
580+ }
581+ }
582+
583+ return nil
584+}
585+
586+func renderedRouteListSections(sections []routeListSection) []routeListSection {
587+ if !shouldHideLocalRouteListSection(sections) {
588+ return sections
589+ }
590+ return sections[1:]
591+}
592+
593+func shouldHideLocalRouteListSection(sections []routeListSection) bool {
594+ if len(sections) < 2 {
595+ return false
596+ }
597+ local := sections[0]
598+ if local.Kind != "local" || local.Error == "" {
599+ return false
600+ }
601+ for _, section := range sections[1:] {
602+ if section.Kind == "remote" && section.Error == "" {
603+ return true
604+ }
605+ }
606+ return false
607+}
608+
609+func renderedRouteStatusSections(sections []routeStatusSection) []routeStatusSection {
610+ if !shouldHideLocalRouteStatusSection(sections) {
611+ return sections
612+ }
613+ return sections[1:]
614+}
615+
616+func shouldHideLocalRouteStatusSection(sections []routeStatusSection) bool {
617+ if len(sections) < 2 {
618+ return false
619+ }
620+ local := sections[0]
621+ if local.Kind != "local" || local.Error == "" {
622+ return false
623+ }
624+ for _, section := range sections[1:] {
625+ if section.Kind == "remote" && section.Error == "" {
626+ return true
627+ }
628+ }
629+ return false
630+}
631+
632+func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error {
633+ if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
634+ return err
635+ }
636+
637+ if len(section.Routes) > 0 {
638+ tree := buildRouteStatusTree(section.Routes)
639+ if err := cliWriteLine(out, strings.TrimSuffix(tree.Render(), "\n")); err != nil {
255640 return err
256641 }
257642 }
258643
644+ if section.Error != "" {
645+ return cliWriteLine(out, cliRenderWarning(section.Error))
646+ }
647+
648+ if len(section.Routes) == 0 {
649+ return cliWriteLine(out, cliRenderMuted("No routes configured"))
650+ }
651+
259652 return nil
260653 }
261654
262-// runRoutesListRemote lists routes from a remote Gordon instance.
263-func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error {
655+func routeSectionHeading(kind, name string) string {
656+ switch kind {
657+ case "local":
658+ return "Local"
659+ case "remote":
660+ if name != "" {
661+ return "Remote: " + name
662+ }
663+ return "Remote"
664+ default:
665+ if name != "" {
666+ return capitalizeKind(kind) + ": " + name
667+ }
668+ return capitalizeKind(kind)
669+ }
670+}
671+
672+func capitalizeKind(kind string) string {
673+ if kind == "" {
674+ return ""
675+ }
676+ if len(kind) == 1 {
677+ return strings.ToUpper(kind)
678+ }
679+ return strings.ToUpper(kind[:1]) + kind[1:]
680+}
681+
682+func unresolvedRoutesTargetError(target string) string {
683+ return fmt.Sprintf("could not resolve remote target %q", target)
684+}
685+
686+func resolveRoutesExplicitRemote() (*remote.ResolvedRemote, bool, error) {
687+ target, ok := resolveRoutesExplicitTarget()
688+ if !ok {
689+ return nil, false, nil
690+ }
691+
692+ if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
693+ return &remote.ResolvedRemote{
694+ URL: target,
695+ Token: resolveRoutesTokenForTarget("", remote.RemoteEntry{}),
696+ InsecureTLS: resolveRoutesInsecureForTarget("", remote.RemoteEntry{}),
697+ }, true, nil
698+ }
699+
700+ remotes, err := remote.LoadRemotes("")
701+ if err != nil {
702+ return nil, false, err
703+ }
704+
705+ if remotes != nil {
706+ if entry, found := remotes.Remotes[target]; found {
707+ return &remote.ResolvedRemote{
708+ Name: target,
709+ URL: entry.URL,
710+ Token: resolveRoutesTokenForTarget(target, entry),
711+ InsecureTLS: resolveRoutesInsecureForTarget(target, entry),
712+ }, true, nil
713+ }
714+ }
715+
716+ return nil, false, nil
717+}
718+
719+func resolveRoutesExplicitTarget() (string, bool) {
720+ if target := strings.TrimSpace(remoteFlag); target != "" {
721+ return target, true
722+ }
723+ if target := strings.TrimSpace(os.Getenv("GORDON_REMOTE")); target != "" {
724+ return target, true
725+ }
726+ return "", false
727+}
728+
729+func resolveRoutesTokenForTarget(name string, entry remote.RemoteEntry) string {
730+ if token := strings.TrimSpace(tokenFlag); token != "" {
731+ return token
732+ }
733+ if token := strings.TrimSpace(os.Getenv("GORDON_TOKEN")); token != "" {
734+ return token
735+ }
736+ if name != "" {
737+ return remote.ResolveTokenForRemote(name, entry)
738+ }
739+ return ""
740+}
741+
742+func resolveRoutesInsecureForTarget(name string, entry remote.RemoteEntry) bool {
743+ if insecureTLSFlag {
744+ return true
745+ }
746+ if env := strings.TrimSpace(os.Getenv("GORDON_INSECURE")); env != "" {
747+ if value, err := strconv.ParseBool(env); err == nil {
748+ return value
749+ }
750+ }
751+ if name != "" {
752+ return entry.InsecureTLS
753+ }
754+ return false
755+}
756+
757+func newRoutesTargetClient(name string, entry remote.RemoteEntry) *remote.Client {
758+ return remote.NewClient(entry.URL, remoteClientOptions(resolveRoutesTokenForTarget(name, entry), resolveRoutesInsecureForTarget(name, entry))...)
759+}
760+
761+func loadRoutesListLocalSection(ctx context.Context, cfgPath string) (routeListSection, error) {
762+ local, err := GetLocalServices(cfgPath)
763+ if err != nil {
764+ return routeListSection{Kind: "local", Name: "local", Error: err.Error()}, nil
765+ }
766+
767+ routes := local.GetConfigService().GetRoutes(ctx)
768+ section := routeListSection{Kind: "local", Name: "local", Routes: make([]routeListItem, 0, len(routes))}
769+ for _, route := range routes {
770+ section.Routes = append(section.Routes, routeListItem{Domain: route.Domain, Image: route.Image})
771+ }
772+
773+ return section, nil
774+}
775+
776+func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
777+ section := routeListSection{Kind: "remote", Name: name, URL: entry.URL}
778+ client := newRoutesTargetClient(name, entry)
264779 routes, err := client.ListRoutesWithDetails(ctx)
265780 if err != nil {
266- return fmt.Errorf("failed to list routes: %w", err)
781+ section.Error = err.Error()
782+ return section, nil
267783 }
268784
269- health, _ := client.GetHealth(ctx)
270- if health == nil {
271- health = make(map[string]*remote.RouteHealth)
785+ section.Routes = routeListItemsFromInfos(routes)
786+ return section, nil
787+}
788+
789+func loadRoutesStatusLocalSection(ctx context.Context, cfgPath string) (routeStatusSection, error) {
790+ section := routeStatusSection{Kind: "local", Name: "local"}
791+ handle, err := resolveLocalControlPlane(cfgPath)
792+ if err != nil {
793+ section.Error = err.Error()
794+ return section, nil
272795 }
796+ defer handle.close()
273797
274- if jsonOut {
275- return routesListJSON(out, routes, health)
798+ routes, err := handle.plane.ListRoutesWithDetails(ctx)
799+ if err != nil {
800+ section.Error = err.Error()
801+ return section, nil
276802 }
277803
278- if len(routes) == 0 {
279- _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))
280- return nil
804+ health, err := handle.plane.GetHealth(ctx)
805+ if err != nil {
806+ section.Error = err.Error()
807+ health = nil
281808 }
282809
283- groups, solo := groupRoutesByNetwork(routes)
810+ section.Routes = routeStatusItemsFromInfos(routes, health)
811+ return section, nil
812+}
813+
814+func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeStatusSection, error) {
815+ section := routeStatusSection{Kind: "remote", Name: name, URL: entry.URL}
816+ client := newRoutesTargetClient(name, entry)
817+ cp := NewRemoteControlPlane(client)
284818
285- type sortableItem struct {
286- sortKey string
287- group *networkGroup
288- route *remote.RouteInfo
819+ routes, err := cp.ListRoutesWithDetails(ctx)
820+ if err != nil {
821+ section.Error = err.Error()
822+ return section, nil
289823 }
290824
291- var items []sortableItem
292- for i := range groups {
293- items = append(items, sortableItem{
294- sortKey: groups[i].routes[0].Domain,
295- group: &groups[i],
296- })
825+ health, err := cp.GetHealth(ctx)
826+ if err != nil {
827+ section.Error = err.Error()
828+ health = nil
297829 }
298- for i := range solo {
299- items = append(items, sortableItem{
300- sortKey: solo[i].Domain,
301- route: &solo[i],
302- })
830+
831+ section.Routes = routeStatusItemsFromInfos(routes, health)
832+ return section, nil
833+}
834+
835+func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem {
836+ items := make([]routeStatusItem, 0, len(routes))
837+ for _, route := range routes {
838+ item := routeStatusItem{
839+ Domain: route.Domain,
840+ Image: route.Image,
841+ ContainerID: route.ContainerID,
842+ ContainerStatus: route.ContainerStatus,
843+ Network: route.Network,
844+ }
845+ if item.ContainerStatus == "" {
846+ if routeHealth := health[route.Domain]; routeHealth != nil {
847+ item.ContainerStatus = routeHealth.ContainerStatus
848+ item.HTTPStatus = routeHealth.HTTPStatus
849+ item.HealthError = routeHealth.Error
850+ }
851+ }
852+ if item.ContainerStatus == "" {
853+ item.ContainerStatus = "unknown"
854+ }
855+ if item.HTTPStatus == 0 {
856+ if routeHealth := health[route.Domain]; routeHealth != nil {
857+ item.HTTPStatus = routeHealth.HTTPStatus
858+ item.HealthError = routeHealth.Error
859+ }
860+ }
861+ if item.HealthError == "" {
862+ if routeHealth := health[route.Domain]; routeHealth != nil {
863+ item.HealthError = routeHealth.Error
864+ }
865+ }
866+ item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments))
867+ for _, attachment := range route.Attachments {
868+ status := attachment.Status
869+ if status == "" {
870+ status = "unknown"
871+ }
872+ item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status})
873+ }
874+ items = append(items, item)
303875 }
304- sort.Slice(items, func(i, j int) bool {
305- return items[i].sortKey < items[j].sortKey
876+ return items
877+}
878+
879+func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem {
880+ items := make([]routeListItem, 0, len(routes))
881+ for _, route := range routes {
882+ items = append(items, routeListItem{Domain: route.Domain, Image: route.Image})
883+ }
884+ return items
885+}
886+
887+func buildRouteStatusTree(routes []routeStatusItem) *components.Tree {
888+ sortedRoutes := make([]routeStatusItem, len(routes))
889+ copy(sortedRoutes, routes)
890+ sort.SliceStable(sortedRoutes, func(i, j int) bool {
891+ if sortedRoutes[i].Network != sortedRoutes[j].Network {
892+ return sortedRoutes[i].Network < sortedRoutes[j].Network
893+ }
894+ if sortedRoutes[i].Domain != sortedRoutes[j].Domain {
895+ return sortedRoutes[i].Domain < sortedRoutes[j].Domain
896+ }
897+ if sortedRoutes[i].Image != sortedRoutes[j].Image {
898+ return sortedRoutes[i].Image < sortedRoutes[j].Image
899+ }
900+ if sortedRoutes[i].ContainerID != sortedRoutes[j].ContainerID {
901+ return sortedRoutes[i].ContainerID < sortedRoutes[j].ContainerID
902+ }
903+ if sortedRoutes[i].ContainerStatus != sortedRoutes[j].ContainerStatus {
904+ return sortedRoutes[i].ContainerStatus < sortedRoutes[j].ContainerStatus
905+ }
906+ return sortedRoutes[i].HTTPStatus < sortedRoutes[j].HTTPStatus
306907 })
307908
909+ infos := make([]remote.RouteInfo, 0, len(routes))
910+ itemsByDomain := make(map[string]routeStatusItem, len(routes))
911+ for _, route := range sortedRoutes {
912+ infos = append(infos, remote.RouteInfo{
913+ Domain: route.Domain,
914+ Image: route.Image,
915+ ContainerID: route.ContainerID,
916+ ContainerStatus: route.ContainerStatus,
917+ Network: route.Network,
918+ Attachments: routeStatusAttachmentsToRemote(route.Attachments),
919+ })
920+ itemsByDomain[route.Domain] = route
921+ }
922+
923+ groups, solo := groupRoutesByNetwork(infos)
308924 tree := components.NewTree()
309925
310- for _, item := range items {
311- if item.group != nil {
312- g := tree.AddGroup(item.group.name)
313- for _, route := range item.group.routes {
314- title := routeTitle(route, health)
315- node := g.AddNode(title, route.Image)
316- addAttachmentChildren(node, route)
317- }
318- } else {
319- title := routeTitle(*item.route, health)
320- node := tree.AddNode(title, item.route.Image)
321- addAttachmentChildren(node, *item.route)
926+ for _, group := range groups {
927+ g := tree.AddGroup(group.name)
928+ for _, route := range group.routes {
929+ item := itemsByDomain[route.Domain]
930+ node := g.AddNode(routeStatusTitle(item), item.Image)
931+ addRouteStatusAttachmentChildren(node, item)
322932 }
323933 }
324934
325- if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
326- return err
935+ for _, route := range solo {
936+ item := itemsByDomain[route.Domain]
937+ node := tree.AddNode(routeStatusTitle(item), item.Image)
938+ addRouteStatusAttachmentChildren(node, item)
327939 }
328- if err := cliWriteLine(out, ""); err != nil {
329- return err
330- }
331- return cliWriteLine(out, tree.Render())
940+
941+ return tree
332942 }
333943
334-// routeTitle builds the pre-styled title line for a route node.
335-func routeTitle(route remote.RouteInfo, health map[string]*remote.RouteHealth) string {
944+func routeStatusTitle(route routeStatusItem) string {
336945 containerStatus := route.ContainerStatus
337946 if containerStatus == "" {
338- if h := health[route.Domain]; h != nil {
339- containerStatus = h.ContainerStatus
340- } else {
341- containerStatus = "unknown"
342- }
947+ containerStatus = "unknown"
343948 }
344949
345- httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(health[route.Domain]))
950+ httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(&remote.RouteHealth{HTTPStatus: route.HTTPStatus, Error: route.HealthError}))
346951 containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
347952
348953 return httpIcon + " " + containerIcon + " " + route.Domain
349954 }
350955
351-// addAttachmentChildren adds attachment nodes as children of a route node.
352-func addAttachmentChildren(node *components.Node, route remote.RouteInfo) {
956+func addRouteStatusAttachmentChildren(node *components.Node, route routeStatusItem) {
353957 for _, att := range route.Attachments {
354- attStatus := att.Status
355- if attStatus == "" {
356- attStatus = "unknown"
958+ status := att.Status
959+ if status == "" {
960+ status = "unknown"
357961 }
358- attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(attStatus))
962+ attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(status))
359963 node.AddChild(attIcon+" "+att.Name, att.Image)
360964 }
361965 }
362966
363-func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {
364- payload := make([]map[string]any, 0, len(routes))
365- for _, route := range routes {
366- routeHealth := health[route.Domain]
367- containerStatus := route.ContainerStatus
368- if containerStatus == "" {
369- if routeHealth != nil {
370- containerStatus = routeHealth.ContainerStatus
371- } else {
372- containerStatus = "unknown"
373- }
374- }
967+func routeStatusAttachmentsToRemote(attachments []routeStatusAttachment) []remote.Attachment {
968+ result := make([]remote.Attachment, 0, len(attachments))
969+ for _, att := range attachments {
970+ result = append(result, remote.Attachment{Name: att.Name, Image: att.Image, Status: att.Status})
971+ }
972+ return result
973+}
375974
376- httpStatus := 0
377- if routeHealth != nil {
378- httpStatus = routeHealth.HTTPStatus
379- }
975+func newRoutesShowCmd() *cobra.Command {
976+ var jsonOut bool
380977
381- attachments := make([]map[string]string, 0, len(route.Attachments))
382- for _, attachment := range route.Attachments {
383- attachments = append(attachments, map[string]string{
384- "name": attachment.Name,
385- "image": attachment.Image,
386- "status": attachment.Status,
387- })
388- }
978+ cmd := &cobra.Command{
979+ Use: "show <domain>",
980+ Short: "Show details for a single route",
981+ Long: `Display detailed information about a specific route including its image,
982+container status, and health.
389983
390- payload = append(payload, map[string]any{
391- "domain": route.Domain,
392- "image": route.Image,
393- "container_status": containerStatus,
394- "network": route.Network,
395- "http_status": httpStatus,
396- "attachments": attachments,
397- })
984+Examples:
985+ gordon routes show app.mydomain.com
986+ gordon routes show app.mydomain.com --json
987+ gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
988+ Args: cobra.ExactArgs(1),
989+ RunE: func(cmd *cobra.Command, args []string) error {
990+ ctx := cmd.Context()
991+ handle, err := resolveControlPlane(configPath)
992+ if err != nil {
993+ return err
994+ }
995+ defer handle.close()
996+ return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
997+ },
398998 }
399999
400- return writeJSON(out, payload)
1000+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1001+
1002+ return cmd
4011003 }
4021004
403-// runRoutesListLocal lists routes from local configuration.
404-func runRoutesListLocal(ctx context.Context, cfgPath string, jsonOut bool, out io.Writer) error {
405- local, err := GetLocalServices(cfgPath)
1005+func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
1006+ route, err := cp.GetRoute(ctx, routeDomain)
4061007 if err != nil {
407- return fmt.Errorf("failed to initialize local services: %w", err)
1008+ if errors.Is(err, domain.ErrRouteNotFound) {
1009+ return fmt.Errorf("route %q not found", routeDomain)
1010+ }
1011+ return fmt.Errorf("failed to get route: %w", err)
4081012 }
4091013
410- routes := local.GetConfigService().GetRoutes(ctx)
1014+ health, err := cp.GetHealth(ctx)
1015+ healthErr := ""
1016+ if err != nil {
1017+ healthErr = err.Error()
1018+ health = nil
1019+ }
1020+ var routeHealth *remote.RouteHealth
1021+ if health != nil {
1022+ routeHealth = health[routeDomain]
1023+ }
1024+
1025+ containerStatus := "unknown"
1026+ httpStatus := 0
1027+ if routeHealth != nil {
1028+ if routeHealth.ContainerStatus != "" {
1029+ containerStatus = routeHealth.ContainerStatus
1030+ }
1031+ httpStatus = routeHealth.HTTPStatus
1032+ if healthErr == "" && routeHealth.Error != "" {
1033+ healthErr = routeHealth.Error
1034+ }
1035+ }
4111036
4121037 if jsonOut {
413- payload := make([]map[string]string, 0, len(routes))
414- for _, route := range routes {
415- payload = append(payload, map[string]string{
416- "domain": route.Domain,
417- "image": route.Image,
418- })
1038+ payload := map[string]any{
1039+ "domain": route.Domain,
1040+ "image": route.Image,
1041+ "container_status": containerStatus,
1042+ "http_status": httpStatus,
1043+ }
1044+ if healthErr != "" {
1045+ payload["health_error"] = healthErr
4191046 }
4201047 return writeJSON(out, payload)
4211048 }
4221049
423- if len(routes) == 0 {
424- _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))
425- return nil
426- }
1050+ return writeRouteShowText(out, route.Domain, route.Image, containerStatus, httpStatus, healthErr)
1051+}
4271052
428- // Build table rows (no health info in local mode)
429- const imageColWidth = 45
430- rows := make([][]string, len(routes))
431- for i, route := range routes {
432- displayImage := truncateImage(route.Image, imageColWidth)
433- rows[i] = []string{route.Domain, displayImage}
1053+func writeRouteShowText(out io.Writer, domainName, image, containerStatus string, httpStatus int, healthErr string) error {
1054+ if err := cliWriteLine(out, cliRenderTitle("Route: "+domainName)); err != nil {
1055+ return err
1056+ }
1057+ if err := cliWriteLine(out, ""); err != nil {
1058+ return err
1059+ }
1060+ if err := cliWriteLine(out, cliRenderMeta("Domain:", domainName)); err != nil {
1061+ return err
1062+ }
1063+ if err := cliWriteLine(out, cliRenderMeta("Image:", image)); err != nil {
1064+ return err
1065+ }
1066+ if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
1067+ return err
1068+ }
1069+ if httpStatus > 0 {
1070+ if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
1071+ return err
1072+ }
1073+ }
1074+ if healthErr != "" {
1075+ if err := cliWriteLine(out, cliRenderWarning(healthErr)); err != nil {
1076+ return err
1077+ }
4341078 }
435-
436- // Render table
437- table := components.NewTable(
438- components.WithColumns([]components.TableColumn{
439- {Title: "Domain", Width: 30},
440- {Title: "Image", Width: imageColWidth},
441- }),
442- components.WithRows(rows),
443- )
444-
445- _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes (local)"))
446- _, _ = fmt.Fprintln(out)
447- _, _ = fmt.Fprintln(out, table.View())
4481079
4491080 return nil
4501081 }
@@ -5,8 +5,11 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "io"7 "io"
8+ "os"
8 "sort"9 "sort"
10+ "strconv"
9 "strings"11 "strings"
12+ "sync"
10 13
11 "github.com/spf13/cobra"14 "github.com/spf13/cobra"
12 15
@@ -86,6 +89,58 @@ type networkGroup struct {
86 routes []remote.RouteInfo89 routes []remote.RouteInfo
87 }90 }
88 91
92+type routeListItem struct {
93+ Domain string `json:"domain"`
94+ Image string `json:"image"`
95+}
96+
97+type routeListSection struct {
98+ Kind string `json:"kind"`
99+ Name string `json:"name"`
100+ URL string `json:"url,omitempty"`
101+ Error string `json:"error,omitempty"`
102+ Routes []routeListItem `json:"routes,omitempty"`
103+}
104+
105+type routeStatusAttachment struct {
106+ Name string `json:"name"`
107+ Image string `json:"image"`
108+ Status string `json:"status"`
109+}
110+
111+type routeStatusItem struct {
112+ Domain string `json:"domain"`
113+ Image string `json:"image"`
114+ ContainerID string `json:"container_id,omitempty"`
115+ ContainerStatus string `json:"container_status"`
116+ HTTPStatus int `json:"http_status"`
117+ HealthError string `json:"health_error,omitempty"`
118+ Network string `json:"network"`
119+ Attachments []routeStatusAttachment `json:"attachments,omitempty"`
120+}
121+
122+type routeStatusSection struct {
123+ Kind string `json:"kind"`
124+ Name string `json:"name"`
125+ URL string `json:"url,omitempty"`
126+ Error string `json:"error,omitempty"`
127+ Routes []routeStatusItem `json:"routes,omitempty"`
128+}
129+
130+type routesListDeps struct {
131+ explicitRemote func() (*remote.ResolvedRemote, bool, error)
132+ loadLocal func(context.Context, string) (routeListSection, error)
133+ listRemotes func() (map[string]remote.RemoteEntry, string, error)
134+ loadRemote func(context.Context, string, remote.RemoteEntry) (routeListSection, error)
135+}
136+
137+type routesStatusDeps struct {
138+ explicitRemote func() (*remote.ResolvedRemote, bool, error)
139+ loadLocal func(context.Context, string) (routeStatusSection, error)
140+ listRemotes func() (map[string]remote.RemoteEntry, string, error)
141+ loadRemote func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error)
142+}
143+
89 // groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)144 // groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
90 // and solo routes. Both are sorted alphabetically by domain.145 // and solo routes. Both are sorted alphabetically by domain.
91 func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {146 func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
@@ -136,12 +191,7 @@ the local Gordon configuration.`,
136 }191 }
137 192
138 cmd.AddCommand(newRoutesListCmd())193 cmd.AddCommand(newRoutesListCmd())
139-194+ cmd.AddCommand(newRoutesStatusCmd())
140- // "status" is an alias for "list"
141- statusCmd := newRoutesListCmd()
142- statusCmd.Use = "status"
143- statusCmd.Short = "Show status of all routes (alias for list)"
144- cmd.AddCommand(statusCmd)
145 195
146 cmd.AddCommand(newRoutesShowCmd())196 cmd.AddCommand(newRoutesShowCmd())
147 cmd.AddCommand(newRoutesAddCmd())197 cmd.AddCommand(newRoutesAddCmd())
@@ -158,13 +208,16 @@ func newRoutesListCmd() *cobra.Command {
158 Use: "list",208 Use: "list",
159 Short: "List all routes",209 Short: "List all routes",
160 RunE: func(cmd *cobra.Command, args []string) error {210 RunE: func(cmd *cobra.Command, args []string) error {
161- ctx := cmd.Context()211+ sections, err := collectRoutesListSections(cmd.Context(), configPath, routesListDeps{})
212+ if err != nil {
213+ return err
214+ }
162 215
163- client, isRemote := GetRemoteClient()216+ if jsonOut {
164- if isRemote {217+ return writeJSON(cmd.OutOrStdout(), sections)
165- return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout())
166 }218 }
167- return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout())219+
220+ return renderRoutesListSections(cmd.OutOrStdout(), sections)
168 },221 },
169 }222 }
170 223
@@ -173,28 +226,23 @@ func newRoutesListCmd() *cobra.Command {
173 return cmd226 return cmd
174 }227 }
175 228
176-func newRoutesShowCmd() *cobra.Command {229+func newRoutesStatusCmd() *cobra.Command {
177 var jsonOut bool230 var jsonOut bool
178 231
179 cmd := &cobra.Command{232 cmd := &cobra.Command{
180- Use: "show <domain>",233+ Use: "status",
181- Short: "Show details for a single route",234+ Short: "Show detailed route status",
182- Long: `Display detailed information about a specific route including its image,
183-container status, and health.
184-
185-Examples:
186- gordon routes show app.mydomain.com
187- gordon routes show app.mydomain.com --json
188- gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
189- Args: cobra.ExactArgs(1),
190 RunE: func(cmd *cobra.Command, args []string) error {235 RunE: func(cmd *cobra.Command, args []string) error {
191- ctx := cmd.Context()236+ sections, err := collectRoutesStatusSections(cmd.Context(), configPath, routesStatusDeps{})
192- handle, err := resolveControlPlane(configPath)
193 if err != nil {237 if err != nil {
194 return err238 return err
195 }239 }
196- defer handle.close()240+
197- return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)241+ if jsonOut {
242+ return writeJSON(cmd.OutOrStdout(), sections)
243+ }
244+
245+ return renderRoutesStatusSections(cmd.OutOrStdout(), sections)
198 },246 },
199 }247 }
200 248
@@ -203,248 +251,831 @@ Examples:
203 return cmd251 return cmd
204 }252 }
205 253
206-func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {254+func collectRoutesListSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
207- route, err := cp.GetRoute(ctx, routeDomain)255+ if deps.explicitRemote == nil {
208- if err != nil {256+ deps.explicitRemote = resolveRoutesExplicitRemote
209- if errors.Is(err, domain.ErrRouteNotFound) {257+ }
210- return fmt.Errorf("route %q not found", routeDomain)258+ if deps.loadLocal == nil {
259+ deps.loadLocal = loadRoutesListLocalSection
260+ }
261+ if deps.listRemotes == nil {
262+ deps.listRemotes = remote.ListRemotes
263+ }
264+ if deps.loadRemote == nil {
265+ deps.loadRemote = loadRoutesListRemoteSection
266+ }
267+
268+ if resolved, ok, err := deps.explicitRemote(); err != nil {
269+ return nil, err
270+ } else if ok {
271+ section := loadRoutesListExplicitRemoteSection(ctx, deps, resolved)
272+ return []routeListSection{section}, nil
273+ }
274+ if target, ok := resolveRoutesExplicitTarget(); ok {
275+ return []routeListSection{{
276+ Kind: "remote",
277+ Name: target,
278+ Error: unresolvedRoutesTargetError(target),
279+ }}, nil
280+ }
281+
282+ return collectRoutesListAggregateSections(ctx, cfgPath, deps)
283+}
284+
285+func loadRoutesListExplicitRemoteSection(ctx context.Context, deps routesListDeps, resolved *remote.ResolvedRemote) routeListSection {
286+ section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
287+ if err != nil && section.Error == "" {
288+ section.Error = err.Error()
289+ }
290+ return normalizeRouteListRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
291+}
292+
293+func collectRoutesListAggregateSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
294+
295+ type localResult struct {
296+ section routeListSection
297+ err error
298+ }
299+ type remotesResult struct {
300+ entries map[string]remote.RemoteEntry
301+ err error
302+ }
303+
304+ localCh := make(chan localResult, 1)
305+ remotesCh := make(chan remotesResult, 1)
306+
307+ go func() {
308+ section, err := deps.loadLocal(ctx, cfgPath)
309+ localCh <- localResult{section: section, err: err}
310+ }()
311+ go func() {
312+ entries, _, err := deps.listRemotes()
313+ remotesCh <- remotesResult{entries: entries, err: err}
314+ }()
315+
316+ localRes := <-localCh
317+ remotes := <-remotesCh
318+ local := localRes.section
319+ if err := localRes.err; err != nil {
320+ if local.Error == "" {
321+ local.Error = err.Error()
211 }322 }
212- return fmt.Errorf("failed to get route: %w", err)
213 }323 }
214 324
215- health, _ := cp.GetHealth(ctx)325+ sections := make([]routeListSection, 0, 1)
216- var routeHealth *remote.RouteHealth326+ sections = append(sections, normalizeRouteListLocalSection(local))
217- if health != nil {327+
218- routeHealth = health[routeDomain]328+ if remotes.err != nil {
329+ sections = append(sections, routeListSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
330+ return sections, nil
219 }331 }
220 332
221- containerStatus := "unknown"333+ names := sortedRemoteNames(remotes.entries)
222- httpStatus := 0334+ remoteSections := make([]routeListSection, len(names))
223- if routeHealth != nil {335+ var wg sync.WaitGroup
224- containerStatus = routeHealth.ContainerStatus336+ for i, name := range names {
225- httpStatus = routeHealth.HTTPStatus337+ i, name := i, name
338+ entry := remotes.entries[name]
339+ wg.Add(1)
340+ go func() {
341+ defer wg.Done()
342+ section, err := deps.loadRemote(ctx, name, entry)
343+ if err != nil && section.Error == "" {
344+ section.Error = err.Error()
345+ }
346+ remoteSections[i] = normalizeRouteListRemoteSection(section, name, entry)
347+ }()
226 }348 }
349+ wg.Wait()
227 350
228- if jsonOut {351+ sections = append(sections, remoteSections...)
229- payload := map[string]any{352+ return sections, nil
230- "domain": route.Domain,353+}
231- "image": route.Image,354+
232- "container_status": containerStatus,355+func normalizeRouteListLocalSection(section routeListSection) routeListSection {
233- "http_status": httpStatus,356+ if section.Kind == "" {
357+ section.Kind = "local"
358+ }
359+ if section.Name == "" {
360+ section.Name = "local"
361+ }
362+ return section
363+}
364+
365+func normalizeRouteListRemoteSection(section routeListSection, name string, entry remote.RemoteEntry) routeListSection {
366+ if section.Kind == "" {
367+ section.Kind = "remote"
368+ }
369+ if section.Name == "" {
370+ section.Name = name
371+ }
372+ if section.URL == "" {
373+ section.URL = entry.URL
374+ }
375+ return section
376+}
377+
378+func collectRoutesStatusSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
379+ if deps.explicitRemote == nil {
380+ deps.explicitRemote = resolveRoutesExplicitRemote
381+ }
382+ if deps.loadLocal == nil {
383+ deps.loadLocal = loadRoutesStatusLocalSection
384+ }
385+ if deps.listRemotes == nil {
386+ deps.listRemotes = remote.ListRemotes
387+ }
388+ if deps.loadRemote == nil {
389+ deps.loadRemote = loadRoutesStatusRemoteSection
390+ }
391+
392+ if resolved, ok, err := deps.explicitRemote(); err != nil {
393+ return nil, err
394+ } else if ok {
395+ section := loadRoutesStatusExplicitRemoteSection(ctx, deps, resolved)
396+ return []routeStatusSection{section}, nil
397+ }
398+ if target, ok := resolveRoutesExplicitTarget(); ok {
399+ return []routeStatusSection{{
400+ Kind: "remote",
401+ Name: target,
402+ Error: unresolvedRoutesTargetError(target),
403+ }}, nil
404+ }
405+
406+ return collectRoutesStatusAggregateSections(ctx, cfgPath, deps)
407+}
408+
409+func loadRoutesStatusExplicitRemoteSection(ctx context.Context, deps routesStatusDeps, resolved *remote.ResolvedRemote) routeStatusSection {
410+ section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
411+ if err != nil && section.Error == "" {
412+ section.Error = err.Error()
413+ }
414+ return normalizeRouteStatusRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
415+}
416+
417+func collectRoutesStatusAggregateSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
418+
419+ type localResult struct {
420+ section routeStatusSection
421+ err error
422+ }
423+ type remotesResult struct {
424+ entries map[string]remote.RemoteEntry
425+ err error
426+ }
427+
428+ localCh := make(chan localResult, 1)
429+ remotesCh := make(chan remotesResult, 1)
430+
431+ go func() {
432+ section, err := deps.loadLocal(ctx, cfgPath)
433+ localCh <- localResult{section: section, err: err}
434+ }()
435+ go func() {
436+ entries, _, err := deps.listRemotes()
437+ remotesCh <- remotesResult{entries: entries, err: err}
438+ }()
439+
440+ localRes := <-localCh
441+ remotes := <-remotesCh
442+ local := localRes.section
443+ if err := localRes.err; err != nil {
444+ if local.Error == "" {
445+ local.Error = err.Error()
234 }446 }
235- return writeJSON(out, payload)
236 }447 }
237 448
238- if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil {449+ sections := make([]routeStatusSection, 0, 1)
450+ sections = append(sections, normalizeRouteStatusLocalSection(local))
451+
452+ if remotes.err != nil {
453+ sections = append(sections, routeStatusSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
454+ return sections, nil
455+ }
456+
457+ names := sortedRemoteNames(remotes.entries)
458+ remoteSections := make([]routeStatusSection, len(names))
459+ var wg sync.WaitGroup
460+ for i, name := range names {
461+ i, name := i, name
462+ entry := remotes.entries[name]
463+ wg.Add(1)
464+ go func() {
465+ defer wg.Done()
466+ section, err := deps.loadRemote(ctx, name, entry)
467+ if err != nil && section.Error == "" {
468+ section.Error = err.Error()
469+ }
470+ remoteSections[i] = normalizeRouteStatusRemoteSection(section, name, entry)
471+ }()
472+ }
473+ wg.Wait()
474+
475+ sections = append(sections, remoteSections...)
476+ return sections, nil
477+}
478+
479+func normalizeRouteStatusLocalSection(section routeStatusSection) routeStatusSection {
480+ if section.Kind == "" {
481+ section.Kind = "local"
482+ }
483+ if section.Name == "" {
484+ section.Name = "local"
485+ }
486+ return section
487+}
488+
489+func normalizeRouteStatusRemoteSection(section routeStatusSection, name string, entry remote.RemoteEntry) routeStatusSection {
490+ if section.Kind == "" {
491+ section.Kind = "remote"
492+ }
493+ if section.Name == "" {
494+ section.Name = name
495+ }
496+ if section.URL == "" {
497+ section.URL = entry.URL
498+ }
499+ return section
500+}
501+
502+func renderRoutesListSections(out io.Writer, sections []routeListSection) error {
503+ sections = renderedRouteListSections(sections)
504+
505+ if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
239 return err506 return err
240 }507 }
241 if err := cliWriteLine(out, ""); err != nil {508 if err := cliWriteLine(out, ""); err != nil {
242 return err509 return err
243 }510 }
244- if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil {511+
512+ for i, section := range sections {
513+ if i > 0 {
514+ if err := cliWriteLine(out, ""); err != nil {
515+ return err
516+ }
517+ }
518+ if err := renderRoutesListSection(out, section); err != nil {
519+ return err
520+ }
521+ }
522+
523+ return nil
524+}
525+
526+func renderRoutesListSection(out io.Writer, section routeListSection) error {
527+ if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
245 return err528 return err
246 }529 }
247- if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil {530+
531+ if len(section.Routes) > 0 {
532+ const imageColWidth = 45
533+ rows := make([][]string, 0, len(section.Routes))
534+ for _, route := range section.Routes {
535+ rows = append(rows, []string{route.Domain, truncateImage(route.Image, imageColWidth)})
536+ }
537+
538+ table := components.NewTable(
539+ components.WithColumns([]components.TableColumn{
540+ {Title: "Domain", Width: 30},
541+ {Title: "Image", Width: imageColWidth},
542+ }),
543+ components.WithRows(rows),
544+ )
545+
546+ if err := cliWriteLine(out, table.View()); err != nil {
547+ return err
548+ }
549+ }
550+
551+ if section.Error != "" {
552+ return cliWriteLine(out, cliRenderWarning(section.Error))
553+ }
554+
555+ if len(section.Routes) == 0 {
556+ return cliWriteLine(out, cliRenderMuted("No routes configured"))
557+ }
558+
559+ return nil
560+}
561+
562+func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error {
563+ sections = renderedRouteStatusSections(sections)
564+
565+ if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil {
248 return err566 return err
249 }567 }
250- if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {568+ if err := cliWriteLine(out, ""); err != nil {
251 return err569 return err
252 }570 }
253- if httpStatus > 0 {571+
254- if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {572+ for i, section := range sections {
573+ if i > 0 {
574+ if err := cliWriteLine(out, ""); err != nil {
575+ return err
576+ }
577+ }
578+ if err := renderRoutesStatusSection(out, section); err != nil {
579+ return err
580+ }
581+ }
582+
583+ return nil
584+}
585+
586+func renderedRouteListSections(sections []routeListSection) []routeListSection {
587+ if !shouldHideLocalRouteListSection(sections) {
588+ return sections
589+ }
590+ return sections[1:]
591+}
592+
593+func shouldHideLocalRouteListSection(sections []routeListSection) bool {
594+ if len(sections) < 2 {
595+ return false
596+ }
597+ local := sections[0]
598+ if local.Kind != "local" || local.Error == "" {
599+ return false
600+ }
601+ for _, section := range sections[1:] {
602+ if section.Kind == "remote" && section.Error == "" {
603+ return true
604+ }
605+ }
606+ return false
607+}
608+
609+func renderedRouteStatusSections(sections []routeStatusSection) []routeStatusSection {
610+ if !shouldHideLocalRouteStatusSection(sections) {
611+ return sections
612+ }
613+ return sections[1:]
614+}
615+
616+func shouldHideLocalRouteStatusSection(sections []routeStatusSection) bool {
617+ if len(sections) < 2 {
618+ return false
619+ }
620+ local := sections[0]
621+ if local.Kind != "local" || local.Error == "" {
622+ return false
623+ }
624+ for _, section := range sections[1:] {
625+ if section.Kind == "remote" && section.Error == "" {
626+ return true
627+ }
628+ }
629+ return false
630+}
631+
632+func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error {
633+ if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
634+ return err
635+ }
636+
637+ if len(section.Routes) > 0 {
638+ tree := buildRouteStatusTree(section.Routes)
639+ if err := cliWriteLine(out, strings.TrimSuffix(tree.Render(), "\n")); err != nil {
255 return err640 return err
256 }641 }
257 }642 }
258 643
644+ if section.Error != "" {
645+ return cliWriteLine(out, cliRenderWarning(section.Error))
646+ }
647+
648+ if len(section.Routes) == 0 {
649+ return cliWriteLine(out, cliRenderMuted("No routes configured"))
650+ }
651+
259 return nil652 return nil
260 }653 }
261 654
262-// runRoutesListRemote lists routes from a remote Gordon instance.655+func routeSectionHeading(kind, name string) string {
263-func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error {656+ switch kind {
657+ case "local":
658+ return "Local"
659+ case "remote":
660+ if name != "" {
661+ return "Remote: " + name
662+ }
663+ return "Remote"
664+ default:
665+ if name != "" {
666+ return capitalizeKind(kind) + ": " + name
667+ }
668+ return capitalizeKind(kind)
669+ }
670+}
671+
672+func capitalizeKind(kind string) string {
673+ if kind == "" {
674+ return ""
675+ }
676+ if len(kind) == 1 {
677+ return strings.ToUpper(kind)
678+ }
679+ return strings.ToUpper(kind[:1]) + kind[1:]
680+}
681+
682+func unresolvedRoutesTargetError(target string) string {
683+ return fmt.Sprintf("could not resolve remote target %q", target)
684+}
685+
686+func resolveRoutesExplicitRemote() (*remote.ResolvedRemote, bool, error) {
687+ target, ok := resolveRoutesExplicitTarget()
688+ if !ok {
689+ return nil, false, nil
690+ }
691+
692+ if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
693+ return &remote.ResolvedRemote{
694+ URL: target,
695+ Token: resolveRoutesTokenForTarget("", remote.RemoteEntry{}),
696+ InsecureTLS: resolveRoutesInsecureForTarget("", remote.RemoteEntry{}),
697+ }, true, nil
698+ }
699+
700+ remotes, err := remote.LoadRemotes("")
701+ if err != nil {
702+ return nil, false, err
703+ }
704+
705+ if remotes != nil {
706+ if entry, found := remotes.Remotes[target]; found {
707+ return &remote.ResolvedRemote{
708+ Name: target,
709+ URL: entry.URL,
710+ Token: resolveRoutesTokenForTarget(target, entry),
711+ InsecureTLS: resolveRoutesInsecureForTarget(target, entry),
712+ }, true, nil
713+ }
714+ }
715+
716+ return nil, false, nil
717+}
718+
719+func resolveRoutesExplicitTarget() (string, bool) {
720+ if target := strings.TrimSpace(remoteFlag); target != "" {
721+ return target, true
722+ }
723+ if target := strings.TrimSpace(os.Getenv("GORDON_REMOTE")); target != "" {
724+ return target, true
725+ }
726+ return "", false
727+}
728+
729+func resolveRoutesTokenForTarget(name string, entry remote.RemoteEntry) string {
730+ if token := strings.TrimSpace(tokenFlag); token != "" {
731+ return token
732+ }
733+ if token := strings.TrimSpace(os.Getenv("GORDON_TOKEN")); token != "" {
734+ return token
735+ }
736+ if name != "" {
737+ return remote.ResolveTokenForRemote(name, entry)
738+ }
739+ return ""
740+}
741+
742+func resolveRoutesInsecureForTarget(name string, entry remote.RemoteEntry) bool {
743+ if insecureTLSFlag {
744+ return true
745+ }
746+ if env := strings.TrimSpace(os.Getenv("GORDON_INSECURE")); env != "" {
747+ if value, err := strconv.ParseBool(env); err == nil {
748+ return value
749+ }
750+ }
751+ if name != "" {
752+ return entry.InsecureTLS
753+ }
754+ return false
755+}
756+
757+func newRoutesTargetClient(name string, entry remote.RemoteEntry) *remote.Client {
758+ return remote.NewClient(entry.URL, remoteClientOptions(resolveRoutesTokenForTarget(name, entry), resolveRoutesInsecureForTarget(name, entry))...)
759+}
760+
761+func loadRoutesListLocalSection(ctx context.Context, cfgPath string) (routeListSection, error) {
762+ local, err := GetLocalServices(cfgPath)
763+ if err != nil {
764+ return routeListSection{Kind: "local", Name: "local", Error: err.Error()}, nil
765+ }
766+
767+ routes := local.GetConfigService().GetRoutes(ctx)
768+ section := routeListSection{Kind: "local", Name: "local", Routes: make([]routeListItem, 0, len(routes))}
769+ for _, route := range routes {
770+ section.Routes = append(section.Routes, routeListItem{Domain: route.Domain, Image: route.Image})
771+ }
772+
773+ return section, nil
774+}
775+
776+func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
777+ section := routeListSection{Kind: "remote", Name: name, URL: entry.URL}
778+ client := newRoutesTargetClient(name, entry)
264 routes, err := client.ListRoutesWithDetails(ctx)779 routes, err := client.ListRoutesWithDetails(ctx)
265 if err != nil {780 if err != nil {
266- return fmt.Errorf("failed to list routes: %w", err)781+ section.Error = err.Error()
782+ return section, nil
267 }783 }
268 784
269- health, _ := client.GetHealth(ctx)785+ section.Routes = routeListItemsFromInfos(routes)
270- if health == nil {786+ return section, nil
271- health = make(map[string]*remote.RouteHealth)787+}
788+
789+func loadRoutesStatusLocalSection(ctx context.Context, cfgPath string) (routeStatusSection, error) {
790+ section := routeStatusSection{Kind: "local", Name: "local"}
791+ handle, err := resolveLocalControlPlane(cfgPath)
792+ if err != nil {
793+ section.Error = err.Error()
794+ return section, nil
272 }795 }
796+ defer handle.close()
273 797
274- if jsonOut {798+ routes, err := handle.plane.ListRoutesWithDetails(ctx)
275- return routesListJSON(out, routes, health)799+ if err != nil {
800+ section.Error = err.Error()
801+ return section, nil
276 }802 }
277 803
278- if len(routes) == 0 {804+ health, err := handle.plane.GetHealth(ctx)
279- _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))805+ if err != nil {
280- return nil806+ section.Error = err.Error()
807+ health = nil
281 }808 }
282 809
283- groups, solo := groupRoutesByNetwork(routes)810+ section.Routes = routeStatusItemsFromInfos(routes, health)
811+ return section, nil
812+}
813+
814+func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeStatusSection, error) {
815+ section := routeStatusSection{Kind: "remote", Name: name, URL: entry.URL}
816+ client := newRoutesTargetClient(name, entry)
817+ cp := NewRemoteControlPlane(client)
284 818
285- type sortableItem struct {819+ routes, err := cp.ListRoutesWithDetails(ctx)
286- sortKey string820+ if err != nil {
287- group *networkGroup821+ section.Error = err.Error()
288- route *remote.RouteInfo822+ return section, nil
289 }823 }
290 824
291- var items []sortableItem825+ health, err := cp.GetHealth(ctx)
292- for i := range groups {826+ if err != nil {
293- items = append(items, sortableItem{827+ section.Error = err.Error()
294- sortKey: groups[i].routes[0].Domain,828+ health = nil
295- group: &groups[i],
296- })
297 }829 }
298- for i := range solo {830+
299- items = append(items, sortableItem{831+ section.Routes = routeStatusItemsFromInfos(routes, health)
300- sortKey: solo[i].Domain,832+ return section, nil
301- route: &solo[i],833+}
302- })834+
835+func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem {
836+ items := make([]routeStatusItem, 0, len(routes))
837+ for _, route := range routes {
838+ item := routeStatusItem{
839+ Domain: route.Domain,
840+ Image: route.Image,
841+ ContainerID: route.ContainerID,
842+ ContainerStatus: route.ContainerStatus,
843+ Network: route.Network,
844+ }
845+ if item.ContainerStatus == "" {
846+ if routeHealth := health[route.Domain]; routeHealth != nil {
847+ item.ContainerStatus = routeHealth.ContainerStatus
848+ item.HTTPStatus = routeHealth.HTTPStatus
849+ item.HealthError = routeHealth.Error
850+ }
851+ }
852+ if item.ContainerStatus == "" {
853+ item.ContainerStatus = "unknown"
854+ }
855+ if item.HTTPStatus == 0 {
856+ if routeHealth := health[route.Domain]; routeHealth != nil {
857+ item.HTTPStatus = routeHealth.HTTPStatus
858+ item.HealthError = routeHealth.Error
859+ }
860+ }
861+ if item.HealthError == "" {
862+ if routeHealth := health[route.Domain]; routeHealth != nil {
863+ item.HealthError = routeHealth.Error
864+ }
865+ }
866+ item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments))
867+ for _, attachment := range route.Attachments {
868+ status := attachment.Status
869+ if status == "" {
870+ status = "unknown"
871+ }
872+ item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status})
873+ }
874+ items = append(items, item)
303 }875 }
304- sort.Slice(items, func(i, j int) bool {876+ return items
305- return items[i].sortKey < items[j].sortKey877+}
878+
879+func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem {
880+ items := make([]routeListItem, 0, len(routes))
881+ for _, route := range routes {
882+ items = append(items, routeListItem{Domain: route.Domain, Image: route.Image})
883+ }
884+ return items
885+}
886+
887+func buildRouteStatusTree(routes []routeStatusItem) *components.Tree {
888+ sortedRoutes := make([]routeStatusItem, len(routes))
889+ copy(sortedRoutes, routes)
890+ sort.SliceStable(sortedRoutes, func(i, j int) bool {
891+ if sortedRoutes[i].Network != sortedRoutes[j].Network {
892+ return sortedRoutes[i].Network < sortedRoutes[j].Network
893+ }
894+ if sortedRoutes[i].Domain != sortedRoutes[j].Domain {
895+ return sortedRoutes[i].Domain < sortedRoutes[j].Domain
896+ }
897+ if sortedRoutes[i].Image != sortedRoutes[j].Image {
898+ return sortedRoutes[i].Image < sortedRoutes[j].Image
899+ }
900+ if sortedRoutes[i].ContainerID != sortedRoutes[j].ContainerID {
901+ return sortedRoutes[i].ContainerID < sortedRoutes[j].ContainerID
902+ }
903+ if sortedRoutes[i].ContainerStatus != sortedRoutes[j].ContainerStatus {
904+ return sortedRoutes[i].ContainerStatus < sortedRoutes[j].ContainerStatus
905+ }
906+ return sortedRoutes[i].HTTPStatus < sortedRoutes[j].HTTPStatus
306 })907 })
307 908
909+ infos := make([]remote.RouteInfo, 0, len(routes))
910+ itemsByDomain := make(map[string]routeStatusItem, len(routes))
911+ for _, route := range sortedRoutes {
912+ infos = append(infos, remote.RouteInfo{
913+ Domain: route.Domain,
914+ Image: route.Image,
915+ ContainerID: route.ContainerID,
916+ ContainerStatus: route.ContainerStatus,
917+ Network: route.Network,
918+ Attachments: routeStatusAttachmentsToRemote(route.Attachments),
919+ })
920+ itemsByDomain[route.Domain] = route
921+ }
922+
923+ groups, solo := groupRoutesByNetwork(infos)
308 tree := components.NewTree()924 tree := components.NewTree()
309 925
310- for _, item := range items {926+ for _, group := range groups {
311- if item.group != nil {927+ g := tree.AddGroup(group.name)
312- g := tree.AddGroup(item.group.name)928+ for _, route := range group.routes {
313- for _, route := range item.group.routes {929+ item := itemsByDomain[route.Domain]
314- title := routeTitle(route, health)930+ node := g.AddNode(routeStatusTitle(item), item.Image)
315- node := g.AddNode(title, route.Image)931+ addRouteStatusAttachmentChildren(node, item)
316- addAttachmentChildren(node, route)
317- }
318- } else {
319- title := routeTitle(*item.route, health)
320- node := tree.AddNode(title, item.route.Image)
321- addAttachmentChildren(node, *item.route)
322 }932 }
323 }933 }
324 934
325- if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {935+ for _, route := range solo {
326- return err936+ item := itemsByDomain[route.Domain]
937+ node := tree.AddNode(routeStatusTitle(item), item.Image)
938+ addRouteStatusAttachmentChildren(node, item)
327 }939 }
328- if err := cliWriteLine(out, ""); err != nil {940+
329- return err941+ return tree
330- }
331- return cliWriteLine(out, tree.Render())
332 }942 }
333 943
334-// routeTitle builds the pre-styled title line for a route node.944+func routeStatusTitle(route routeStatusItem) string {
335-func routeTitle(route remote.RouteInfo, health map[string]*remote.RouteHealth) string {
336 containerStatus := route.ContainerStatus945 containerStatus := route.ContainerStatus
337 if containerStatus == "" {946 if containerStatus == "" {
338- if h := health[route.Domain]; h != nil {947+ containerStatus = "unknown"
339- containerStatus = h.ContainerStatus
340- } else {
341- containerStatus = "unknown"
342- }
343 }948 }
344 949
345- httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(health[route.Domain]))950+ httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(&remote.RouteHealth{HTTPStatus: route.HTTPStatus, Error: route.HealthError}))
346 containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))951 containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
347 952
348 return httpIcon + " " + containerIcon + " " + route.Domain953 return httpIcon + " " + containerIcon + " " + route.Domain
349 }954 }
350 955
351-// addAttachmentChildren adds attachment nodes as children of a route node.956+func addRouteStatusAttachmentChildren(node *components.Node, route routeStatusItem) {
352-func addAttachmentChildren(node *components.Node, route remote.RouteInfo) {
353 for _, att := range route.Attachments {957 for _, att := range route.Attachments {
354- attStatus := att.Status958+ status := att.Status
355- if attStatus == "" {959+ if status == "" {
356- attStatus = "unknown"960+ status = "unknown"
357 }961 }
358- attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(attStatus))962+ attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(status))
359 node.AddChild(attIcon+" "+att.Name, att.Image)963 node.AddChild(attIcon+" "+att.Name, att.Image)
360 }964 }
361 }965 }
362 966
363-func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {967+func routeStatusAttachmentsToRemote(attachments []routeStatusAttachment) []remote.Attachment {
364- payload := make([]map[string]any, 0, len(routes))968+ result := make([]remote.Attachment, 0, len(attachments))
365- for _, route := range routes {969+ for _, att := range attachments {
366- routeHealth := health[route.Domain]970+ result = append(result, remote.Attachment{Name: att.Name, Image: att.Image, Status: att.Status})
367- containerStatus := route.ContainerStatus971+ }
368- if containerStatus == "" {972+ return result
369- if routeHealth != nil {973+}
370- containerStatus = routeHealth.ContainerStatus
371- } else {
372- containerStatus = "unknown"
373- }
374- }
375 974
376- httpStatus := 0975+func newRoutesShowCmd() *cobra.Command {
377- if routeHealth != nil {976+ var jsonOut bool
378- httpStatus = routeHealth.HTTPStatus
379- }
380 977
381- attachments := make([]map[string]string, 0, len(route.Attachments))978+ cmd := &cobra.Command{
382- for _, attachment := range route.Attachments {979+ Use: "show <domain>",
383- attachments = append(attachments, map[string]string{980+ Short: "Show details for a single route",
384- "name": attachment.Name,981+ Long: `Display detailed information about a specific route including its image,
385- "image": attachment.Image,982+container status, and health.
386- "status": attachment.Status,
387- })
388- }
389 983
390- payload = append(payload, map[string]any{984+Examples:
391- "domain": route.Domain,985+ gordon routes show app.mydomain.com
392- "image": route.Image,986+ gordon routes show app.mydomain.com --json
393- "container_status": containerStatus,987+ gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
394- "network": route.Network,988+ Args: cobra.ExactArgs(1),
395- "http_status": httpStatus,989+ RunE: func(cmd *cobra.Command, args []string) error {
396- "attachments": attachments,990+ ctx := cmd.Context()
397- })991+ handle, err := resolveControlPlane(configPath)
992+ if err != nil {
993+ return err
994+ }
995+ defer handle.close()
996+ return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
997+ },
398 }998 }
399 999
400- return writeJSON(out, payload)1000+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1001+
1002+ return cmd
401 }1003 }
402 1004
403-// runRoutesListLocal lists routes from local configuration.1005+func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
404-func runRoutesListLocal(ctx context.Context, cfgPath string, jsonOut bool, out io.Writer) error {1006+ route, err := cp.GetRoute(ctx, routeDomain)
405- local, err := GetLocalServices(cfgPath)
406 if err != nil {1007 if err != nil {
407- return fmt.Errorf("failed to initialize local services: %w", err)1008+ if errors.Is(err, domain.ErrRouteNotFound) {
1009+ return fmt.Errorf("route %q not found", routeDomain)
1010+ }
1011+ return fmt.Errorf("failed to get route: %w", err)
408 }1012 }
409 1013
410- routes := local.GetConfigService().GetRoutes(ctx)1014+ health, err := cp.GetHealth(ctx)
1015+ healthErr := ""
1016+ if err != nil {
1017+ healthErr = err.Error()
1018+ health = nil
1019+ }
1020+ var routeHealth *remote.RouteHealth
1021+ if health != nil {
1022+ routeHealth = health[routeDomain]
1023+ }
1024+
1025+ containerStatus := "unknown"
1026+ httpStatus := 0
1027+ if routeHealth != nil {
1028+ if routeHealth.ContainerStatus != "" {
1029+ containerStatus = routeHealth.ContainerStatus
1030+ }
1031+ httpStatus = routeHealth.HTTPStatus
1032+ if healthErr == "" && routeHealth.Error != "" {
1033+ healthErr = routeHealth.Error
1034+ }
1035+ }
411 1036
412 if jsonOut {1037 if jsonOut {
413- payload := make([]map[string]string, 0, len(routes))1038+ payload := map[string]any{
414- for _, route := range routes {1039+ "domain": route.Domain,
415- payload = append(payload, map[string]string{1040+ "image": route.Image,
416- "domain": route.Domain,1041+ "container_status": containerStatus,
417- "image": route.Image,1042+ "http_status": httpStatus,
418- })1043+ }
1044+ if healthErr != "" {
1045+ payload["health_error"] = healthErr
419 }1046 }
420 return writeJSON(out, payload)1047 return writeJSON(out, payload)
421 }1048 }
422 1049
423- if len(routes) == 0 {1050+ return writeRouteShowText(out, route.Domain, route.Image, containerStatus, httpStatus, healthErr)
424- _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))1051+}
425- return nil
426- }
427 1052
428- // Build table rows (no health info in local mode)1053+func writeRouteShowText(out io.Writer, domainName, image, containerStatus string, httpStatus int, healthErr string) error {
429- const imageColWidth = 451054+ if err := cliWriteLine(out, cliRenderTitle("Route: "+domainName)); err != nil {
430- rows := make([][]string, len(routes))1055+ return err
431- for i, route := range routes {1056+ }
432- displayImage := truncateImage(route.Image, imageColWidth)1057+ if err := cliWriteLine(out, ""); err != nil {
433- rows[i] = []string{route.Domain, displayImage}1058+ return err
1059+ }
1060+ if err := cliWriteLine(out, cliRenderMeta("Domain:", domainName)); err != nil {
1061+ return err
1062+ }
1063+ if err := cliWriteLine(out, cliRenderMeta("Image:", image)); err != nil {
1064+ return err
1065+ }
1066+ if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
1067+ return err
1068+ }
1069+ if httpStatus > 0 {
1070+ if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
1071+ return err
1072+ }
1073+ }
1074+ if healthErr != "" {
1075+ if err := cliWriteLine(out, cliRenderWarning(healthErr)); err != nil {
1076+ return err
1077+ }
434 }1078 }
435-
436- // Render table
437- table := components.NewTable(
438- components.WithColumns([]components.TableColumn{
439- {Title: "Domain", Width: 30},
440- {Title: "Image", Width: imageColWidth},
441- }),
442- components.WithRows(rows),
443- )
444-
445- _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes (local)"))
446- _, _ = fmt.Fprintln(out)
447- _, _ = fmt.Fprintln(out, table.View())
448 1079
449 return nil1080 return nil
450 }1081 }
M internal/adapters/in/cli/routes_test.go +704 -0
@@ -1,13 +1,44 @@
11 package cli
22
33 import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "strconv"
12+ "strings"
13+ "sync/atomic"
414 "testing"
515
616 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
717 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
18+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
19+ "github.com/bnema/gordon/internal/domain"
820 "github.com/stretchr/testify/assert"
21+ "github.com/stretchr/testify/require"
922 )
1023
24+type routesShowTestControlPlane struct {
25+ resolveFromImageTestControlPlane
26+ getHealth func(context.Context) (map[string]*remote.RouteHealth, error)
27+}
28+
29+var _ ControlPlane = (*routesShowTestControlPlane)(nil)
30+
31+func (c *routesShowTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) {
32+ panic("unexpected call")
33+}
34+
35+func (c *routesShowTestControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
36+ if c.getHealth != nil {
37+ return c.getHealth(ctx)
38+ }
39+ panic("unexpected call")
40+}
41+
1142 func TestTruncateImage(t *testing.T) {
1243 tests := []struct {
1344 name string
@@ -197,3 +228,676 @@ func TestStripNetworkPrefix(t *testing.T) {
197228 })
198229 }
199230 }
231+
232+func TestResolveRoutesExplicitRemote_AllowsAdHocURLWhenSavedRemotesConfigUnreadable(t *testing.T) {
233+ originalRemoteFlag := remoteFlag
234+ originalTokenFlag := tokenFlag
235+ originalInsecureTLSFlag := insecureTLSFlag
236+ t.Cleanup(func() {
237+ remoteFlag = originalRemoteFlag
238+ tokenFlag = originalTokenFlag
239+ insecureTLSFlag = originalInsecureTLSFlag
240+ })
241+
242+ remoteFlag = ""
243+ tokenFlag = ""
244+ insecureTLSFlag = false
245+
246+ configHome := t.TempDir()
247+ t.Setenv("XDG_CONFIG_HOME", configHome)
248+ t.Setenv("HOME", t.TempDir())
249+ t.Setenv("GORDON_REMOTE", "https://ad-hoc.example.com")
250+
251+ configPath := filepath.Join(configHome, "gordon", "remotes.toml")
252+ require.NoError(t, os.MkdirAll(configPath, 0o755))
253+
254+ resolved, ok, err := resolveRoutesExplicitRemote()
255+ require.True(t, ok)
256+ require.NoError(t, err)
257+ require.NotNil(t, resolved)
258+ assert.Equal(t, "https://ad-hoc.example.com", resolved.URL)
259+ assert.Empty(t, resolved.Name)
260+}
261+
262+func TestResolveRoutesExplicitRemote_ReturnsErrorForUnreadableNamedRemoteConfig(t *testing.T) {
263+ originalRemoteFlag := remoteFlag
264+ originalTokenFlag := tokenFlag
265+ originalInsecureTLSFlag := insecureTLSFlag
266+ t.Cleanup(func() {
267+ remoteFlag = originalRemoteFlag
268+ tokenFlag = originalTokenFlag
269+ insecureTLSFlag = originalInsecureTLSFlag
270+ })
271+
272+ remoteFlag = "missing-remote"
273+ tokenFlag = ""
274+ insecureTLSFlag = false
275+
276+ configHome := t.TempDir()
277+ t.Setenv("XDG_CONFIG_HOME", configHome)
278+ t.Setenv("HOME", t.TempDir())
279+ t.Setenv("GORDON_REMOTE", "")
280+
281+ configPath := filepath.Join(configHome, "gordon", "remotes.toml")
282+ require.NoError(t, os.MkdirAll(configPath, 0o755))
283+
284+ resolved, ok, err := resolveRoutesExplicitRemote()
285+ require.Error(t, err)
286+ require.False(t, ok)
287+ require.Nil(t, resolved)
288+ assert.Contains(t, err.Error(), "failed to read remotes")
289+}
290+
291+func TestRouteStatusTitle_PreservesProbeFailureError(t *testing.T) {
292+ item := routeStatusItem{
293+ Domain: "app.example.com",
294+ Image: "app:latest",
295+ ContainerStatus: "running",
296+ HTTPStatus: 0,
297+ HealthError: "connection refused",
298+ }
299+
300+ expected := components.StatusIcon(styles.IconHTTPStatus, components.StatusError) + " " +
301+ components.StatusIcon(styles.IconContainerStatus, components.ParseStatus("running")) +
302+ " app.example.com"
303+
304+ assert.Equal(t, expected, routeStatusTitle(item))
305+}
306+
307+func TestRunRoutesShow_JSONIncludesHealthError(t *testing.T) {
308+ fake := &routesShowTestControlPlane{
309+ resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{
310+ getRoute: func(context.Context, string) (*domain.Route, error) {
311+ return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil
312+ },
313+ },
314+ getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) {
315+ return nil, errors.New("probe failed")
316+ },
317+ }
318+
319+ var out bytes.Buffer
320+ err := runRoutesShow(context.Background(), fake, &out, "app.example.com", true)
321+
322+ require.NoError(t, err)
323+
324+ var got map[string]any
325+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
326+ assert.Equal(t, "app.example.com", got["domain"])
327+ assert.Equal(t, "app:latest", got["image"])
328+ assert.Equal(t, "unknown", got["container_status"])
329+ assert.Equal(t, float64(0), got["http_status"])
330+ assert.Equal(t, "probe failed", got["health_error"])
331+}
332+
333+func TestRunRoutesShow_TextIncludesHealthWarning(t *testing.T) {
334+ fake := &routesShowTestControlPlane{
335+ resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{
336+ getRoute: func(context.Context, string) (*domain.Route, error) {
337+ return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil
338+ },
339+ },
340+ getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) {
341+ return nil, errors.New("probe failed")
342+ },
343+ }
344+
345+ var out bytes.Buffer
346+ err := runRoutesShow(context.Background(), fake, &out, "app.example.com", false)
347+
348+ require.NoError(t, err)
349+
350+ text := stripANSI(out.String())
351+ assert.Contains(t, text, "Route: app.example.com")
352+ assert.Contains(t, text, "Domain:")
353+ assert.Contains(t, text, "Image:")
354+ assert.Contains(t, text, "Container:")
355+ assert.Contains(t, text, "probe failed")
356+}
357+
358+func TestRunRoutesShow_JSONIncludesRouteProbeFailureAndUnknownContainerStatus(t *testing.T) {
359+ fake := &routesShowTestControlPlane{
360+ resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{
361+ getRoute: func(context.Context, string) (*domain.Route, error) {
362+ return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil
363+ },
364+ },
365+ getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) {
366+ return map[string]*remote.RouteHealth{
367+ "app.example.com": {
368+ HTTPStatus: 503,
369+ ContainerStatus: "",
370+ Error: "probe failed",
371+ },
372+ }, nil
373+ },
374+ }
375+
376+ var out bytes.Buffer
377+ err := runRoutesShow(context.Background(), fake, &out, "app.example.com", true)
378+
379+ require.NoError(t, err)
380+
381+ var got map[string]any
382+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
383+ assert.Equal(t, "app.example.com", got["domain"])
384+ assert.Equal(t, "app:latest", got["image"])
385+ assert.Equal(t, "unknown", got["container_status"])
386+ assert.Equal(t, float64(503), got["http_status"])
387+ assert.Equal(t, "probe failed", got["health_error"])
388+}
389+
390+func TestRunRoutesShow_TextIncludesRouteProbeFailureAndUnknownContainerStatus(t *testing.T) {
391+ fake := &routesShowTestControlPlane{
392+ resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{
393+ getRoute: func(context.Context, string) (*domain.Route, error) {
394+ return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil
395+ },
396+ },
397+ getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) {
398+ return map[string]*remote.RouteHealth{
399+ "app.example.com": {
400+ HTTPStatus: 503,
401+ ContainerStatus: "",
402+ Error: "probe failed",
403+ },
404+ }, nil
405+ },
406+ }
407+
408+ var out bytes.Buffer
409+ err := runRoutesShow(context.Background(), fake, &out, "app.example.com", false)
410+
411+ require.NoError(t, err)
412+
413+ text := stripANSI(out.String())
414+ assert.Contains(t, text, "Route: app.example.com")
415+ assert.Contains(t, text, "Container:")
416+ assert.Contains(t, text, "unknown")
417+ assert.Contains(t, text, "probe failed")
418+}
419+
420+func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) {
421+ testsDeps := routesListDeps{
422+ explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
423+ return nil, false, nil
424+ },
425+ loadLocal: func(context.Context, string) (routeListSection, error) {
426+ return routeListSection{
427+ Kind: "local",
428+ Name: "local",
429+ Routes: []routeListItem{{
430+ Domain: "app.local",
431+ Image: "myapp:latest",
432+ }},
433+ }, nil
434+ },
435+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
436+ return map[string]remote.RemoteEntry{
437+ "remote-a": {URL: "https://remote-a.example.com"},
438+ "remote-b": {URL: "https://remote-b.example.com"},
439+ }, "remote-a", nil
440+ },
441+ loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
442+ if name == "remote-b" {
443+ return routeListSection{
444+ Kind: "remote",
445+ Name: name,
446+ URL: entry.URL,
447+ Error: "remote unavailable",
448+ }, nil
449+ }
450+ return routeListSection{
451+ Kind: "remote",
452+ Name: name,
453+ URL: entry.URL,
454+ Routes: []routeListItem{{
455+ Domain: "dashboard.example.com",
456+ Image: "dashboard",
457+ }},
458+ }, nil
459+ },
460+ }
461+
462+ sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
463+
464+ require.NoError(t, err)
465+ require.Len(t, sections, 3)
466+ assert.Equal(t, "local", sections[0].Name)
467+ assert.Equal(t, "remote-a", sections[1].Name)
468+ assert.Equal(t, "remote-b", sections[2].Name)
469+ assert.Equal(t, "dashboard.example.com", sections[1].Routes[0].Domain)
470+ assert.Equal(t, "remote unavailable", sections[2].Error)
471+}
472+
473+func TestCollectRoutesListSections_ExplicitRemoteSkipsAggregate(t *testing.T) {
474+ testsDeps := routesListDeps{
475+ explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
476+ return &remote.ResolvedRemote{
477+ Name: "remote-a",
478+ URL: "https://remote-a.example.com",
479+ }, true, nil
480+ },
481+ loadLocal: func(context.Context, string) (routeListSection, error) {
482+ t.Fatal("loadLocal should not be called when an explicit remote is selected")
483+ return routeListSection{}, nil
484+ },
485+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
486+ t.Fatal("listRemotes should not be called when an explicit remote is selected")
487+ return nil, "", nil
488+ },
489+ loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
490+ return routeListSection{
491+ Kind: "remote",
492+ Name: name,
493+ URL: entry.URL,
494+ Routes: []routeListItem{{
495+ Domain: "test.example.com",
496+ Image: "hello-app",
497+ }},
498+ }, nil
499+ },
500+ }
501+
502+ sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
503+
504+ require.NoError(t, err)
505+ require.Len(t, sections, 1)
506+ assert.Equal(t, "remote-a", sections[0].Name)
507+ assert.Equal(t, "test.example.com", sections[0].Routes[0].Domain)
508+}
509+
510+func TestCollectRoutesSections_ExplicitRemoteResolutionErrorReturnsError(t *testing.T) {
511+
512+ t.Run("list", func(t *testing.T) {
513+ var loadLocalCalls atomic.Int32
514+ var listRemotesCalls atomic.Int32
515+ var loadRemoteCalls atomic.Int32
516+
517+ deps := routesListDeps{
518+ explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
519+ return nil, false, errors.New("failed to read remotes: boom")
520+ },
521+ loadLocal: func(context.Context, string) (routeListSection, error) {
522+ loadLocalCalls.Add(1)
523+ return routeListSection{}, nil
524+ },
525+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
526+ listRemotesCalls.Add(1)
527+ return map[string]remote.RemoteEntry{}, "", nil
528+ },
529+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
530+ loadRemoteCalls.Add(1)
531+ return routeListSection{}, nil
532+ },
533+ }
534+
535+ sections, err := collectRoutesListSections(context.Background(), "", deps)
536+
537+ require.Error(t, err)
538+ require.Nil(t, sections)
539+ assert.Contains(t, err.Error(), "failed to read remotes")
540+ assert.Zero(t, loadLocalCalls.Load())
541+ assert.Zero(t, listRemotesCalls.Load())
542+ assert.Zero(t, loadRemoteCalls.Load())
543+ })
544+
545+ t.Run("status", func(t *testing.T) {
546+ var loadLocalCalls atomic.Int32
547+ var listRemotesCalls atomic.Int32
548+ var loadRemoteCalls atomic.Int32
549+
550+ deps := routesStatusDeps{
551+ explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
552+ return nil, false, errors.New("failed to read remotes: boom")
553+ },
554+ loadLocal: func(context.Context, string) (routeStatusSection, error) {
555+ loadLocalCalls.Add(1)
556+ return routeStatusSection{}, nil
557+ },
558+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
559+ listRemotesCalls.Add(1)
560+ return map[string]remote.RemoteEntry{}, "", nil
561+ },
562+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
563+ loadRemoteCalls.Add(1)
564+ return routeStatusSection{}, nil
565+ },
566+ }
567+
568+ sections, err := collectRoutesStatusSections(context.Background(), "", deps)
569+
570+ require.Error(t, err)
571+ require.Nil(t, sections)
572+ assert.Contains(t, err.Error(), "failed to read remotes")
573+ assert.Zero(t, loadLocalCalls.Load())
574+ assert.Zero(t, listRemotesCalls.Load())
575+ assert.Zero(t, loadRemoteCalls.Load())
576+ })
577+}
578+
579+func TestLoadRoutesListExplicitRemoteSection_PropagatesLoaderError(t *testing.T) {
580+ section := loadRoutesListExplicitRemoteSection(context.Background(), routesListDeps{
581+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
582+ return routeListSection{}, errors.New("boom")
583+ },
584+ }, &remote.ResolvedRemote{Name: "remote-a", URL: "https://remote-a.example.com"})
585+
586+ assert.Equal(t, "remote", section.Kind)
587+ assert.Equal(t, "remote-a", section.Name)
588+ assert.Equal(t, "https://remote-a.example.com", section.URL)
589+ assert.Equal(t, "boom", section.Error)
590+}
591+
592+func TestLoadRoutesStatusExplicitRemoteSection_PropagatesLoaderError(t *testing.T) {
593+ section := loadRoutesStatusExplicitRemoteSection(context.Background(), routesStatusDeps{
594+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
595+ return routeStatusSection{}, errors.New("boom")
596+ },
597+ }, &remote.ResolvedRemote{Name: "remote-a", URL: "https://remote-a.example.com"})
598+
599+ assert.Equal(t, "remote", section.Kind)
600+ assert.Equal(t, "remote-a", section.Name)
601+ assert.Equal(t, "https://remote-a.example.com", section.URL)
602+ assert.Equal(t, "boom", section.Error)
603+}
604+
605+func TestCollectRoutesListAggregateSections_EncodesLoaderErrors(t *testing.T) {
606+ t.Run("local loader error", func(t *testing.T) {
607+ sections, err := collectRoutesListAggregateSections(context.Background(), "config.toml", routesListDeps{
608+ loadLocal: func(context.Context, string) (routeListSection, error) {
609+ return routeListSection{Kind: "local", Name: "local"}, errors.New("local failed")
610+ },
611+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
612+ return map[string]remote.RemoteEntry{}, "", nil
613+ },
614+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
615+ return routeListSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, nil
616+ },
617+ })
618+
619+ require.NoError(t, err)
620+ require.Len(t, sections, 1)
621+ assert.Equal(t, "local", sections[0].Kind)
622+ assert.Equal(t, "local failed", sections[0].Error)
623+ })
624+
625+ t.Run("remote loader error", func(t *testing.T) {
626+ sections, err := collectRoutesListAggregateSections(context.Background(), "config.toml", routesListDeps{
627+ loadLocal: func(context.Context, string) (routeListSection, error) {
628+ return routeListSection{Kind: "local", Name: "local"}, nil
629+ },
630+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
631+ return map[string]remote.RemoteEntry{"remote-a": {URL: "https://remote-a.example.com"}}, "", nil
632+ },
633+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
634+ return routeListSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, errors.New("remote failed")
635+ },
636+ })
637+
638+ require.NoError(t, err)
639+ require.Len(t, sections, 2)
640+ assert.Equal(t, "local", sections[0].Kind)
641+ assert.Equal(t, "remote", sections[1].Kind)
642+ assert.Equal(t, "remote failed", sections[1].Error)
643+ })
644+}
645+
646+func TestCollectRoutesStatusAggregateSections_EncodesLoaderErrors(t *testing.T) {
647+ t.Run("local loader error", func(t *testing.T) {
648+ sections, err := collectRoutesStatusAggregateSections(context.Background(), "config.toml", routesStatusDeps{
649+ loadLocal: func(context.Context, string) (routeStatusSection, error) {
650+ return routeStatusSection{Kind: "local", Name: "local"}, errors.New("local failed")
651+ },
652+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
653+ return map[string]remote.RemoteEntry{}, "", nil
654+ },
655+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
656+ return routeStatusSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, nil
657+ },
658+ })
659+
660+ require.NoError(t, err)
661+ require.Len(t, sections, 1)
662+ assert.Equal(t, "local", sections[0].Kind)
663+ assert.Equal(t, "local failed", sections[0].Error)
664+ })
665+
666+ t.Run("remote loader error", func(t *testing.T) {
667+ sections, err := collectRoutesStatusAggregateSections(context.Background(), "config.toml", routesStatusDeps{
668+ loadLocal: func(context.Context, string) (routeStatusSection, error) {
669+ return routeStatusSection{Kind: "local", Name: "local"}, nil
670+ },
671+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
672+ return map[string]remote.RemoteEntry{"remote-a": {URL: "https://remote-a.example.com"}}, "", nil
673+ },
674+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
675+ return routeStatusSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, errors.New("remote failed")
676+ },
677+ })
678+
679+ require.NoError(t, err)
680+ require.Len(t, sections, 2)
681+ assert.Equal(t, "local", sections[0].Kind)
682+ assert.Equal(t, "remote", sections[1].Kind)
683+ assert.Equal(t, "remote failed", sections[1].Error)
684+ })
685+}
686+
687+func TestLoadRoutesStatusLocalSection_DoesNotLeakKernelInitLogs(t *testing.T) {
688+ tmpDir := t.TempDir()
689+ cfgPath := filepath.Join(tmpDir, "gordon.toml")
690+ require.NoError(t, os.WriteFile(cfgPath, []byte(`[server]
691+gordon_domain = "gordon.local"
692+data_dir = `+strconv.Quote(filepath.Join(tmpDir, "data"))+`
693+
694+[auth]
695+enabled = true
696+`), 0o600))
697+
698+ stdout, stderr := captureOutput(t, func() {
699+ section, err := loadRoutesStatusLocalSection(context.Background(), cfgPath)
700+ require.NoError(t, err)
701+ assert.Equal(t, "local", section.Kind)
702+ assert.Equal(t, "local", section.Name)
703+ assert.Contains(t, section.Error, "failed to initialize local control plane")
704+ assert.Contains(t, section.Error, "auth.secrets_backend is required")
705+ })
706+
707+ combined := stdout + stderr
708+ assert.NotContains(t, combined, "failed to resolve secrets backend")
709+ assert.NotContains(t, combined, "local kernel running in minimal mode")
710+}
711+
712+func captureOutput(t *testing.T, fn func()) (string, string) {
713+ t.Helper()
714+
715+ oldStdout, oldStderr := os.Stdout, os.Stderr
716+ outR, outW, err := os.Pipe()
717+ require.NoError(t, err)
718+ errR, errW, err := os.Pipe()
719+ require.NoError(t, err)
720+
721+ os.Stdout = outW
722+ os.Stderr = errW
723+
724+ stdoutCh := make(chan string, 1)
725+ stderrCh := make(chan string, 1)
726+ go func() {
727+ var buf bytes.Buffer
728+ _, _ = io.Copy(&buf, outR)
729+ stdoutCh <- buf.String()
730+ }()
731+ go func() {
732+ var buf bytes.Buffer
733+ _, _ = io.Copy(&buf, errR)
734+ stderrCh <- buf.String()
735+ }()
736+
737+ defer func() {
738+ os.Stdout = oldStdout
739+ os.Stderr = oldStderr
740+ }()
741+
742+ fn()
743+
744+ require.NoError(t, outW.Close())
745+ require.NoError(t, errW.Close())
746+
747+ return <-stdoutCh, <-stderrCh
748+}
749+
750+func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) {
751+ sections := []routeStatusSection{
752+ {
753+ Kind: "local",
754+ Name: "local",
755+ Routes: []routeStatusItem{{
756+ Domain: "app.local",
757+ Image: "myapp:latest",
758+ ContainerStatus: "running",
759+ }},
760+ },
761+ {
762+ Kind: "remote",
763+ Name: "remote-a",
764+ URL: "https://remote-a.example.com",
765+ Error: "dial tcp timeout",
766+ },
767+ }
768+
769+ var out bytes.Buffer
770+ err := renderRoutesStatusSections(&out, sections)
771+
772+ require.NoError(t, err)
773+ rendered := stripANSI(out.String())
774+ lines := strings.Split(rendered, "\n")
775+
776+ lineIndex := func(substr string) int {
777+ for i, line := range lines {
778+ if strings.Contains(line, substr) {
779+ return i
780+ }
781+ }
782+ return -1
783+ }
784+
785+ titleIdx := lineIndex("Route Status")
786+ require.NotEqual(t, -1, titleIdx)
787+
788+ localIdx := lineIndex("Local")
789+ require.NotEqual(t, -1, localIdx)
790+
791+ routeIdx := lineIndex("app.local")
792+ require.NotEqual(t, -1, routeIdx)
793+
794+ remoteIdx := lineIndex("Remote: remote-a")
795+ require.NotEqual(t, -1, remoteIdx)
796+
797+ errorIdx := lineIndex("dial tcp timeout")
798+ require.NotEqual(t, -1, errorIdx)
799+
800+ assert.Less(t, titleIdx, localIdx)
801+ assert.Less(t, localIdx, routeIdx)
802+ assert.Less(t, routeIdx, remoteIdx)
803+ assert.Less(t, remoteIdx, errorIdx)
804+}
805+
806+func TestRenderRoutesStatusSections_DoesNotAddExtraBlankLineBetweenSections(t *testing.T) {
807+ sections := []routeStatusSection{
808+ {
809+ Kind: "remote",
810+ Name: "remote-b",
811+ Routes: []routeStatusItem{{
812+ Domain: "app.example.com",
813+ Image: "app:latest",
814+ ContainerStatus: "running",
815+ }},
816+ },
817+ {
818+ Kind: "remote",
819+ Name: "remote-a",
820+ Routes: []routeStatusItem{{
821+ Domain: "dashboard.example.com",
822+ Image: "dashboard",
823+ ContainerStatus: "running",
824+ }},
825+ },
826+ }
827+
828+ var out bytes.Buffer
829+ err := renderRoutesStatusSections(&out, sections)
830+
831+ require.NoError(t, err)
832+ rendered := stripANSI(out.String())
833+ assert.NotContains(t, rendered, "app:latest\n\n\nRemote: remote-a")
834+ assert.Contains(t, rendered, "app:latest\n\nRemote: remote-a")
835+}
836+
837+func TestRenderRoutesListSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
838+ sections := []routeListSection{
839+ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
840+ {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"},
841+ }
842+
843+ var out bytes.Buffer
844+ err := renderRoutesListSections(&out, sections)
845+
846+ require.NoError(t, err)
847+ rendered := stripANSI(out.String())
848+ assert.NotContains(t, rendered, "Local")
849+ assert.NotContains(t, rendered, "failed to initialize local control plane")
850+ assert.Contains(t, rendered, "Remote: remote-a")
851+ assert.Contains(t, rendered, "No routes configured")
852+}
853+
854+func TestRenderRoutesStatusSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
855+ sections := []routeStatusSection{
856+ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
857+ {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"},
858+ }
859+
860+ var out bytes.Buffer
861+ err := renderRoutesStatusSections(&out, sections)
862+
863+ require.NoError(t, err)
864+ rendered := stripANSI(out.String())
865+ assert.NotContains(t, rendered, "Local")
866+ assert.NotContains(t, rendered, "failed to initialize local control plane")
867+ assert.Contains(t, rendered, "Remote: remote-a")
868+ assert.Contains(t, rendered, "No routes configured")
869+}
870+
871+func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing.T) {
872+ sections := []routeStatusSection{
873+ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
874+ {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com", Error: "dial tcp timeout"},
875+ }
876+
877+ var out bytes.Buffer
878+ err := renderRoutesStatusSections(&out, sections)
879+
880+ require.NoError(t, err)
881+ rendered := stripANSI(out.String())
882+ assert.Contains(t, rendered, "Local")
883+ assert.Contains(t, rendered, "failed to initialize local control plane")
884+ assert.Contains(t, rendered, "Remote: remote-a")
885+ assert.Contains(t, rendered, "dial tcp timeout")
886+}
887+
888+func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
889+ routes := []routeStatusItem{
890+ {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},
891+ {Domain: "alpha.example", Image: "alpha:v1", Network: "gordon-alpha", ContainerStatus: "running"},
892+ {Domain: "mike.example", Image: "solo:v1", ContainerStatus: "running"},
893+ }
894+
895+ var outA bytes.Buffer
896+ _, _ = outA.WriteString(stripANSI(buildRouteStatusTree(routes).Render()))
897+
898+ var outB bytes.Buffer
899+ reordered := []routeStatusItem{routes[2], routes[0], routes[1]}
900+ _, _ = outB.WriteString(stripANSI(buildRouteStatusTree(reordered).Render()))
901+
902+ assert.Equal(t, outA.String(), outB.String())
903+}
@@ -1,13 +1,44 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "strconv"
12+ "strings"
13+ "sync/atomic"
4 "testing"14 "testing"
5 15
6 "github.com/bnema/gordon/internal/adapters/in/cli/remote"16 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
7 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"17 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
18+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
19+ "github.com/bnema/gordon/internal/domain"
8 "github.com/stretchr/testify/assert"20 "github.com/stretchr/testify/assert"
21+ "github.com/stretchr/testify/require"
9 )22 )
10 23
24+type routesShowTestControlPlane struct {
25+ resolveFromImageTestControlPlane
26+ getHealth func(context.Context) (map[string]*remote.RouteHealth, error)
27+}
28+
29+var _ ControlPlane = (*routesShowTestControlPlane)(nil)
30+
31+func (c *routesShowTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) {
32+ panic("unexpected call")
33+}
34+
35+func (c *routesShowTestControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
36+ if c.getHealth != nil {
37+ return c.getHealth(ctx)
38+ }
39+ panic("unexpected call")
40+}
41+
11 func TestTruncateImage(t *testing.T) {42 func TestTruncateImage(t *testing.T) {
12 tests := []struct {43 tests := []struct {
13 name string44 name string
@@ -197,3 +228,676 @@ func TestStripNetworkPrefix(t *testing.T) {
197 })228 })
198 }229 }
199 }230 }
231+
232+func TestResolveRoutesExplicitRemote_AllowsAdHocURLWhenSavedRemotesConfigUnreadable(t *testing.T) {
233+ originalRemoteFlag := remoteFlag
234+ originalTokenFlag := tokenFlag
235+ originalInsecureTLSFlag := insecureTLSFlag
236+ t.Cleanup(func() {
237+ remoteFlag = originalRemoteFlag
238+ tokenFlag = originalTokenFlag
239+ insecureTLSFlag = originalInsecureTLSFlag
240+ })
241+
242+ remoteFlag = ""
243+ tokenFlag = ""
244+ insecureTLSFlag = false
245+
246+ configHome := t.TempDir()
247+ t.Setenv("XDG_CONFIG_HOME", configHome)
248+ t.Setenv("HOME", t.TempDir())
249+ t.Setenv("GORDON_REMOTE", "https://ad-hoc.example.com")
250+
251+ configPath := filepath.Join(configHome, "gordon", "remotes.toml")
252+ require.NoError(t, os.MkdirAll(configPath, 0o755))
253+
254+ resolved, ok, err := resolveRoutesExplicitRemote()
255+ require.True(t, ok)
256+ require.NoError(t, err)
257+ require.NotNil(t, resolved)
258+ assert.Equal(t, "https://ad-hoc.example.com", resolved.URL)
259+ assert.Empty(t, resolved.Name)
260+}
261+
262+func TestResolveRoutesExplicitRemote_ReturnsErrorForUnreadableNamedRemoteConfig(t *testing.T) {
263+ originalRemoteFlag := remoteFlag
264+ originalTokenFlag := tokenFlag
265+ originalInsecureTLSFlag := insecureTLSFlag
266+ t.Cleanup(func() {
267+ remoteFlag = originalRemoteFlag
268+ tokenFlag = originalTokenFlag
269+ insecureTLSFlag = originalInsecureTLSFlag
270+ })
271+
272+ remoteFlag = "missing-remote"
273+ tokenFlag = ""
274+ insecureTLSFlag = false
275+
276+ configHome := t.TempDir()
277+ t.Setenv("XDG_CONFIG_HOME", configHome)
278+ t.Setenv("HOME", t.TempDir())
279+ t.Setenv("GORDON_REMOTE", "")
280+
281+ configPath := filepath.Join(configHome, "gordon", "remotes.toml")
282+ require.NoError(t, os.MkdirAll(configPath, 0o755))
283+
284+ resolved, ok, err := resolveRoutesExplicitRemote()
285+ require.Error(t, err)
286+ require.False(t, ok)
287+ require.Nil(t, resolved)
288+ assert.Contains(t, err.Error(), "failed to read remotes")
289+}
290+
291+func TestRouteStatusTitle_PreservesProbeFailureError(t *testing.T) {
292+ item := routeStatusItem{
293+ Domain: "app.example.com",
294+ Image: "app:latest",
295+ ContainerStatus: "running",
296+ HTTPStatus: 0,
297+ HealthError: "connection refused",
298+ }
299+
300+ expected := components.StatusIcon(styles.IconHTTPStatus, components.StatusError) + " " +
301+ components.StatusIcon(styles.IconContainerStatus, components.ParseStatus("running")) +
302+ " app.example.com"
303+
304+ assert.Equal(t, expected, routeStatusTitle(item))
305+}
306+
307+func TestRunRoutesShow_JSONIncludesHealthError(t *testing.T) {
308+ fake := &routesShowTestControlPlane{
309+ resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{
310+ getRoute: func(context.Context, string) (*domain.Route, error) {
311+ return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil
312+ },
313+ },
314+ getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) {
315+ return nil, errors.New("probe failed")
316+ },
317+ }
318+
319+ var out bytes.Buffer
320+ err := runRoutesShow(context.Background(), fake, &out, "app.example.com", true)
321+
322+ require.NoError(t, err)
323+
324+ var got map[string]any
325+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
326+ assert.Equal(t, "app.example.com", got["domain"])
327+ assert.Equal(t, "app:latest", got["image"])
328+ assert.Equal(t, "unknown", got["container_status"])
329+ assert.Equal(t, float64(0), got["http_status"])
330+ assert.Equal(t, "probe failed", got["health_error"])
331+}
332+
333+func TestRunRoutesShow_TextIncludesHealthWarning(t *testing.T) {
334+ fake := &routesShowTestControlPlane{
335+ resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{
336+ getRoute: func(context.Context, string) (*domain.Route, error) {
337+ return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil
338+ },
339+ },
340+ getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) {
341+ return nil, errors.New("probe failed")
342+ },
343+ }
344+
345+ var out bytes.Buffer
346+ err := runRoutesShow(context.Background(), fake, &out, "app.example.com", false)
347+
348+ require.NoError(t, err)
349+
350+ text := stripANSI(out.String())
351+ assert.Contains(t, text, "Route: app.example.com")
352+ assert.Contains(t, text, "Domain:")
353+ assert.Contains(t, text, "Image:")
354+ assert.Contains(t, text, "Container:")
355+ assert.Contains(t, text, "probe failed")
356+}
357+
358+func TestRunRoutesShow_JSONIncludesRouteProbeFailureAndUnknownContainerStatus(t *testing.T) {
359+ fake := &routesShowTestControlPlane{
360+ resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{
361+ getRoute: func(context.Context, string) (*domain.Route, error) {
362+ return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil
363+ },
364+ },
365+ getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) {
366+ return map[string]*remote.RouteHealth{
367+ "app.example.com": {
368+ HTTPStatus: 503,
369+ ContainerStatus: "",
370+ Error: "probe failed",
371+ },
372+ }, nil
373+ },
374+ }
375+
376+ var out bytes.Buffer
377+ err := runRoutesShow(context.Background(), fake, &out, "app.example.com", true)
378+
379+ require.NoError(t, err)
380+
381+ var got map[string]any
382+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
383+ assert.Equal(t, "app.example.com", got["domain"])
384+ assert.Equal(t, "app:latest", got["image"])
385+ assert.Equal(t, "unknown", got["container_status"])
386+ assert.Equal(t, float64(503), got["http_status"])
387+ assert.Equal(t, "probe failed", got["health_error"])
388+}
389+
390+func TestRunRoutesShow_TextIncludesRouteProbeFailureAndUnknownContainerStatus(t *testing.T) {
391+ fake := &routesShowTestControlPlane{
392+ resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{
393+ getRoute: func(context.Context, string) (*domain.Route, error) {
394+ return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil
395+ },
396+ },
397+ getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) {
398+ return map[string]*remote.RouteHealth{
399+ "app.example.com": {
400+ HTTPStatus: 503,
401+ ContainerStatus: "",
402+ Error: "probe failed",
403+ },
404+ }, nil
405+ },
406+ }
407+
408+ var out bytes.Buffer
409+ err := runRoutesShow(context.Background(), fake, &out, "app.example.com", false)
410+
411+ require.NoError(t, err)
412+
413+ text := stripANSI(out.String())
414+ assert.Contains(t, text, "Route: app.example.com")
415+ assert.Contains(t, text, "Container:")
416+ assert.Contains(t, text, "unknown")
417+ assert.Contains(t, text, "probe failed")
418+}
419+
420+func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) {
421+ testsDeps := routesListDeps{
422+ explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
423+ return nil, false, nil
424+ },
425+ loadLocal: func(context.Context, string) (routeListSection, error) {
426+ return routeListSection{
427+ Kind: "local",
428+ Name: "local",
429+ Routes: []routeListItem{{
430+ Domain: "app.local",
431+ Image: "myapp:latest",
432+ }},
433+ }, nil
434+ },
435+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
436+ return map[string]remote.RemoteEntry{
437+ "remote-a": {URL: "https://remote-a.example.com"},
438+ "remote-b": {URL: "https://remote-b.example.com"},
439+ }, "remote-a", nil
440+ },
441+ loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
442+ if name == "remote-b" {
443+ return routeListSection{
444+ Kind: "remote",
445+ Name: name,
446+ URL: entry.URL,
447+ Error: "remote unavailable",
448+ }, nil
449+ }
450+ return routeListSection{
451+ Kind: "remote",
452+ Name: name,
453+ URL: entry.URL,
454+ Routes: []routeListItem{{
455+ Domain: "dashboard.example.com",
456+ Image: "dashboard",
457+ }},
458+ }, nil
459+ },
460+ }
461+
462+ sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
463+
464+ require.NoError(t, err)
465+ require.Len(t, sections, 3)
466+ assert.Equal(t, "local", sections[0].Name)
467+ assert.Equal(t, "remote-a", sections[1].Name)
468+ assert.Equal(t, "remote-b", sections[2].Name)
469+ assert.Equal(t, "dashboard.example.com", sections[1].Routes[0].Domain)
470+ assert.Equal(t, "remote unavailable", sections[2].Error)
471+}
472+
473+func TestCollectRoutesListSections_ExplicitRemoteSkipsAggregate(t *testing.T) {
474+ testsDeps := routesListDeps{
475+ explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
476+ return &remote.ResolvedRemote{
477+ Name: "remote-a",
478+ URL: "https://remote-a.example.com",
479+ }, true, nil
480+ },
481+ loadLocal: func(context.Context, string) (routeListSection, error) {
482+ t.Fatal("loadLocal should not be called when an explicit remote is selected")
483+ return routeListSection{}, nil
484+ },
485+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
486+ t.Fatal("listRemotes should not be called when an explicit remote is selected")
487+ return nil, "", nil
488+ },
489+ loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
490+ return routeListSection{
491+ Kind: "remote",
492+ Name: name,
493+ URL: entry.URL,
494+ Routes: []routeListItem{{
495+ Domain: "test.example.com",
496+ Image: "hello-app",
497+ }},
498+ }, nil
499+ },
500+ }
501+
502+ sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
503+
504+ require.NoError(t, err)
505+ require.Len(t, sections, 1)
506+ assert.Equal(t, "remote-a", sections[0].Name)
507+ assert.Equal(t, "test.example.com", sections[0].Routes[0].Domain)
508+}
509+
510+func TestCollectRoutesSections_ExplicitRemoteResolutionErrorReturnsError(t *testing.T) {
511+
512+ t.Run("list", func(t *testing.T) {
513+ var loadLocalCalls atomic.Int32
514+ var listRemotesCalls atomic.Int32
515+ var loadRemoteCalls atomic.Int32
516+
517+ deps := routesListDeps{
518+ explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
519+ return nil, false, errors.New("failed to read remotes: boom")
520+ },
521+ loadLocal: func(context.Context, string) (routeListSection, error) {
522+ loadLocalCalls.Add(1)
523+ return routeListSection{}, nil
524+ },
525+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
526+ listRemotesCalls.Add(1)
527+ return map[string]remote.RemoteEntry{}, "", nil
528+ },
529+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
530+ loadRemoteCalls.Add(1)
531+ return routeListSection{}, nil
532+ },
533+ }
534+
535+ sections, err := collectRoutesListSections(context.Background(), "", deps)
536+
537+ require.Error(t, err)
538+ require.Nil(t, sections)
539+ assert.Contains(t, err.Error(), "failed to read remotes")
540+ assert.Zero(t, loadLocalCalls.Load())
541+ assert.Zero(t, listRemotesCalls.Load())
542+ assert.Zero(t, loadRemoteCalls.Load())
543+ })
544+
545+ t.Run("status", func(t *testing.T) {
546+ var loadLocalCalls atomic.Int32
547+ var listRemotesCalls atomic.Int32
548+ var loadRemoteCalls atomic.Int32
549+
550+ deps := routesStatusDeps{
551+ explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
552+ return nil, false, errors.New("failed to read remotes: boom")
553+ },
554+ loadLocal: func(context.Context, string) (routeStatusSection, error) {
555+ loadLocalCalls.Add(1)
556+ return routeStatusSection{}, nil
557+ },
558+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
559+ listRemotesCalls.Add(1)
560+ return map[string]remote.RemoteEntry{}, "", nil
561+ },
562+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
563+ loadRemoteCalls.Add(1)
564+ return routeStatusSection{}, nil
565+ },
566+ }
567+
568+ sections, err := collectRoutesStatusSections(context.Background(), "", deps)
569+
570+ require.Error(t, err)
571+ require.Nil(t, sections)
572+ assert.Contains(t, err.Error(), "failed to read remotes")
573+ assert.Zero(t, loadLocalCalls.Load())
574+ assert.Zero(t, listRemotesCalls.Load())
575+ assert.Zero(t, loadRemoteCalls.Load())
576+ })
577+}
578+
579+func TestLoadRoutesListExplicitRemoteSection_PropagatesLoaderError(t *testing.T) {
580+ section := loadRoutesListExplicitRemoteSection(context.Background(), routesListDeps{
581+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
582+ return routeListSection{}, errors.New("boom")
583+ },
584+ }, &remote.ResolvedRemote{Name: "remote-a", URL: "https://remote-a.example.com"})
585+
586+ assert.Equal(t, "remote", section.Kind)
587+ assert.Equal(t, "remote-a", section.Name)
588+ assert.Equal(t, "https://remote-a.example.com", section.URL)
589+ assert.Equal(t, "boom", section.Error)
590+}
591+
592+func TestLoadRoutesStatusExplicitRemoteSection_PropagatesLoaderError(t *testing.T) {
593+ section := loadRoutesStatusExplicitRemoteSection(context.Background(), routesStatusDeps{
594+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
595+ return routeStatusSection{}, errors.New("boom")
596+ },
597+ }, &remote.ResolvedRemote{Name: "remote-a", URL: "https://remote-a.example.com"})
598+
599+ assert.Equal(t, "remote", section.Kind)
600+ assert.Equal(t, "remote-a", section.Name)
601+ assert.Equal(t, "https://remote-a.example.com", section.URL)
602+ assert.Equal(t, "boom", section.Error)
603+}
604+
605+func TestCollectRoutesListAggregateSections_EncodesLoaderErrors(t *testing.T) {
606+ t.Run("local loader error", func(t *testing.T) {
607+ sections, err := collectRoutesListAggregateSections(context.Background(), "config.toml", routesListDeps{
608+ loadLocal: func(context.Context, string) (routeListSection, error) {
609+ return routeListSection{Kind: "local", Name: "local"}, errors.New("local failed")
610+ },
611+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
612+ return map[string]remote.RemoteEntry{}, "", nil
613+ },
614+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
615+ return routeListSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, nil
616+ },
617+ })
618+
619+ require.NoError(t, err)
620+ require.Len(t, sections, 1)
621+ assert.Equal(t, "local", sections[0].Kind)
622+ assert.Equal(t, "local failed", sections[0].Error)
623+ })
624+
625+ t.Run("remote loader error", func(t *testing.T) {
626+ sections, err := collectRoutesListAggregateSections(context.Background(), "config.toml", routesListDeps{
627+ loadLocal: func(context.Context, string) (routeListSection, error) {
628+ return routeListSection{Kind: "local", Name: "local"}, nil
629+ },
630+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
631+ return map[string]remote.RemoteEntry{"remote-a": {URL: "https://remote-a.example.com"}}, "", nil
632+ },
633+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
634+ return routeListSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, errors.New("remote failed")
635+ },
636+ })
637+
638+ require.NoError(t, err)
639+ require.Len(t, sections, 2)
640+ assert.Equal(t, "local", sections[0].Kind)
641+ assert.Equal(t, "remote", sections[1].Kind)
642+ assert.Equal(t, "remote failed", sections[1].Error)
643+ })
644+}
645+
646+func TestCollectRoutesStatusAggregateSections_EncodesLoaderErrors(t *testing.T) {
647+ t.Run("local loader error", func(t *testing.T) {
648+ sections, err := collectRoutesStatusAggregateSections(context.Background(), "config.toml", routesStatusDeps{
649+ loadLocal: func(context.Context, string) (routeStatusSection, error) {
650+ return routeStatusSection{Kind: "local", Name: "local"}, errors.New("local failed")
651+ },
652+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
653+ return map[string]remote.RemoteEntry{}, "", nil
654+ },
655+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
656+ return routeStatusSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, nil
657+ },
658+ })
659+
660+ require.NoError(t, err)
661+ require.Len(t, sections, 1)
662+ assert.Equal(t, "local", sections[0].Kind)
663+ assert.Equal(t, "local failed", sections[0].Error)
664+ })
665+
666+ t.Run("remote loader error", func(t *testing.T) {
667+ sections, err := collectRoutesStatusAggregateSections(context.Background(), "config.toml", routesStatusDeps{
668+ loadLocal: func(context.Context, string) (routeStatusSection, error) {
669+ return routeStatusSection{Kind: "local", Name: "local"}, nil
670+ },
671+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
672+ return map[string]remote.RemoteEntry{"remote-a": {URL: "https://remote-a.example.com"}}, "", nil
673+ },
674+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
675+ return routeStatusSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, errors.New("remote failed")
676+ },
677+ })
678+
679+ require.NoError(t, err)
680+ require.Len(t, sections, 2)
681+ assert.Equal(t, "local", sections[0].Kind)
682+ assert.Equal(t, "remote", sections[1].Kind)
683+ assert.Equal(t, "remote failed", sections[1].Error)
684+ })
685+}
686+
687+func TestLoadRoutesStatusLocalSection_DoesNotLeakKernelInitLogs(t *testing.T) {
688+ tmpDir := t.TempDir()
689+ cfgPath := filepath.Join(tmpDir, "gordon.toml")
690+ require.NoError(t, os.WriteFile(cfgPath, []byte(`[server]
691+gordon_domain = "gordon.local"
692+data_dir = `+strconv.Quote(filepath.Join(tmpDir, "data"))+`
693+
694+[auth]
695+enabled = true
696+`), 0o600))
697+
698+ stdout, stderr := captureOutput(t, func() {
699+ section, err := loadRoutesStatusLocalSection(context.Background(), cfgPath)
700+ require.NoError(t, err)
701+ assert.Equal(t, "local", section.Kind)
702+ assert.Equal(t, "local", section.Name)
703+ assert.Contains(t, section.Error, "failed to initialize local control plane")
704+ assert.Contains(t, section.Error, "auth.secrets_backend is required")
705+ })
706+
707+ combined := stdout + stderr
708+ assert.NotContains(t, combined, "failed to resolve secrets backend")
709+ assert.NotContains(t, combined, "local kernel running in minimal mode")
710+}
711+
712+func captureOutput(t *testing.T, fn func()) (string, string) {
713+ t.Helper()
714+
715+ oldStdout, oldStderr := os.Stdout, os.Stderr
716+ outR, outW, err := os.Pipe()
717+ require.NoError(t, err)
718+ errR, errW, err := os.Pipe()
719+ require.NoError(t, err)
720+
721+ os.Stdout = outW
722+ os.Stderr = errW
723+
724+ stdoutCh := make(chan string, 1)
725+ stderrCh := make(chan string, 1)
726+ go func() {
727+ var buf bytes.Buffer
728+ _, _ = io.Copy(&buf, outR)
729+ stdoutCh <- buf.String()
730+ }()
731+ go func() {
732+ var buf bytes.Buffer
733+ _, _ = io.Copy(&buf, errR)
734+ stderrCh <- buf.String()
735+ }()
736+
737+ defer func() {
738+ os.Stdout = oldStdout
739+ os.Stderr = oldStderr
740+ }()
741+
742+ fn()
743+
744+ require.NoError(t, outW.Close())
745+ require.NoError(t, errW.Close())
746+
747+ return <-stdoutCh, <-stderrCh
748+}
749+
750+func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) {
751+ sections := []routeStatusSection{
752+ {
753+ Kind: "local",
754+ Name: "local",
755+ Routes: []routeStatusItem{{
756+ Domain: "app.local",
757+ Image: "myapp:latest",
758+ ContainerStatus: "running",
759+ }},
760+ },
761+ {
762+ Kind: "remote",
763+ Name: "remote-a",
764+ URL: "https://remote-a.example.com",
765+ Error: "dial tcp timeout",
766+ },
767+ }
768+
769+ var out bytes.Buffer
770+ err := renderRoutesStatusSections(&out, sections)
771+
772+ require.NoError(t, err)
773+ rendered := stripANSI(out.String())
774+ lines := strings.Split(rendered, "\n")
775+
776+ lineIndex := func(substr string) int {
777+ for i, line := range lines {
778+ if strings.Contains(line, substr) {
779+ return i
780+ }
781+ }
782+ return -1
783+ }
784+
785+ titleIdx := lineIndex("Route Status")
786+ require.NotEqual(t, -1, titleIdx)
787+
788+ localIdx := lineIndex("Local")
789+ require.NotEqual(t, -1, localIdx)
790+
791+ routeIdx := lineIndex("app.local")
792+ require.NotEqual(t, -1, routeIdx)
793+
794+ remoteIdx := lineIndex("Remote: remote-a")
795+ require.NotEqual(t, -1, remoteIdx)
796+
797+ errorIdx := lineIndex("dial tcp timeout")
798+ require.NotEqual(t, -1, errorIdx)
799+
800+ assert.Less(t, titleIdx, localIdx)
801+ assert.Less(t, localIdx, routeIdx)
802+ assert.Less(t, routeIdx, remoteIdx)
803+ assert.Less(t, remoteIdx, errorIdx)
804+}
805+
806+func TestRenderRoutesStatusSections_DoesNotAddExtraBlankLineBetweenSections(t *testing.T) {
807+ sections := []routeStatusSection{
808+ {
809+ Kind: "remote",
810+ Name: "remote-b",
811+ Routes: []routeStatusItem{{
812+ Domain: "app.example.com",
813+ Image: "app:latest",
814+ ContainerStatus: "running",
815+ }},
816+ },
817+ {
818+ Kind: "remote",
819+ Name: "remote-a",
820+ Routes: []routeStatusItem{{
821+ Domain: "dashboard.example.com",
822+ Image: "dashboard",
823+ ContainerStatus: "running",
824+ }},
825+ },
826+ }
827+
828+ var out bytes.Buffer
829+ err := renderRoutesStatusSections(&out, sections)
830+
831+ require.NoError(t, err)
832+ rendered := stripANSI(out.String())
833+ assert.NotContains(t, rendered, "app:latest\n\n\nRemote: remote-a")
834+ assert.Contains(t, rendered, "app:latest\n\nRemote: remote-a")
835+}
836+
837+func TestRenderRoutesListSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
838+ sections := []routeListSection{
839+ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
840+ {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"},
841+ }
842+
843+ var out bytes.Buffer
844+ err := renderRoutesListSections(&out, sections)
845+
846+ require.NoError(t, err)
847+ rendered := stripANSI(out.String())
848+ assert.NotContains(t, rendered, "Local")
849+ assert.NotContains(t, rendered, "failed to initialize local control plane")
850+ assert.Contains(t, rendered, "Remote: remote-a")
851+ assert.Contains(t, rendered, "No routes configured")
852+}
853+
854+func TestRenderRoutesStatusSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
855+ sections := []routeStatusSection{
856+ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
857+ {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"},
858+ }
859+
860+ var out bytes.Buffer
861+ err := renderRoutesStatusSections(&out, sections)
862+
863+ require.NoError(t, err)
864+ rendered := stripANSI(out.String())
865+ assert.NotContains(t, rendered, "Local")
866+ assert.NotContains(t, rendered, "failed to initialize local control plane")
867+ assert.Contains(t, rendered, "Remote: remote-a")
868+ assert.Contains(t, rendered, "No routes configured")
869+}
870+
871+func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing.T) {
872+ sections := []routeStatusSection{
873+ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
874+ {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com", Error: "dial tcp timeout"},
875+ }
876+
877+ var out bytes.Buffer
878+ err := renderRoutesStatusSections(&out, sections)
879+
880+ require.NoError(t, err)
881+ rendered := stripANSI(out.String())
882+ assert.Contains(t, rendered, "Local")
883+ assert.Contains(t, rendered, "failed to initialize local control plane")
884+ assert.Contains(t, rendered, "Remote: remote-a")
885+ assert.Contains(t, rendered, "dial tcp timeout")
886+}
887+
888+func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
889+ routes := []routeStatusItem{
890+ {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},
891+ {Domain: "alpha.example", Image: "alpha:v1", Network: "gordon-alpha", ContainerStatus: "running"},
892+ {Domain: "mike.example", Image: "solo:v1", ContainerStatus: "running"},
893+ }
894+
895+ var outA bytes.Buffer
896+ _, _ = outA.WriteString(stripANSI(buildRouteStatusTree(routes).Render()))
897+
898+ var outB bytes.Buffer
899+ reordered := []routeStatusItem{routes[2], routes[0], routes[1]}
900+ _, _ = outB.WriteString(stripANSI(buildRouteStatusTree(reordered).Render()))
901+
902+ assert.Equal(t, outA.String(), outB.String())
903+}
M internal/app/kernel.go +17 -1
@@ -3,6 +3,7 @@ package app
33 import (
44 "context"
55 "fmt"
6+ "io"
67
78 "github.com/bnema/zerowrap"
89
@@ -29,13 +30,24 @@ type Kernel struct {
2930
3031 // NewKernel initializes local services without starting server listeners.
3132 func NewKernel(configPath string) (*Kernel, error) {
33+ return newKernel(configPath, initLogger)
34+}
35+
36+// NewKernelQuiet initializes local services without emitting console logs.
37+func NewKernelQuiet(configPath string) (*Kernel, error) {
38+ return newKernel(configPath, quietInitLogger)
39+}
40+
41+type kernelLoggerInit func(cfg Config) (zerowrap.Logger, func(), error)
42+
43+func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
3244 ctx := context.Background()
3345 v, cfg, err := initConfig(configPath)
3446 if err != nil {
3547 return nil, err
3648 }
3749
38- log, cleanup, err := initLogger(cfg)
50+ log, cleanup, err := initLog(cfg)
3951 if err != nil {
4052 return nil, err
4153 }
@@ -86,6 +98,10 @@ func NewKernel(configPath string) (*Kernel, error) {
8698 }, nil
8799 }
88100
101+func quietInitLogger(Config) (zerowrap.Logger, func(), error) {
102+ return zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard}), func() {}, nil
103+}
104+
89105 func (k *Kernel) Close() error {
90106 if k == nil || k.cleanup == nil {
91107 return nil
@@ -3,6 +3,7 @@ package app
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "io"
6 7
7 "github.com/bnema/zerowrap"8 "github.com/bnema/zerowrap"
8 9
@@ -29,13 +30,24 @@ type Kernel struct {
29 30
30 // NewKernel initializes local services without starting server listeners.31 // NewKernel initializes local services without starting server listeners.
31 func NewKernel(configPath string) (*Kernel, error) {32 func NewKernel(configPath string) (*Kernel, error) {
33+ return newKernel(configPath, initLogger)
34+}
35+
36+// NewKernelQuiet initializes local services without emitting console logs.
37+func NewKernelQuiet(configPath string) (*Kernel, error) {
38+ return newKernel(configPath, quietInitLogger)
39+}
40+
41+type kernelLoggerInit func(cfg Config) (zerowrap.Logger, func(), error)
42+
43+func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
32 ctx := context.Background()44 ctx := context.Background()
33 v, cfg, err := initConfig(configPath)45 v, cfg, err := initConfig(configPath)
34 if err != nil {46 if err != nil {
35 return nil, err47 return nil, err
36 }48 }
37 49
38- log, cleanup, err := initLogger(cfg)50+ log, cleanup, err := initLog(cfg)
39 if err != nil {51 if err != nil {
40 return nil, err52 return nil, err
41 }53 }
@@ -86,6 +98,10 @@ func NewKernel(configPath string) (*Kernel, error) {
86 }, nil98 }, nil
87 }99 }
88 100
101+func quietInitLogger(Config) (zerowrap.Logger, func(), error) {
102+ return zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard}), func() {}, nil
103+}
104+
89 func (k *Kernel) Close() error {105 func (k *Kernel) Close() error {
90 if k == nil || k.cleanup == nil {106 if k == nil || k.cleanup == nil {
91 return nil107 return nil

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