Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a78cac2
docs: plan closing avoidable downtime
vishr Oct 17, 2026
df824fd
refactor(proxy): stage a config digest and a compose digest
vishr Oct 17, 2026
76f1202
refactor(proxy): observe the host proxy and decide its converge path
vishr Oct 17, 2026
a830982
fix(proxy): restart Traefik only when its configuration changes
vishr Oct 17, 2026
e93daa4
fix(status): say which half of the proxy drifted
vishr Oct 17, 2026
f55ea36
fix(proxy): log the short proxy digests
vishr Oct 17, 2026
a2a4f50
feat(plan): show what a deploy does to the proxy
vishr Oct 17, 2026
465b6c0
fix(services): give managed services two minutes to shut down
vishr Oct 17, 2026
7bf03c5
feat(rolling): read the routes discovery publishes
vishr Oct 17, 2026
3e783d2
fix(rolling): never retire a replica before its replacement is routed
vishr Oct 17, 2026
fddc334
fix(rolling): stop a replica only after the proxy has dropped it
vishr Oct 17, 2026
c7c91f6
fix(recovery): restore the predecessor before removing serving contai…
vishr Oct 17, 2026
416e3bf
docs: make recovery retention ownership-aware
vishr Oct 17, 2026
f91deaf
fix(recovery): roll workloads the interrupted release still owns
vishr Oct 17, 2026
22447e0
docs: describe the routing gates and when Traefik restarts
vishr Oct 17, 2026
e7de59e
fix(rolling): satisfy the linter on the routing gate
vishr Oct 17, 2026
a24cd8c
docs: record the review round decisions
vishr Oct 17, 2026
f499503
fix(rolling): withdraw old replicas whatever the new routes are
vishr Oct 17, 2026
da2afa7
docs: correct the single-replica recovery test
vishr Oct 17, 2026
9c40ce9
fix(recovery): keep a single-replica workload serving through recovery
vishr Oct 17, 2026
e89848e
fix(recovery): abort an undrain on a lost lock
vishr Oct 17, 2026
92cea20
fix(proxy): retry a converge as a converge and keep routes until restart
vishr Oct 18, 2026
409cbb4
fix(plan): say when Traefik itself will be replaced
vishr Oct 18, 2026
d5793e7
docs: describe proxy actions and the one-time upgrade restarts
vishr Oct 18, 2026
a5148ea
docs: name every command that triggers the one-time restarts
vishr Oct 18, 2026
a92c3a9
docs: record the second review round decisions
vishr Oct 18, 2026
9ff4612
fix(proxy): never leave discovery stopped and keep a failed replace l…
vishr Oct 18, 2026
af3ead6
docs(status): list the routing gates among shipped capabilities
vishr Oct 18, 2026
86d4e71
docs: record the code review round decisions
vishr Oct 18, 2026
5a07dcc
fix(proxy): restore discovery on every path and bind the planned prox…
vishr Oct 18, 2026
bd35c34
perf(rolling): admit each newcomer once
vishr Oct 18, 2026
6f4e5e7
docs: record the final review round decisions
vishr Oct 18, 2026
c26ade0
fix(rolling): re-check every newcomer before each retirement
vishr Oct 18, 2026
c230c20
fix(proxy): commit the marker only after health on every path
vishr Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/ob/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,10 @@ func renderDeployPlan(cmd *cobra.Command, u *ui.UI, plan onebox.DeployPlan) {
u.Infof("compose unchanged (release labels aside) — but the payload differs (env files / secrets); deploying ships it")
}

if plan.ProxyAction != "" {
u.Infof("proxy: %s", plan.ProxyAction)
}

u.Println(u.Bold("operation:"))
u.Println(fmt.Sprintf(" risk=%s reversibility=%s approval=%s",
plan.Operation.Risk, plan.Operation.Reversibility, plan.Operation.Approval))
Expand Down
12 changes: 12 additions & 0 deletions cmd/ob/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

"github.com/labstack/onebox/internal/app"
"github.com/labstack/onebox/internal/onebox"
"github.com/labstack/onebox/internal/ui"
)

func TestConfirmDefaultsToNo(t *testing.T) {
Expand Down Expand Up @@ -191,3 +192,14 @@ func TestNotifyOutcome(t *testing.T) {
t.Fatal("nil notify must be a no-op")
}
}

