Skip to content

Commit 4a6e0cc

Browse files
authored
fix: close the downtime Onebox causes on change and deploy (#222)
Onebox caused downtime it did not need to when a project changed and deployed. The managed proxy now restarts Traefik only when its configuration changes, recreates it only when its own definition changes, and keeps its routes until it restarts. ob upgrades and discovery repair no longer restart it. A failed update retries as an update, discovery is never left stopped, and the deploy plan's proxy: line names the action and binds execution. Rolls retire a replica only after every replacement is healthy and published by discovery, and stop one only after the proxy has dropped it, including when routes are added, removed or change protocol. Managed services get a two-minute shutdown window. Recovery restores the predecessor before removing serving containers, including for single-replica workloads. Upgrading: the first deploy or ob proxy apply restarts Traefik once. The first ob service apply recreates each managed service once, and the first deploy does so for PostgreSQL services with declared extensions. Claude-Session: https://claude.ai/code/session_01Dm55dV6FgUFE7vnWQEHCB9
1 parent d6a2130 commit 4a6e0cc

39 files changed

Lines changed: 4957 additions & 204 deletions

‎cmd/ob/commands.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,10 @@ func renderDeployPlan(cmd *cobra.Command, u *ui.UI, plan onebox.DeployPlan) {
337337
u.Infof("compose unchanged (release labels aside) — but the payload differs (env files / secrets); deploying ships it")
338338
}
339339

340+
if plan.ProxyAction != "" {
341+
u.Infof("proxy: %s", plan.ProxyAction)
342+
}
343+
340344
u.Println(u.Bold("operation:"))
341345
u.Println(fmt.Sprintf(" risk=%s reversibility=%s approval=%s",
342346
plan.Operation.Risk, plan.Operation.Reversibility, plan.Operation.Approval))

‎cmd/ob/commands_test.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
"github.com/labstack/onebox/internal/app"
1919
"github.com/labstack/onebox/internal/onebox"
20+
"github.com/labstack/onebox/internal/ui"
2021
)
2122

2223
func TestConfirmDefaultsToNo(t *testing.T) {
@@ -191,3 +192,14 @@ func TestNotifyOutcome(t *testing.T) {
191192
t.Fatal("nil notify must be a no-op")
192193
}
193194
}
195+
196+
func TestRenderDeployPlanShowsProxyAction(t *testing.T) {
197+
var out bytes.Buffer
198+
cmd := &cobra.Command{}
199+
cmd.SetOut(&out)
200+
plan := onebox.DeployPlan{ProxyAction: "restart Traefik (configuration changed)"}
201+
renderDeployPlan(cmd, ui.New(&out, false), plan)
202+
if !strings.Contains(out.String(), "proxy: restart Traefik (configuration changed)") {
203+
t.Fatalf("proxy action missing: %q", out.String())
204+
}
205+
}

‎docs/plans/2026-10-17-zero-downtime-gaps.md‎

Lines changed: 363 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/superpowers/plans/2026-10-17-zero-downtime-gaps.md‎

Lines changed: 644 additions & 0 deletions
Large diffs are not rendered by default.

‎internal/app/services.go‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import (
44
"fmt"
55
"sort"
66
"strings"
7+
"time"
78

89
"github.com/compose-spec/compose-go/v2/types"
910

1011
"github.com/labstack/onebox/internal/shellquote"
1112
)
1213

