detached 637b278

Clarify startup recovery behavior and tests

commit
637b278167463d556a82535603567c408eff7982
author
brice · · 2026-05-10 13:20 UTC
parent
ec88514
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +23 -59
M docs/cli/serve.md +6 -4
@@ -114,12 +114,14 @@ sudo loginctl enable-linger $USER
114114 After Gordon starts, including after a host reboot, it runs a best-effort recovery pass once the listeners are bound. Errors are logged, but Gordon keeps starting.
115115
116116 1. **Sync existing containers** - Gordon reconciles runtime state with configured routes.
117-2. **Recover configured routes** - Gordon starts any configured route that has no running container.
117+2. **Recover configured routes** - Gordon runs `AutoStart` for any configured route that has no running container.
118118 3. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass.
119119
120120 This recovery runs even when `[auto_route].enabled = false`. `auto_route` only controls whether new image pushes create routes automatically; it does not disable restart recovery for routes already in the config.
121121
122-Recovery stays inside Gordon's own control flow instead of relying on Docker or Podman restart policies. That keeps the behavior consistent across both runtimes and applies Gordon's normal deploy checks, including fresh image pulls, readiness checks, and drain behavior when needed.
122+Recovery stays inside Gordon's own control flow instead of relying on Docker or Podman restart policies. That keeps the behavior consistent across both runtimes.
123+
124+Startup recovery is intentionally narrower than a manual deploy. It only starts routes that are missing a running container, skips readiness checks during boot, and does not perform drain/replacement logic for routes that are already running.
123125
124126 ### Shutdown Sequence
125127
@@ -189,11 +191,11 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
189191
190192 ### Description
191193
192-**Local mode:** On the Gordon host, writes a deploy request and sends `SIGUSR2` to the local Gordon process. Gordon then runs the deploy inside the server through the same internal path it uses during startup recovery.
194+**Local mode:** On the Gordon host, Gordon uses the explicit deploy path for the selected route. When the CLI cannot execute that path directly, it falls back to queueing the request with `SIGUSR2` for the running server. This is different from startup recovery: startup recovery uses `AutoStart`, while a manual deploy performs an explicit redeploy for the selected route.
193195
194196 **Remote mode:** Calls the remote Gordon Admin API to trigger deployment. The remote Gordon instance still performs the actual deploy internally; the CLI only submits the request.
195197
196-Both modes use Gordon's internal deploy path:
198+Both local and remote manual deploys use Gordon's explicit deploy path:
197199
198200 - Fresh image content is pulled for the route
199201 - The specified route is redeployed
@@ -114,12 +114,14 @@ sudo loginctl enable-linger $USER
114 After Gordon starts, including after a host reboot, it runs a best-effort recovery pass once the listeners are bound. Errors are logged, but Gordon keeps starting.114 After Gordon starts, including after a host reboot, it runs a best-effort recovery pass once the listeners are bound. Errors are logged, but Gordon keeps starting.
115 115
116 1. **Sync existing containers** - Gordon reconciles runtime state with configured routes.116 1. **Sync existing containers** - Gordon reconciles runtime state with configured routes.
117-2. **Recover configured routes** - Gordon starts any configured route that has no running container.117+2. **Recover configured routes** - Gordon runs `AutoStart` for any configured route that has no running container.
118 3. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass.118 3. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass.
119 119
120 This recovery runs even when `[auto_route].enabled = false`. `auto_route` only controls whether new image pushes create routes automatically; it does not disable restart recovery for routes already in the config.120 This recovery runs even when `[auto_route].enabled = false`. `auto_route` only controls whether new image pushes create routes automatically; it does not disable restart recovery for routes already in the config.
121 121
122-Recovery stays inside Gordon's own control flow instead of relying on Docker or Podman restart policies. That keeps the behavior consistent across both runtimes and applies Gordon's normal deploy checks, including fresh image pulls, readiness checks, and drain behavior when needed.122+Recovery stays inside Gordon's own control flow instead of relying on Docker or Podman restart policies. That keeps the behavior consistent across both runtimes.
123+
124+Startup recovery is intentionally narrower than a manual deploy. It only starts routes that are missing a running container, skips readiness checks during boot, and does not perform drain/replacement logic for routes that are already running.
123 125
124 ### Shutdown Sequence126 ### Shutdown Sequence
125 127
@@ -189,11 +191,11 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
189 191
190 ### Description192 ### Description
191 193
192-**Local mode:** On the Gordon host, writes a deploy request and sends `SIGUSR2` to the local Gordon process. Gordon then runs the deploy inside the server through the same internal path it uses during startup recovery.194+**Local mode:** On the Gordon host, Gordon uses the explicit deploy path for the selected route. When the CLI cannot execute that path directly, it falls back to queueing the request with `SIGUSR2` for the running server. This is different from startup recovery: startup recovery uses `AutoStart`, while a manual deploy performs an explicit redeploy for the selected route.
193 195
194 **Remote mode:** Calls the remote Gordon Admin API to trigger deployment. The remote Gordon instance still performs the actual deploy internally; the CLI only submits the request.196 **Remote mode:** Calls the remote Gordon Admin API to trigger deployment. The remote Gordon instance still performs the actual deploy internally; the CLI only submits the request.
195 197
196-Both modes use Gordon's internal deploy path:198+Both local and remote manual deploys use Gordon's explicit deploy path:
197 199
198 - Fresh image content is pulled for the route200 - Fresh image content is pulled for the route
199 - The specified route is redeployed201 - The specified route is redeployed
M internal/app/startup_recovery.go +4 -2
@@ -3,12 +3,14 @@ package app
33 import (
44 "context"
55
6- "github.com/bnema/gordon/internal/domain"
76 "github.com/bnema/zerowrap"
7+
8+ "github.com/bnema/gordon/internal/domain"
89 )
910
1011 // startupConfigService defines the route configuration needed during startup
11-// recovery.
12+// recovery. It intentionally excludes auto-route settings because reboot
13+// recovery always works from configured routes.
1214 type startupConfigService interface {
1315 GetRoutes(ctx context.Context) []domain.Route
1416 }
@@ -3,12 +3,14 @@ package app
3 import (3 import (
4 "context"4 "context"
5 5
6- "github.com/bnema/gordon/internal/domain"
7 "github.com/bnema/zerowrap"6 "github.com/bnema/zerowrap"
7+
8+ "github.com/bnema/gordon/internal/domain"
8 )9 )
9 10
10 // startupConfigService defines the route configuration needed during startup11 // startupConfigService defines the route configuration needed during startup
11-// recovery.12+// recovery. It intentionally excludes auto-route settings because reboot
13+// recovery always works from configured routes.
12 type startupConfigService interface {14 type startupConfigService interface {
13 GetRoutes(ctx context.Context) []domain.Route15 GetRoutes(ctx context.Context) []domain.Route
14 }16 }
M internal/app/startup_recovery_test.go +6 -37
@@ -11,27 +11,17 @@ import (
1111 )
1212
1313 type startupRecoveryFakeConfigService struct {
14- calls *[]string
15- routes []domain.Route
16- autoRouteEnabled bool
17- panicOnAutoRouteCheck bool
14+ calls *[]string
15+ routes []domain.Route
1816 }
1917
18+var _ startupConfigService = (*startupRecoveryFakeConfigService)(nil)
19+
2020 func (f *startupRecoveryFakeConfigService) GetRoutes(_ context.Context) []domain.Route {
2121 *f.calls = append(*f.calls, "routes")
2222 return append([]domain.Route(nil), f.routes...)
2323 }
2424
25-func (f *startupRecoveryFakeConfigService) IsAutoRouteEnabled(_ context.Context) bool {
26- if f.panicOnAutoRouteCheck {
27- panic("unexpected auto-route gate during startup recovery")
28- }
29- if f.calls != nil {
30- *f.calls = append(*f.calls, "auto-route-enabled")
31- }
32- return f.autoRouteEnabled
33-}
34-
3525 type startupRecoveryFakeContainerService struct {
3626 calls *[]string
3727 syncErr error
@@ -41,6 +31,8 @@ type startupRecoveryFakeContainerService struct {
4131 startMonitorCtx context.Context
4232 }
4333
34+var _ startupContainerService = (*startupRecoveryFakeContainerService)(nil)
35+
4436 func (f *startupRecoveryFakeContainerService) SyncContainers(_ context.Context) error {
4537 *f.calls = append(*f.calls, "sync")
4638 return f.syncErr
@@ -125,26 +117,3 @@ func TestSyncAndRecoverConfiguredRoutes_AutoStartFailureStillStartsMonitor(t *te
125117 assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
126118 assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
127119 }
128-
129-func TestSyncAndRecoverConfiguredRoutes_ProceedsEvenWhenAutoRouteDisabled(t *testing.T) {
130- ctx := context.Background()
131- routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
132- calls := make([]string, 0, 4)
133-
134- configSvc := &startupRecoveryFakeConfigService{
135- calls: &calls,
136- routes: routes,
137- autoRouteEnabled: false,
138- panicOnAutoRouteCheck: true,
139- }
140- containerSvc := &startupRecoveryFakeContainerService{calls: &calls}
141-
142- assert.NotPanics(t, func() {
143- syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
144- })
145-
146- assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
147- assert.Equal(t, routes, containerSvc.autoStartRoutes)
148- assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
149- assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
150-}
@@ -11,27 +11,17 @@ import (
11 )11 )
12 12
13 type startupRecoveryFakeConfigService struct {13 type startupRecoveryFakeConfigService struct {
14- calls *[]string14+ calls *[]string
15- routes []domain.Route15+ routes []domain.Route
16- autoRouteEnabled bool
17- panicOnAutoRouteCheck bool
18 }16 }
19 17
18+var _ startupConfigService = (*startupRecoveryFakeConfigService)(nil)
19+
20 func (f *startupRecoveryFakeConfigService) GetRoutes(_ context.Context) []domain.Route {20 func (f *startupRecoveryFakeConfigService) GetRoutes(_ context.Context) []domain.Route {
21 *f.calls = append(*f.calls, "routes")21 *f.calls = append(*f.calls, "routes")
22 return append([]domain.Route(nil), f.routes...)22 return append([]domain.Route(nil), f.routes...)
23 }23 }
24 24
25-func (f *startupRecoveryFakeConfigService) IsAutoRouteEnabled(_ context.Context) bool {
26- if f.panicOnAutoRouteCheck {
27- panic("unexpected auto-route gate during startup recovery")
28- }
29- if f.calls != nil {
30- *f.calls = append(*f.calls, "auto-route-enabled")
31- }
32- return f.autoRouteEnabled
33-}
34-
35 type startupRecoveryFakeContainerService struct {25 type startupRecoveryFakeContainerService struct {
36 calls *[]string26 calls *[]string
37 syncErr error27 syncErr error
@@ -41,6 +31,8 @@ type startupRecoveryFakeContainerService struct {
41 startMonitorCtx context.Context31 startMonitorCtx context.Context
42 }32 }
43 33
34+var _ startupContainerService = (*startupRecoveryFakeContainerService)(nil)
35+
44 func (f *startupRecoveryFakeContainerService) SyncContainers(_ context.Context) error {36 func (f *startupRecoveryFakeContainerService) SyncContainers(_ context.Context) error {
45 *f.calls = append(*f.calls, "sync")37 *f.calls = append(*f.calls, "sync")
46 return f.syncErr38 return f.syncErr
@@ -125,26 +117,3 @@ func TestSyncAndRecoverConfiguredRoutes_AutoStartFailureStillStartsMonitor(t *te
125 assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))117 assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
126 assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))118 assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
127 }119 }
128-
129-func TestSyncAndRecoverConfiguredRoutes_ProceedsEvenWhenAutoRouteDisabled(t *testing.T) {
130- ctx := context.Background()
131- routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
132- calls := make([]string, 0, 4)
133-
134- configSvc := &startupRecoveryFakeConfigService{
135- calls: &calls,
136- routes: routes,
137- autoRouteEnabled: false,
138- panicOnAutoRouteCheck: true,
139- }
140- containerSvc := &startupRecoveryFakeContainerService{calls: &calls}
141-
142- assert.NotPanics(t, func() {
143- syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
144- })
145-
146- assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
147- assert.Equal(t, routes, containerSvc.autoStartRoutes)
148- assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
149- assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
150-}
M internal/domain/registry_image_test.go +7 -16
@@ -1,9 +1,10 @@
11 package domain_test
22
33 import (
4- "reflect"
54 "testing"
65
6+ "github.com/stretchr/testify/assert"
7+
78 "github.com/bnema/gordon/internal/domain"
89 )
910
@@ -22,9 +23,7 @@ func TestRegistryImageKnownGordonRegistryDomains(t *testing.T) {
2223 "old-registry.example.com:5000",
2324 }
2425
25- if !reflect.DeepEqual(got, want) {
26- t.Fatalf("KnownGordonRegistryDomains() = %v, want %v", got, want)
27- }
26+ assert.Equal(t, want, got)
2827 }
2928
3029 func TestRegistryImageStripKnownGordonRegistry(t *testing.T) {
@@ -48,9 +47,7 @@ func TestRegistryImageStripKnownGordonRegistry(t *testing.T) {
4847 for _, tt := range tests {
4948 t.Run(tt.name, func(t *testing.T) {
5049 got := domain.StripKnownGordonRegistry(tt.imageRef, current, legacy)
51- if got != tt.want {
52- t.Fatalf("StripKnownGordonRegistry(%q) = %q, want %q", tt.imageRef, got, tt.want)
53- }
50+ assert.Equal(t, tt.want, got)
5451 })
5552 }
5653 }
@@ -75,9 +72,7 @@ func TestRegistryImageExtractGordonRepoName(t *testing.T) {
7572 for _, tt := range tests {
7673 t.Run(tt.name, func(t *testing.T) {
7774 got := domain.ExtractGordonRepoName(tt.imageRef, current, legacy)
78- if got != tt.want {
79- t.Fatalf("ExtractGordonRepoName(%q) = %q, want %q", tt.imageRef, got, tt.want)
80- }
75+ assert.Equal(t, tt.want, got)
8176 })
8277 }
8378 }
@@ -101,9 +96,7 @@ func TestRegistryImageCanonicalizeGordonImageRef(t *testing.T) {
10196 for _, tt := range tests {
10297 t.Run(tt.name, func(t *testing.T) {
10398 got := domain.CanonicalizeGordonImageRef(tt.imageRef, tt.current, legacy)
104- if got != tt.want {
105- t.Fatalf("CanonicalizeGordonImageRef(%q) = %q, want %q", tt.imageRef, got, tt.want)
106- }
99+ assert.Equal(t, tt.want, got)
107100 })
108101 }
109102 }
@@ -128,9 +121,7 @@ func TestRegistryImageIsGordonRegistryImageRef(t *testing.T) {
128121 for _, tt := range tests {
129122 t.Run(tt.name, func(t *testing.T) {
130123 got := domain.IsGordonRegistryImageRef(tt.imageRef, current, legacy)
131- if got != tt.want {
132- t.Fatalf("IsGordonRegistryImageRef(%q) = %t, want %t", tt.imageRef, got, tt.want)
133- }
124+ assert.Equal(t, tt.want, got)
134125 })
135126 }
136127 }
@@ -1,9 +1,10 @@
1 package domain_test1 package domain_test
2 2
3 import (3 import (
4- "reflect"
5 "testing"4 "testing"
6 5
6+ "github.com/stretchr/testify/assert"
7+
7 "github.com/bnema/gordon/internal/domain"8 "github.com/bnema/gordon/internal/domain"
8 )9 )
9 10
@@ -22,9 +23,7 @@ func TestRegistryImageKnownGordonRegistryDomains(t *testing.T) {
22 "old-registry.example.com:5000",23 "old-registry.example.com:5000",
23 }24 }
24 25
25- if !reflect.DeepEqual(got, want) {26+ assert.Equal(t, want, got)
26- t.Fatalf("KnownGordonRegistryDomains() = %v, want %v", got, want)
27- }
28 }27 }
29 28
30 func TestRegistryImageStripKnownGordonRegistry(t *testing.T) {29 func TestRegistryImageStripKnownGordonRegistry(t *testing.T) {
@@ -48,9 +47,7 @@ func TestRegistryImageStripKnownGordonRegistry(t *testing.T) {
48 for _, tt := range tests {47 for _, tt := range tests {
49 t.Run(tt.name, func(t *testing.T) {48 t.Run(tt.name, func(t *testing.T) {
50 got := domain.StripKnownGordonRegistry(tt.imageRef, current, legacy)49 got := domain.StripKnownGordonRegistry(tt.imageRef, current, legacy)
51- if got != tt.want {50+ assert.Equal(t, tt.want, got)
52- t.Fatalf("StripKnownGordonRegistry(%q) = %q, want %q", tt.imageRef, got, tt.want)
53- }
54 })51 })
55 }52 }
56 }53 }
@@ -75,9 +72,7 @@ func TestRegistryImageExtractGordonRepoName(t *testing.T) {
75 for _, tt := range tests {72 for _, tt := range tests {
76 t.Run(tt.name, func(t *testing.T) {73 t.Run(tt.name, func(t *testing.T) {
77 got := domain.ExtractGordonRepoName(tt.imageRef, current, legacy)74 got := domain.ExtractGordonRepoName(tt.imageRef, current, legacy)
78- if got != tt.want {75+ assert.Equal(t, tt.want, got)
79- t.Fatalf("ExtractGordonRepoName(%q) = %q, want %q", tt.imageRef, got, tt.want)
80- }
81 })76 })
82 }77 }
83 }78 }
@@ -101,9 +96,7 @@ func TestRegistryImageCanonicalizeGordonImageRef(t *testing.T) {
101 for _, tt := range tests {96 for _, tt := range tests {
102 t.Run(tt.name, func(t *testing.T) {97 t.Run(tt.name, func(t *testing.T) {
103 got := domain.CanonicalizeGordonImageRef(tt.imageRef, tt.current, legacy)98 got := domain.CanonicalizeGordonImageRef(tt.imageRef, tt.current, legacy)
104- if got != tt.want {99+ assert.Equal(t, tt.want, got)
105- t.Fatalf("CanonicalizeGordonImageRef(%q) = %q, want %q", tt.imageRef, got, tt.want)
106- }
107 })100 })
108 }101 }
109 }102 }
@@ -128,9 +121,7 @@ func TestRegistryImageIsGordonRegistryImageRef(t *testing.T) {
128 for _, tt := range tests {121 for _, tt := range tests {
129 t.Run(tt.name, func(t *testing.T) {122 t.Run(tt.name, func(t *testing.T) {
130 got := domain.IsGordonRegistryImageRef(tt.imageRef, current, legacy)123 got := domain.IsGordonRegistryImageRef(tt.imageRef, current, legacy)
131- if got != tt.want {124+ assert.Equal(t, tt.want, got)
132- t.Fatalf("IsGordonRegistryImageRef(%q) = %t, want %t", tt.imageRef, got, tt.want)
133- }
134 })125 })
135 }126 }
136 }127 }

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