func TestRenderDeployPlanShowsProxyAction(t *testing.T) {
var out bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&out)
plan := onebox.DeployPlan{ProxyAction: "restart Traefik (configuration changed)"}
renderDeployPlan(cmd, ui.New(&out, false), plan)
if !strings.Contains(out.String(), "proxy: restart Traefik (configuration changed)") {
t.Fatalf("proxy action missing: %q", out.String())
}
}
363 changes: 363 additions & 0 deletions docs/plans/2026-10-17-zero-downtime-gaps.md

Large diffs are not rendered by default.

644 changes: 644 additions & 0 deletions docs/superpowers/plans/2026-10-17-zero-downtime-gaps.md

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions internal/app/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import (
"fmt"
"sort"
"strings"
"time"

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

"github.com/labstack/onebox/internal/shellquote"
)

// ServiceStopGrace gives managed services time to flush their durable state.
const ServiceStopGrace = 2 * time.Minute

// Supporting services are the reason this tool exists. Nearly every
// application needs a database and a cache, nobody's application *is* the
// database, and writing the same Postgres service block into every project is
Expand Down Expand Up @@ -365,9 +369,10 @@ func (p *Spec) renderService(n Names, name string, s Service, selectedImage stri
return nil, err
}
svc := map[string]any{
"image": image,
"restart": "unless-stopped",
"container_name": n.ServiceContainer(name),
"image": image,
"restart": "unless-stopped",
"stop_grace_period": ServiceStopGrace.String(),
"container_name": n.ServiceContainer(name),
"labels": map[string]any{
"onebox.app": p.Name,
"onebox.service": name,
Expand Down
11 changes: 11 additions & 0 deletions internal/app/services_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -646,3 +646,14 @@ func TestEphemeralServiceCannotDeclareBackup(t *testing.T) {
t.Fatalf("refusal does not explain itself: %v", err)
}
}

func TestRenderedServicesCarryStopGrace(t *testing.T) {
for _, driver := range []string{"postgres", "redis"} {
t.Run(driver, func(t *testing.T) {
doc := renderStore(t, "services: {store: {driver: "+driver+", version: '17'}}\n")
if !strings.Contains(doc, "stop_grace_period: 2m0s") {
t.Fatalf("service has no two-minute shutdown window:\n%s", doc)
}
})
}
}
24 changes: 12 additions & 12 deletions internal/app/testdata/contract-verdicts.json
Original file line number Diff line number Diff line change
Expand Up @@ -392,17 +392,17 @@
{
"case": "conformance/service backup policy",
"loads": true,
"digest": "73135190e9f9fae42347e107f496211e4d2f75bfe5ff934ead0096f4f3571a19 postgres=ee21ed4fc49969fb"
"digest": "73135190e9f9fae42347e107f496211e4d2f75bfe5ff934ead0096f4f3571a19 postgres=d48aeac2a592c834"
},
{
"case": "conformance/service scalar",
"loads": true,
"digest": "f6ddf429bf783de806d4b1475df352d4b3ff85210dfe379804248dd3fdd0b80a postgres=ff6eeff3e4bbd9bd"
"digest": "f6ddf429bf783de806d4b1475df352d4b3ff85210dfe379804248dd3fdd0b80a postgres=45f65def6f3b45e9"
},
{
"case": "conformance/settings key that is a real driver flag",
"loads": true,
"digest": "f6ddf429bf783de806d4b1475df352d4b3ff85210dfe379804248dd3fdd0b80a redis=fd038e9c5b5c3b09"
"digest": "f6ddf429bf783de806d4b1475df352d4b3ff85210dfe379804248dd3fdd0b80a redis=c4d310541968b4d6"
},
{
"case": "conformance/settings key with a shell metacharacter",
Expand Down Expand Up @@ -532,12 +532,12 @@
{
"case": "corpus/authentik.yml",
"loads": true,
"digest": "e82f2e9eac87e3494cf0aa86b80f91a60f363f0af7aac28bb45e1ec6ed2e40fb postgres=4d380770b261847a redis=53bad39e8f359a02"
"digest": "e82f2e9eac87e3494cf0aa86b80f91a60f363f0af7aac28bb45e1ec6ed2e40fb postgres=f81c980270118d21 redis=4f8002b0a56335d5"
},
{
"case": "corpus/ext-authentik-managed.yml",
"loads": true,
"digest": "10748708444a383dc2e587286c13f06a4f3ea218835e2eeab1af83ed312ec741 postgres=4d380770b261847a redis=53bad39e8f359a02"
"digest": "10748708444a383dc2e587286c13f06a4f3ea218835e2eeab1af83ed312ec741 postgres=f81c980270118d21 redis=4f8002b0a56335d5"
},
{
"case": "corpus/ext-authentik.yml",
Expand All @@ -552,7 +552,7 @@
{
"case": "corpus/ext-gitea.yml",
"loads": true,
"digest": "f6028df90e48b43f68c3375115ef402255ebd0aadf0ddde9eb9e2d538598cb65 postgres=105be1b298f59e44"
"digest": "f6028df90e48b43f68c3375115ef402255ebd0aadf0ddde9eb9e2d538598cb65 postgres=24bccf98808db97d"
},
{
"case": "corpus/ext-immich-sourced.yml",
Expand All @@ -567,7 +567,7 @@
{
"case": "corpus/ext-n8n.yml",
"loads": true,
"digest": "3831bd9a52d01deada4936e218fc8ba33c96a0b1d939e6bf503f3fbda8ba0895 postgres=12dc8bcbdff40808 redis=83f7ec43501bb1f4"
"digest": "3831bd9a52d01deada4936e218fc8ba33c96a0b1d939e6bf503f3fbda8ba0895 postgres=5b23cbde31e9dc29 redis=0644b31adce178f9"
},
{
"case": "corpus/ext-paperless.yml",
Expand All @@ -577,17 +577,17 @@
{
"case": "corpus/ext-plausible.yml",
"loads": true,
"digest": "4574810017062b8d857ad2c43597b484fe88e6b4198f75ec74c2d705cbcf17d1 events=79a3b4327069a1dc postgres=1aea911e88a3631c"
"digest": "4574810017062b8d857ad2c43597b484fe88e6b4198f75ec74c2d705cbcf17d1 events=47324284a6e77538 postgres=361a106a8e2ffde3"
},
{
"case": "corpus/ext-umami.yml",
"loads": true,
"digest": "fd193a8c2f51cbe206e2e038ccd277c3e6785c84912b5967a2afd58c1320ac13 postgres=47bbd25b517473d9"
"digest": "fd193a8c2f51cbe206e2e038ccd277c3e6785c84912b5967a2afd58c1320ac13 postgres=d07ceb7c2c0392b1"
},
{
"case": "corpus/ghost.yml",
"loads": true,
"digest": "7ab89c09054e821edfb0edd5c014b488ca06785d052f4178a688d4008961e1ad mysql=6dc2ab74dac73160"
"digest": "7ab89c09054e821edfb0edd5c014b488ca06785d052f4178a688d4008961e1ad mysql=393016b70855b2cd"
},
{
"case": "corpus/gitea.yml",
Expand Down Expand Up @@ -622,7 +622,7 @@
{
"case": "corpus/penpot.yml",
"loads": true,
"digest": "1cd500aae4deac5cf744dec3f1222b13aec493457de4b6069a3449126ad70210 postgres=8a2a89524e0749e0 redis=e6f4fb5264fe75ae"
"digest": "1cd500aae4deac5cf744dec3f1222b13aec493457de4b6069a3449126ad70210 postgres=e98b19cff65fe766 redis=f8e5a12917a49994"
},
{
"case": "corpus/pursue.yml",
Expand All @@ -637,7 +637,7 @@
{
"case": "corpus/rocketchat.yml",
"loads": true,
"digest": "4a091cd29c4a572eb57fcc9cd68edb76fbd56eb88ce16aab29efb30cb16af69f mongodb=09562fa5123be0a1"
"digest": "4a091cd29c4a572eb57fcc9cd68edb76fbd56eb88ce16aab29efb30cb16af69f mongodb=ada71cea467aa56d"
},
{
"case": "corpus/umami.yml",
Expand Down
8 changes: 8 additions & 0 deletions internal/discovery/discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,11 @@ func TestWriteAtomicProducesTraefikYAML(t *testing.T) {
t.Fatalf("temporary files remain: %v", matches)
}
}

func TestEndpointAddressPrefersIPv4(t *testing.T) {
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"}} {
if got := EndpointAddress(tc.v4, tc.v6); got != tc.want {
t.Fatalf("EndpointAddress = %q, want %q", got, tc.want)
}
}
}
14 changes: 9 additions & 5 deletions internal/discovery/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,19 @@ func (c *Client) inspect(ctx context.Context, id string) (Container, error) {
container.Health = raw.State.Health.Status
}
for name, endpoint := range raw.NetworkSettings.Networks {
address := endpoint.IPAddress
if net.ParseIP(address) == nil {
address = endpoint.GlobalIPv6Address
}
container.Networks[name] = address
container.Networks[name] = EndpointAddress(endpoint.IPAddress, endpoint.GlobalIPv6Address)
}
return container, nil
}

// EndpointAddress selects the same network address for discovery and rollout gates.
func EndpointAddress(ipv4, ipv6 string) string {
if net.ParseIP(ipv4) != nil {
return ipv4
}
return ipv6
}

func (c *Client) getJSON(ctx context.Context, path string, target any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker"+path, nil)
if err != nil {
Expand Down
40 changes: 39 additions & 1 deletion internal/engine/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,23 @@ func happyFake() *transport.Fake {
scaleCount, recreateCount := 0, 0
initialScaleCount, initialRecreateCount := 0, 0
seenExactReleaseQuery := false
recoveryRelease := ""
interruptedOldGone := false
newGone, workerGone := false, false
name := map[string]string{"OLD1": "web"}
for _, c := range f.Commands {
if recoveryRelease == "" && strings.Contains(c, "docker ps -a --filter label=onebox.app=") && strings.Contains(c, "label=onebox.release=") {
_, releaseFilter, _ := strings.Cut(c, "label=onebox.release=")
recoveryRelease = strings.Fields(releaseFilter)[0]
// Recovery rolls the predecessor into NEW1/W1; the interrupted
// web replica becomes OLD1 in this fixture's next roll.
scaled, oldGone, drained = false, false, false
scaleCount, recreateCount = 0, 0
initialScaleCount, initialRecreateCount = 0, 0
newGone, workerGone = false, true
name = map[string]string{"OLD1": "web"}
seenExactReleaseQuery = true
}
if strings.Contains(c, "docker ps -aq") && strings.Contains(c, "label=onebox.app=") && strings.Contains(c, "label=onebox.release=") {
seenExactReleaseQuery = true
}
Expand All @@ -90,8 +104,11 @@ func happyFake() *transport.Fake {
initialRecreateCount++
}
}
if strings.Contains(c, "docker rm OLD1") {
if strings.Contains(c, "docker rm OLD1") || strings.Contains(c, "docker rm -f OLD1") {
oldGone = true
if recoveryRelease != "" {
interruptedOldGone = true
}
}
if strings.Contains(c, "docker rm -f NEW1") {
newGone = true
Expand All @@ -114,6 +131,11 @@ func happyFake() *transport.Fake {
return fs[len(fs)-1]
}
switch {
case strings.Contains(cmd, "docker ps -a --filter label=onebox.app=") && strings.Contains(cmd, "label=onebox.release="):
if !interruptedOldGone {
return transport.Result{Stdout: "OLD1|web|sample-web-1|running"}, true
}
return transport.Result{}, true
// Compose counts stopped replicas toward --scale, so the roll asks for
// them separately; these fakes model none.
case strings.Contains(cmd, "status=exited"):
Expand Down Expand Up @@ -180,6 +202,12 @@ func happyFake() *transport.Fake {
}
return transport.Result{}, true
case strings.Contains(cmd, "docker ps -aq") && strings.Contains(cmd, "label=onebox.app=") && strings.Contains(cmd, "label=onebox.release="):
if recoveryRelease != "" && strings.Contains(cmd, "label=onebox.release="+recoveryRelease) {
if !interruptedOldGone {
return transport.Result{Stdout: "OLD1\n"}, true
}
return transport.Result{}, true
}
var ids []string
if initialScaleCount > 0 && !newGone {
ids = append(ids, "NEW1")
Expand Down Expand Up @@ -299,7 +327,17 @@ func TestDeployUpgradesManagedProxyAfterReadOnlyPreconditions(t *testing.T) {
f := happyFake()
base := f.Dynamic
proxyRuntime := legacyProxyPS(f)
drained := false
f.Dynamic = func(command string) (transport.Result, bool) {
if strings.Contains(command, "docker exec OLD1 touch ") {
drained = true
}
if strings.HasPrefix(command, "cat ") && strings.Contains(command, "/dynamic/onebox.yml") {
if !drained {
return transport.Result{Stdout: "http:\n services:\n previous:\n loadBalancer:\n servers:\n - url: http://172.20.0.5:3000\n"}, true
}
return transport.Result{Stdout: "{}\n"}, true
}
if result, ok := proxyRuntime(command); ok {
return result, true
}
Expand Down
2 changes: 2 additions & 0 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ type Options struct {
// WorkloadPlans are the sealed per-workload actions approved for this
// deployment. An absent entry preserves the historical release behavior.
WorkloadPlans map[string]WorkloadPlan
// ExpectedProxyAction is the proxy plan line approved for this deployment.
ExpectedProxyAction string
// UI is the output layer; built from Out+Verbose when unset (cmd shares
// one instance so the command log and narrative interleave in order).
UI *ui.UI
Expand Down
Loading
Loading