From 41257db8f3128bc128adb7de4235f1d3d803e798 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Fri, 21 Aug 2026 16:07:29 -0700 Subject: [PATCH] fix(network): enforce owned app networks Create app-scoped networks under explicit ownership, reject foreign collisions, and keep release teardown from removing shared endpoints. Full destroy now removes owned networks safely before releasing state. Refs #35 --- docs/onebox.run-v1.schema.json | 2 +- e2e/network_ownership_test.go | 119 ++++++++++++ internal/app/generate.go | 8 + internal/app/generate_test.go | 3 + internal/app/load_test.go | 11 ++ internal/app/names.go | 11 +- internal/app/names_test.go | 2 + internal/app/naming_scope_test.go | 44 ++--- internal/app/preflight.go | 65 +++++-- internal/app/preflight_test.go | 79 ++++++++ internal/app/testdata/contract-verdicts.json | 114 ++++++------ internal/app/types.go | 2 +- internal/app/validate.go | 13 ++ internal/engine/bootstrap.go | 3 + internal/engine/bootstrap_test.go | 19 +- internal/engine/deploy.go | 7 + internal/engine/deploy_test.go | 2 + internal/engine/networks.go | 159 ++++++++++++++++ internal/engine/networks_test.go | 172 ++++++++++++++++++ internal/engine/ops.go | 9 + internal/engine/ops_test.go | 45 +++++ internal/engine/services.go | 8 +- internal/onebox/bootstrap_test.go | 3 + site/public/onebox.run-v1.schema.json | 2 +- .../docs/explanation/generated-compose.mdx | 12 +- .../docs/explanation/ownership-boundary.mdx | 13 ++ .../content/docs/reference/fields/proxy.mdx | 2 +- .../content/docs/reference/project-file.mdx | 12 +- 28 files changed, 829 insertions(+), 112 deletions(-) create mode 100644 e2e/network_ownership_test.go create mode 100644 internal/engine/networks.go create mode 100644 internal/engine/networks_test.go diff --git a/docs/onebox.run-v1.schema.json b/docs/onebox.run-v1.schema.json index e4887f01..0e6cbe71 100644 --- a/docs/onebox.run-v1.schema.json +++ b/docs/onebox.run-v1.schema.json @@ -1147,7 +1147,7 @@ }, "network": { "default": "ob-ingress", - "description": "External container network shared with routed workloads.", + "description": "External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved.", "type": "string" } }, diff --git a/e2e/network_ownership_test.go b/e2e/network_ownership_test.go new file mode 100644 index 00000000..96d875c8 --- /dev/null +++ b/e2e/network_ownership_test.go @@ -0,0 +1,119 @@ +package e2e + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/labstack/onebox/internal/app" + "github.com/labstack/onebox/internal/engine" + "github.com/labstack/onebox/internal/transport" +) + +// The application default network outlives a release. An unmanaged proxy may +// still be attached when the release Compose document is taken down, and a +// hand-created network at the derived name must never be adopted silently. +func TestApplicationNetworkOwnershipAndExternalLifecycle(t *testing.T) { + gate(t) + ctx := context.Background() + application := fmt.Sprintf("obnet%d", os.Getpid()) + network := application + "_default" + + projectBody := fmt.Sprintf(`api_version: onebox.run/v1 +app: %s +environments: + production: {server: root@localhost} +workloads: + web: {image: alpine:3} +`, application) + project, err := app.LoadBytes([]byte(projectBody), filepath.Join(t.TempDir(), "ob.yml")) + if err != nil { + t.Fatal(err) + } + resolved, err := project.Resolve("production") + if err != nil { + t.Fatal(err) + } + e := engine.New(resolved, nil, transport.NewLocal(), engine.Options{Environment: "production", Out: &bytes.Buffer{}}) + + if out, err := exec.Command("docker", "network", "create", network).CombinedOutput(); err != nil { + t.Fatalf("create foreign network: %v\n%s", err, out) + } + t.Cleanup(func() { _ = exec.Command("docker", "network", "rm", network).Run() }) + if err := e.EnsureApplicationNetwork(ctx); err == nil || !strings.Contains(err.Error(), "refusing to adopt") { + t.Fatalf("foreign network was not refused: %v", err) + } + if out, err := exec.Command("docker", "network", "rm", network).CombinedOutput(); err != nil { + t.Fatalf("remove foreign network: %v\n%s", err, out) + } + if err := e.EnsureApplicationNetwork(ctx); err != nil { + t.Fatalf("create owned application network: %v", err) + } + owner, err := exec.Command("docker", "network", "inspect", "-f", `{{index .Labels "ob.app"}}`, network).Output() + if err != nil || strings.TrimSpace(string(owner)) != application { + t.Fatalf("new network owner = %q, %v", owner, err) + } + if out, err := exec.Command("docker", "network", "rm", network).CombinedOutput(); err != nil { + t.Fatalf("remove owned network fixture: %v\n%s", err, out) + } + + dir := t.TempDir() + legacyPath := filepath.Join(dir, "legacy.yaml") + legacy := `services: + proxy: + image: alpine:3 + command: ["sh", "-c", "sleep 600"] +` + if err := os.WriteFile(legacyPath, []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + legacyArgs := []string{"compose", "-p", application, "-f", legacyPath} + t.Cleanup(func() { + args := append(append([]string{}, legacyArgs...), "down", "--remove-orphans") + _ = exec.Command("docker", args...).Run() + }) + up := append(append([]string{}, legacyArgs...), "up", "-d") + if out, err := exec.Command("docker", up...).CombinedOutput(); err != nil { + t.Fatalf("start legacy proxy: %v\n%s", err, out) + } + + if err := e.EnsureApplicationNetwork(ctx); err != nil { + t.Fatalf("migrate legacy Compose network: %v", err) + } + + runtimePath := filepath.Join(dir, "runtime.yaml") + runtime := fmt.Sprintf(`name: %s +services: + web: + image: alpine:3 + command: ["sh", "-c", "sleep 600"] +networks: + default: + external: true + name: %s +`, application, network) + if err := os.WriteFile(runtimePath, []byte(runtime), 0o600); err != nil { + t.Fatal(err) + } + runtimeArgs := []string{"compose", "-p", application, "-f", runtimePath} + up = append(append([]string{}, runtimeArgs...), "up", "-d") + if out, err := exec.Command("docker", up...).CombinedOutput(); err != nil { + t.Fatalf("start external-network release: %v\n%s", err, out) + } + down := append(append([]string{}, runtimeArgs...), "down") + if out, err := exec.Command("docker", down...).CombinedOutput(); err != nil { + t.Fatalf("tear down release with proxy attached: %v\n%s", err, out) + } + if err := exec.Command("docker", "network", "inspect", network).Run(); err != nil { + t.Fatalf("external application network was removed: %v", err) + } + proxy, err := exec.Command("docker", "ps", "-q", "--filter", "label=com.docker.compose.service=proxy", "--filter", "network="+network).Output() + if err != nil || strings.TrimSpace(string(proxy)) == "" { + t.Fatalf("unmanaged proxy endpoint did not survive release teardown: %q, %v", proxy, err) + } +} diff --git a/internal/app/generate.go b/internal/app/generate.go index 773bdc10..698bd04b 100644 --- a/internal/app/generate.go +++ b/internal/app/generate.go @@ -116,6 +116,14 @@ func (r *Resolved) render(env, releaseID string, images Images) (*Rendered, erro if p.routesAnywhere() && p.Proxy.Kind != "none" { nets[p.Proxy.Network] = map[string]any{"external": true} } + // Compose's implicit default network is a runtime name just like a volume or + // container. Keep it external so removing one release cannot remove a + // network still used by an unmanaged proxy, and pin the name so preflight + // checks the exact object workloads will join. + nets["default"] = map[string]any{ + "external": true, + "name": n.ApplicationNetwork(), + } // The service network is external because the services on it outlive every // release. Compose would otherwise create it with the release and remove it // with the release, taking the database's reachability with it. diff --git a/internal/app/generate_test.go b/internal/app/generate_test.go index 0f2934a8..3061ad01 100644 --- a/internal/app/generate_test.go +++ b/internal/app/generate_test.go @@ -144,6 +144,9 @@ func TestRenderedRuntime(t *testing.T) { if strings.Contains(out, "container_name") { t.Error("rendered runtime must not set container_name") } + if !strings.Contains(out, "default:\n external: true\n name: ledger_default") { + t.Fatalf("the application network must be external and carry its fixed runtime name:\n%s", out) + } } // TestEnvFilesAreNotProjectedIntoDaemons is the rule seven real projects forced: diff --git a/internal/app/load_test.go b/internal/app/load_test.go index 87afcc96..5fda1517 100644 --- a/internal/app/load_test.go +++ b/internal/app/load_test.go @@ -13,6 +13,17 @@ const min = base + "build: .\ndomain: ledger.example.com\nport: 8080\n" func wl(body string) string { return base + "workloads: {" + body + "}\n" } +func TestRoutedProjectRefusesDefaultAsProxyNetwork(t *testing.T) { + for _, network := range []string{"default", "ledger_default", "ob_ledger"} { + t.Run(network, func(t *testing.T) { + _, err := LoadBytes([]byte(min+"proxy: {network: "+network+"}\n"), "ob.yml") + if err == nil || !strings.Contains(err.Error(), "proxy.network") || !strings.Contains(err.Error(), "reserved") { + t.Fatalf("reserved proxy network error = %v", err) + } + }) + } +} + type conformanceCase struct { name string yaml string diff --git a/internal/app/names.go b/internal/app/names.go index 80ed5716..fcdbff09 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -55,6 +55,12 @@ func (p *Spec) NamesFor(env string) Names { // identifiers contain no underscore and may not begin `ob-`. func (n Names) ComposeProject() string { return n.App } +// ApplicationNetwork is the stable default network shared by every workload +// in the application Compose project. It is created outside Compose so a +// release teardown cannot remove a network that still has an unmanaged proxy +// or another long-lived endpoint attached. +func (n Names) ApplicationNetwork() string { return join(n.App, "default") } + // ServiceProject is a supporting service's own Compose project, kept separate // from the application's so a release or rollback cannot remove it. func (n Names) ServiceProject(service string) string { @@ -279,7 +285,7 @@ func (n Names) HostOwnerPath() string { return n.HostDir() + "/owner" } // would make preflight report conflicts that do not exist. func (p *Spec) All(env string) []string { n := p.NamesFor(env) - out := []string{n.ComposeProject()} + out := []string{n.ComposeProject(), n.ApplicationNetwork()} for _, w := range sortedKeys(p.Workloads) { wl := p.Workloads[w] out = append(out, n.Container(w, 1), n.TransientContainer(w)) @@ -306,6 +312,9 @@ func (p *Spec) All(env string) []string { ) } } + if len(p.Services) > 0 { + out = append(out, n.ServiceNetwork()) + } sort.Strings(out) return out } diff --git a/internal/app/names_test.go b/internal/app/names_test.go index f90d763e..6f958a98 100644 --- a/internal/app/names_test.go +++ b/internal/app/names_test.go @@ -51,6 +51,8 @@ func TestDerivedNamesGolden(t *testing.T) { "ledger-web-new", "ledger-worker-1", "ledger-worker-new", + "ledger_default", + "ob_ledger", "ob_ledger_postgres", "ob_ledger_postgres_data", "ob_ledger_postgres_wal", diff --git a/internal/app/naming_scope_test.go b/internal/app/naming_scope_test.go index 8895f475..b91a5a5d 100644 --- a/internal/app/naming_scope_test.go +++ b/internal/app/naming_scope_test.go @@ -18,20 +18,21 @@ func TestEveryDerivedNameCarriesTheApplication(t *testing.T) { n := Names{App: "shop", BasePath: DefaultBasePath} for label, got := range map[string]string{ - "container": n.Container("web", 1), - "replica container": n.Container("web", 2), - "transient rollout": n.TransientContainer("web"), - "workload volume": n.WorkloadVolume("web", "uploads"), - "service container": n.ServiceContainer("postgres"), - "service project": n.ServiceProject("postgres"), - "service volume": n.ServiceVolume("postgres", "data"), - "service network": n.ServiceNetwork(), - "compose project": n.ComposeProject(), - "proxy service": n.ProxyService("web"), - "proxy service r1": n.ProxyServiceFor("web", 1), - "router": n.Router("web", 0), - "application dir": n.AppDir(), - "release dir": n.ReleaseDir("R1"), + "container": n.Container("web", 1), + "replica container": n.Container("web", 2), + "transient rollout": n.TransientContainer("web"), + "workload volume": n.WorkloadVolume("web", "uploads"), + "service container": n.ServiceContainer("postgres"), + "service project": n.ServiceProject("postgres"), + "service volume": n.ServiceVolume("postgres", "data"), + "service network": n.ServiceNetwork(), + "application network": n.ApplicationNetwork(), + "compose project": n.ComposeProject(), + "proxy service": n.ProxyService("web"), + "proxy service r1": n.ProxyServiceFor("web", 1), + "router": n.Router("web", 0), + "application dir": n.AppDir(), + "release dir": n.ReleaseDir("R1"), } { if !strings.Contains(got, "shop") { t.Errorf("%s = %q, which does not carry the application", label, got) @@ -41,13 +42,14 @@ func TestEveryDerivedNameCarriesTheApplication(t *testing.T) { // And two applications never derive the same name for the same thing. other := Names{App: "ledger", BasePath: DefaultBasePath} for label, pair := range map[string][2]string{ - "container": {n.Container("web", 1), other.Container("web", 1)}, - "transient": {n.TransientContainer("web"), other.TransientContainer("web")}, - "workload volume": {n.WorkloadVolume("web", "data"), other.WorkloadVolume("web", "data")}, - "service volume": {n.ServiceVolume("postgres", "data"), other.ServiceVolume("postgres", "data")}, - "service network": {n.ServiceNetwork(), other.ServiceNetwork()}, - "router": {n.Router("web", 0), other.Router("web", 0)}, - "application dir": {n.AppDir(), other.AppDir()}, + "container": {n.Container("web", 1), other.Container("web", 1)}, + "transient": {n.TransientContainer("web"), other.TransientContainer("web")}, + "workload volume": {n.WorkloadVolume("web", "data"), other.WorkloadVolume("web", "data")}, + "service volume": {n.ServiceVolume("postgres", "data"), other.ServiceVolume("postgres", "data")}, + "service network": {n.ServiceNetwork(), other.ServiceNetwork()}, + "application network": {n.ApplicationNetwork(), other.ApplicationNetwork()}, + "router": {n.Router("web", 0), other.Router("web", 0)}, + "application dir": {n.AppDir(), other.AppDir()}, } { if pair[0] == pair[1] { t.Errorf("%s: two applications derive the same name %q", label, pair[0]) diff --git a/internal/app/preflight.go b/internal/app/preflight.go index b8215634..b3a07db9 100644 --- a/internal/app/preflight.go +++ b/internal/app/preflight.go @@ -9,6 +9,7 @@ import ( "strings" "bytes" + "github.com/labstack/onebox/internal/shellquote" "github.com/labstack/onebox/internal/transport" "github.com/compose-spec/compose-go/v2/dotenv" @@ -104,7 +105,7 @@ func (r *Resolved) Preflight(ctx context.Context, run Runner) (*Report, error) { // 3. Name collisions. One listing per resource kind rather than one command // per name — a project with twenty derived names should not cost twenty // round trips. - owned, err := ownedNames(ctx, run, p.Name) + owned, err := ownedNames(ctx, run, p, r.Env) if err != nil { return nil, err } @@ -284,13 +285,26 @@ func basePathCheck(ctx context.Context, run Runner, base string) Check { // ownedNames lists the container, volume and network names already on the host, // with whichever application owns each. A name held by this application is the // normal case — a previous release — and only a foreign holder is a collision. -func ownedNames(ctx context.Context, run Runner, app string) (map[string]string, error) { +func ownedNames(ctx context.Context, run Runner, project *Spec, environment string) (map[string]string, error) { owned := map[string]string{} + application := project.Name + n := project.NamesFor(environment) + legacyServiceState := false + if len(project.Services) > 0 { + res, err := run.Run(ctx, "test -d "+shellquote.Quote(n.ServiceDir())) + if err != nil { + return nil, errf("server_unreachable", "", "", "cannot inspect legacy service-network ownership: %v", err) + } + legacyServiceState = res.ExitCode == 0 + } - for _, q := range []struct{ cmd, kind string }{ - {`docker ps -a --format '{{.Names}}\t{{.Label "ob.app"}}'`, "container"}, - {`docker volume ls --format '{{.Name}}\t{{.Label "ob.app"}}'`, "volume"}, - {`docker network ls --format '{{.Name}}\t{{.Label "ob.app"}}'`, "network"}, + for _, q := range []struct { + cmd, kind string + composeProject bool + }{ + {`docker ps -a --format '{{.Names}}\t{{.Label "ob.app"}}'`, "container", false}, + {`docker volume ls --format '{{.Name}}\t{{.Label "ob.app"}}'`, "volume", false}, + {`docker network ls --format '{{.Name}}\t{{.Label "ob.app"}}\t{{.Label "com.docker.compose.project"}}'`, "network", true}, } { res, err := run.Run(ctx, q.cmd) if err != nil { @@ -301,21 +315,48 @@ func ownedNames(ctx context.Context, run Runner, app string) (map[string]string, continue } for _, line := range strings.Split(res.Stdout, "\n") { - line = strings.TrimSpace(line) - if line == "" { + line = strings.TrimSuffix(line, "\r") + if strings.TrimSpace(line) == "" { continue } - name, owner, _ := strings.Cut(line, "\t") + fields := strings.SplitN(line, "\t", 3) + name := strings.TrimSpace(fields[0]) if name == "" { continue } - // Later kinds must not clobber an earlier owner record. - if prev, seen := owned[name]; seen && prev != "" { + owner := "" + if len(fields) > 1 { + owner = strings.TrimSpace(fields[1]) + } + // Before Onebox labelled networks, Compose still labelled the + // application default with its project. That is sufficient migration + // evidence for this exact application, but not for a hand-created + // network with only the derived name. + if owner == "" && q.composeProject && name == n.ApplicationNetwork() && len(fields) > 2 && strings.TrimSpace(fields[2]) == application { + owner = application + } + // Durable service state proves only an observed legacy service + // network. Applying it after all resource kinds are merged would also + // bless an unlabelled container or volume with the same name. + if owner == "" && q.kind == "network" && name == n.ServiceNetwork() && legacyServiceState { + owner = application + } + // Docker permits the same name in different resource kinds. Every + // holder must belong to this application: one foreign or unlabelled + // holder is a collision even if another kind is app-owned. + if prev, seen := owned[name]; seen { + if prev != application { + continue + } + if owner != application { + owned[name] = owner + } continue } - owned[name] = strings.TrimSpace(owner) + owned[name] = owner } } + return owned, nil } diff --git a/internal/app/preflight_test.go b/internal/app/preflight_test.go index 021c4bda..0340ef0a 100644 --- a/internal/app/preflight_test.go +++ b/internal/app/preflight_test.go @@ -174,6 +174,85 @@ func TestForeignHolderIsACollision(t *testing.T) { } } +func TestForeignApplicationNetworkIsACollision(t *testing.T) { + run := healthyRunner() + run.answers["docker network ls"] = transport.Result{Stdout: "ledger_default\t\t\n"} + + report := preflight(t, run, preflightProject) + if report.OK() || !strings.Contains(report.Failures()[0].Detail, "ledger_default") { + t.Fatalf("an unlabelled application network must be foreign: %+v", report.Failures()) + } +} + +func TestLegacyComposeApplicationNetworkBelongsToTheApp(t *testing.T) { + run := healthyRunner() + run.answers["docker network ls"] = transport.Result{Stdout: "ledger_default\t\tledger\n"} + + report := preflight(t, run, preflightProject) + for _, failure := range report.Failures() { + if failure.Name == "name collisions" { + t.Fatalf("the app's legacy Compose network was reported as foreign: %s", failure.Detail) + } + } +} + +func TestOwnedNetworkDoesNotMaskForeignHolderOfTheSameName(t *testing.T) { + run := healthyRunner() + run.answers["docker volume ls"] = transport.Result{Stdout: "ledger_default\t\n"} + run.answers["docker network ls"] = transport.Result{Stdout: "ledger_default\tledger\tledger\n"} + + report := preflight(t, run, preflightProject) + if report.OK() || !strings.Contains(report.Failures()[0].Detail, "ledger_default") { + t.Fatalf("an app-owned network masked a foreign volume with the same name: %+v", report.Failures()) + } +} + +func TestLegacyServiceNetworkRequiresOneboxState(t *testing.T) { + project := preflightProject + "services: {postgres: {version: 17}}\n" + + t.Run("fresh host refuses an unlabelled network", func(t *testing.T) { + run := healthyRunner() + run.answers["docker network ls"] = transport.Result{Stdout: "ob_ledger\t\t\n"} + run.answers["test -d '/var/lib/ob/ledger/services'"] = transport.Result{ExitCode: 1} + report := preflight(t, run, project) + if report.OK() || !strings.Contains(report.Failures()[0].Detail, "ob_ledger") { + t.Fatalf("an unproved service network must be foreign: %+v", report.Failures()) + } + }) + + t.Run("compose project is not service ownership evidence", func(t *testing.T) { + run := healthyRunner() + run.answers["docker network ls"] = transport.Result{Stdout: "ob_ledger\t\tledger\n"} + run.answers["test -d '/var/lib/ob/ledger/services'"] = transport.Result{ExitCode: 1} + report := preflight(t, run, project) + if report.OK() || !strings.Contains(report.Failures()[0].Detail, "ob_ledger") { + t.Fatalf("a Compose label incorrectly proved service-network ownership: %+v", report.Failures()) + } + }) + + t.Run("existing service state proves the legacy network", func(t *testing.T) { + run := healthyRunner() + run.answers["docker network ls"] = transport.Result{Stdout: "ob_ledger\t\t\n"} + run.answers["test -d '/var/lib/ob/ledger/services'"] = transport.Result{} + report := preflight(t, run, project) + for _, failure := range report.Failures() { + if failure.Name == "name collisions" { + t.Fatalf("the app's legacy service network was reported as foreign: %s", failure.Detail) + } + } + }) + + t.Run("service state does not bless another resource kind", func(t *testing.T) { + run := healthyRunner() + run.answers["docker volume ls"] = transport.Result{Stdout: "ob_ledger\t\n"} + run.answers["test -d '/var/lib/ob/ledger/services'"] = transport.Result{} + report := preflight(t, run, project) + if report.OK() || !strings.Contains(report.Failures()[0].Detail, "ob_ledger") { + t.Fatalf("legacy service state masked a foreign volume: %+v", report.Failures()) + } + }) +} + // TestRuntimeFailureShortCircuits: without a container runtime every other // check would fail too, and a wall of consequences hides the cause. func TestRuntimeFailureShortCircuits(t *testing.T) { diff --git a/internal/app/testdata/contract-verdicts.json b/internal/app/testdata/contract-verdicts.json index f10b7fd7..a323153d 100644 --- a/internal/app/testdata/contract-verdicts.json +++ b/internal/app/testdata/contract-verdicts.json @@ -2,7 +2,7 @@ { "case": "conformance/a bind mount is not durable", "loads": true, - "digest": "ed4d60c87aec3dca217f0c3721377c7f5330ebfc7362b0a41608b57c1a44ea0f" + "digest": "99a55cca6736488c1c6303c5b2240e34663aada93da745b5fd20909735861bd6" }, { "case": "conformance/a near-miss field name", @@ -12,7 +12,7 @@ { "case": "conformance/a plugin log driver", "loads": true, - "digest": "0f64ad0f34da5c6b72a781b5866a36009049ade8784229bdeeea15489ac6a766" + "digest": "3b5d875e1fdba2f638cb72ccf24654cf40e694ae2f8b0863e2b2d764c7b4745a" }, { "case": "conformance/absolute compose ref", @@ -82,12 +82,12 @@ { "case": "conformance/base_path absolute", "loads": true, - "digest": "2687af022cbd5bbc2daa11a9a9fe0aad37933378d5d5a4f759f5fec4331915bc" + "digest": "041fef4c505591809641cbaf261251631cfc8e6a4772cdbfed7b77fde493b962" }, { "case": "conformance/bind mount volume", "loads": true, - "digest": "37f4eb9c3ab03f69198a65f3f8c0e68d1a8515a99e6a6539390f8891d6de7965" + "digest": "58675aed6f40fe771ccea794590367f0aa0d29f74069f361d15bc30eba20d137" }, { "case": "conformance/components is not a field", @@ -97,7 +97,7 @@ { "case": "conformance/daemon role", "loads": true, - "digest": "0481a5635e8afaee4f26d84f4df9ef123c82f3c5f74516de0eb757fb499335c8" + "digest": "b89884bdd3868bb40116e32e335135e2e7abb1cfb693f52be141de3de1bc691e" }, { "case": "conformance/declared durability still refuses replicas", @@ -117,12 +117,12 @@ { "case": "conformance/duration in days", "loads": true, - "digest": "7a431791a1c3b31ce1d06ec2cd56702bdc68208a3c286671e769baad96aa2892" + "digest": "cffbe7c2b68f2c43e4a7349a840f0864f351f174cd09f31a8c6ff3ec4b45d1d2" }, { "case": "conformance/encrypted env file entry", "loads": true, - "digest": "c92184d40076555d14a0c0c05bd88ecae355c4f8fe472448c6773763c63e97b8" + "digest": "c5d7256c8b087e20b303beefffd6e4cad1676c738d90d53d4e5d3de2bdf0f42b" }, { "case": "conformance/env file entry without a file", @@ -132,17 +132,17 @@ { "case": "conformance/environment-scoped env files", "loads": true, - "digest": "1e92b0ede1f0e996df3f8dc9af17f80b16ae876d73a9758b5d486a38e1b7d58d" + "digest": "eac3f9c5833f6ba787e1b0a8f194ea14c625a15ed84c9b4a834dd367ec124b01" }, { "case": "conformance/explicit manual job remains a runtime service", "loads": true, - "digest": "9a3e5ddfb89ac4b00767ad3a27be20eb69fb2ab07e8a2ce8cfbfebe0d9cce208" + "digest": "fa32a001e109ec5b66168b204cb5966b9b58bdcc09fd421bfa6d93614cdb7cae" }, { "case": "conformance/explicit workloads block", "loads": true, - "digest": "74af0694829a1c0c88c17ed313cbcdd8387e70c80fc7c0089d099cfcab4d53a4" + "digest": "ae9dd3eccbc1422dfeeb5c3675f353e5049318bfbf7ae970e214c4226912cabc" }, { "case": "conformance/external lifecycle field", @@ -152,12 +152,12 @@ { "case": "conformance/external service connection", "loads": true, - "digest": "21ea4b4de122e9f7c3b02d200aa1fc6fcafb85adb8c2ac0e360736d4952c03e6" + "digest": "3c9ae3579472af0a2d99f318616168b1b2c9fb0cce6d861ea926f6d51e88791c" }, { "case": "conformance/hook naming a declared job", "loads": true, - "digest": "78f7b728401351cc89b2ea0419c17ff53bbe5626984e70a0b0bc3205340a7971" + "digest": "b25afee3041292b0b920ffb367c8c4cfec721119ded45753478af49e8438a880" }, { "case": "conformance/hook naming an unlisted seam", @@ -172,7 +172,7 @@ { "case": "conformance/hook with local", "loads": true, - "digest": "2687af022cbd5bbc2daa11a9a9fe0aad37933378d5d5a4f759f5fec4331915bc" + "digest": "041fef4c505591809641cbaf261251631cfc8e6a4772cdbfed7b77fde493b962" }, { "case": "conformance/host proxy name", @@ -187,7 +187,7 @@ { "case": "conformance/image reference with registry port", "loads": true, - "digest": "21b0f222f6b5bb6be9e820b5f78764624d1584c6cf5adea0454a749e312ead5d" + "digest": "0ebf9e2ab73e934960e19f7c0b7830c275b1e6de5d7bd924062fc854bb7c7eac" }, { "case": "conformance/image reference with uppercase repository", @@ -202,12 +202,12 @@ { "case": "conformance/inferred durability does not refuse replicas", "loads": true, - "digest": "da7276b116782df45c1fe6ec8fc5eab89392adf7b433885c8733d93cb56e41a4" + "digest": "e487363053bbdb54744003aed9114c602d92191da097096d9d9299d44ccea6bc" }, { "case": "conformance/job data_effect unknown", "loads": true, - "digest": "9a3e5ddfb89ac4b00767ad3a27be20eb69fb2ab07e8a2ce8cfbfebe0d9cce208" + "digest": "fa32a001e109ec5b66168b204cb5966b9b58bdcc09fd421bfa6d93614cdb7cae" }, { "case": "conformance/job requires data_effect", @@ -217,7 +217,7 @@ { "case": "conformance/job with data_effect", "loads": true, - "digest": "9a3e5ddfb89ac4b00767ad3a27be20eb69fb2ab07e8a2ce8cfbfebe0d9cce208" + "digest": "fa32a001e109ec5b66168b204cb5966b9b58bdcc09fd421bfa6d93614cdb7cae" }, { "case": "conformance/log driver with a space", @@ -237,12 +237,12 @@ { "case": "conformance/migration_policy expand-only", "loads": true, - "digest": "2687af022cbd5bbc2daa11a9a9fe0aad37933378d5d5a4f759f5fec4331915bc" + "digest": "041fef4c505591809641cbaf261251631cfc8e6a4772cdbfed7b77fde493b962" }, { "case": "conformance/minimum project", "loads": true, - "digest": "2687af022cbd5bbc2daa11a9a9fe0aad37933378d5d5a4f759f5fec4331915bc" + "digest": "041fef4c505591809641cbaf261251631cfc8e6a4772cdbfed7b77fde493b962" }, { "case": "conformance/missing api_version", @@ -277,17 +277,17 @@ { "case": "conformance/notification with no events", "loads": true, - "digest": "2687af022cbd5bbc2daa11a9a9fe0aad37933378d5d5a4f759f5fec4331915bc" + "digest": "041fef4c505591809641cbaf261251631cfc8e6a4772cdbfed7b77fde493b962" }, { "case": "conformance/one-char identifier", "loads": true, - "digest": "7a431791a1c3b31ce1d06ec2cd56702bdc68208a3c286671e769baad96aa2892" + "digest": "cffbe7c2b68f2c43e4a7349a840f0864f351f174cd09f31a8c6ff3ec4b45d1d2" }, { "case": "conformance/operator proxy owns route middleware", "loads": true, - "digest": "6513d9dbfe8373cd8c986f78d61346f49490b3d27264047ded4c2f5af62ee50b" + "digest": "e3dc5db8e2cac2078521ab270d4a4b6d5c846f591081d3b7696762be868d9a58" }, { "case": "conformance/persistence block with no mode still refuses replicas", @@ -297,7 +297,7 @@ { "case": "conformance/persistence external", "loads": true, - "digest": "c7d47947b4512f4b43a42f49fc3f0ad6f26bbaf68dca6418a40b3bd377712e25" + "digest": "0893e2759e7adb83b8fa5b3ff381502f6e6b5253e7506e9401e0e4b0a51d9e28" }, { "case": "conformance/port out of range", @@ -307,7 +307,7 @@ { "case": "conformance/provider-qualified route middlewares", "loads": true, - "digest": "5b51f8b77ea5a83635d1dba0470729b0d4ba2925f7a029b3fe6b86203bab5582" + "digest": "24af3916f5525727cc2c2d0cb01411414f1c7bfd2df59966d7d1938b557f1bbb" }, { "case": "conformance/proxy kind none with a route", @@ -317,17 +317,17 @@ { "case": "conformance/proxy kind none without a route", "loads": true, - "digest": "74af0694829a1c0c88c17ed313cbcdd8387e70c80fc7c0089d099cfcab4d53a4" + "digest": "ae9dd3eccbc1422dfeeb5c3675f353e5049318bfbf7ae970e214c4226912cabc" }, { "case": "conformance/published udp port", "loads": true, - "digest": "0979f4d25c39158939bb41f04ca82839ae8021d56cff62ae0f41043d831bd695" + "digest": "dd15c6a1495681e48f78fcbb011a3eedc76a82b1bbf6e2aff002ca0ca6196657" }, { "case": "conformance/recreate workload with published host port", "loads": true, - "digest": "08d756ffdd909355169e1edea244892a06354aed9720123717b5ad9e03d5b1dc" + "digest": "5a94c95c1cd277ff2b1d354167e64a1d8d6b836d532a29a968586a675769c190" }, { "case": "conformance/relative compose ref", @@ -337,12 +337,12 @@ { "case": "conformance/relative env_file", "loads": true, - "digest": "d06998bfca47f1f5fd670feec5ea41bf720c35ae99474ec24633d636a9c0bd70" + "digest": "cfceac5e1d96c958a853553ab4816fcd9a7ac250797445e562faa889053a0abb" }, { "case": "conformance/repeated route middleware remains ordered", "loads": true, - "digest": "af54580b167899b337a4b7fb37e6f88b98904dac3906de38c5b17d21a865e2fe" + "digest": "4f296959dfd5d99140ae231cefda9b8cfea7e55af1bde252c5f5c7268428f75e" }, { "case": "conformance/rolling workload with published host port", @@ -352,27 +352,27 @@ { "case": "conformance/routes list", "loads": true, - "digest": "ec9b3e1de67e1bf376ff19512c1435f4e7b999921895c7a03b25651754ae0e1b" + "digest": "b6bbd0823484ae06bcf0d033b437b3572e659ec84ec0aec531a394102b7539d9" }, { "case": "conformance/scheduled job", "loads": true, - "digest": "9a3e5ddfb89ac4b00767ad3a27be20eb69fb2ab07e8a2ce8cfbfebe0d9cce208" + "digest": "fa32a001e109ec5b66168b204cb5966b9b58bdcc09fd421bfa6d93614cdb7cae" }, { "case": "conformance/service backup policy", "loads": true, - "digest": "1bce81b664f94d47dea544b3de676178615ed8a0a2c9efb8eda0a9d7f424b1e4 postgres=c1475eb63145a73b" + "digest": "7d2ed1e5ce63520ed182033a3183de11e3ffc5cea5f7f00034a875a73aa482d8 postgres=c1475eb63145a73b" }, { "case": "conformance/service scalar", "loads": true, - "digest": "37ef191260abddc6f674ea8bed3bb82970358f8395df2d5a7f5b9b5142268b47 postgres=51d530eb9102fbd1" + "digest": "ccc9c85ed831288eea293328b1785da5e34e857f1cb38ecc3149df1f660c1a75 postgres=51d530eb9102fbd1" }, { "case": "conformance/settings key that is a real driver flag", "loads": true, - "digest": "37ef191260abddc6f674ea8bed3bb82970358f8395df2d5a7f5b9b5142268b47 redis=f5e4171f39cd0dcb" + "digest": "ccc9c85ed831288eea293328b1785da5e34e857f1cb38ecc3149df1f660c1a75 redis=f5e4171f39cd0dcb" }, { "case": "conformance/settings key with a shell metacharacter", @@ -447,7 +447,7 @@ { "case": "conformance/unmanaged proxy keeps its routes", "loads": true, - "digest": "2687af022cbd5bbc2daa11a9a9fe0aad37933378d5d5a4f759f5fec4331915bc" + "digest": "041fef4c505591809641cbaf261251631cfc8e6a4772cdbfed7b77fde493b962" }, { "case": "conformance/unqualified route middleware", @@ -462,12 +462,12 @@ { "case": "conformance/url check with contains and advisory", "loads": true, - "digest": "2687af022cbd5bbc2daa11a9a9fe0aad37933378d5d5a4f759f5fec4331915bc" + "digest": "041fef4c505591809641cbaf261251631cfc8e6a4772cdbfed7b77fde493b962" }, { "case": "conformance/volume scalar with a path", "loads": true, - "digest": "d45e80f712c3333cfd93df0ea3c4354b21192fd0c8e54ed709bc093bd6865e38" + "digest": "204ad5e8bbd7f94f4d69201c06d47b3708d87dc7ea186df0bceefb14486171d4" }, { "case": "conformance/volume scalar without a path", @@ -477,7 +477,7 @@ { "case": "conformance/volumes without persistence still load", "loads": true, - "digest": "d45e80f712c3333cfd93df0ea3c4354b21192fd0c8e54ed709bc093bd6865e38" + "digest": "204ad5e8bbd7f94f4d69201c06d47b3708d87dc7ea186df0bceefb14486171d4" }, { "case": "conformance/worker with schedule", @@ -492,7 +492,7 @@ { "case": "conformance/x- extension accepted", "loads": true, - "digest": "2687af022cbd5bbc2daa11a9a9fe0aad37933378d5d5a4f759f5fec4331915bc" + "digest": "041fef4c505591809641cbaf261251631cfc8e6a4772cdbfed7b77fde493b962" }, { "case": "conformance/zero replicas", @@ -502,17 +502,17 @@ { "case": "corpus/authentik.yml", "loads": true, - "digest": "63f575430f70b00d2708dcbc93b8239e0f7c92f9889d156b1a4d6ca914e00aac postgres=dc4f8448b8b82b4a redis=d2660eeb4faa49fe" + "digest": "3cd927a4bf78330bba4642d740c9ece86964a30218d6d50689c2e23370aae6b2 postgres=dc4f8448b8b82b4a redis=d2660eeb4faa49fe" }, { "case": "corpus/ext-authentik-managed.yml", "loads": true, - "digest": "52a1eae2842164841682dcc468a20ffe3f700279f3e18224f5cf348e3eef3cea postgres=dc4f8448b8b82b4a redis=d2660eeb4faa49fe" + "digest": "7525c3c11a6ecdf36e6d2609b89f3c891d65b208f4658434bfe8ba64cb1eedc7 postgres=dc4f8448b8b82b4a redis=d2660eeb4faa49fe" }, { "case": "corpus/ext-authentik.yml", "loads": true, - "digest": "f25496dd235618927b97f8476fbdecc3ccce577cc52995559aa20d373a490f56" + "digest": "8595f20b0961f6e99e1d6b9cfd250466b83b1a9589bc9081a9b1ec7e4b87ba06" }, { "case": "corpus/ext-frigate.yml", @@ -522,7 +522,7 @@ { "case": "corpus/ext-gitea.yml", "loads": true, - "digest": "24544823fe1007300da3b22f82f06c2cd1a693adcec6ee47307f1f09e264d708 postgres=e70cc45c347098f9" + "digest": "aa26f69004bf9a169e005a24be991a67f22287ec7d99428aa15d39d41a6e6a6d postgres=e70cc45c347098f9" }, { "case": "corpus/ext-immich-sourced.yml", @@ -537,22 +537,22 @@ { "case": "corpus/ext-n8n.yml", "loads": true, - "digest": "9b6fbbcdcfe32b45d1e095b90e0b96ff93123ebdc58d884eaf18d3014d6c93ac postgres=809549d286e2dbdc redis=86933b446609e6d8" + "digest": "2c553da86bf863420b725d26c37aa25fac407adf77b3d4502de883c75cf14fcb postgres=809549d286e2dbdc redis=86933b446609e6d8" }, { "case": "corpus/ext-paperless.yml", "loads": true, - "digest": "f11f765d9b72177c94d492020440c6245f62472bdce107289d6540ac0aa6d39f" + "digest": "679d8a339a26c611793a5bf92ff1b5bab676dd35122bf2a826ac3f0f3f13cfef" }, { "case": "corpus/ext-plausible.yml", "loads": true, - "digest": "be23ed70c8eec24e2b4746bd6839d46e6ec4fb2b45c1a234c8618caffa74e2e3 events=1e798590e3a5dda4 postgres=b1fac70440c33545" + "digest": "1116c463820143c01194fea526e8c1c6bf9cfe03c7ff7c81c7e1ae509ab32de2 events=1e798590e3a5dda4 postgres=b1fac70440c33545" }, { "case": "corpus/ext-umami.yml", "loads": true, - "digest": "822b9460beb259b9b7772258465e79c621e0e0571afc765d0d9948aaba7939f4 postgres=36c6c38ba304b445" + "digest": "79b4c4a3d6a6ef07bca7bc0b14d9bc16c44bd2f111754909961067523497f6c9 postgres=36c6c38ba304b445" }, { "case": "corpus/fanout.yml", @@ -562,12 +562,12 @@ { "case": "corpus/ghost.yml", "loads": true, - "digest": "88dd73e41c3566e107aec5fc23b1458daf406a29d7fb3a10c4fe64c5d0532479 mysql=0f13a6374095d11b" + "digest": "9d1a0a4a2940c74895c58e99d3db96d70c2f7882433ea9cadc6f4f24867c33cb mysql=0f13a6374095d11b" }, { "case": "corpus/gitea.yml", "loads": true, - "digest": "cbe804b82e046201170393161481c3faef0649c52e64ee009c4612997ff95233" + "digest": "0c7b84f5b5d850e4489f27c40ae17176db81f94016a76ff65e683513e1109d30" }, { "case": "corpus/goal.yml", @@ -577,7 +577,7 @@ { "case": "corpus/immich.yml", "loads": true, - "digest": "eccb7be79bef04153b3e998ce4f2f6cd5e35f072da74b5b9eac6b334c1118a8a" + "digest": "f7e7a345ebcfa542e54f85ce35f4e9773a3c6a0532b88f5e5a807dbccb93e4c7" }, { "case": "corpus/monk.yml", @@ -587,17 +587,17 @@ { "case": "corpus/n8n.yml", "loads": true, - "digest": "404e41227962f8e1b0ed02980f4c2f245c2c5addda87feba7d4be314eb9dd4cf" + "digest": "044dcabe64d885d4eafa025cb187deff9a03886d582429c979de4839599bf186" }, { "case": "corpus/paperless.yml", "loads": true, - "digest": "223eb7692be0b2d680f1e095bbf96ec5b4a03e7965ee0cef98c4d70b041d19f9" + "digest": "069aa81ec83b61bcdced44bcd677e38c8f182889cce91d34a161781fb9c3dc77" }, { "case": "corpus/penpot.yml", "loads": true, - "digest": "bdd52bee442c6f91c2dbe9c9dafa4cae1050116a58c7151d870f33e72cde5991 postgres=fc584b1b50db23a6 redis=fcccb6a023ae5734" + "digest": "e6914dc2df92f319d5a69b58515a9e870c8a0e849cec6a57d1b116955bb503a2 postgres=fc584b1b50db23a6 redis=fcccb6a023ae5734" }, { "case": "corpus/pursue.yml", @@ -612,21 +612,21 @@ { "case": "corpus/rocketchat.yml", "loads": true, - "digest": "d406317867cf94a915ae85a90abf4be709e8a6b08aa7a834417be5f0f6f0d690 mongodb=eaca06e5d1b88e4b" + "digest": "06ef5ded6f0afc5abef7e7ff29f778100b976e6393450418a75350683dad44cc mongodb=eaca06e5d1b88e4b" }, { "case": "corpus/umami.yml", "loads": true, - "digest": "e969c3fa27ec71f300e8c65e42ae8c95e08ab56388692438b46640c4350b8c64" + "digest": "f2e6a77d9bb6123eafc531a3cb6969db42cb5ab8d89e3549d06d742b272f4c42" }, { "case": "corpus/uptime-kuma.yml", "loads": true, - "digest": "1ec690b55cbe4361efbcc5be90f7468d9e6b75b0133f79b7740129d1ef9d144c" + "digest": "6d2448adb382018b44831e7fff60739658817a5955661e49141353c6de83988f" }, { "case": "corpus/vaultwarden.yml", "loads": true, - "digest": "8d2b1e2990a723b085e09497ff135c3435ed96f55035d7470f0d5fddbdbbccc0" + "digest": "9ffa34f0d6e056eab762fc3b91576b1d6b91c81d6d26045b90c9a9b06f141447" } ] diff --git a/internal/app/types.go b/internal/app/types.go index a46e5ecc..c804a73d 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -473,7 +473,7 @@ type Proxy struct { Kind string `json:"kind" description:"Proxy implementation, or none to disable routing." default:"traefik-docker"` Image string `json:"image,omitempty" description:"Container image used for the managed proxy."` Config string `json:"config,omitempty" description:"Repository-relative static proxy configuration directory owned by the project; it must contain exactly one of traefik.yml or traefik.yaml."` - Network string `json:"network" description:"External container network shared with routed workloads." default:"ob-ingress"` + Network string `json:"network" description:"External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved." default:"ob-ingress"` CertResolver string `json:"cert_resolver,omitempty" description:"Traefik certificate resolver used by terminating TLS routes."` } diff --git a/internal/app/validate.go b/internal/app/validate.go index 2d794407..d1342b27 100644 --- a/internal/app/validate.go +++ b/internal/app/validate.go @@ -77,6 +77,19 @@ func validateTopLevel(p *Spec) error { if err := gRepoPath.checkOptional("proxy.config", p.Proxy.Config); err != nil { return err } + // Compose reserves `default` for the application's implicit network, and + // Onebox owns the two derived app-scoped networks. Letting ingress reuse one + // makes the proxy create it first under different Compose ownership, after + // which bootstrap must either adopt a foreign network or refuse. + if p.Proxy.Kind != "none" && p.routesAnywhere() { + n := p.NamesFor("") + for _, reserved := range []string{"default", n.ApplicationNetwork(), n.ServiceNetwork()} { + if p.Proxy.Network == reserved { + return errf("project_invalid", "proxy.network", "", + "proxy network %q is reserved by Onebox; choose another external network name", p.Proxy.Network) + } + } + } if err := checkEnum("deployment.migration_policy", p.Deployment.MigrationPolicy, eMigrationPolicy); err != nil { return err } diff --git a/internal/engine/bootstrap.go b/internal/engine/bootstrap.go index 3d854886..27e7293d 100644 --- a/internal/engine/bootstrap.go +++ b/internal/engine/bootstrap.go @@ -84,6 +84,9 @@ func (e *Engine) Bootstrap(ctx context.Context, releaseID string) (err error) { } return fmt.Errorf("container runtime unavailable after bootstrap hook; install Docker with operator-managed provisioning or configure a remote bootstrap hook that installs a pinned runtime%s", detail) } + if err := e.EnsureApplicationNetwork(ctx); err != nil { + return fmt.Errorf("application network: %w", err) + } for _, name := range sortedNames(e.Spec.Registries) { r, password := e.Spec.Registries[name], passwords[name] diff --git a/internal/engine/bootstrap_test.go b/internal/engine/bootstrap_test.go index a5d9ba83..a6a4951a 100644 --- a/internal/engine/bootstrap_test.go +++ b/internal/engine/bootstrap_test.go @@ -13,6 +13,18 @@ import ( "github.com/labstack/onebox/internal/transport" ) +type bootstrapNetworkLocal struct { + *transport.Local + owner string +} + +func (l *bootstrapNetworkLocal) Run(ctx context.Context, command string) (transport.Result, error) { + if strings.Contains(command, "docker network inspect --format") { + return transport.Result{Stdout: "abc123|" + l.owner + "|\n"}, nil + } + return l.Local.Run(ctx, command) +} + func TestBootstrapSequence(t *testing.T) { f := happyFake() dir := t.TempDir() @@ -113,7 +125,7 @@ func TestConcurrentBootstrapDoesNotRunSecondHook(t *testing.T) { if err := os.Mkdir(binDir, 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(binDir, "docker"), []byte("#!/bin/sh\n[ \"$1\" = version ] && printf '27.0.3\\n'\nexit 0\n"), 0o700); err != nil { + if err := os.WriteFile(filepath.Join(binDir, "docker"), []byte("#!/bin/sh\n[ \"$1\" = version ] && printf '27.0.3\\n'\n[ \"$1\" = network ] && [ \"$2\" = inspect ] && exit 1\nexit 0\n"), 0o700); err != nil { t.Fatal(err) } t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) @@ -126,8 +138,9 @@ func TestConcurrentBootstrapDoesNotRunSecondHook(t *testing.T) { cfg.Services = nil cfg.Hooks["bootstrap"] = app.Command{Run: "printf x >> " + q(runs) + "; touch " + q(entered) + "; while [ ! -f " + q(release) + " ]; do sleep 0.01; done"} - first := New(cfg, testProject(t), transport.NewLocal(), Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - second := New(cfg, testProject(t), transport.NewLocal(), Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + local := &bootstrapNetworkLocal{Local: transport.NewLocal(), owner: cfg.Name} + first := New(cfg, testProject(t), local, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + second := New(cfg, testProject(t), local, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) firstDone := make(chan error, 1) go func() { firstDone <- first.Bootstrap(context.Background(), engineTestBootstrapReleaseID) diff --git a/internal/engine/deploy.go b/internal/engine/deploy.go index 814c7a7b..f003d307 100644 --- a/internal/engine/deploy.go +++ b/internal/engine/deploy.go @@ -237,6 +237,13 @@ func (e *Engine) runPhases(ctx context.Context, jw *journal.Writer, releaseID, l return &ActivationRefusedError{ReleaseID: releaseID, State: manifest.State} } + // The generated runtime declares its default network external so release + // teardown cannot remove a long-lived proxy endpoint. Establish and verify + // ownership before any job or workload can join it. + if err := e.EnsureApplicationNetwork(ctx); err != nil { + return fmt.Errorf("application network: %w", err) + } + // Before any job runs: a job can need a database as readily as an // application can, and both read a file that only exists once it is // written. diff --git a/internal/engine/deploy_test.go b/internal/engine/deploy_test.go index c2da3073..e4dbbe82 100644 --- a/internal/engine/deploy_test.go +++ b/internal/engine/deploy_test.go @@ -93,6 +93,8 @@ func happyFake() *transport.Fake { switch { case strings.Contains(cmd, "/_host/owner"): return transport.Result{Stdout: "sample\n"}, true + case strings.Contains(cmd, "docker network inspect --format"): + return transport.Result{Stdout: "abc123|sample|\n"}, true case strings.Contains(cmd, "docker version"): return transport.Result{Stdout: "27.0.3\n"}, true case strings.Contains(cmd, "compose version"): diff --git a/internal/engine/networks.go b/internal/engine/networks.go new file mode 100644 index 00000000..e827e4e2 --- /dev/null +++ b/internal/engine/networks.go @@ -0,0 +1,159 @@ +package engine + +import ( + "context" + "fmt" + "strings" + + "github.com/labstack/onebox/internal/app" +) + +// EnsureApplicationNetwork establishes the external default network every +// release joins. Compose must not own its lifecycle: an unmanaged proxy can +// remain attached while one release is torn down. +func (e *Engine) EnsureApplicationNetwork(ctx context.Context) error { + n := e.names() + return e.ensureOwnedNetwork(ctx, n.ApplicationNetwork(), n.ComposeProject(), "") +} + +// ensureServiceNetwork establishes the long-lived network shared by workloads +// and supporting services. A legacy state directory is accepted as migration +// evidence because older Onebox versions created this network without labels. +func (e *Engine) ensureServiceNetwork(ctx context.Context, n app.Names) error { + return e.ensureOwnedNetwork(ctx, n.ServiceNetwork(), "", n.ServiceDir()) +} + +// ensureOwnedNetwork creates a labelled network or accepts a network whose +// legacy ownership is independently provable. Docker cannot add labels to an +// existing network, and recreating one would sever live endpoints, so legacy +// networks remain intact. A derived name alone is never +// evidence: silently adopting a hand-created network is the bug this boundary +// exists to prevent. +func (e *Engine) ensureOwnedNetwork(ctx context.Context, name, legacyComposeProject, legacyStateDir string) error { + exists, err := e.ownedNetworkExists(ctx, name, legacyComposeProject, legacyStateDir) + if err != nil { + return err + } + if exists { + return nil + } + created, createErr := e.mutate(ctx, "docker network create --label "+q("ob.app="+e.Spec.Name)+" "+q(name)) + if createErr != nil { + return createErr + } + if created.ExitCode != 0 { + return fmt.Errorf("network %s: cannot create owned network: %s", name, strings.TrimSpace(created.Stderr)) + } + return nil +} + +// removeOwnedNetworks removes the two app-scoped external networks during a +// full destroy. A release teardown leaves them alone; full destruction must +// either remove them or stop before deleting state and releasing host ownership. +func (e *Engine) removeOwnedNetworks(ctx context.Context) error { + n := e.names() + networks := []struct { + name, legacyComposeProject, legacyStateDir string + }{ + {n.ApplicationNetwork(), n.ComposeProject(), ""}, + } + // `ob_` is reserved only when the app has services. A project that + // never declared one must not have full destroy blocked by an unrelated, + // unlabelled network at that otherwise-unused name. Durable service state + // also includes the network for projects that removed services from the + // working declaration before destroying an older installation. + includeServiceNetwork := len(e.Spec.Services) > 0 + if !includeServiceNetwork { + state, err := e.T.Run(ctx, "test -d "+q(n.ServiceDir())) + if err != nil { + return err + } + includeServiceNetwork = state.ExitCode == 0 + } + if includeServiceNetwork { + networks = append(networks, struct { + name, legacyComposeProject, legacyStateDir string + }{n.ServiceNetwork(), "", n.ServiceDir()}) + } + for _, network := range networks { + exists, err := e.ownedNetworkExists(ctx, network.name, network.legacyComposeProject, network.legacyStateDir) + if err != nil { + return err + } + if !exists { + continue + } + removed, removeErr := e.mutate(ctx, "docker network rm "+q(network.name)) + if removeErr != nil { + return removeErr + } + if removed.ExitCode != 0 { + return fmt.Errorf("network %s: cannot remove owned network: %s; detach its remaining endpoints, then retry destroy", network.name, strings.TrimSpace(removed.Stderr)) + } + } + return nil +} + +// ownedNetworkExists reports absence and otherwise proves that an existing +// network belongs to this application before a caller creates, uses, or removes +// it. The same proof must guard every lifecycle transition. +func (e *Engine) ownedNetworkExists(ctx context.Context, name, legacyComposeProject, legacyStateDir string) (bool, error) { + // `docker network inspect --format` prints a backslash-t literally on some + // Docker releases (unlike the list formatter). Use a delimiter that the + // formatter does not have to interpret; none of these validated identities + // can contain a pipe. + inspect := "docker network inspect --format '{{.Id}}|{{index .Labels \"ob.app\"}}|{{index .Labels \"com.docker.compose.project\"}}' " + q(name) + res, err := e.T.Run(ctx, inspect) + if err != nil { + return false, err + } + if res.ExitCode != 0 { + message := strings.ToLower(strings.TrimSpace(res.Stderr)) + missing := strings.Contains(message, "no such network") || + strings.Contains(message, "network "+strings.ToLower(name)+" not found") + if missing { + return false, nil + } + return false, fmt.Errorf("network %s: cannot inspect ownership (exit %d): %s", name, res.ExitCode, strings.TrimSpace(res.Stderr)) + } + + fields := strings.SplitN(strings.TrimSpace(res.Stdout), "|", 3) + if len(fields) == 0 || !validID.MatchString(strings.TrimSpace(fields[0])) { + return false, fmt.Errorf("network %s: inspect returned no valid identity", name) + } + owner := "" + if len(fields) > 1 { + owner = networkLabel(fields[1]) + } + if owner != "" { + if owner != e.Spec.Name { + return false, fmt.Errorf("network %s is owned by application %s; refusing to adopt it", name, owner) + } + return true, nil + } + + legacyOwned := false + if len(fields) > 2 && legacyComposeProject != "" { + legacyOwned = networkLabel(fields[2]) == legacyComposeProject + } + if !legacyOwned && legacyStateDir != "" { + state, stateErr := e.T.Run(ctx, "test -d "+q(legacyStateDir)) + if stateErr != nil { + return false, stateErr + } + legacyOwned = state.ExitCode == 0 + } + if !legacyOwned { + return false, fmt.Errorf("network %s exists without Onebox ownership; refusing to adopt it", name) + } + + return true, nil +} + +func networkLabel(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return value +} diff --git a/internal/engine/networks_test.go b/internal/engine/networks_test.go new file mode 100644 index 00000000..e3ed307d --- /dev/null +++ b/internal/engine/networks_test.go @@ -0,0 +1,172 @@ +package engine + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/labstack/onebox/internal/transport" +) + +func TestApplicationNetworkIsCreatedWithOwnership(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(command string) (transport.Result, bool) { + if strings.Contains(command, "network inspect") && strings.Contains(command, "sample_default") { + return transport.Result{ExitCode: 1, Stderr: "Error response from daemon: network sample_default not found"}, true + } + return base(command) + } + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + if err := e.EnsureApplicationNetwork(context.Background()); err != nil { + t.Fatal(err) + } + commands := strings.Join(f.Commands, "\n") + if !strings.Contains(commands, "docker network create --label 'ob.app=sample' 'sample_default'") { + t.Fatalf("application network was not created with ownership:\n%s", commands) + } +} + +func TestApplicationNetworkDoesNotTreatInspectFailureAsAbsence(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(command string) (transport.Result, bool) { + if strings.Contains(command, "network inspect") && strings.Contains(command, "sample_default") { + return transport.Result{ExitCode: 1, Stderr: "permission denied"}, true + } + return base(command) + } + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + err := e.EnsureApplicationNetwork(context.Background()) + if err == nil || !strings.Contains(err.Error(), "cannot inspect ownership") { + t.Fatalf("inspect failure error = %v", err) + } + if strings.Contains(strings.Join(f.Commands, "\n"), "network create") { + t.Fatalf("inspect failure was treated as absence:\n%s", strings.Join(f.Commands, "\n")) + } +} + +func TestApplicationNetworkRefusesForeignOwner(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(command string) (transport.Result, bool) { + if strings.Contains(command, "network inspect") && strings.Contains(command, "sample_default") { + return transport.Result{Stdout: "abc123|other-app|other-app\n"}, true + } + return base(command) + } + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + err := e.EnsureApplicationNetwork(context.Background()) + if err == nil || !strings.Contains(err.Error(), "owned by application other-app") { + t.Fatalf("foreign network error = %v", err) + } + if strings.Contains(strings.Join(f.Commands, "\n"), "network update") { + t.Fatal("a foreign network must never be relabelled") + } +} + +func TestLegacyComposeNetworkIsAcceptedByIdentity(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(command string) (transport.Result, bool) { + if strings.Contains(command, "network inspect") && strings.Contains(command, "sample_default") { + return transport.Result{Stdout: "abc123||sample\n"}, true + } + return base(command) + } + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + if err := e.EnsureApplicationNetwork(context.Background()); err != nil { + t.Fatal(err) + } + commands := strings.Join(f.Commands, "\n") + if strings.Contains(commands, "network create") { + t.Fatalf("legacy application network was replaced:\n%s", commands) + } +} + +func TestLegacyServiceNetworkRequiresServiceStateBeforeAcceptance(t *testing.T) { + for _, tt := range []struct { + name string + stateExit int + wantErr bool + }{ + {name: "legacy state", stateExit: 0}, + {name: "no state", stateExit: 1, wantErr: true}, + } { + t.Run(tt.name, func(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(command string) (transport.Result, bool) { + if strings.Contains(command, "network inspect") && strings.Contains(command, "ob_sample") { + return transport.Result{Stdout: "def456||\n"}, true + } + if strings.Contains(command, "test -d '/var/lib/ob/sample/services'") { + return transport.Result{ExitCode: tt.stateExit}, true + } + return base(command) + } + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, Environment: "production"}) + err := e.EnsureServiceConnections(context.Background()) + if tt.wantErr { + if err == nil || !strings.Contains(err.Error(), "refusing to adopt") { + t.Fatalf("missing legacy state error = %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(f.Commands, "\n"), "network create") { + t.Fatalf("legacy service network was replaced:\n%s", strings.Join(f.Commands, "\n")) + } + }) + } +} + +func TestRemoveOwnedNetworksRefusesAttachedEndpoints(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(command string) (transport.Result, bool) { + if strings.Contains(command, "docker network rm 'sample_default'") { + return transport.Result{ExitCode: 1, Stderr: "network has active endpoints"}, true + } + return base(command) + } + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + err := e.removeOwnedNetworks(context.Background()) + if err == nil || !strings.Contains(err.Error(), "detach its remaining endpoints") { + t.Fatalf("attached endpoint error = %v", err) + } + commands := strings.Join(f.Commands, "\n") + if strings.Contains(commands, "docker network rm 'ob_sample'") { + t.Fatalf("teardown continued after the application network could not be removed:\n%s", commands) + } +} + +func TestRemoveOwnedNetworksIgnoresServiceNameWithoutServiceState(t *testing.T) { + cfg := testConfig() + cfg.Services = nil + f := happyFake() + base := f.Dynamic + f.Dynamic = func(command string) (transport.Result, bool) { + if strings.Contains(command, "test -d '/var/lib/ob/sample/services'") { + return transport.Result{ExitCode: 1}, true + } + if strings.Contains(command, "network inspect") && strings.Contains(command, "ob_sample") { + return transport.Result{Stdout: "def456||\n"}, true + } + return base(command) + } + e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + if err := e.removeOwnedNetworks(context.Background()); err != nil { + t.Fatal(err) + } + commands := strings.Join(f.Commands, "\n") + if strings.Contains(commands, "network inspect") && strings.Contains(commands, "ob_sample") { + t.Fatalf("destroy inspected an undeclared service-network name:\n%s", commands) + } + if strings.Contains(commands, "network rm 'ob_sample'") { + t.Fatalf("destroy removed an undeclared service-network name:\n%s", commands) + } +} diff --git a/internal/engine/ops.go b/internal/engine/ops.go index 947b19d0..8730fbe8 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -120,6 +120,15 @@ func (e *Engine) Destroy(ctx context.Context, removeVolumes, removeProxy bool) e if err := e.removeServices(ctx, removeVolumes); err != nil { return err } + // External means release-independent, not ownerless. A full destroy removes + // both app-scoped networks before deleting the evidence that proves legacy + // ownership. Docker refuses removal while any unmanaged endpoint remains; + // propagate that refusal so state and host ownership stay recoverable. + if removeVolumes { + if err := e.removeOwnedNetworks(ctx); err != nil { + return err + } + } // state dir last (takes the lock, fence, and journals with it — that is // the point of destroy) base := release.PathsFor(e.names()).Base diff --git a/internal/engine/ops_test.go b/internal/engine/ops_test.go index 846d63b4..2a13c586 100644 --- a/internal/engine/ops_test.go +++ b/internal/engine/ops_test.go @@ -75,6 +75,51 @@ func TestDestroyWithVolumesRemovesEverything(t *testing.T) { if !strings.Contains(seq, "rm -f '/var/lib/ob/_host/owner'") { t.Fatalf("complete teardown without a managed proxy retained host ownership:\n%s", seq) } + for _, network := range []string{"sample_default", "ob_sample"} { + if !strings.Contains(seq, "docker network rm '"+network+"'") { + t.Fatalf("complete teardown retained network %s:\n%s", network, seq) + } + } +} + +func TestDestroyStopsBeforeStateRemovalWhenNetworkHasEndpoints(t *testing.T) { + f := opsFake("x") + base := f.Dynamic + f.Dynamic = func(command string) (transport.Result, bool) { + if strings.Contains(command, "docker network rm 'sample_default'") { + return transport.Result{ExitCode: 1, Stderr: "network has active endpoints"}, true + } + return base(command) + } + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + err := e.Destroy(context.Background(), true, false) + if err == nil || !strings.Contains(err.Error(), "detach its remaining endpoints") { + t.Fatalf("destroy endpoint error = %v", err) + } + seq := strings.Join(f.Commands, "\n") + if strings.Contains(seq, "rm -rf '/var/lib/ob/sample'") || strings.Contains(seq, "rm -f '/var/lib/ob/_host/owner'") { + t.Fatalf("destroy discarded recovery state after network removal failed:\n%s", seq) + } +} + +func TestDestroyStopsBeforeStateRemovalWhenNetworkInspectFails(t *testing.T) { + f := opsFake("x") + base := f.Dynamic + f.Dynamic = func(command string) (transport.Result, bool) { + if strings.Contains(command, "docker network inspect") && strings.Contains(command, "sample_default") { + return transport.Result{ExitCode: 1, Stderr: "permission denied"}, true + } + return base(command) + } + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + err := e.Destroy(context.Background(), true, false) + if err == nil || !strings.Contains(err.Error(), "cannot inspect ownership") { + t.Fatalf("destroy inspect error = %v", err) + } + seq := strings.Join(f.Commands, "\n") + if strings.Contains(seq, "rm -rf '/var/lib/ob/sample'") || strings.Contains(seq, "rm -f '/var/lib/ob/_host/owner'") { + t.Fatalf("destroy discarded recovery state after network inspection failed:\n%s", seq) + } } // Teardown belongs to the release being removed, not the project currently in diff --git a/internal/engine/services.go b/internal/engine/services.go index 7c9b65f5..136bc549 100644 --- a/internal/engine/services.go +++ b/internal/engine/services.go @@ -48,12 +48,8 @@ func (e *Engine) EnsureServiceConnections(ctx context.Context) error { // The network is shared by the application and every service, and it // outlives both. Creating it here rather than in a release is what lets a // release be removed without cutting the application off from its data. - if res, err := e.mutate(ctx, fmt.Sprintf( - "docker network inspect %s >/dev/null 2>&1 || docker network create %s", - q(n.ServiceNetwork()), q(n.ServiceNetwork()))); err != nil { - return err - } else if res.ExitCode != 0 { - return fmt.Errorf("service network: %s", strings.TrimSpace(res.Stderr)) + if err := e.ensureServiceNetwork(ctx, n); err != nil { + return fmt.Errorf("service network: %w", err) } if res, err := e.mutate(ctx, "install -d -m 700 "+q(n.ServiceDir())); err != nil { return err diff --git a/internal/onebox/bootstrap_test.go b/internal/onebox/bootstrap_test.go index 0fc53695..c1cdd586 100644 --- a/internal/onebox/bootstrap_test.go +++ b/internal/onebox/bootstrap_test.go @@ -47,6 +47,9 @@ func TestBootstrapAcceptsBuildSourceWithoutStagingApplicationPayload(t *testing. if strings.Contains(command, "/_host/owner") { return transport.Result{Stdout: "demo\n"}, true } + if strings.Contains(command, "docker network inspect --format") { + return transport.Result{ExitCode: 1, Stderr: "Error response from daemon: network demo_default not found"}, true + } return transport.Result{}, false }, } diff --git a/site/public/onebox.run-v1.schema.json b/site/public/onebox.run-v1.schema.json index e4887f01..0e6cbe71 100644 --- a/site/public/onebox.run-v1.schema.json +++ b/site/public/onebox.run-v1.schema.json @@ -1147,7 +1147,7 @@ }, "network": { "default": "ob-ingress", - "description": "External container network shared with routed workloads.", + "description": "External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved.", "type": "string" } }, diff --git a/site/src/content/docs/explanation/generated-compose.mdx b/site/src/content/docs/explanation/generated-compose.mdx index e21a5af1..1ccaa5e9 100644 --- a/site/src/content/docs/explanation/generated-compose.mdx +++ b/site/src/content/docs/explanation/generated-compose.mdx @@ -35,10 +35,14 @@ becomes a question rather than a fact. - **Derived, stable names.** Application containers use the uniform `--` grammar, such as `shop-web-1`; persistent and - provider resources include `ob_shop_postgres_data`, `ob_shop`, and - `ob-ingress`. Once a volume exists its name cannot change without moving data, - and a foreign resource already holding a derived name is refused rather than - adopted. + provider resources include `shop_default`, `ob_shop_postgres_data`, `ob_shop`, + and `ob-ingress`. The application and service networks are declared external + and created under Onebox's ownership fence, so Compose cannot remove a live + shared network during release teardown. A full `ob destroy --volumes` removes + them; if an unmanaged endpoint remains attached, destruction stops without + deleting Onebox's recovery state or releasing host ownership. Once a resource + exists its name cannot change without migration, and a foreign resource already + holding a derived name is refused rather than adopted. - **Digest binding.** The rendered Compose is bound into the plan, so what was reviewed is what executes. - **Refusals that mean something.** `strategy_ungated`, `route_collision`, diff --git a/site/src/content/docs/explanation/ownership-boundary.mdx b/site/src/content/docs/explanation/ownership-boundary.mdx index 168e6369..101bd340 100644 --- a/site/src/content/docs/explanation/ownership-boundary.mdx +++ b/site/src/content/docs/explanation/ownership-boundary.mdx @@ -48,6 +48,19 @@ the environment that claimed the host, and every mutating command reads that record before it does anything. A different application is refused with `host_owner_mismatch`. +Runtime resources carry the same boundary. Containers, volumes, and networks +created by Onebox have an `ob.app` label, and preflight includes the application +default network (`_default`) and service network (`ob_`) in its +collision set. Both networks are external to a release's Compose lifecycle: a +release can be removed without deleting a network still used by an unmanaged +proxy or a supporting service. Networks created by older versions cannot be +labelled in place; Onebox accepts one only when its Compose project or durable +service state independently proves the same legacy owner. A matching name by +itself is never ownership evidence. Full destruction removes both external +networks before deleting that evidence and releasing host ownership. If Docker +reports a remaining endpoint, destruction stops and tells the operator to detach +it, leaving Onebox's state available for a safe retry. + A different *environment* of the same application is refused too, with `host_environment_mismatch`, and that refusal is the less obvious one. Every runtime name Onebox derives — the Compose project, container names, volume diff --git a/site/src/content/docs/reference/fields/proxy.mdx b/site/src/content/docs/reference/fields/proxy.mdx index 14a14974..efaf4bdf 100644 --- a/site/src/content/docs/reference/fields/proxy.mdx +++ b/site/src/content/docs/reference/fields/proxy.mdx @@ -28,4 +28,4 @@ cannot drift from what `ob validate` accepts. | `image` | string | — | Container image used for the managed proxy. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:…. | | `kind` | `traefik-docker` · `none` | `traefik-docker` | Proxy implementation, or none to disable routing. | | `managed` | boolean | — | Let Onebox converge the host-scoped proxy when routes are declared. | -| `network` | string | `ob-ingress` | External container network shared with routed workloads. | +| `network` | string | `ob-ingress` | External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved. | diff --git a/site/src/content/docs/reference/project-file.mdx b/site/src/content/docs/reference/project-file.mdx index 5acdd203..527355ce 100644 --- a/site/src/content/docs/reference/project-file.mdx +++ b/site/src/content/docs/reference/project-file.mdx @@ -97,15 +97,19 @@ Full explanation, including why level four outranks the rest: **Names** — application containers are uniformly numbered: `shop-web-1`, `shop-web-2`, and `shop-postgres-1`. The managed proxy is `onebox-proxy`. Persistent and provider names include `ob_shop_postgres`, -`ob_shop_postgres_data`, `ob_shop` (the service network), and `ob-ingress`. -These are contract: once a volume exists its name cannot change without moving -data. A foreign resource already holding a derived name is refused, not adopted. +`ob_shop_postgres_data`, `shop_default` (the external application network), +`ob_shop` (the external service network), and `ob-ingress`. These are contract: +once a persistent resource exists its name cannot change without migration. A +foreign resource already holding a derived name is refused, not adopted. **Layout** — `/var/lib/ob//releases/`, plus `current`, `journal`, and `services`. Configurable per environment with `base_path`. **The proxy** — if anything is routed, Onebox runs Traefik and writes its static -configuration. Declare `proxy.config` to own that configuration instead. +configuration. Declare `proxy.config` to own that configuration instead. The +external `proxy.network` may be changed, but `default` is reserved for the +application's own Compose network. The derived `_default` and `ob_` +names are reserved too; routed projects must use a distinct ingress network. ### Route middleware