Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion docs/onebox.run-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
},
Expand Down
119 changes: 119 additions & 0 deletions e2e/network_ownership_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions internal/app/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions internal/app/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions internal/app/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion internal/app/names.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand All @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions internal/app/names_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 23 additions & 21 deletions internal/app/naming_scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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])
Expand Down
65 changes: 53 additions & 12 deletions internal/app/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
Loading