detached a17d8d2

fix(cli): tighten review follow-ups

commit
a17d8d23b2d0f4ae56ed5f634389c75dbff7c000
author
brice · · 2026-05-23 13:09 UTC
parent
93fc814
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +77 -10
M docs/config/reference.md +1 -1
@@ -232,7 +232,7 @@ keep_last = 3 # Keep N newest tags per repository
232232 | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run |
233233 | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |
234234 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
235-| `auth.token_expiry` | `"720h"` | 30 days |
235+| `auth.token_expiry` | `"30d"` | 30 days |
236236 | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime |
237237 | `api.rate_limit.enabled` | `true` | Enable rate limiting |
238238 | `api.rate_limit.global_rps` | `500` | Global requests/second |
@@ -232,7 +232,7 @@ keep_last = 3 # Keep N newest tags per repository
232 | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run |232 | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run |
233 | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |233 | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |
234 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |234 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
235-| `auth.token_expiry` | `"720h"` | 30 days |235+| `auth.token_expiry` | `"30d"` | 30 days |
236 | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime |236 | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime |
237 | `api.rate_limit.enabled` | `true` | Enable rate limiting |237 | `api.rate_limit.enabled` | `true` | Enable rate limiting |
238 | `api.rate_limit.global_rps` | `500` | Global requests/second |238 | `api.rate_limit.global_rps` | `500` | Global requests/second |
M internal/adapters/in/cli/controlplane_local.go +3 -0
@@ -165,6 +165,9 @@ func (l *localControlPlane) GetRouteCleanupPreview(ctx context.Context, routeDom
165165 if err != nil {
166166 return nil, err
167167 }
168+ if report == nil {
169+ return nil, nil
170+ }
168171 if l.volumeSvc == nil {
169172 return report, nil
170173 }
@@ -165,6 +165,9 @@ func (l *localControlPlane) GetRouteCleanupPreview(ctx context.Context, routeDom
165 if err != nil {165 if err != nil {
166 return nil, err166 return nil, err
167 }167 }
168+ if report == nil {
169+ return nil, nil
170+ }
168 if l.volumeSvc == nil {171 if l.volumeSvc == nil {
169 return report, nil172 return report, nil
170 }173 }
M internal/adapters/in/cli/controlplane_local_test.go +28 -0
@@ -13,6 +13,18 @@ import (
1313 "github.com/stretchr/testify/require"
1414 )
1515
16+type previewContainerService struct {
17+ *inmocks.MockContainerService
18+ preview func(context.Context, string) (*domain.CleanupReport, error)
19+}
20+
21+func (s *previewContainerService) PreviewRemovedRouteCleanup(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
22+ if s.preview == nil {
23+ return nil, nil
24+ }
25+ return s.preview(ctx, routeDomain)
26+}
27+
1628 func TestLocalControlPlane_GetStatus(t *testing.T) {
1729 t.Parallel()
1830
@@ -216,6 +228,22 @@ func TestLocalControlPlane_GetContainerLogs(t *testing.T) {
216228 require.Equal(t, []string{"line1", "line2"}, lines)
217229 }
218230
231+func TestLocalControlPlane_GetRouteCleanupPreview_ReturnsNilReportWithoutPanic(t *testing.T) {
232+ ctx := context.Background()
233+ volumeSvc := inmocks.NewMockVolumeService(t)
234+ containerSvc := &previewContainerService{
235+ MockContainerService: inmocks.NewMockContainerService(t),
236+ preview: func(context.Context, string) (*domain.CleanupReport, error) {
237+ return nil, nil
238+ },
239+ }
240+ cp := &localControlPlane{containerSvc: containerSvc, volumeSvc: volumeSvc}
241+
242+ report, err := cp.GetRouteCleanupPreview(ctx, "app.local")
243+ require.NoError(t, err)
244+ assert.Nil(t, report)
245+}
246+
219247 func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) {
220248 ctx := context.Background()
221249 configSvc := inmocks.NewMockConfigService(t)
@@ -13,6 +13,18 @@ import (
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 )14 )
15 15
16+type previewContainerService struct {
17+ *inmocks.MockContainerService
18+ preview func(context.Context, string) (*domain.CleanupReport, error)
19+}
20+
21+func (s *previewContainerService) PreviewRemovedRouteCleanup(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
22+ if s.preview == nil {
23+ return nil, nil
24+ }
25+ return s.preview(ctx, routeDomain)
26+}
27+
16 func TestLocalControlPlane_GetStatus(t *testing.T) {28 func TestLocalControlPlane_GetStatus(t *testing.T) {
17 t.Parallel()29 t.Parallel()
18 30
@@ -216,6 +228,22 @@ func TestLocalControlPlane_GetContainerLogs(t *testing.T) {
216 require.Equal(t, []string{"line1", "line2"}, lines)228 require.Equal(t, []string{"line1", "line2"}, lines)
217 }229 }
218 230
231+func TestLocalControlPlane_GetRouteCleanupPreview_ReturnsNilReportWithoutPanic(t *testing.T) {
232+ ctx := context.Background()
233+ volumeSvc := inmocks.NewMockVolumeService(t)
234+ containerSvc := &previewContainerService{
235+ MockContainerService: inmocks.NewMockContainerService(t),
236+ preview: func(context.Context, string) (*domain.CleanupReport, error) {
237+ return nil, nil
238+ },
239+ }
240+ cp := &localControlPlane{containerSvc: containerSvc, volumeSvc: volumeSvc}
241+
242+ report, err := cp.GetRouteCleanupPreview(ctx, "app.local")
243+ require.NoError(t, err)
244+ assert.Nil(t, report)
245+}
246+
219 func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) {247 func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) {
220 ctx := context.Background()248 ctx := context.Background()
221 configSvc := inmocks.NewMockConfigService(t)249 configSvc := inmocks.NewMockConfigService(t)
M internal/adapters/in/cli/pin.go +2 -2
@@ -240,9 +240,9 @@ func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route
240240 route.Image = oldImage
241241 if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {
242242 _ = cliWritef(ew, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
243- return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, formattedErr)
243+ return fmt.Errorf("failed to deploy; revert failed: %w; deploy error: %v", revertErr, formattedErr)
244244 }
245- return fmt.Errorf("%w\nroute reverted to previous image", formattedErr)
245+ return fmt.Errorf("deploy failed; route reverted to previous image: %w", formattedErr)
246246 }
247247 containerID := shortContainerID(result.ContainerID)
248248 return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Pinned %s to %s (container: %s)", pinDomain, selectedTag, containerID)))
@@ -240,9 +240,9 @@ func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route
240 route.Image = oldImage240 route.Image = oldImage
241 if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {241 if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {
242 _ = cliWritef(ew, "WARNING: deploy failed and could not revert route: %v\n", revertErr)242 _ = cliWritef(ew, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
243- return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, formattedErr)243+ return fmt.Errorf("failed to deploy; revert failed: %w; deploy error: %v", revertErr, formattedErr)
244 }244 }
245- return fmt.Errorf("%w\nroute reverted to previous image", formattedErr)245+ return fmt.Errorf("deploy failed; route reverted to previous image: %w", formattedErr)
246 }246 }
247 containerID := shortContainerID(result.ContainerID)247 containerID := shortContainerID(result.ContainerID)
248 return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Pinned %s to %s (container: %s)", pinDomain, selectedTag, containerID)))248 return cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Pinned %s to %s (container: %s)", pinDomain, selectedTag, containerID)))
M internal/adapters/in/cli/pin_test.go +1 -1
@@ -100,5 +100,5 @@ func TestDeploySelectedTag_FormatsDeployFailureAfterRevert(t *testing.T) {
100100 assert.Contains(t, err.Error(), "failed to deploy")
101101 assert.Contains(t, err.Error(), "Cause: health check failed")
102102 assert.Contains(t, err.Error(), "Hint: check DATABASE_URL")
103- assert.Contains(t, err.Error(), "route reverted")
103+ assert.Contains(t, err.Error(), "route reverted to previous image")
104104 }
@@ -100,5 +100,5 @@ func TestDeploySelectedTag_FormatsDeployFailureAfterRevert(t *testing.T) {
100 assert.Contains(t, err.Error(), "failed to deploy")100 assert.Contains(t, err.Error(), "failed to deploy")
101 assert.Contains(t, err.Error(), "Cause: health check failed")101 assert.Contains(t, err.Error(), "Cause: health check failed")
102 assert.Contains(t, err.Error(), "Hint: check DATABASE_URL")102 assert.Contains(t, err.Error(), "Hint: check DATABASE_URL")
103- assert.Contains(t, err.Error(), "route reverted")103+ assert.Contains(t, err.Error(), "route reverted to previous image")
104 }104 }
M internal/adapters/in/cli/remote/config.go +6 -3
@@ -249,17 +249,20 @@ func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote,
249249 // when an explicit remote selector is invalid instead of silently falling back
250250 // to local mode.
251251 func ResolveStrict(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool, error) {
252- remotes, _ := LoadRemotes("")
252+ remotes, err := LoadRemotes("")
253+ if err != nil {
254+ return nil, false, fmt.Errorf("loading remotes: %w", err)
255+ }
253256
254257 name, url, found := resolveTarget(flagRemote, remotes)
255258 if !found && flagRemote != "" {
256- return nil, false, fmt.Errorf("remote %q not found", flagRemote)
259+ return nil, false, fmt.Errorf("remote %q: %w", flagRemote, domain.ErrRemoteNotFound)
257260 }
258261 if !found {
259262 if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
260263 name, url, found = resolveTarget(envRemote, remotes)
261264 if !found {
262- return nil, false, fmt.Errorf("remote %q from GORDON_REMOTE not found", envRemote)
265+ return nil, false, fmt.Errorf("remote %q from GORDON_REMOTE: %w", envRemote, domain.ErrRemoteNotFound)
263266 }
264267 }
265268 }
@@ -249,17 +249,20 @@ func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote,
249 // when an explicit remote selector is invalid instead of silently falling back249 // when an explicit remote selector is invalid instead of silently falling back
250 // to local mode.250 // to local mode.
251 func ResolveStrict(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool, error) {251 func ResolveStrict(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool, error) {
252- remotes, _ := LoadRemotes("")252+ remotes, err := LoadRemotes("")
253+ if err != nil {
254+ return nil, false, fmt.Errorf("loading remotes: %w", err)
255+ }
253 256
254 name, url, found := resolveTarget(flagRemote, remotes)257 name, url, found := resolveTarget(flagRemote, remotes)
255 if !found && flagRemote != "" {258 if !found && flagRemote != "" {
256- return nil, false, fmt.Errorf("remote %q not found", flagRemote)259+ return nil, false, fmt.Errorf("remote %q: %w", flagRemote, domain.ErrRemoteNotFound)
257 }260 }
258 if !found {261 if !found {
259 if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {262 if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
260 name, url, found = resolveTarget(envRemote, remotes)263 name, url, found = resolveTarget(envRemote, remotes)
261 if !found {264 if !found {
262- return nil, false, fmt.Errorf("remote %q from GORDON_REMOTE not found", envRemote)265+ return nil, false, fmt.Errorf("remote %q from GORDON_REMOTE: %w", envRemote, domain.ErrRemoteNotFound)
263 }266 }
264 }267 }
265 }268 }
M internal/adapters/in/cli/remote/resolve_test.go +34 -0
@@ -1,10 +1,15 @@
11 package remote
22
33 import (
4+ "errors"
5+ "os"
6+ "path/filepath"
47 "testing"
58
69 "github.com/stretchr/testify/assert"
710 "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/domain"
813 )
914
1015 func TestResolve_FlagNameLookup(t *testing.T) {
@@ -202,3 +207,32 @@ func TestResolve_UnknownNameReturnsNotFound(t *testing.T) {
202207 assert.False(t, ok)
203208 assert.Nil(t, resolved)
204209 }
210+
211+func TestResolveStrict_UnknownFlagRemoteWrapsErrRemoteNotFound(t *testing.T) {
212+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
213+ t.Setenv("GORDON_REMOTE", "")
214+ t.Setenv("GORDON_TOKEN", "")
215+
216+ resolved, ok, err := ResolveStrict("nonexistent", "", false)
217+ require.Error(t, err)
218+ assert.False(t, ok)
219+ assert.Nil(t, resolved)
220+ assert.ErrorIs(t, err, domain.ErrRemoteNotFound)
221+}
222+
223+func TestResolveStrict_LoadRemotesErrorIsReturned(t *testing.T) {
224+ configHome := t.TempDir()
225+ t.Setenv("XDG_CONFIG_HOME", configHome)
226+ t.Setenv("GORDON_REMOTE", "")
227+ t.Setenv("GORDON_TOKEN", "")
228+
229+ remotesPath := filepath.Join(configHome, "gordon", "remotes.toml")
230+ require.NoError(t, os.MkdirAll(remotesPath, 0o755))
231+
232+ resolved, ok, err := ResolveStrict("", "", false)
233+ require.Error(t, err)
234+ assert.False(t, ok)
235+ assert.Nil(t, resolved)
236+ assert.Contains(t, err.Error(), "loading remotes")
237+ assert.False(t, errors.Is(err, domain.ErrRemoteNotFound))
238+}
@@ -1,10 +1,15 @@
1 package remote1 package remote
2 2
3 import (3 import (
4+ "errors"
5+ "os"
6+ "path/filepath"
4 "testing"7 "testing"
5 8
6 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
7 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/domain"
8 )13 )
9 14
10 func TestResolve_FlagNameLookup(t *testing.T) {15 func TestResolve_FlagNameLookup(t *testing.T) {
@@ -202,3 +207,32 @@ func TestResolve_UnknownNameReturnsNotFound(t *testing.T) {
202 assert.False(t, ok)207 assert.False(t, ok)
203 assert.Nil(t, resolved)208 assert.Nil(t, resolved)
204 }209 }
210+
211+func TestResolveStrict_UnknownFlagRemoteWrapsErrRemoteNotFound(t *testing.T) {
212+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
213+ t.Setenv("GORDON_REMOTE", "")
214+ t.Setenv("GORDON_TOKEN", "")
215+
216+ resolved, ok, err := ResolveStrict("nonexistent", "", false)
217+ require.Error(t, err)
218+ assert.False(t, ok)
219+ assert.Nil(t, resolved)
220+ assert.ErrorIs(t, err, domain.ErrRemoteNotFound)
221+}
222+
223+func TestResolveStrict_LoadRemotesErrorIsReturned(t *testing.T) {
224+ configHome := t.TempDir()
225+ t.Setenv("XDG_CONFIG_HOME", configHome)
226+ t.Setenv("GORDON_REMOTE", "")
227+ t.Setenv("GORDON_TOKEN", "")
228+
229+ remotesPath := filepath.Join(configHome, "gordon", "remotes.toml")
230+ require.NoError(t, os.MkdirAll(remotesPath, 0o755))
231+
232+ resolved, ok, err := ResolveStrict("", "", false)
233+ require.Error(t, err)
234+ assert.False(t, ok)
235+ assert.Nil(t, resolved)
236+ assert.Contains(t, err.Error(), "loading remotes")
237+ assert.False(t, errors.Is(err, domain.ErrRemoteNotFound))
238+}
M internal/adapters/in/cli/routes.go +2 -3
@@ -1579,7 +1579,7 @@ Examples:
15791579 gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,
15801580 Args: cobra.RangeArgs(1, 2),
15811581 RunE: func(cmd *cobra.Command, args []string) error {
1582- ctx := context.Background()
1582+ ctx := cmd.Context()
15831583 routeDomain := args[0]
15841584
15851585 // Resolve image from positional argument or flag
@@ -1623,8 +1623,7 @@ Examples:
16231623 }
16241624 }
16251625
1626- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))
1627- return nil
1626+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))
16281627 },
16291628 }
16301629
@@ -1579,7 +1579,7 @@ Examples:
1579 gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,1579 gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,
1580 Args: cobra.RangeArgs(1, 2),1580 Args: cobra.RangeArgs(1, 2),
1581 RunE: func(cmd *cobra.Command, args []string) error {1581 RunE: func(cmd *cobra.Command, args []string) error {
1582- ctx := context.Background()1582+ ctx := cmd.Context()
1583 routeDomain := args[0]1583 routeDomain := args[0]
1584 1584
1585 // Resolve image from positional argument or flag1585 // Resolve image from positional argument or flag
@@ -1623,8 +1623,7 @@ Examples:
1623 }1623 }
1624 }1624 }
1625 1625
1626- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))1626+ return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))
1627- return nil
1628 },1627 },
1629 }1628 }
1630 1629

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close