Merge pull request #100 from bnema/cli-completeness
feat: CLI completeness — config show, routes show, networks list, images tags
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: | |||
| 12 | 12 | ||
| 13 | 13 | - `list` - List runtime images and registry tags. | |
| 14 | 14 | - `prune` - Prune dangling runtime images and old registry tags. | |
| 15 | + | - `tags` - List registry tags for a specific repository. | |
| 15 | 16 | ||
| 16 | 17 | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | |
| 17 | 18 | ||
| @@ -81,6 +82,34 @@ Flags: | |||
| 81 | 82 | - `--keep-releases` counts non-`latest` tags, ordered by most recent first. | |
| 82 | 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 | 113 | ## Examples | |
| 85 | 114 | ||
| 86 | 115 | ```bash | |
| @@ -90,6 +119,9 @@ gordon images list | |||
| 90 | 119 | # Prune everything with defaults (dangling + registry, keep latest + 3) | |
| 91 | 120 | gordon images prune | |
| 92 | 121 | ||
| 122 | + | # List tags for a repository | |
| 123 | + | gordon images tags myapp | |
| 124 | + | ||
| 93 | 125 | # Prune dangling runtime images only | |
| 94 | 126 | gordon images prune --dangling | |
| 95 | 127 | ||
| @@ -105,8 +137,9 @@ gordon images prune --no-confirm | |||
| 105 | 137 | ||
| 106 | 138 | ## Required Permissions | |
| 107 | 139 | ||
| 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`. | |
| 110 | 143 | ||
| 111 | 144 | ## Related | |
| 112 | 145 | ||
| @@ -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 | ## Examples | 113 | ## Examples | ||
| 85 | 114 | ||||
| 86 | ```bash | 115 | ```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 prune | 120 | gordon images prune | ||
| 92 | 121 | ||||
| 122 | + | # List tags for a repository | |||
| 123 | + | gordon images tags myapp | |||
| 124 | + | ||||
| 93 | # Prune dangling runtime images only | 125 | # Prune dangling runtime images only | ||
| 94 | gordon images prune --dangling | 126 | gordon images prune --dangling | ||
| 95 | 127 | ||||
| @@ -105,8 +137,9 @@ gordon images prune --no-confirm | |||||
| 105 | 137 | ||||
| 106 | ## Required Permissions | 138 | ## 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 | ## Related | 144 | ## 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 | |||
| 36 | 36 | | `gordon status` | Show Gordon server status | [status](./status.md) | | |
| 37 | 37 | | `gordon backups` | Manage database backups | [backup](./backup.md) | | |
| 38 | 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 | 42 | ## Client Commands | |
| 41 | 43 | ||
| @@ -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 Commands | 42 | ## 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). | |||
| 12 | 12 | | Subcommand | Description | | |
| 13 | 13 | |------------|-------------| | |
| 14 | 14 | | `list` | List all routes | | |
| 15 | + | | `show` | Show details for a single route | | |
| 15 | 16 | | `add` | Create or update a route | | |
| 16 | 17 | | `remove` | Remove a route | | |
| 17 | 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 | 175 | ## gordon routes remove | |
| 120 | 176 | ||
| 121 | 177 | 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 remove | 175 | ## 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 | |||
| 2 | 2 | ||
| 3 | 3 | // Network represents a network in API responses. | |
| 4 | 4 | type Network struct { | |
| 5 | - | ID string `json:"id"` | |
| 5 | + | ID string `json:"-"` | |
| 6 | 6 | Name string `json:"name"` | |
| 7 | 7 | Driver string `json:"driver"` | |
| 8 | 8 | Containers []string `json:"containers"` | |
| 9 | - | Labels map[string]string `json:"labels"` | |
| 9 | + | Labels map[string]string `json:"-"` | |
| 10 | 10 | } | |
| 11 | 11 | ||
| 12 | 12 | // 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 { | |||
| 35 | 35 | ||
| 36 | 36 | GetStatus(ctx context.Context) (*remote.Status, error) | |
| 37 | 37 | Reload(ctx context.Context) error | |
| 38 | + | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | |
| 39 | + | GetConfig(ctx context.Context) (*remote.Config, error) | |
| 38 | 40 | DeployIntent(ctx context.Context, imageName string) error | |
| 39 | 41 | Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) | |
| 40 | 42 | 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) error | 37 | 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) error | 40 | 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 { | |||
| 321 | 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 | 349 | func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error { | |
| 325 | 350 | if l.deployCoord != nil { | |
| 326 | 351 | 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 { | |||
| 92 | 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 | 103 | func (r *remoteControlPlane) DeployIntent(ctx context.Context, imageName string) error { | |
| 96 | 104 | return r.client.DeployIntent(ctx, imageName) | |
| 97 | 105 | } | |
| @@ -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.`, | |||
| 76 | 76 | ||
| 77 | 77 | cmd.AddCommand(newImagesListCmd()) | |
| 78 | 78 | cmd.AddCommand(newImagesPruneCmd()) | |
| 79 | + | cmd.AddCommand(newImagesTagsCmd()) | |
| 79 | 80 | ||
| 80 | 81 | return cmd | |
| 81 | 82 | } | |
| @@ -131,6 +132,75 @@ release tags per repository. Use --dangling or --registry to restrict scope.`, | |||
| 131 | 132 | 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 | 204 | // resolvePruneScopes determines which prune subsystems are active. | |
| 135 | 205 | // No scope flags → both true. Any scope flag present → only selected scopes. | |
| 136 | 206 | 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 cmd | 81 | return cmd | ||
| 81 | } | 82 | } | ||
| @@ -131,6 +132,75 @@ release tags per repository. Use --dangling or --registry to restrict scope.`, | |||||
| 131 | return cmd | 132 | 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) { | |||
| 20 | 20 | {"auth token list", newTokenListCmd}, | |
| 21 | 21 | {"remotes list", newRemotesListCmd}, | |
| 22 | 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 | 29 | 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 { | |||
| 100 | 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 | 111 | func (c *resolveFromImageTestControlPlane) DeployIntent(context.Context, string) error { | |
| 104 | 112 | panic("unexpected call") | |
| 105 | 113 | } | |
| @@ -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: | |||
| 129 | 129 | bootstrapCmd.GroupID = groupManage | |
| 130 | 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 | 140 | // Client-only commands (no server needed) | |
| 133 | 141 | remotesCmd := newRemotesCmd() | |
| 134 | 142 | remotesCmd.GroupID = groupClient | |
| @@ -129,6 +129,14 @@ Commands are organized by where they run: | |||||
| 129 | bootstrapCmd.GroupID = groupManage | 129 | 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 = groupClient | 142 | remotesCmd.GroupID = groupClient | ||
M internal/adapters/in/cli/routes.go +90 -2
| @@ -2,16 +2,17 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "io" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ||
| 10 | + | "github.com/spf13/cobra" | |
| 11 | + | ||
| 9 | 12 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 10 | 13 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 11 | 14 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 12 | 15 | "github.com/bnema/gordon/internal/domain" | |
| 13 | - | ||
| 14 | - | "github.com/spf13/cobra" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | // configPath for local operations. If empty, config is auto-discovered | |
| @@ -83,6 +84,7 @@ the local Gordon configuration.`, | |||
| 83 | 84 | } | |
| 84 | 85 | ||
| 85 | 86 | cmd.AddCommand(newRoutesListCmd()) | |
| 87 | + | cmd.AddCommand(newRoutesShowCmd()) | |
| 86 | 88 | cmd.AddCommand(newRoutesAddCmd()) | |
| 87 | 89 | cmd.AddCommand(newRoutesRemoveCmd()) | |
| 88 | 90 | ||
| @@ -133,6 +135,92 @@ func newRoutesListCmd() *cobra.Command { | |||
| 133 | 135 | 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 | 224 | // runRoutesListRemote lists routes from a remote Gordon instance. | |
| 137 | 225 | func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error { | |
| 138 | 226 | 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-discovered | 18 | // 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 cmd | 135 | 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) { | |||
| 671 | 671 | if network == nil { | |
| 672 | 672 | 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 | 674 | response = append(response, dto.Network{ | |
| 682 | - | ID: network.ID, | |
| 683 | 675 | Name: network.Name, | |
| 684 | 676 | Driver: network.Driver, | |
| 685 | 677 | Containers: append([]string{}, network.Containers...), | |
| 686 | - | Labels: labelsCopy, | |
| 687 | 678 | }) | |
| 688 | 679 | } | |
| 689 | 680 | ||
| @@ -671,19 +671,10 @@ func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) { | |||||
| 671 | if network == nil { | 671 | if network == nil { | ||
| 672 | continue | 672 | 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) { | |||
| 157 | 157 | ctx := testContext() | |
| 158 | 158 | ||
| 159 | 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 | 161 | {Name: "bridge"}, | |
| 162 | - | {Name: "gordon-shared"}, | |
| 162 | + | {Name: "gordon-shared", Labels: map[string]string{domain.LabelManaged: "true"}}, | |
| 163 | + | {Name: "gordon-unmanaged"}, | |
| 163 | 164 | }, nil) | |
| 164 | 165 | ||
| 165 | 166 | networks, err := svc.ListNetworks(ctx) | |
| @@ -168,4 +169,5 @@ func TestService_ListNetworks(t *testing.T) { | |||
| 168 | 169 | assert.Len(t, networks, 2) | |
| 169 | 170 | assert.Equal(t, "gordon-app", networks[0].Name) | |
| 170 | 171 | assert.Equal(t, "gordon-shared", networks[1].Name) | |
| 172 | + | assert.NotContains(t, []string{networks[0].Name, networks[1].Name}, "gordon-unmanaged") | |
| 171 | 173 | } | |
| @@ -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 | |||
| 1038 | 1038 | ||
| 1039 | 1039 | var filtered []*domain.NetworkInfo | |
| 1040 | 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 | 1042 | filtered = append(filtered, network) | |
| 1043 | 1043 | } | |
| 1044 | 1044 | } | |
| @@ -1038,7 +1038,7 @@ func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, erro | |||||
| 1038 | 1038 | ||||
| 1039 | var filtered []*domain.NetworkInfo | 1039 | 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 | } | ||