14+
// ServiceStopGrace gives managed services time to flush their durable state.
15+
const ServiceStopGrace = 2 * time.Minute
16+
1317
// Supporting services are the reason this tool exists. Nearly every
1418
// application needs a database and a cache, nobody's application *is* the
1519
// database, and writing the same Postgres service block into every project is
@@ -365,9 +369,10 @@ func (p *Spec) renderService(n Names, name string, s Service, selectedImage stri
365369
return nil, err
366370
}
367371
svc := map[string]any{
368-
"image": image,
369-
"restart": "unless-stopped",
370-
"container_name": n.ServiceContainer(name),
372+
"image": image,
373+
"restart": "unless-stopped",
374+
"stop_grace_period": ServiceStopGrace.String(),
375+
"container_name": n.ServiceContainer(name),
371376
"labels": map[string]any{
372377
"onebox.app": p.Name,
373378
"onebox.service": name,

‎internal/app/services_test.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,3 +646,14 @@ func TestEphemeralServiceCannotDeclareBackup(t *testing.T) {
646646
t.Fatalf("refusal does not explain itself: %v", err)
647647
}
648648
}
649+
650+
func TestRenderedServicesCarryStopGrace(t *testing.T) {
651+
for _, driver := range []string{"postgres", "redis"} {
652+
t.Run(driver, func(t *testing.T) {
653+
doc := renderStore(t, "services: {store: {driver: "+driver+", version: '17'}}\n")
654+
if !strings.Contains(doc, "stop_grace_period: 2m0s") {
655+
t.Fatalf("service has no two-minute shutdown window:\n%s", doc)
656+
}
657+
})
658+
}
659+
}

‎internal/app/testdata/contract-verdicts.json‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -392,17 +392,17 @@
392392
{
393393
"case": "conformance/service backup policy",
394394
"loads": true,
395-
"digest": "73135190e9f9fae42347e107f496211e4d2f75bfe5ff934ead0096f4f3571a19 postgres=ee21ed4fc49969fb"
395+
"digest": "73135190e9f9fae42347e107f496211e4d2f75bfe5ff934ead0096f4f3571a19 postgres=d48aeac2a592c834"
396396
},
397397
{
398398
"case": "conformance/service scalar",
399399
"loads": true,
400-
"digest": "f6ddf429bf783de806d4b1475df352d4b3ff85210dfe379804248dd3fdd0b80a postgres=ff6eeff3e4bbd9bd"
400+
"digest": "f6ddf429bf783de806d4b1475df352d4b3ff85210dfe379804248dd3fdd0b80a postgres=45f65def6f3b45e9"
401401
},
402402
{
403403
"case": "conformance/settings key that is a real driver flag",
404404
"loads": true,
405-
"digest": "f6ddf429bf783de806d4b1475df352d4b3ff85210dfe379804248dd3fdd0b80a redis=fd038e9c5b5c3b09"
405+
"digest": "f6ddf429bf783de806d4b1475df352d4b3ff85210dfe379804248dd3fdd0b80a redis=c4d310541968b4d6"
406406
},
407407
{
408408
"case": "conformance/settings key with a shell metacharacter",
@@ -532,12 +532,12 @@
532532
{
533533
"case": "corpus/authentik.yml",
534534
"loads": true,
535-
"digest": "e82f2e9eac87e3494cf0aa86b80f91a60f363f0af7aac28bb45e1ec6ed2e40fb postgres=4d380770b261847a redis=53bad39e8f359a02"
535+
"digest": "e82f2e9eac87e3494cf0aa86b80f91a60f363f0af7aac28bb45e1ec6ed2e40fb postgres=f81c980270118d21 redis=4f8002b0a56335d5"
536536
},
537537
{
538538
"case": "corpus/ext-authentik-managed.yml",
539539
"loads": true,
540-
"digest": "10748708444a383dc2e587286c13f06a4f3ea218835e2eeab1af83ed312ec741 postgres=4d380770b261847a redis=53bad39e8f359a02"
540+
"digest": "10748708444a383dc2e587286c13f06a4f3ea218835e2eeab1af83ed312ec741 postgres=f81c980270118d21 redis=4f8002b0a56335d5"
541541
},
542542
{
543543
"case": "corpus/ext-authentik.yml",
@@ -552,7 +552,7 @@
552552
{
553553
"case": "corpus/ext-gitea.yml",
554554
"loads": true,
555-
"digest": "f6028df90e48b43f68c3375115ef402255ebd0aadf0ddde9eb9e2d538598cb65 postgres=105be1b298f59e44"
555+
"digest": "f6028df90e48b43f68c3375115ef402255ebd0aadf0ddde9eb9e2d538598cb65 postgres=24bccf98808db97d"
556556
},
557557
{
558558
"case": "corpus/ext-immich-sourced.yml",
@@ -567,7 +567,7 @@
567567
{
568568
"case": "corpus/ext-n8n.yml",
569569
"loads": true,
570-
"digest": "3831bd9a52d01deada4936e218fc8ba33c96a0b1d939e6bf503f3fbda8ba0895 postgres=12dc8bcbdff40808 redis=83f7ec43501bb1f4"
570+
"digest": "3831bd9a52d01deada4936e218fc8ba33c96a0b1d939e6bf503f3fbda8ba0895 postgres=5b23cbde31e9dc29 redis=0644b31adce178f9"
571571
},
572572
{
573573
"case": "corpus/ext-paperless.yml",
@@ -577,17 +577,17 @@
577577
{
578578
"case": "corpus/ext-plausible.yml",
579579
"loads": true,
580-
"digest": "4574810017062b8d857ad2c43597b484fe88e6b4198f75ec74c2d705cbcf17d1 events=79a3b4327069a1dc postgres=1aea911e88a3631c"
580+
"digest": "4574810017062b8d857ad2c43597b484fe88e6b4198f75ec74c2d705cbcf17d1 events=47324284a6e77538 postgres=361a106a8e2ffde3"
581581
},
582582
{
583583
"case": "corpus/ext-umami.yml",
584584
"loads": true,
585-
"digest": "fd193a8c2f51cbe206e2e038ccd277c3e6785c84912b5967a2afd58c1320ac13 postgres=47bbd25b517473d9"
585+
"digest": "fd193a8c2f51cbe206e2e038ccd277c3e6785c84912b5967a2afd58c1320ac13 postgres=d07ceb7c2c0392b1"
586586
},
587587
{
588588
"case": "corpus/ghost.yml",
589589
"loads": true,
590-
"digest": "7ab89c09054e821edfb0edd5c014b488ca06785d052f4178a688d4008961e1ad mysql=6dc2ab74dac73160"
590+
"digest": "7ab89c09054e821edfb0edd5c014b488ca06785d052f4178a688d4008961e1ad mysql=393016b70855b2cd"
591591
},
592592
{
593593
"case": "corpus/gitea.yml",
@@ -622,7 +622,7 @@
622622
{
623623
"case": "corpus/penpot.yml",
624624
"loads": true,
625-
"digest": "1cd500aae4deac5cf744dec3f1222b13aec493457de4b6069a3449126ad70210 postgres=8a2a89524e0749e0 redis=e6f4fb5264fe75ae"
625+
"digest": "1cd500aae4deac5cf744dec3f1222b13aec493457de4b6069a3449126ad70210 postgres=e98b19cff65fe766 redis=f8e5a12917a49994"
626626
},
627627
{
628628
"case": "corpus/pursue.yml",
@@ -637,7 +637,7 @@
637637
{
638638
"case": "corpus/rocketchat.yml",
639639
"loads": true,
640-
"digest": "4a091cd29c4a572eb57fcc9cd68edb76fbd56eb88ce16aab29efb30cb16af69f mongodb=09562fa5123be0a1"
640+
"digest": "4a091cd29c4a572eb57fcc9cd68edb76fbd56eb88ce16aab29efb30cb16af69f mongodb=ada71cea467aa56d"
641641
},
642642
{
643643
"case": "corpus/umami.yml",

‎internal/discovery/discovery_test.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,11 @@ func TestWriteAtomicProducesTraefikYAML(t *testing.T) {
160160
t.Fatalf("temporary files remain: %v", matches)
161161
}
162162
}
163+
164+
func TestEndpointAddressPrefersIPv4(t *testing.T) {
165+
for _, tc := range []struct{ v4, v6, want string }{{"10.0.0.5", "fd00::5", "10.0.0.5"}, {"", "fd00::5", "fd00::5"}, {"invalid", "fd00::5", "fd00::5"}} {
166+
if got := EndpointAddress(tc.v4, tc.v6); got != tc.want {
167+
t.Fatalf("EndpointAddress = %q, want %q", got, tc.want)
168+
}
169+
}
170+
}

‎internal/discovery/docker.go‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,19 @@ func (c *Client) inspect(ctx context.Context, id string) (Container, error) {
100100
container.Health = raw.State.Health.Status
101101
}
102102
for name, endpoint := range raw.NetworkSettings.Networks {
103-
address := endpoint.IPAddress
104-
if net.ParseIP(address) == nil {
105-
address = endpoint.GlobalIPv6Address
106-
}
107-
container.Networks[name] = address
103+
container.Networks[name] = EndpointAddress(endpoint.IPAddress, endpoint.GlobalIPv6Address)
108104
}
109105
return container, nil
110106
}
111107

108+
// EndpointAddress selects the same network address for discovery and rollout gates.
109+
func EndpointAddress(ipv4, ipv6 string) string {
110+
if net.ParseIP(ipv4) != nil {
111+
return ipv4
112+
}
113+
return ipv6
114+
}
115+
112116
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
113117
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker"+path, nil)
114118
if err != nil {

‎internal/engine/deploy_test.go‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,23 @@ func happyFake() *transport.Fake {
7171
scaleCount, recreateCount := 0, 0
7272
initialScaleCount, initialRecreateCount := 0, 0
7373
seenExactReleaseQuery := false
74+
recoveryRelease := ""
75+
interruptedOldGone := false
7476
newGone, workerGone := false, false
7577
name := map[string]string{"OLD1": "web"}
7678
for _, c := range f.Commands {
79+
if recoveryRelease == "" && strings.Contains(c, "docker ps -a --filter label=onebox.app=") && strings.Contains(c, "label=onebox.release=") {
80+
_, releaseFilter, _ := strings.Cut(c, "label=onebox.release=")
81+
recoveryRelease = strings.Fields(releaseFilter)[0]
82+
// Recovery rolls the predecessor into NEW1/W1; the interrupted
83+
// web replica becomes OLD1 in this fixture's next roll.
84+
scaled, oldGone, drained = false, false, false
85+
scaleCount, recreateCount = 0, 0
86+
initialScaleCount, initialRecreateCount = 0, 0
87+
newGone, workerGone = false, true
88+
name = map[string]string{"OLD1": "web"}
89+
seenExactReleaseQuery = true
90+
}
7791
if strings.Contains(c, "docker ps -aq") && strings.Contains(c, "label=onebox.app=") && strings.Contains(c, "label=onebox.release=") {
7892
seenExactReleaseQuery = true
7993
}
@@ -90,8 +104,11 @@ func happyFake() *transport.Fake {
90104
initialRecreateCount++
91105
}
92106
}
93-
if strings.Contains(c, "docker rm OLD1") {
107+
if strings.Contains(c, "docker rm OLD1") || strings.Contains(c, "docker rm -f OLD1") {
94108
oldGone = true
109+
if recoveryRelease != "" {
110+
interruptedOldGone = true
111+
}
95112
}
96113
if strings.Contains(c, "docker rm -f NEW1") {
97114
newGone = true
@@ -114,6 +131,11 @@ func happyFake() *transport.Fake {
114131
return fs[len(fs)-1]
115132
}
116133
switch {
134+
case strings.Contains(cmd, "docker ps -a --filter label=onebox.app=") && strings.Contains(cmd, "label=onebox.release="):
135+
if !interruptedOldGone {
136+
return transport.Result{Stdout: "OLD1|web|sample-web-1|running"}, true
137+
}
138+
return transport.Result{}, true
117139
// Compose counts stopped replicas toward --scale, so the roll asks for
118140
// them separately; these fakes model none.
119141
case strings.Contains(cmd, "status=exited"):
@@ -180,6 +202,12 @@ func happyFake() *transport.Fake {
180202
}
181203
return transport.Result{}, true
182204
case strings.Contains(cmd, "docker ps -aq") && strings.Contains(cmd, "label=onebox.app=") && strings.Contains(cmd, "label=onebox.release="):
205+
if recoveryRelease != "" && strings.Contains(cmd, "label=onebox.release="+recoveryRelease) {
206+
if !interruptedOldGone {
207+
return transport.Result{Stdout: "OLD1\n"}, true
208+
}
209+
return transport.Result{}, true
210+
}
183211
var ids []string
184212
if initialScaleCount > 0 && !newGone {
185213
ids = append(ids, "NEW1")
@@ -299,7 +327,17 @@ func TestDeployUpgradesManagedProxyAfterReadOnlyPreconditions(t *testing.T) {
299327
f := happyFake()
300328
base := f.Dynamic
301329
proxyRuntime := legacyProxyPS(f)
330+
drained := false
302331
f.Dynamic = func(command string) (transport.Result, bool) {
332+
if strings.Contains(command, "docker exec OLD1 touch ") {
333+
drained = true
334+
}
335+
if strings.HasPrefix(command, "cat ") && strings.Contains(command, "/dynamic/onebox.yml") {
336+
if !drained {
337+
return transport.Result{Stdout: "http:\n services:\n previous:\n loadBalancer:\n servers:\n - url: http://172.20.0.5:3000\n"}, true
338+
}
339+
return transport.Result{Stdout: "{}\n"}, true
340+
}
303341
if result, ok := proxyRuntime(command); ok {
304342
return result, true
305343
}

0 commit comments

Comments
 (0)