fix: address code review findings across CLI and docs
- Add nil guard in runConfigShow and empty-state for external routes - Normalize nil tags to empty slice in images JSON output - Distinguish NotFound from operational errors in routes show - Use json:"-" for network DTO ID/Labels to prevent serialization - Emit snake_case keys in networks JSON output for consistency - Vary permission bullet lead text in images docs
- commit
- 35b3ba99099cbbbcdee7d116bb7d78394e7333e0
- author
- brice · · 2026-03-14 04:02 UTC
- parent
- 658b8a7
- signature
- unsigned
7 files · +59 -36
M docs/cli/images.md +3 -3
| @@ -137,9 +137,9 @@ gordon images prune --no-confirm | |||
| 137 | 137 | ||
| 138 | 138 | ## Required Permissions | |
| 139 | 139 | ||
| 140 | - | - `list` requires `admin:status:read`. | |
| 141 | - | - `prune` requires `admin:config:write`. | |
| 142 | - | - `tags` requires `admin:status:read`. | |
| 140 | + | - The `list` subcommand requires `admin:status:read`. | |
| 141 | + | - The `prune` subcommand requires `admin:config:write`. | |
| 142 | + | - The `tags` subcommand requires `admin:status:read`. | |
| 143 | 143 | ||
| 144 | 144 | ## Related | |
| 145 | 145 | ||
| @@ -137,9 +137,9 @@ gordon images prune --no-confirm | |||||
| 137 | 137 | ||||
| 138 | ## Required Permissions | 138 | ## Required Permissions | ||
| 139 | 139 | ||||
| 140 | - | - `list` requires `admin:status:read`. | 140 | + | - The `list` subcommand requires `admin:status:read`. |
| 141 | - | - `prune` requires `admin:config:write`. | 141 | + | - The `prune` subcommand requires `admin:config:write`. |
| 142 | - | - `tags` requires `admin:status:read`. | 142 | + | - The `tags` subcommand requires `admin:status:read`. |
| 143 | 143 | ||||
| 144 | ## Related | 144 | ## Related | ||
| 145 | 145 | ||||
M docs/cli/networks.md +3 -3
| @@ -37,9 +37,9 @@ gordon networks list --remote https://gordon.mydomain.com --token $TOKEN | |||
| 37 | 37 | ```json | |
| 38 | 38 | [ | |
| 39 | 39 | { | |
| 40 | - | "Name": "gordon_myapp", | |
| 41 | - | "Driver": "bridge", | |
| 42 | - | "Containers": ["container1", "container2"] | |
| 40 | + | "name": "gordon_myapp", | |
| 41 | + | "driver": "bridge", | |
| 42 | + | "containers": ["container1", "container2"] | |
| 43 | 43 | } | |
| 44 | 44 | ] | |
| 45 | 45 | ``` | |
| @@ -37,9 +37,9 @@ gordon networks list --remote https://gordon.mydomain.com --token $TOKEN | |||||
| 37 | ```json | 37 | ```json | ||
| 38 | [ | 38 | [ | ||
| 39 | { | 39 | { | ||
| 40 | - | "Name": "gordon_myapp", | 40 | + | "name": "gordon_myapp", |
| 41 | - | "Driver": "bridge", | 41 | + | "driver": "bridge", |
| 42 | - | "Containers": ["container1", "container2"] | 42 | + | "containers": ["container1", "container2"] |
| 43 | } | 43 | } | ||
| 44 | ] | 44 | ] | ||
| 45 | ``` | 45 | ``` | ||
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,omitempty"` | |
| 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,omitempty"` | |
| 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,omitempty"` | 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,omitempty"` | 9 | + | Labels map[string]string `json:"-"` |
| 10 | } | 10 | } | ||
| 11 | 11 | ||||
| 12 | // NetworksResponse represents a list of networks. | 12 | // NetworksResponse represents a list of networks. | ||
M internal/adapters/in/cli/config.go +29 -24
| @@ -57,6 +57,10 @@ func runConfigShow(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut | |||
| 57 | 57 | return fmt.Errorf("failed to get config: %w", err) | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | + | if config == nil { | |
| 61 | + | return fmt.Errorf("no config available") | |
| 62 | + | } | |
| 63 | + | ||
| 60 | 64 | if jsonOut { | |
| 61 | 65 | return writeJSON(out, config) | |
| 62 | 66 | } | |
| @@ -138,34 +142,35 @@ func renderConfigRoutes(out io.Writer, config *remote.Config) error { | |||
| 138 | 142 | } | |
| 139 | 143 | ||
| 140 | 144 | func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error { | |
| 141 | - | if len(config.ExternalRoutes) > 0 { | |
| 142 | - | if err := cliWriteLine(out, ""); err != nil { | |
| 143 | - | return err | |
| 144 | - | } | |
| 145 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 146 | + | return err | |
| 147 | + | } | |
| 148 | + | if len(config.ExternalRoutes) == 0 { | |
| 145 | 149 | if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil { | |
| 146 | 150 | return err | |
| 147 | 151 | } | |
| 148 | - | domains := make([]string, 0, len(config.ExternalRoutes)) | |
| 149 | - | for d := range config.ExternalRoutes { | |
| 150 | - | domains = append(domains, d) | |
| 151 | - | } | |
| 152 | - | sort.Strings(domains) | |
| 152 | + | return cliWriteLine(out, cliRenderMuted("No external routes configured")) | |
| 153 | + | } | |
| 153 | 154 | ||
| 154 | - | rows := make([][]string, 0, len(domains)) | |
| 155 | - | for _, d := range domains { | |
| 156 | - | rows = append(rows, []string{d, config.ExternalRoutes[d]}) | |
| 157 | - | } | |
| 158 | - | table := components.NewTable( | |
| 159 | - | components.WithColumns([]components.TableColumn{ | |
| 160 | - | {Title: "Domain", Width: 30}, | |
| 161 | - | {Title: "Target", Width: 45}, | |
| 162 | - | }), | |
| 163 | - | components.WithRows(rows), | |
| 164 | - | ) | |
| 165 | - | if err := cliWriteLine(out, table.View()); err != nil { | |
| 166 | - | return err | |
| 167 | - | } | |
| 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) | |
| 168 | 161 | } | |
| 162 | + | sort.Strings(domains) | |
| 169 | 163 | ||
| 170 | - | return nil | |
| 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()) | |
| 171 | 176 | } | |
| @@ -57,6 +57,10 @@ func runConfigShow(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut | |||||
| 57 | return fmt.Errorf("failed to get config: %w", err) | 57 | return fmt.Errorf("failed to get config: %w", err) | ||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | + | if config == nil { | |||
| 61 | + | return fmt.Errorf("no config available") | |||
| 62 | + | } | |||
| 63 | + | ||||
| 60 | if jsonOut { | 64 | if jsonOut { | ||
| 61 | return writeJSON(out, config) | 65 | return writeJSON(out, config) | ||
| 62 | } | 66 | } | ||
| @@ -138,34 +142,35 @@ func renderConfigRoutes(out io.Writer, config *remote.Config) error { | |||||
| 138 | } | 142 | } | ||
| 139 | 143 | ||||
| 140 | func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error { | 144 | func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error { | ||
| 141 | - | if len(config.ExternalRoutes) > 0 { | 145 | + | if err := cliWriteLine(out, ""); err != nil { |
| 142 | - | if err := cliWriteLine(out, ""); err != nil { | 146 | + | return err |
| 143 | - | return err | 147 | + | } |
| 144 | - | } | 148 | + | if len(config.ExternalRoutes) == 0 { |
| 145 | if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil { | 149 | if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil { | ||
| 146 | return err | 150 | return err | ||
| 147 | } | 151 | } | ||
| 148 | - | domains := make([]string, 0, len(config.ExternalRoutes)) | 152 | + | return cliWriteLine(out, cliRenderMuted("No external routes configured")) |
| 149 | - | for d := range config.ExternalRoutes { | 153 | + | } |
| 150 | - | domains = append(domains, d) | |||
| 151 | - | } | |||
| 152 | - | sort.Strings(domains) | |||
| 153 | 154 | ||||
| 154 | - | rows := make([][]string, 0, len(domains)) | 155 | + | if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil { |
| 155 | - | for _, d := range domains { | 156 | + | return err |
| 156 | - | rows = append(rows, []string{d, config.ExternalRoutes[d]}) | 157 | + | } |
| 157 | - | } | 158 | + | domains := make([]string, 0, len(config.ExternalRoutes)) |
| 158 | - | table := components.NewTable( | 159 | + | for d := range config.ExternalRoutes { |
| 159 | - | components.WithColumns([]components.TableColumn{ | 160 | + | domains = append(domains, d) |
| 160 | - | {Title: "Domain", Width: 30}, | |||
| 161 | - | {Title: "Target", Width: 45}, | |||
| 162 | - | }), | |||
| 163 | - | components.WithRows(rows), | |||
| 164 | - | ) | |||
| 165 | - | if err := cliWriteLine(out, table.View()); err != nil { | |||
| 166 | - | return err | |||
| 167 | - | } | |||
| 168 | } | 161 | } | ||
| 162 | + | sort.Strings(domains) | |||
| 169 | 163 | ||||
| 170 | - | return nil | 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()) | |||
| 171 | } | 176 | } | ||
M internal/adapters/in/cli/images.go +3 -0
| @@ -168,6 +168,9 @@ func runImagesTags(ctx context.Context, cp ControlPlane, out io.Writer, reposito | |||
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | 170 | if jsonOut { | |
| 171 | + | if tags == nil { | |
| 172 | + | tags = []string{} | |
| 173 | + | } | |
| 171 | 174 | payload := map[string]any{ | |
| 172 | 175 | "repository": repository, | |
| 173 | 176 | "tags": tags, | |
| @@ -168,6 +168,9 @@ func runImagesTags(ctx context.Context, cp ControlPlane, out io.Writer, reposito | |||||
| 168 | } | 168 | } | ||
| 169 | 169 | ||||
| 170 | if jsonOut { | 170 | if jsonOut { | ||
| 171 | + | if tags == nil { | |||
| 172 | + | tags = []string{} | |||
| 173 | + | } | |||
| 171 | payload := map[string]any{ | 174 | payload := map[string]any{ | ||
| 172 | "repository": repository, | 175 | "repository": repository, | ||
| 173 | "tags": tags, | 176 | "tags": tags, | ||
M internal/adapters/in/cli/networks.go +12 -1
| @@ -58,7 +58,18 @@ func runNetworksList(ctx context.Context, cp ControlPlane, out io.Writer, jsonOu | |||
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | if jsonOut { | |
| 61 | - | return writeJSON(out, networks) | |
| 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) | |
| 62 | 73 | } | |
| 63 | 74 | ||
| 64 | 75 | if len(networks) == 0 { | |
| @@ -58,7 +58,18 @@ func runNetworksList(ctx context.Context, cp ControlPlane, out io.Writer, jsonOu | |||||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | if jsonOut { | 60 | if jsonOut { | ||
| 61 | - | return writeJSON(out, networks) | 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) | |||
| 62 | } | 73 | } | ||
| 63 | 74 | ||||
| 64 | if len(networks) == 0 { | 75 | if len(networks) == 0 { | ||
M internal/adapters/in/cli/routes.go +7 -3
| @@ -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 | |
| @@ -167,7 +168,10 @@ Examples: | |||
| 167 | 168 | func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error { | |
| 168 | 169 | route, err := cp.GetRoute(ctx, routeDomain) | |
| 169 | 170 | if err != nil { | |
| 170 | - | return fmt.Errorf("route not found: %w", err) | |
| 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) | |
| 171 | 175 | } | |
| 172 | 176 | ||
| 173 | 177 | health, _ := cp.GetHealth(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 | ||
| @@ -167,7 +168,10 @@ Examples: | |||||
| 167 | func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error { | 168 | func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error { | ||
| 168 | route, err := cp.GetRoute(ctx, routeDomain) | 169 | route, err := cp.GetRoute(ctx, routeDomain) | ||
| 169 | if err != nil { | 170 | if err != nil { | ||
| 170 | - | return fmt.Errorf("route not found: %w", err) | 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) | |||
| 171 | } | 175 | } | ||
| 172 | 176 | ||||
| 173 | health, _ := cp.GetHealth(ctx) | 177 | health, _ := cp.GetHealth(ctx) | ||