detached b477285

Merge pull request #100 from bnema/cli-completeness

feat: CLI completeness — config show, routes show, networks list, images tags
commit
b477285c81cf919a419916474624fa64a8cb96bf
author
bnema · · 2026-03-14 04:07 UTC
committer
GitHub · · 2026-03-14 04:07 UTC
merge of
c016c64 + d97b732 · diff against the first parent
signature
unsigned
20 files · +883 -18
A AGENTS.md +158 -0
@@ -0,0 +1,158 @@
1+# AGENTS.md — Gordon
2+
3+Guidelines for AI coding agents operating in this repository.
4+
5+## Project
6+
7+Gordon is a self-hosted container deployment platform: Docker registry + reverse proxy + auto-deploy.
8+Module: `github.com/bnema/gordon`. Go 1.25+. Single binary, no external database.
9+
10+## Build & Test Commands
11+
12+```bash
13+# Build
14+go build ./...
15+
16+# Test — full suite (~1800 tests, 48 packages)
17+go test ./...
18+
19+# Test — single package
20+go test ./internal/usecase/container/...
21+
22+# Test — single test function
23+go test ./internal/usecase/container/... -run TestService_ListNetworks -v
24+
25+# Test — by layer
26+go test ./internal/adapters/... # adapters only
27+go test ./internal/usecase/... # use cases only
28+
29+# Lint (MUST pass before any commit)
30+golangci-lint run ./...
31+
32+# Generate mocks (after changing boundaries/in or boundaries/out interfaces)
33+mockery
34+```
35+
36+Lint config is in `.golangci.yml` (v2): errcheck, gocyclo (max 15), gosec, govet, staticcheck.
37+Errcheck is relaxed for `Close`, `Write`, `Publish`, `Stop`, `RemoveContainer`, and zerowrap log methods.
38+Test files are exempt from gocyclo, errcheck, and gosec.
39+
40+## Architecture — Hexagonal
41+
42+```
43+internal/
44+ domain/ # Entities, value objects, sentinel errors, labels
45+ boundaries/ # Port interfaces (in = driving, out = driven)
46+ in/ # Service interfaces (ConfigService, ContainerService, etc.)
47+ out/ # Infrastructure interfaces (ContainerRuntime, BlobStorage, etc.)
48+ usecase/ # Business logic (container/, config/, registry/, proxy/, etc.)
49+ adapters/ # Implementations
50+ in/ # Driving adapters
51+ http/admin/ # Admin HTTP API (handler.go)
52+ cli/ # Cobra CLI commands
53+ remote/ # HTTP client for remote Gordon instances
54+ ui/ # Terminal UI components (tables, spinners, styles)
55+ out/ # Driven adapters (docker/, filesystem/, sqlite/)
56+ dto/ # Data transfer objects (request/response structs)
57+ app/ # Application bootstrap (Kernel wiring)
58+```
59+
60+Dependencies flow inward: adapters → boundaries → domain. Never import adapters from domain/usecase.
61+
62+## Code Style
63+
64+### Imports
65+
66+Three groups separated by blank lines: stdlib, external, internal.
67+
68+```go
69+import (
70+ "context"
71+ "fmt"
72+
73+ "github.com/spf13/cobra"
74+
75+ "github.com/bnema/gordon/internal/domain"
76+)
77+```
78+
79+### Error Handling
80+
81+- Sentinel errors in `internal/domain/errors.go` — use `errors.New`, check with `errors.Is`.
82+- Wrap errors with context: `fmt.Errorf("failed to list routes: %w", err)`.
83+- Usecase/service methods return `error`. Handlers translate to HTTP status codes.
84+- CLI commands return `error` from `RunE` — cobra prints it.
85+
86+### Logging
87+
88+Use `zerowrap` (structured zerolog wrapper) in usecase and adapter layers:
89+
90+```go
91+ctx = zerowrap.CtxWithFields(ctx, map[string]any{
92+ zerowrap.FieldLayer: "usecase",
93+ zerowrap.FieldUseCase: "AddRoute",
94+ "domain": route.Domain,
95+})
96+log := zerowrap.FromCtx(ctx)
97+log.Info().Str("image", route.Image).Msg("route added")
98+```
99+
100+CLI commands do NOT use zerowrap — they use `cliWriteLine`/`cliWritef` for output.
101+
102+### CLI Commands (Cobra)
103+
104+- Each command lives in its own file under `internal/adapters/in/cli/`.
105+- Use `cmd.OutOrStdout()` for output (testable), never raw `fmt.Println`.
106+- Use helpers: `cliWriteLine(out, msg)`, `cliWritef(out, fmt, args...)`, `cliRenderTitle`, `cliRenderMeta`, `cliRenderMuted`, `cliRenderSuccess`, `cliRenderWarning`.
107+- All list/show commands support `--json` via `writeJSON(out, v)`.
108+- Use `cmd.Context()` for context (not `context.Background()`).
109+- Commands register in `root.go` under `groupServer`, `groupManage`, or `groupClient`.
110+- Local/remote dispatch via `resolveControlPlane(configPath)` or `GetRemoteClient()`.
111+
112+### ControlPlane Pattern
113+
114+`ControlPlane` interface (`controlplane.go`) abstracts local vs remote operations.
115+- `controlplane_remote.go` — delegates to `remote.Client` HTTP methods.
116+- `controlplane_local.go` — calls service interfaces directly.
117+- Test fakes in `push_test.go` — update when adding interface methods.
118+
119+### HTTP Admin Handlers
120+
121+- Single `Handler` struct in `handler.go` with `matchRoute` dispatcher.
122+- Permission checks: `HasAccess(ctx, resource, action)` at the top of each handler.
123+- Response via `h.sendJSON(w, status, payload)` or `h.sendError(w, status, msg)`.
124+- DTOs in `internal/adapters/dto/` with JSON tags.
125+
126+### Mocks
127+
128+Generated by mockery (config in `.mockery.yaml`). Output: `internal/boundaries/in/mocks/`.
129+Only boundary interfaces are mocked. Run `mockery` after modifying any interface in `boundaries/`.
130+
131+### Testing
132+
133+- Framework: `testing` + `testify` (assert/require).
134+- Test files: `*_test.go` in the same package.
135+- Use `httptest.NewServer` for HTTP handler tests.
136+- Use mockery-generated mocks for boundary interfaces.
137+- Hand-rolled fakes for CLI `ControlPlane` in test files.
138+
139+### Naming
140+
141+- Files: `snake_case.go`.
142+- Types: `PascalCase`. Interfaces describe capability (`ConfigService`, `ContainerRuntime`).
143+- Functions: `PascalCase` exported, `camelCase` unexported.
144+- CLI constructors: `newXxxCmd()` returns `*cobra.Command`.
145+- Runner functions: `runXxx(ctx, ..., out, jsonOut)`.
146+- Domain labels: constants in `domain/labels.go` prefixed with `Label`.
147+
148+### Documentation
149+
150+- CLI docs in `docs/cli/`. Every command page has `## Related` links at the bottom.
151+- Docs follow the pattern: header, subcommands table, usage examples, flags table, JSON output example.
152+
153+## Key Rules
154+
155+1. **Run `golangci-lint run ./...` before committing** — CI enforces it.
156+2. **gocyclo max 15** — extract helpers to reduce complexity.
157+3. **errcheck enabled** — always handle all error returns gracefully by wrapping with context (fmt.Errorf) and logging critical errors with zerowrap.
158+
@@ -0,0 +1,158 @@
1+# AGENTS.md — Gordon
2+
3+Guidelines for AI coding agents operating in this repository.
4+
5+## Project
6+
7+Gordon is a self-hosted container deployment platform: Docker registry + reverse proxy + auto-deploy.
8+Module: `github.com/bnema/gordon`. Go 1.25+. Single binary, no external database.
9+
10+## Build & Test Commands
11+
12+```bash
13+# Build
14+go build ./...
15+
16+# Test — full suite (~1800 tests, 48 packages)
17+go test ./...
18+
19+# Test — single package
20+go test ./internal/usecase/container/...
21+
22+# Test — single test function
23+go test ./internal/usecase/container/... -run TestService_ListNetworks -v
24+
25+# Test — by layer
26+go test ./internal/adapters/... # adapters only
27+go test ./internal/usecase/... # use cases only
28+
29+# Lint (MUST pass before any commit)
30+golangci-lint run ./...
31+
32+# Generate mocks (after changing boundaries/in or boundaries/out interfaces)
33+mockery
34+```
35+
36+Lint config is in `.golangci.yml` (v2): errcheck, gocyclo (max 15), gosec, govet, staticcheck.
37+Errcheck is relaxed for `Close`, `Write`, `Publish`, `Stop`, `RemoveContainer`, and zerowrap log methods.
38+Test files are exempt from gocyclo, errcheck, and gosec.
39+
40+## Architecture — Hexagonal
41+
42+```
43+internal/
44+ domain/ # Entities, value objects, sentinel errors, labels
45+ boundaries/ # Port interfaces (in = driving, out = driven)
46+ in/ # Service interfaces (ConfigService, ContainerService, etc.)
47+ out/ # Infrastructure interfaces (ContainerRuntime, BlobStorage, etc.)
48+ usecase/ # Business logic (container/, config/, registry/, proxy/, etc.)
49+ adapters/ # Implementations
50+ in/ # Driving adapters
51+ http/admin/ # Admin HTTP API (handler.go)
52+ cli/ # Cobra CLI commands
53+ remote/ # HTTP client for remote Gordon instances
54+ ui/ # Terminal UI components (tables, spinners, styles)
55+ out/ # Driven adapters (docker/, filesystem/, sqlite/)
56+ dto/ # Data transfer objects (request/response structs)
57+ app/ # Application bootstrap (Kernel wiring)
58+```
59+
60+Dependencies flow inward: adapters → boundaries → domain. Never import adapters from domain/usecase.
61+
62+## Code Style
63+
64+### Imports
65+
66+Three groups separated by blank lines: stdlib, external, internal.
67+
68+```go
69+import (
70+ "context"
71+ "fmt"
72+
73+ "github.com/spf13/cobra"
74+
75+ "github.com/bnema/gordon/internal/domain"
76+)
77+```
78+
79+### Error Handling
80+
81+- Sentinel errors in `internal/domain/errors.go` — use `errors.New`, check with `errors.Is`.
82+- Wrap errors with context: `fmt.Errorf("failed to list routes: %w", err)`.
83+- Usecase/service methods return `error`. Handlers translate to HTTP status codes.
84+- CLI commands return `error` from `RunE` — cobra prints it.
85+
86+### Logging
87+
88+Use `zerowrap` (structured zerolog wrapper) in usecase and adapter layers:
89+
90+```go
91+ctx = zerowrap.CtxWithFields(ctx, map[string]any{
92+ zerowrap.FieldLayer: "usecase",
93+ zerowrap.FieldUseCase: "AddRoute",
94+ "domain": route.Domain,
95+})
96+log := zerowrap.FromCtx(ctx)
97+log.Info().Str("image", route.Image).Msg("route added")
98+```
99+
100+CLI commands do NOT use zerowrap — they use `cliWriteLine`/`cliWritef` for output.
101+
102+### CLI Commands (Cobra)
103+
104+- Each command lives in its own file under `internal/adapters/in/cli/`.
105+- Use `cmd.OutOrStdout()` for output (testable), never raw `fmt.Println`.
106+- Use helpers: `cliWriteLine(out, msg)`, `cliWritef(out, fmt, args...)`, `cliRenderTitle`, `cliRenderMeta`, `cliRenderMuted`, `cliRenderSuccess`, `cliRenderWarning`.
107+- All list/show commands support `--json` via `writeJSON(out, v)`.
108+- Use `cmd.Context()` for context (not `context.Background()`).
109+- Commands register in `root.go` under `groupServer`, `groupManage`, or `groupClient`.
110+- Local/remote dispatch via `resolveControlPlane(configPath)` or `GetRemoteClient()`.
111+
112+### ControlPlane Pattern
113+
114+`ControlPlane` interface (`controlplane.go`) abstracts local vs remote operations.
115+- `controlplane_remote.go` — delegates to `remote.Client` HTTP methods.
116+- `controlplane_local.go` — calls service interfaces directly.
117+- Test fakes in `push_test.go` — update when adding interface methods.
118+
119+### HTTP Admin Handlers
120+
121+- Single `Handler` struct in `handler.go` with `matchRoute` dispatcher.
122+- Permission checks: `HasAccess(ctx, resource, action)` at the top of each handler.
123+- Response via `h.sendJSON(w, status, payload)` or `h.sendError(w, status, msg)`.
124+- DTOs in `internal/adapters/dto/` with JSON tags.
125+
126+### Mocks
127+
128+Generated by mockery (config in `.mockery.yaml`). Output: `internal/boundaries/in/mocks/`.
129+Only boundary interfaces are mocked. Run `mockery` after modifying any interface in `boundaries/`.
130+
131+### Testing
132+
133+- Framework: `testing` + `testify` (assert/require).
134+- Test files: `*_test.go` in the same package.
135+- Use `httptest.NewServer` for HTTP handler tests.
136+- Use mockery-generated mocks for boundary interfaces.
137+- Hand-rolled fakes for CLI `ControlPlane` in test files.
138+
139+### Naming
140+
141+- Files: `snake_case.go`.
142+- Types: `PascalCase`. Interfaces describe capability (`ConfigService`, `ContainerRuntime`).
143+- Functions: `PascalCase` exported, `camelCase` unexported.
144+- CLI constructors: `newXxxCmd()` returns `*cobra.Command`.
145+- Runner functions: `runXxx(ctx, ..., out, jsonOut)`.
146+- Domain labels: constants in `domain/labels.go` prefixed with `Label`.
147+
148+### Documentation
149+
150+- CLI docs in `docs/cli/`. Every command page has `## Related` links at the bottom.
151+- Docs follow the pattern: header, subcommands table, usage examples, flags table, JSON output example.
152+
153+## Key Rules
154+
155+1. **Run `golangci-lint run ./...` before committing** — CI enforces it.
156+2. **gocyclo max 15** — extract helpers to reduce complexity.
157+3. **errcheck enabled** — always handle all error returns gracefully by wrapping with context (fmt.Errorf) and logging critical errors with zerowrap.
158+
A docs/cli/config.md +65 -0
@@ -0,0 +1,65 @@
1+# Config Commands
2+
3+Inspect Gordon server configuration.
4+
5+Remote targeting uses client config or an active remote by default.
6+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7+
8+## gordon config
9+
10+### Subcommands
11+
12+| Subcommand | Description |
13+|------------|-------------|
14+| `show` | Show server configuration |
15+
16+---
17+
18+## gordon config show
19+
20+Display the full Gordon server configuration including server settings,
21+auto-route, network isolation, routes, and external routes.
22+
23+```bash
24+gordon config show
25+gordon config show --json
26+gordon config show --remote https://gordon.mydomain.com --token $TOKEN
27+```
28+
29+### Flags
30+
31+| Flag | Description |
32+|------|-------------|
33+| `--json` | Output as JSON |
34+
35+### JSON Output
36+
37+```json
38+{
39+ "server": {
40+ "port": 1111,
41+ "registry_port": 5000,
42+ "registry_domain": "reg.example.com",
43+ "data_dir": "/var/lib/gordon"
44+ },
45+ "auto_route": {
46+ "enabled": true
47+ },
48+ "network_isolation": {
49+ "enabled": true,
50+ "prefix": "gordon_"
51+ },
52+ "routes": [
53+ {"domain": "app.example.com", "image": "myapp:latest"}
54+ ],
55+ "external_routes": {
56+ "reg.example.com": "localhost:5000"
57+ }
58+}
59+```
60+
61+## Related
62+
63+- [CLI Overview](./index.md)
64+- [Status Command](./status.md)
65+- [Routes Command](./routes.md)
@@ -0,0 +1,65 @@
1+# Config Commands
2+
3+Inspect Gordon server configuration.
4+
5+Remote targeting uses client config or an active remote by default.
6+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7+
8+## gordon config
9+
10+### Subcommands
11+
12+| Subcommand | Description |
13+|------------|-------------|
14+| `show` | Show server configuration |
15+
16+---
17+
18+## gordon config show
19+
20+Display the full Gordon server configuration including server settings,
21+auto-route, network isolation, routes, and external routes.
22+
23+```bash
24+gordon config show
25+gordon config show --json
26+gordon config show --remote https://gordon.mydomain.com --token $TOKEN
27+```
28+
29+### Flags
30+
31+| Flag | Description |
32+|------|-------------|
33+| `--json` | Output as JSON |
34+
35+### JSON Output
36+
37+```json
38+{
39+ "server": {
40+ "port": 1111,
41+ "registry_port": 5000,
42+ "registry_domain": "reg.example.com",
43+ "data_dir": "/var/lib/gordon"
44+ },
45+ "auto_route": {
46+ "enabled": true
47+ },
48+ "network_isolation": {
49+ "enabled": true,
50+ "prefix": "gordon_"
51+ },
52+ "routes": [
53+ {"domain": "app.example.com", "image": "myapp:latest"}
54+ ],
55+ "external_routes": {
56+ "reg.example.com": "localhost:5000"
57+ }
58+}
59+```
60+
61+## Related
62+
63+- [CLI Overview](./index.md)
64+- [Status Command](./status.md)
65+- [Routes Command](./routes.md)
M docs/cli/images.md +35 -2
@@ -12,6 +12,7 @@ Subcommands:
1212
1313 - `list` - List runtime images and registry tags.
1414 - `prune` - Prune dangling runtime images and old registry tags.
15+- `tags` - List registry tags for a specific repository.
1516
1617 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).
1718
@@ -81,6 +82,34 @@ Flags:
8182 - `--keep-releases` counts non-`latest` tags, ordered by most recent first.
8283 - `--keep-releases=0` with registry scope enabled still runs registry cleanup but keeps no non-`latest` tags.
8384
85+## gordon images tags
86+
87+```bash
88+gordon images tags <repository>
89+gordon images tags <repository> --json
90+```
91+
92+Lists all tags in the Gordon registry for the specified repository.
93+
94+Flags:
95+
96+| Flag | Description |
97+|------|-------------|
98+| `--json` | Output repository and tags as JSON |
99+
100+### JSON Output
101+
102+```bash
103+gordon images tags myapp --json
104+```
105+
106+```json
107+{
108+ "repository": "myapp",
109+ "tags": ["latest", "v1.2.0", "v1.1.0"]
110+}
111+```
112+
84113 ## Examples
85114
86115 ```bash
@@ -90,6 +119,9 @@ gordon images list
90119 # Prune everything with defaults (dangling + registry, keep latest + 3)
91120 gordon images prune
92121
122+# List tags for a repository
123+gordon images tags myapp
124+
93125 # Prune dangling runtime images only
94126 gordon images prune --dangling
95127
@@ -105,8 +137,9 @@ gordon images prune --no-confirm
105137
106138 ## Required Permissions
107139
108-- `list` requires `admin:status:read`.
109-- `prune` requires `admin:config:write`.
140+- The `list` subcommand requires `admin:status:read`.
141+- The `prune` subcommand requires `admin:config:write`.
142+- The `tags` subcommand requires `admin:status:read`.
110143
111144 ## Related
112145
@@ -12,6 +12,7 @@ Subcommands:
12 12
13 - `list` - List runtime images and registry tags.13 - `list` - List runtime images and registry tags.
14 - `prune` - Prune dangling runtime images and old registry tags.14 - `prune` - Prune dangling runtime images and old registry tags.
15+- `tags` - List registry tags for a specific repository.
15 16
16 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).17 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).
17 18
@@ -81,6 +82,34 @@ Flags:
81 - `--keep-releases` counts non-`latest` tags, ordered by most recent first.82 - `--keep-releases` counts non-`latest` tags, ordered by most recent first.
82 - `--keep-releases=0` with registry scope enabled still runs registry cleanup but keeps no non-`latest` tags.83 - `--keep-releases=0` with registry scope enabled still runs registry cleanup but keeps no non-`latest` tags.
83 84
85+## gordon images tags
86+
87+```bash
88+gordon images tags <repository>
89+gordon images tags <repository> --json
90+```
91+
92+Lists all tags in the Gordon registry for the specified repository.
93+
94+Flags:
95+
96+| Flag | Description |
97+|------|-------------|
98+| `--json` | Output repository and tags as JSON |
99+
100+### JSON Output
101+
102+```bash
103+gordon images tags myapp --json
104+```
105+
106+```json
107+{
108+ "repository": "myapp",
109+ "tags": ["latest", "v1.2.0", "v1.1.0"]
110+}
111+```
112+
84 ## Examples113 ## Examples
85 114
86 ```bash115 ```bash
@@ -90,6 +119,9 @@ gordon images list
90 # Prune everything with defaults (dangling + registry, keep latest + 3)119 # Prune everything with defaults (dangling + registry, keep latest + 3)
91 gordon images prune120 gordon images prune
92 121
122+# List tags for a repository
123+gordon images tags myapp
124+
93 # Prune dangling runtime images only125 # Prune dangling runtime images only
94 gordon images prune --dangling126 gordon images prune --dangling
95 127
@@ -105,8 +137,9 @@ gordon images prune --no-confirm
105 137
106 ## Required Permissions138 ## Required Permissions
107 139
108-- `list` requires `admin:status:read`.140+- The `list` subcommand requires `admin:status:read`.
109-- `prune` requires `admin:config:write`.141+- The `prune` subcommand requires `admin:config:write`.
142+- The `tags` subcommand requires `admin:status:read`.
110 143
111 ## Related144 ## Related
112 145
M docs/cli/index.md +2 -0
@@ -36,6 +36,8 @@ Management commands run locally through in-process services by default. Add `--r
3636 | `gordon status` | Show Gordon server status | [status](./status.md) |
3737 | `gordon backups` | Manage database backups | [backup](./backup.md) |
3838 | `gordon images` | List and prune images | [images](./images.md) |
39+| `gordon config show` | Show server configuration | [config](./config.md) |
40+| `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) |
3941
4042 ## Client Commands
4143
@@ -36,6 +36,8 @@ Management commands run locally through in-process services by default. Add `--r
36 | `gordon status` | Show Gordon server status | [status](./status.md) |36 | `gordon status` | Show Gordon server status | [status](./status.md) |
37 | `gordon backups` | Manage database backups | [backup](./backup.md) |37 | `gordon backups` | Manage database backups | [backup](./backup.md) |
38 | `gordon images` | List and prune images | [images](./images.md) |38 | `gordon images` | List and prune images | [images](./images.md) |
39+| `gordon config show` | Show server configuration | [config](./config.md) |
40+| `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) |
39 41
40 ## Client Commands42 ## Client Commands
41 43
A docs/cli/networks.md +51 -0
@@ -0,0 +1,51 @@
1+# Networks Commands
2+
3+Inspect Gordon-managed Docker networks.
4+
5+Remote targeting uses client config or an active remote by default.
6+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7+
8+## gordon networks
9+
10+### Subcommands
11+
12+| Subcommand | Description |
13+|------------|-------------|
14+| `list` | List Gordon-managed Docker networks |
15+
16+---
17+
18+## gordon networks list
19+
20+Display Docker networks managed by Gordon, including which containers
21+are connected to each network.
22+
23+```bash
24+gordon networks list
25+gordon networks list --json
26+gordon networks list --remote https://gordon.mydomain.com --token $TOKEN
27+```
28+
29+### Flags
30+
31+| Flag | Description |
32+|------|-------------|
33+| `--json` | Output as JSON |
34+
35+### JSON Output
36+
37+```json
38+[
39+ {
40+ "name": "gordon_myapp",
41+ "driver": "bridge",
42+ "containers": ["container1", "container2"]
43+ }
44+]
45+```
46+
47+## Related
48+
49+- [CLI Overview](./index.md)
50+- [Routes Command](./routes.md)
51+- [Attachments Command](./attachments.md)
@@ -0,0 +1,51 @@
1+# Networks Commands
2+
3+Inspect Gordon-managed Docker networks.
4+
5+Remote targeting uses client config or an active remote by default.
6+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7+
8+## gordon networks
9+
10+### Subcommands
11+
12+| Subcommand | Description |
13+|------------|-------------|
14+| `list` | List Gordon-managed Docker networks |
15+
16+---
17+
18+## gordon networks list
19+
20+Display Docker networks managed by Gordon, including which containers
21+are connected to each network.
22+
23+```bash
24+gordon networks list
25+gordon networks list --json
26+gordon networks list --remote https://gordon.mydomain.com --token $TOKEN
27+```
28+
29+### Flags
30+
31+| Flag | Description |
32+|------|-------------|
33+| `--json` | Output as JSON |
34+
35+### JSON Output
36+
37+```json
38+[
39+ {
40+ "name": "gordon_myapp",
41+ "driver": "bridge",
42+ "containers": ["container1", "container2"]
43+ }
44+]
45+```
46+
47+## Related
48+
49+- [CLI Overview](./index.md)
50+- [Routes Command](./routes.md)
51+- [Attachments Command](./attachments.md)
M docs/cli/routes.md +56 -0
@@ -12,6 +12,7 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
1212 | Subcommand | Description |
1313 |------------|-------------|
1414 | `list` | List all routes |
15+| `show` | Show details for a single route |
1516 | `add` | Create or update a route |
1617 | `remove` | Remove a route |
1718 | `deploy` | Deploy a specific route |
@@ -116,6 +117,61 @@ gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomai
116117
117118 ---
118119
120+## gordon routes show
121+
122+Show detailed information about a single route.
123+
124+```bash
125+gordon routes show <domain>
126+gordon routes show myapp.example.com
127+gordon routes show myapp.example.com --json
128+```
129+
130+### Arguments
131+
132+| Argument | Description |
133+|----------|-------------|
134+| `<domain>` | The domain name of the route to inspect |
135+
136+### Options
137+
138+| Option | Description |
139+|--------|-------------|
140+| `--json` | Output route details as JSON |
141+| `--remote` | Remote Gordon URL |
142+| `--token` | Authentication token for remote |
143+
144+### Description
145+
146+Displays the configured image for the route plus any available container and HTTP health information.
147+In local-only mode, health data may be unavailable.
148+
149+### Examples
150+
151+```bash
152+# Local
153+gordon routes show myapp.example.com
154+
155+# JSON
156+gordon routes show myapp.example.com --json
157+
158+# Remote (override)
159+gordon routes show myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
160+```
161+
162+### JSON Output
163+
164+```json
165+{
166+ "domain": "myapp.example.com",
167+ "image": "myapp:latest",
168+ "container_status": "running",
169+ "http_status": 200
170+}
171+```
172+
173+---
174+
119175 ## gordon routes remove
120176
121177 Remove a route.
@@ -12,6 +12,7 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
12 | Subcommand | Description |12 | Subcommand | Description |
13 |------------|-------------|13 |------------|-------------|
14 | `list` | List all routes |14 | `list` | List all routes |
15+| `show` | Show details for a single route |
15 | `add` | Create or update a route |16 | `add` | Create or update a route |
16 | `remove` | Remove a route |17 | `remove` | Remove a route |
17 | `deploy` | Deploy a specific route |18 | `deploy` | Deploy a specific route |
@@ -116,6 +117,61 @@ gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomai
116 117
117 ---118 ---
118 119
120+## gordon routes show
121+
122+Show detailed information about a single route.
123+
124+```bash
125+gordon routes show <domain>
126+gordon routes show myapp.example.com
127+gordon routes show myapp.example.com --json
128+```
129+
130+### Arguments
131+
132+| Argument | Description |
133+|----------|-------------|
134+| `<domain>` | The domain name of the route to inspect |
135+
136+### Options
137+
138+| Option | Description |
139+|--------|-------------|
140+| `--json` | Output route details as JSON |
141+| `--remote` | Remote Gordon URL |
142+| `--token` | Authentication token for remote |
143+
144+### Description
145+
146+Displays the configured image for the route plus any available container and HTTP health information.
147+In local-only mode, health data may be unavailable.
148+
149+### Examples
150+
151+```bash
152+# Local
153+gordon routes show myapp.example.com
154+
155+# JSON
156+gordon routes show myapp.example.com --json
157+
158+# Remote (override)
159+gordon routes show myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
160+```
161+
162+### JSON Output
163+
164+```json
165+{
166+ "domain": "myapp.example.com",
167+ "image": "myapp:latest",
168+ "container_status": "running",
169+ "http_status": 200
170+}
171+```
172+
173+---
174+
119 ## gordon routes remove175 ## gordon routes remove
120 176
121 Remove a route.177 Remove a route.
M internal/adapters/dto/admin_networks.go +2 -2
@@ -2,11 +2,11 @@ package dto
22
33 // Network represents a network in API responses.
44 type Network struct {
5- ID string `json:"id"`
5+ ID string `json:"-"`
66 Name string `json:"name"`
77 Driver string `json:"driver"`
88 Containers []string `json:"containers"`
9- Labels map[string]string `json:"labels"`
9+ Labels map[string]string `json:"-"`
1010 }
1111
1212 // NetworksResponse represents a list of networks.
@@ -2,11 +2,11 @@ package dto
2 2
3 // Network represents a network in API responses.3 // Network represents a network in API responses.
4 type Network struct {4 type Network struct {
5- ID string `json:"id"`5+ ID string `json:"-"`
6 Name string `json:"name"`6 Name string `json:"name"`
7 Driver string `json:"driver"`7 Driver string `json:"driver"`
8 Containers []string `json:"containers"`8 Containers []string `json:"containers"`
9- Labels map[string]string `json:"labels"`9+ Labels map[string]string `json:"-"`
10 }10 }
11 11
12 // NetworksResponse represents a list of networks.12 // NetworksResponse represents a list of networks.
A internal/adapters/in/cli/config.go +176 -0
@@ -0,0 +1,176 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "sort"
8+
9+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
10+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
11+ "github.com/spf13/cobra"
12+)
13+
14+func newConfigCmd() *cobra.Command {
15+ cmd := &cobra.Command{
16+ Use: "config",
17+ Short: "Inspect Gordon configuration",
18+ }
19+
20+ cmd.AddCommand(newConfigShowCmd())
21+
22+ return cmd
23+}
24+
25+func newConfigShowCmd() *cobra.Command {
26+ var jsonOut bool
27+
28+ cmd := &cobra.Command{
29+ Use: "show",
30+ Short: "Show server configuration",
31+ Long: `Display the full Gordon server configuration including server settings,
32+auto-route, network isolation, routes, and external routes.
33+
34+Examples:
35+ gordon config show
36+ gordon config show --json
37+ gordon config show --remote https://gordon.mydomain.com --token $TOKEN`,
38+ RunE: func(cmd *cobra.Command, _ []string) error {
39+ ctx := cmd.Context()
40+ handle, err := resolveControlPlane(configPath)
41+ if err != nil {
42+ return err
43+ }
44+ defer handle.close()
45+ return runConfigShow(ctx, handle.plane, cmd.OutOrStdout(), jsonOut)
46+ },
47+ }
48+
49+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
50+
51+ return cmd
52+}
53+
54+func runConfigShow(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut bool) error {
55+ config, err := cp.GetConfig(ctx)
56+ if err != nil {
57+ return fmt.Errorf("failed to get config: %w", err)
58+ }
59+
60+ if config == nil {
61+ return fmt.Errorf("no config available")
62+ }
63+
64+ if jsonOut {
65+ return writeJSON(out, config)
66+ }
67+
68+ return renderConfigTable(out, config)
69+}
70+
71+func renderConfigTable(out io.Writer, config *remote.Config) error {
72+ if err := renderConfigSummary(out, config); err != nil {
73+ return err
74+ }
75+ if err := renderConfigRoutes(out, config); err != nil {
76+ return err
77+ }
78+ return renderConfigExternalRoutes(out, config)
79+}
80+
81+func renderConfigSummary(out io.Writer, config *remote.Config) error {
82+ if err := cliWriteLine(out, cliRenderTitle("Gordon Configuration")); err != nil {
83+ return err
84+ }
85+ if err := cliWriteLine(out, ""); err != nil {
86+ return err
87+ }
88+ if err := cliWriteLine(out, cliRenderMeta("Server Port:", fmt.Sprintf("%d", config.Server.Port))); err != nil {
89+ return err
90+ }
91+ if err := cliWriteLine(out, cliRenderMeta("Registry Port:", fmt.Sprintf("%d", config.Server.RegistryPort))); err != nil {
92+ return err
93+ }
94+ if err := cliWriteLine(out, cliRenderMeta("Registry Domain:", config.Server.RegistryDomain)); err != nil {
95+ return err
96+ }
97+ if err := cliWriteLine(out, cliRenderMeta("Data Directory:", config.Server.DataDir)); err != nil {
98+ return err
99+ }
100+ if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil {
101+ return err
102+ }
103+ if err := cliWriteLine(out, cliRenderMeta("Network Isolation:", fmt.Sprintf("%v", config.NetworkIsolation.Enabled))); err != nil {
104+ return err
105+ }
106+ if config.NetworkIsolation.Prefix != "" {
107+ if err := cliWriteLine(out, cliRenderMeta("Network Prefix:", config.NetworkIsolation.Prefix)); err != nil {
108+ return err
109+ }
110+ }
111+ return nil
112+}
113+
114+func renderConfigRoutes(out io.Writer, config *remote.Config) error {
115+ if err := cliWriteLine(out, ""); err != nil {
116+ return err
117+ }
118+ if len(config.Routes) == 0 {
119+ if err := cliWriteLine(out, cliRenderMuted("No routes configured")); err != nil {
120+ return err
121+ }
122+ } else {
123+ if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
124+ return err
125+ }
126+ rows := make([][]string, 0, len(config.Routes))
127+ for _, route := range config.Routes {
128+ rows = append(rows, []string{route.Domain, route.Image})
129+ }
130+ table := components.NewTable(
131+ components.WithColumns([]components.TableColumn{
132+ {Title: "Domain", Width: 30},
133+ {Title: "Image", Width: 45},
134+ }),
135+ components.WithRows(rows),
136+ )
137+ if err := cliWriteLine(out, table.View()); err != nil {
138+ return err
139+ }
140+ }
141+ return nil
142+}
143+
144+func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error {
145+ if err := cliWriteLine(out, ""); err != nil {
146+ return err
147+ }
148+ if len(config.ExternalRoutes) == 0 {
149+ if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil {
150+ return err
151+ }
152+ return cliWriteLine(out, cliRenderMuted("No external routes configured"))
153+ }
154+
155+ if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil {
156+ return err
157+ }
158+ domains := make([]string, 0, len(config.ExternalRoutes))
159+ for d := range config.ExternalRoutes {
160+ domains = append(domains, d)
161+ }
162+ sort.Strings(domains)
163+
164+ rows := make([][]string, 0, len(domains))
165+ for _, d := range domains {
166+ rows = append(rows, []string{d, config.ExternalRoutes[d]})
167+ }
168+ table := components.NewTable(
169+ components.WithColumns([]components.TableColumn{
170+ {Title: "Domain", Width: 30},
171+ {Title: "Target", Width: 45},
172+ }),
173+ components.WithRows(rows),
174+ )
175+ return cliWriteLine(out, table.View())
176+}
@@ -0,0 +1,176 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "sort"
8+
9+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
10+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
11+ "github.com/spf13/cobra"
12+)
13+
14+func newConfigCmd() *cobra.Command {
15+ cmd := &cobra.Command{
16+ Use: "config",
17+ Short: "Inspect Gordon configuration",
18+ }
19+
20+ cmd.AddCommand(newConfigShowCmd())
21+
22+ return cmd
23+}
24+
25+func newConfigShowCmd() *cobra.Command {
26+ var jsonOut bool
27+
28+ cmd := &cobra.Command{
29+ Use: "show",
30+ Short: "Show server configuration",
31+ Long: `Display the full Gordon server configuration including server settings,
32+auto-route, network isolation, routes, and external routes.
33+
34+Examples:
35+ gordon config show
36+ gordon config show --json
37+ gordon config show --remote https://gordon.mydomain.com --token $TOKEN`,
38+ RunE: func(cmd *cobra.Command, _ []string) error {
39+ ctx := cmd.Context()
40+ handle, err := resolveControlPlane(configPath)
41+ if err != nil {
42+ return err
43+ }
44+ defer handle.close()
45+ return runConfigShow(ctx, handle.plane, cmd.OutOrStdout(), jsonOut)
46+ },
47+ }
48+
49+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
50+
51+ return cmd
52+}
53+
54+func runConfigShow(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut bool) error {
55+ config, err := cp.GetConfig(ctx)
56+ if err != nil {
57+ return fmt.Errorf("failed to get config: %w", err)
58+ }
59+
60+ if config == nil {
61+ return fmt.Errorf("no config available")
62+ }
63+
64+ if jsonOut {
65+ return writeJSON(out, config)
66+ }
67+
68+ return renderConfigTable(out, config)
69+}
70+
71+func renderConfigTable(out io.Writer, config *remote.Config) error {
72+ if err := renderConfigSummary(out, config); err != nil {
73+ return err
74+ }
75+ if err := renderConfigRoutes(out, config); err != nil {
76+ return err
77+ }
78+ return renderConfigExternalRoutes(out, config)
79+}
80+
81+func renderConfigSummary(out io.Writer, config *remote.Config) error {
82+ if err := cliWriteLine(out, cliRenderTitle("Gordon Configuration")); err != nil {
83+ return err
84+ }
85+ if err := cliWriteLine(out, ""); err != nil {
86+ return err
87+ }
88+ if err := cliWriteLine(out, cliRenderMeta("Server Port:", fmt.Sprintf("%d", config.Server.Port))); err != nil {
89+ return err
90+ }
91+ if err := cliWriteLine(out, cliRenderMeta("Registry Port:", fmt.Sprintf("%d", config.Server.RegistryPort))); err != nil {
92+ return err
93+ }
94+ if err := cliWriteLine(out, cliRenderMeta("Registry Domain:", config.Server.RegistryDomain)); err != nil {
95+ return err
96+ }
97+ if err := cliWriteLine(out, cliRenderMeta("Data Directory:", config.Server.DataDir)); err != nil {
98+ return err
99+ }
100+ if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil {
101+ return err
102+ }
103+ if err := cliWriteLine(out, cliRenderMeta("Network Isolation:", fmt.Sprintf("%v", config.NetworkIsolation.Enabled))); err != nil {
104+ return err
105+ }
106+ if config.NetworkIsolation.Prefix != "" {
107+ if err := cliWriteLine(out, cliRenderMeta("Network Prefix:", config.NetworkIsolation.Prefix)); err != nil {
108+ return err
109+ }
110+ }
111+ return nil
112+}
113+
114+func renderConfigRoutes(out io.Writer, config *remote.Config) error {
115+ if err := cliWriteLine(out, ""); err != nil {
116+ return err
117+ }
118+ if len(config.Routes) == 0 {
119+ if err := cliWriteLine(out, cliRenderMuted("No routes configured")); err != nil {
120+ return err
121+ }
122+ } else {
123+ if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
124+ return err
125+ }
126+ rows := make([][]string, 0, len(config.Routes))
127+ for _, route := range config.Routes {
128+ rows = append(rows, []string{route.Domain, route.Image})
129+ }
130+ table := components.NewTable(
131+ components.WithColumns([]components.TableColumn{
132+ {Title: "Domain", Width: 30},
133+ {Title: "Image", Width: 45},
134+ }),
135+ components.WithRows(rows),
136+ )
137+ if err := cliWriteLine(out, table.View()); err != nil {
138+ return err
139+ }
140+ }
141+ return nil
142+}
143+
144+func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error {
145+ if err := cliWriteLine(out, ""); err != nil {
146+ return err
147+ }
148+ if len(config.ExternalRoutes) == 0 {
149+ if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil {
150+ return err
151+ }
152+ return cliWriteLine(out, cliRenderMuted("No external routes configured"))
153+ }
154+
155+ if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil {
156+ return err
157+ }
158+ domains := make([]string, 0, len(config.ExternalRoutes))
159+ for d := range config.ExternalRoutes {
160+ domains = append(domains, d)
161+ }
162+ sort.Strings(domains)
163+
164+ rows := make([][]string, 0, len(domains))
165+ for _, d := range domains {
166+ rows = append(rows, []string{d, config.ExternalRoutes[d]})
167+ }
168+ table := components.NewTable(
169+ components.WithColumns([]components.TableColumn{
170+ {Title: "Domain", Width: 30},
171+ {Title: "Target", Width: 45},
172+ }),
173+ components.WithRows(rows),
174+ )
175+ return cliWriteLine(out, table.View())
176+}
M internal/adapters/in/cli/controlplane.go +2 -0
@@ -35,6 +35,8 @@ type ControlPlane interface {
3535
3636 GetStatus(ctx context.Context) (*remote.Status, error)
3737 Reload(ctx context.Context) error
38+ ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
39+ GetConfig(ctx context.Context) (*remote.Config, error)
3840 DeployIntent(ctx context.Context, imageName string) error
3941 Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)
4042 Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error)
@@ -35,6 +35,8 @@ type ControlPlane interface {
35 35
36 GetStatus(ctx context.Context) (*remote.Status, error)36 GetStatus(ctx context.Context) (*remote.Status, error)
37 Reload(ctx context.Context) error37 Reload(ctx context.Context) error
38+ ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
39+ GetConfig(ctx context.Context) (*remote.Config, error)
38 DeployIntent(ctx context.Context, imageName string) error40 DeployIntent(ctx context.Context, imageName string) error
39 Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)41 Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)
40 Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error)42 Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error)
M internal/adapters/in/cli/controlplane_local.go +25 -0
@@ -321,6 +321,31 @@ func (l *localControlPlane) Reload(_ context.Context) error {
321321 return app.SendReloadSignal()
322322 }
323323
324+func (l *localControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
325+ if l.containerSvc == nil {
326+ return nil, fmt.Errorf("container service unavailable")
327+ }
328+ return l.containerSvc.ListNetworks(ctx)
329+}
330+
331+func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
332+ if l.configSvc == nil {
333+ return nil, fmt.Errorf("config service unavailable")
334+ }
335+ cfg := &remote.Config{
336+ Routes: l.configSvc.GetRoutes(ctx),
337+ ExternalRoutes: l.configSvc.GetExternalRoutes(),
338+ }
339+ cfg.Server.Port = l.configSvc.GetServerPort()
340+ cfg.Server.RegistryPort = l.configSvc.GetRegistryPort()
341+ cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain()
342+ cfg.Server.DataDir = l.configSvc.GetDataDir()
343+ cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled()
344+ cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled()
345+ cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix()
346+ return cfg, nil
347+}
348+
324349 func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error {
325350 if l.deployCoord != nil {
326351 l.deployCoord.SuppressDeployEvent(imageName)
@@ -321,6 +321,31 @@ func (l *localControlPlane) Reload(_ context.Context) error {
321 return app.SendReloadSignal()321 return app.SendReloadSignal()
322 }322 }
323 323
324+func (l *localControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
325+ if l.containerSvc == nil {
326+ return nil, fmt.Errorf("container service unavailable")
327+ }
328+ return l.containerSvc.ListNetworks(ctx)
329+}
330+
331+func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
332+ if l.configSvc == nil {
333+ return nil, fmt.Errorf("config service unavailable")
334+ }
335+ cfg := &remote.Config{
336+ Routes: l.configSvc.GetRoutes(ctx),
337+ ExternalRoutes: l.configSvc.GetExternalRoutes(),
338+ }
339+ cfg.Server.Port = l.configSvc.GetServerPort()
340+ cfg.Server.RegistryPort = l.configSvc.GetRegistryPort()
341+ cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain()
342+ cfg.Server.DataDir = l.configSvc.GetDataDir()
343+ cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled()
344+ cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled()
345+ cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix()
346+ return cfg, nil
347+}
348+
324 func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error {349 func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error {
325 if l.deployCoord != nil {350 if l.deployCoord != nil {
326 l.deployCoord.SuppressDeployEvent(imageName)351 l.deployCoord.SuppressDeployEvent(imageName)
M internal/adapters/in/cli/controlplane_remote.go +8 -0
@@ -92,6 +92,14 @@ func (r *remoteControlPlane) Reload(ctx context.Context) error {
9292 return r.client.Reload(ctx)
9393 }
9494
95+func (r *remoteControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
96+ return r.client.ListNetworks(ctx)
97+}
98+
99+func (r *remoteControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
100+ return r.client.GetConfig(ctx)
101+}
102+
95103 func (r *remoteControlPlane) DeployIntent(ctx context.Context, imageName string) error {
96104 return r.client.DeployIntent(ctx, imageName)
97105 }
@@ -92,6 +92,14 @@ func (r *remoteControlPlane) Reload(ctx context.Context) error {
92 return r.client.Reload(ctx)92 return r.client.Reload(ctx)
93 }93 }
94 94
95+func (r *remoteControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
96+ return r.client.ListNetworks(ctx)
97+}
98+
99+func (r *remoteControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
100+ return r.client.GetConfig(ctx)
101+}
102+
95 func (r *remoteControlPlane) DeployIntent(ctx context.Context, imageName string) error {103 func (r *remoteControlPlane) DeployIntent(ctx context.Context, imageName string) error {
96 return r.client.DeployIntent(ctx, imageName)104 return r.client.DeployIntent(ctx, imageName)
97 }105 }
M internal/adapters/in/cli/images.go +70 -0
@@ -76,6 +76,7 @@ These commands currently require remote mode with a configured target.`,
7676
7777 cmd.AddCommand(newImagesListCmd())
7878 cmd.AddCommand(newImagesPruneCmd())
79+ cmd.AddCommand(newImagesTagsCmd())
7980
8081 return cmd
8182 }
@@ -131,6 +132,75 @@ release tags per repository. Use --dangling or --registry to restrict scope.`,
131132 return cmd
132133 }
133134
135+func newImagesTagsCmd() *cobra.Command {
136+ var jsonOut bool
137+
138+ cmd := &cobra.Command{
139+ Use: "tags <repository>",
140+ Short: "List registry tags for a repository",
141+ Long: `Display all tags in the Gordon registry for a given repository.
142+
143+Examples:
144+ gordon images tags myapp
145+ gordon images tags myapp --json
146+ gordon images tags myapp --remote https://gordon.mydomain.com --token $TOKEN`,
147+ Args: cobra.ExactArgs(1),
148+ RunE: func(cmd *cobra.Command, args []string) error {
149+ ctx := cmd.Context()
150+ handle, err := resolveControlPlane(configPath)
151+ if err != nil {
152+ return err
153+ }
154+ defer handle.close()
155+ return runImagesTags(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
156+ },
157+ }
158+
159+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
160+
161+ return cmd
162+}
163+
164+func runImagesTags(ctx context.Context, cp ControlPlane, out io.Writer, repository string, jsonOut bool) error {
165+ tags, err := cp.ListTags(ctx, repository)
166+ if err != nil {
167+ return fmt.Errorf("failed to list tags: %w", err)
168+ }
169+
170+ if jsonOut {
171+ if tags == nil {
172+ tags = []string{}
173+ }
174+ payload := map[string]any{
175+ "repository": repository,
176+ "tags": tags,
177+ }
178+ return writeJSON(out, payload)
179+ }
180+
181+ if len(tags) == 0 {
182+ return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No tags found for %s", repository)))
183+ }
184+
185+ if err := cliWriteLine(out, cliRenderTitle(fmt.Sprintf("Tags for %s", repository))); err != nil {
186+ return err
187+ }
188+ if err := cliWriteLine(out, ""); err != nil {
189+ return err
190+ }
191+
192+ for _, tag := range tags {
193+ if err := cliWriteLine(out, cliRenderListItem(tag)); err != nil {
194+ return err
195+ }
196+ }
197+
198+ if err := cliWriteLine(out, ""); err != nil {
199+ return err
200+ }
201+ return cliWritef(out, "Total: %d tags\n", len(tags))
202+}
203+
134204 // resolvePruneScopes determines which prune subsystems are active.
135205 // No scope flags → both true. Any scope flag present → only selected scopes.
136206 func resolvePruneScopes(opts imagesPruneOptions) (pruneDangling, pruneRegistry bool) {
@@ -76,6 +76,7 @@ These commands currently require remote mode with a configured target.`,
76 76
77 cmd.AddCommand(newImagesListCmd())77 cmd.AddCommand(newImagesListCmd())
78 cmd.AddCommand(newImagesPruneCmd())78 cmd.AddCommand(newImagesPruneCmd())
79+ cmd.AddCommand(newImagesTagsCmd())
79 80
80 return cmd81 return cmd
81 }82 }
@@ -131,6 +132,75 @@ release tags per repository. Use --dangling or --registry to restrict scope.`,
131 return cmd132 return cmd
132 }133 }
133 134
135+func newImagesTagsCmd() *cobra.Command {
136+ var jsonOut bool
137+
138+ cmd := &cobra.Command{
139+ Use: "tags <repository>",
140+ Short: "List registry tags for a repository",
141+ Long: `Display all tags in the Gordon registry for a given repository.
142+
143+Examples:
144+ gordon images tags myapp
145+ gordon images tags myapp --json
146+ gordon images tags myapp --remote https://gordon.mydomain.com --token $TOKEN`,
147+ Args: cobra.ExactArgs(1),
148+ RunE: func(cmd *cobra.Command, args []string) error {
149+ ctx := cmd.Context()
150+ handle, err := resolveControlPlane(configPath)
151+ if err != nil {
152+ return err
153+ }
154+ defer handle.close()
155+ return runImagesTags(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
156+ },
157+ }
158+
159+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
160+
161+ return cmd
162+}
163+
164+func runImagesTags(ctx context.Context, cp ControlPlane, out io.Writer, repository string, jsonOut bool) error {
165+ tags, err := cp.ListTags(ctx, repository)
166+ if err != nil {
167+ return fmt.Errorf("failed to list tags: %w", err)
168+ }
169+
170+ if jsonOut {
171+ if tags == nil {
172+ tags = []string{}
173+ }
174+ payload := map[string]any{
175+ "repository": repository,
176+ "tags": tags,
177+ }
178+ return writeJSON(out, payload)
179+ }
180+
181+ if len(tags) == 0 {
182+ return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No tags found for %s", repository)))
183+ }
184+
185+ if err := cliWriteLine(out, cliRenderTitle(fmt.Sprintf("Tags for %s", repository))); err != nil {
186+ return err
187+ }
188+ if err := cliWriteLine(out, ""); err != nil {
189+ return err
190+ }
191+
192+ for _, tag := range tags {
193+ if err := cliWriteLine(out, cliRenderListItem(tag)); err != nil {
194+ return err
195+ }
196+ }
197+
198+ if err := cliWriteLine(out, ""); err != nil {
199+ return err
200+ }
201+ return cliWritef(out, "Total: %d tags\n", len(tags))
202+}
203+
134 // resolvePruneScopes determines which prune subsystems are active.204 // resolvePruneScopes determines which prune subsystems are active.
135 // No scope flags → both true. Any scope flag present → only selected scopes.205 // No scope flags → both true. Any scope flag present → only selected scopes.
136 func resolvePruneScopes(opts imagesPruneOptions) (pruneDangling, pruneRegistry bool) {206 func resolvePruneScopes(opts imagesPruneOptions) (pruneDangling, pruneRegistry bool) {
M internal/adapters/in/cli/json_parity_test.go +4 -0
@@ -20,6 +20,10 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
2020 {"auth token list", newTokenListCmd},
2121 {"remotes list", newRemotesListCmd},
2222 {"rollback list", newRollbackListCmd},
23+ {"config show", newConfigShowCmd},
24+ {"routes show", newRoutesShowCmd},
25+ {"networks list", newNetworksListCmd},
26+ {"images tags", newImagesTagsCmd},
2327 }
2428
2529 for _, tc := range commands {
@@ -20,6 +20,10 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
20 {"auth token list", newTokenListCmd},20 {"auth token list", newTokenListCmd},
21 {"remotes list", newRemotesListCmd},21 {"remotes list", newRemotesListCmd},
22 {"rollback list", newRollbackListCmd},22 {"rollback list", newRollbackListCmd},
23+ {"config show", newConfigShowCmd},
24+ {"routes show", newRoutesShowCmd},
25+ {"networks list", newNetworksListCmd},
26+ {"images tags", newImagesTagsCmd},
23 }27 }
24 28
25 for _, tc := range commands {29 for _, tc := range commands {
A internal/adapters/in/cli/networks.go +118 -0
@@ -0,0 +1,118 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "strings"
8+
9+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
10+ "github.com/bnema/gordon/internal/domain"
11+ "github.com/spf13/cobra"
12+)
13+
14+func newNetworksCmd() *cobra.Command {
15+ cmd := &cobra.Command{
16+ Use: "networks",
17+ Short: "Inspect Gordon-managed networks",
18+ }
19+
20+ cmd.AddCommand(newNetworksListCmd())
21+
22+ return cmd
23+}
24+
25+func newNetworksListCmd() *cobra.Command {
26+ var jsonOut bool
27+
28+ cmd := &cobra.Command{
29+ Use: "list",
30+ Short: "List Gordon-managed Docker networks",
31+ Long: `Display Docker networks managed by Gordon, including which containers
32+are connected to each network.
33+
34+Examples:
35+ gordon networks list
36+ gordon networks list --json
37+ gordon networks list --remote https://gordon.mydomain.com --token $TOKEN`,
38+ RunE: func(cmd *cobra.Command, _ []string) error {
39+ ctx := cmd.Context()
40+ handle, err := resolveControlPlane(configPath)
41+ if err != nil {
42+ return err
43+ }
44+ defer handle.close()
45+ return runNetworksList(ctx, handle.plane, cmd.OutOrStdout(), jsonOut)
46+ },
47+ }
48+
49+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
50+
51+ return cmd
52+}
53+
54+func runNetworksList(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut bool) error {
55+ networks, err := cp.ListNetworks(ctx)
56+ if err != nil {
57+ return fmt.Errorf("failed to list networks: %w", err)
58+ }
59+
60+ if jsonOut {
61+ result := make([]map[string]any, 0, len(networks))
62+ for _, net := range networks {
63+ if net == nil {
64+ continue
65+ }
66+ result = append(result, map[string]any{
67+ "name": net.Name,
68+ "driver": net.Driver,
69+ "containers": net.Containers,
70+ })
71+ }
72+ return writeJSON(out, result)
73+ }
74+
75+ if len(networks) == 0 {
76+ return cliWriteLine(out, cliRenderMuted("No Gordon-managed networks found"))
77+ }
78+
79+ if err := cliWriteLine(out, cliRenderTitle("Networks")); err != nil {
80+ return err
81+ }
82+
83+ return renderNetworksTable(out, networks)
84+}
85+
86+func renderNetworksTable(out io.Writer, networks []*domain.NetworkInfo) error {
87+ rows := make([][]string, 0, len(networks))
88+ for _, net := range networks {
89+ if net == nil {
90+ continue
91+ }
92+ containers := strings.Join(net.Containers, ", ")
93+ if containers == "" {
94+ containers = "-"
95+ }
96+ if len(containers) > 40 {
97+ containers = containers[:37] + "..."
98+ }
99+ rows = append(rows, []string{
100+ net.Name,
101+ net.Driver,
102+ fmt.Sprintf("%d", len(net.Containers)),
103+ containers,
104+ })
105+ }
106+
107+ table := components.NewTable(
108+ components.WithColumns([]components.TableColumn{
109+ {Title: "Name", Width: 25},
110+ {Title: "Driver", Width: 10},
111+ {Title: "Containers", Width: 12},
112+ {Title: "Connected", Width: 40},
113+ }),
114+ components.WithRows(rows),
115+ )
116+
117+ return cliWriteLine(out, table.View())
118+}
@@ -0,0 +1,118 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "strings"
8+
9+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
10+ "github.com/bnema/gordon/internal/domain"
11+ "github.com/spf13/cobra"
12+)
13+
14+func newNetworksCmd() *cobra.Command {
15+ cmd := &cobra.Command{
16+ Use: "networks",
17+ Short: "Inspect Gordon-managed networks",
18+ }
19+
20+ cmd.AddCommand(newNetworksListCmd())
21+
22+ return cmd
23+}
24+
25+func newNetworksListCmd() *cobra.Command {
26+ var jsonOut bool
27+
28+ cmd := &cobra.Command{
29+ Use: "list",
30+ Short: "List Gordon-managed Docker networks",
31+ Long: `Display Docker networks managed by Gordon, including which containers
32+are connected to each network.
33+
34+Examples:
35+ gordon networks list
36+ gordon networks list --json
37+ gordon networks list --remote https://gordon.mydomain.com --token $TOKEN`,
38+ RunE: func(cmd *cobra.Command, _ []string) error {
39+ ctx := cmd.Context()
40+ handle, err := resolveControlPlane(configPath)
41+ if err != nil {
42+ return err
43+ }
44+ defer handle.close()
45+ return runNetworksList(ctx, handle.plane, cmd.OutOrStdout(), jsonOut)
46+ },
47+ }
48+
49+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
50+
51+ return cmd
52+}
53+
54+func runNetworksList(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut bool) error {
55+ networks, err := cp.ListNetworks(ctx)
56+ if err != nil {
57+ return fmt.Errorf("failed to list networks: %w", err)
58+ }
59+
60+ if jsonOut {
61+ result := make([]map[string]any, 0, len(networks))
62+ for _, net := range networks {
63+ if net == nil {
64+ continue
65+ }
66+ result = append(result, map[string]any{
67+ "name": net.Name,
68+ "driver": net.Driver,
69+ "containers": net.Containers,
70+ })
71+ }
72+ return writeJSON(out, result)
73+ }
74+
75+ if len(networks) == 0 {
76+ return cliWriteLine(out, cliRenderMuted("No Gordon-managed networks found"))
77+ }
78+
79+ if err := cliWriteLine(out, cliRenderTitle("Networks")); err != nil {
80+ return err
81+ }
82+
83+ return renderNetworksTable(out, networks)
84+}
85+
86+func renderNetworksTable(out io.Writer, networks []*domain.NetworkInfo) error {
87+ rows := make([][]string, 0, len(networks))
88+ for _, net := range networks {
89+ if net == nil {
90+ continue
91+ }
92+ containers := strings.Join(net.Containers, ", ")
93+ if containers == "" {
94+ containers = "-"
95+ }
96+ if len(containers) > 40 {
97+ containers = containers[:37] + "..."
98+ }
99+ rows = append(rows, []string{
100+ net.Name,
101+ net.Driver,
102+ fmt.Sprintf("%d", len(net.Containers)),
103+ containers,
104+ })
105+ }
106+
107+ table := components.NewTable(
108+ components.WithColumns([]components.TableColumn{
109+ {Title: "Name", Width: 25},
110+ {Title: "Driver", Width: 10},
111+ {Title: "Containers", Width: 12},
112+ {Title: "Connected", Width: 40},
113+ }),
114+ components.WithRows(rows),
115+ )
116+
117+ return cliWriteLine(out, table.View())
118+}
M internal/adapters/in/cli/push_test.go +8 -0
@@ -100,6 +100,14 @@ func (c *resolveFromImageTestControlPlane) Reload(context.Context) error {
100100 panic("unexpected call")
101101 }
102102
103+func (c *resolveFromImageTestControlPlane) ListNetworks(context.Context) ([]*domain.NetworkInfo, error) {
104+ panic("unexpected call")
105+}
106+
107+func (c *resolveFromImageTestControlPlane) GetConfig(context.Context) (*remote.Config, error) {
108+ panic("unexpected call")
109+}
110+
103111 func (c *resolveFromImageTestControlPlane) DeployIntent(context.Context, string) error {
104112 panic("unexpected call")
105113 }
@@ -100,6 +100,14 @@ func (c *resolveFromImageTestControlPlane) Reload(context.Context) error {
100 panic("unexpected call")100 panic("unexpected call")
101 }101 }
102 102
103+func (c *resolveFromImageTestControlPlane) ListNetworks(context.Context) ([]*domain.NetworkInfo, error) {
104+ panic("unexpected call")
105+}
106+
107+func (c *resolveFromImageTestControlPlane) GetConfig(context.Context) (*remote.Config, error) {
108+ panic("unexpected call")
109+}
110+
103 func (c *resolveFromImageTestControlPlane) DeployIntent(context.Context, string) error {111 func (c *resolveFromImageTestControlPlane) DeployIntent(context.Context, string) error {
104 panic("unexpected call")112 panic("unexpected call")
105 }113 }
M internal/adapters/in/cli/root.go +8 -0
@@ -129,6 +129,14 @@ Commands are organized by where they run:
129129 bootstrapCmd.GroupID = groupManage
130130 rootCmd.AddCommand(bootstrapCmd)
131131
132+ configCmd := newConfigCmd()
133+ configCmd.GroupID = groupManage
134+ rootCmd.AddCommand(configCmd)
135+
136+ networksCmd := newNetworksCmd()
137+ networksCmd.GroupID = groupManage
138+ rootCmd.AddCommand(networksCmd)
139+
132140 // Client-only commands (no server needed)
133141 remotesCmd := newRemotesCmd()
134142 remotesCmd.GroupID = groupClient
@@ -129,6 +129,14 @@ Commands are organized by where they run:
129 bootstrapCmd.GroupID = groupManage129 bootstrapCmd.GroupID = groupManage
130 rootCmd.AddCommand(bootstrapCmd)130 rootCmd.AddCommand(bootstrapCmd)
131 131
132+ configCmd := newConfigCmd()
133+ configCmd.GroupID = groupManage
134+ rootCmd.AddCommand(configCmd)
135+
136+ networksCmd := newNetworksCmd()
137+ networksCmd.GroupID = groupManage
138+ rootCmd.AddCommand(networksCmd)
139+
132 // Client-only commands (no server needed)140 // Client-only commands (no server needed)
133 remotesCmd := newRemotesCmd()141 remotesCmd := newRemotesCmd()
134 remotesCmd.GroupID = groupClient142 remotesCmd.GroupID = groupClient
M internal/adapters/in/cli/routes.go +90 -2
@@ -2,16 +2,17 @@ package cli
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "io"
78 "strings"
89
10+ "github.com/spf13/cobra"
11+
912 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1013 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1114 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
1215 "github.com/bnema/gordon/internal/domain"
13-
14- "github.com/spf13/cobra"
1516 )
1617
1718 // configPath for local operations. If empty, config is auto-discovered
@@ -83,6 +84,7 @@ the local Gordon configuration.`,
8384 }
8485
8586 cmd.AddCommand(newRoutesListCmd())
87+ cmd.AddCommand(newRoutesShowCmd())
8688 cmd.AddCommand(newRoutesAddCmd())
8789 cmd.AddCommand(newRoutesRemoveCmd())
8890
@@ -133,6 +135,92 @@ func newRoutesListCmd() *cobra.Command {
133135 return cmd
134136 }
135137
138+func newRoutesShowCmd() *cobra.Command {
139+ var jsonOut bool
140+
141+ cmd := &cobra.Command{
142+ Use: "show <domain>",
143+ Short: "Show details for a single route",
144+ Long: `Display detailed information about a specific route including its image,
145+container status, and health.
146+
147+Examples:
148+ gordon routes show app.mydomain.com
149+ gordon routes show app.mydomain.com --json
150+ gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
151+ Args: cobra.ExactArgs(1),
152+ RunE: func(cmd *cobra.Command, args []string) error {
153+ ctx := cmd.Context()
154+ handle, err := resolveControlPlane(configPath)
155+ if err != nil {
156+ return err
157+ }
158+ defer handle.close()
159+ return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
160+ },
161+ }
162+
163+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
164+
165+ return cmd
166+}
167+
168+func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
169+ route, err := cp.GetRoute(ctx, routeDomain)
170+ if err != nil {
171+ if errors.Is(err, domain.ErrRouteNotFound) {
172+ return fmt.Errorf("route %q not found", routeDomain)
173+ }
174+ return fmt.Errorf("failed to get route: %w", err)
175+ }
176+
177+ health, _ := cp.GetHealth(ctx)
178+ var routeHealth *remote.RouteHealth
179+ if health != nil {
180+ routeHealth = health[routeDomain]
181+ }
182+
183+ containerStatus := "unknown"
184+ httpStatus := 0
185+ if routeHealth != nil {
186+ containerStatus = routeHealth.ContainerStatus
187+ httpStatus = routeHealth.HTTPStatus
188+ }
189+
190+ if jsonOut {
191+ payload := map[string]any{
192+ "domain": route.Domain,
193+ "image": route.Image,
194+ "container_status": containerStatus,
195+ "http_status": httpStatus,
196+ }
197+ return writeJSON(out, payload)
198+ }
199+
200+ if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil {
201+ return err
202+ }
203+ if err := cliWriteLine(out, ""); err != nil {
204+ return err
205+ }
206+ if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil {
207+ return err
208+ }
209+ if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil {
210+ return err
211+ }
212+ if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
213+ return err
214+ }
215+ if httpStatus > 0 {
216+ if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
217+ return err
218+ }
219+ }
220+
221+ return nil
222+}
223+
136224 // runRoutesListRemote lists routes from a remote Gordon instance.
137225 func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error {
138226 routes, err := client.ListRoutesWithDetails(ctx)
@@ -2,16 +2,17 @@ package cli
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "io"7 "io"
7 "strings"8 "strings"
8 9
10+ "github.com/spf13/cobra"
11+
9 "github.com/bnema/gordon/internal/adapters/in/cli/remote"12 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
10 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"13 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
11 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"14 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
12 "github.com/bnema/gordon/internal/domain"15 "github.com/bnema/gordon/internal/domain"
13-
14- "github.com/spf13/cobra"
15 )16 )
16 17
17 // configPath for local operations. If empty, config is auto-discovered18 // configPath for local operations. If empty, config is auto-discovered
@@ -83,6 +84,7 @@ the local Gordon configuration.`,
83 }84 }
84 85
85 cmd.AddCommand(newRoutesListCmd())86 cmd.AddCommand(newRoutesListCmd())
87+ cmd.AddCommand(newRoutesShowCmd())
86 cmd.AddCommand(newRoutesAddCmd())88 cmd.AddCommand(newRoutesAddCmd())
87 cmd.AddCommand(newRoutesRemoveCmd())89 cmd.AddCommand(newRoutesRemoveCmd())
88 90
@@ -133,6 +135,92 @@ func newRoutesListCmd() *cobra.Command {
133 return cmd135 return cmd
134 }136 }
135 137
138+func newRoutesShowCmd() *cobra.Command {
139+ var jsonOut bool
140+
141+ cmd := &cobra.Command{
142+ Use: "show <domain>",
143+ Short: "Show details for a single route",
144+ Long: `Display detailed information about a specific route including its image,
145+container status, and health.
146+
147+Examples:
148+ gordon routes show app.mydomain.com
149+ gordon routes show app.mydomain.com --json
150+ gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
151+ Args: cobra.ExactArgs(1),
152+ RunE: func(cmd *cobra.Command, args []string) error {
153+ ctx := cmd.Context()
154+ handle, err := resolveControlPlane(configPath)
155+ if err != nil {
156+ return err
157+ }
158+ defer handle.close()
159+ return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
160+ },
161+ }
162+
163+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
164+
165+ return cmd
166+}
167+
168+func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
169+ route, err := cp.GetRoute(ctx, routeDomain)
170+ if err != nil {
171+ if errors.Is(err, domain.ErrRouteNotFound) {
172+ return fmt.Errorf("route %q not found", routeDomain)
173+ }
174+ return fmt.Errorf("failed to get route: %w", err)
175+ }
176+
177+ health, _ := cp.GetHealth(ctx)
178+ var routeHealth *remote.RouteHealth
179+ if health != nil {
180+ routeHealth = health[routeDomain]
181+ }
182+
183+ containerStatus := "unknown"
184+ httpStatus := 0
185+ if routeHealth != nil {
186+ containerStatus = routeHealth.ContainerStatus
187+ httpStatus = routeHealth.HTTPStatus
188+ }
189+
190+ if jsonOut {
191+ payload := map[string]any{
192+ "domain": route.Domain,
193+ "image": route.Image,
194+ "container_status": containerStatus,
195+ "http_status": httpStatus,
196+ }
197+ return writeJSON(out, payload)
198+ }
199+
200+ if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil {
201+ return err
202+ }
203+ if err := cliWriteLine(out, ""); err != nil {
204+ return err
205+ }
206+ if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil {
207+ return err
208+ }
209+ if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil {
210+ return err
211+ }
212+ if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
213+ return err
214+ }
215+ if httpStatus > 0 {
216+ if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
217+ return err
218+ }
219+ }
220+
221+ return nil
222+}
223+
136 // runRoutesListRemote lists routes from a remote Gordon instance.224 // runRoutesListRemote lists routes from a remote Gordon instance.
137 func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error {225 func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error {
138 routes, err := client.ListRoutesWithDetails(ctx)226 routes, err := client.ListRoutesWithDetails(ctx)
M internal/adapters/in/http/admin/handler.go +0 -9
@@ -671,19 +671,10 @@ func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
671671 if network == nil {
672672 continue
673673 }
674- var labelsCopy map[string]string
675- if network.Labels != nil {
676- labelsCopy = make(map[string]string, len(network.Labels))
677- for key, value := range network.Labels {
678- labelsCopy[key] = value
679- }
680- }
681674 response = append(response, dto.Network{
682- ID: network.ID,
683675 Name: network.Name,
684676 Driver: network.Driver,
685677 Containers: append([]string{}, network.Containers...),
686- Labels: labelsCopy,
687678 })
688679 }
689680
@@ -671,19 +671,10 @@ func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
671 if network == nil {671 if network == nil {
672 continue672 continue
673 }673 }
674- var labelsCopy map[string]string
675- if network.Labels != nil {
676- labelsCopy = make(map[string]string, len(network.Labels))
677- for key, value := range network.Labels {
678- labelsCopy[key] = value
679- }
680- }
681 response = append(response, dto.Network{674 response = append(response, dto.Network{
682- ID: network.ID,
683 Name: network.Name,675 Name: network.Name,
684 Driver: network.Driver,676 Driver: network.Driver,
685 Containers: append([]string{}, network.Containers...),677 Containers: append([]string{}, network.Containers...),
686- Labels: labelsCopy,
687 })678 })
688 }679 }
689 680
M internal/usecase/container/routes_test.go +4 -2
@@ -157,9 +157,10 @@ func TestService_ListNetworks(t *testing.T) {
157157 ctx := testContext()
158158
159159 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
160- {Name: "gordon-app"},
160+ {Name: "gordon-app", Labels: map[string]string{domain.LabelManaged: "true"}},
161161 {Name: "bridge"},
162- {Name: "gordon-shared"},
162+ {Name: "gordon-shared", Labels: map[string]string{domain.LabelManaged: "true"}},
163+ {Name: "gordon-unmanaged"},
163164 }, nil)
164165
165166 networks, err := svc.ListNetworks(ctx)
@@ -168,4 +169,5 @@ func TestService_ListNetworks(t *testing.T) {
168169 assert.Len(t, networks, 2)
169170 assert.Equal(t, "gordon-app", networks[0].Name)
170171 assert.Equal(t, "gordon-shared", networks[1].Name)
172+ assert.NotContains(t, []string{networks[0].Name, networks[1].Name}, "gordon-unmanaged")
171173 }
@@ -157,9 +157,10 @@ func TestService_ListNetworks(t *testing.T) {
157 ctx := testContext()157 ctx := testContext()
158 158
159 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{159 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
160- {Name: "gordon-app"},160+ {Name: "gordon-app", Labels: map[string]string{domain.LabelManaged: "true"}},
161 {Name: "bridge"},161 {Name: "bridge"},
162- {Name: "gordon-shared"},162+ {Name: "gordon-shared", Labels: map[string]string{domain.LabelManaged: "true"}},
163+ {Name: "gordon-unmanaged"},
163 }, nil)164 }, nil)
164 165
165 networks, err := svc.ListNetworks(ctx)166 networks, err := svc.ListNetworks(ctx)
@@ -168,4 +169,5 @@ func TestService_ListNetworks(t *testing.T) {
168 assert.Len(t, networks, 2)169 assert.Len(t, networks, 2)
169 assert.Equal(t, "gordon-app", networks[0].Name)170 assert.Equal(t, "gordon-app", networks[0].Name)
170 assert.Equal(t, "gordon-shared", networks[1].Name)171 assert.Equal(t, "gordon-shared", networks[1].Name)
172+ assert.NotContains(t, []string{networks[0].Name, networks[1].Name}, "gordon-unmanaged")
171 }173 }
M internal/usecase/container/service.go +1 -1
@@ -1038,7 +1038,7 @@ func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, erro
10381038
10391039 var filtered []*domain.NetworkInfo
10401040 for _, network := range networks {
1041- if strings.HasPrefix(network.Name, cfg.NetworkPrefix+"-") {
1041+ if strings.HasPrefix(network.Name, cfg.NetworkPrefix+"-") && network.Labels[domain.LabelManaged] == "true" {
10421042 filtered = append(filtered, network)
10431043 }
10441044 }
@@ -1038,7 +1038,7 @@ func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, erro
1038 1038
1039 var filtered []*domain.NetworkInfo1039 var filtered []*domain.NetworkInfo
1040 for _, network := range networks {1040 for _, network := range networks {
1041- if strings.HasPrefix(network.Name, cfg.NetworkPrefix+"-") {1041+ if strings.HasPrefix(network.Name, cfg.NetworkPrefix+"-") && network.Labels[domain.LabelManaged] == "true" {
1042 filtered = append(filtered, network)1042 filtered = append(filtered, network)
1043 }1043 }
1044 }1044 }

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 8

Tags 106

↑↓ moveenter openesc close