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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **compose (native)** — a plan restricted to a service subset
(devcontainer `runServices`) now starts and builds the transitive
dependency closure of the selection, matching `docker compose up
<names...>`. Previously a dependency outside the list was neither
built nor started, so the primary came up with its `depends_on`
absent.
- **compose (native)** — `network_mode:`, `pid:` and `ipc:` are now
carried through to the backend instead of being silently replaced by
the project network. `service:<x>` references resolve to the
dependency's container (ordering already guaranteed by the
dependency graph); a service with a network mode is not attached to
the project network, matching Docker's API constraints. In
particular `network_mode: none` — an explicit isolation request —
previously received full project-network connectivity.

## [0.4.1] - 2026-08-22

### Fixed
Expand Down
76 changes: 65 additions & 11 deletions compose/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,9 @@ func TopoSort(project *composetypes.Project) ([]Level, error) {
deps[name] = map[string]struct{}{}
}
for name, svc := range services {
for dep := range svc.DependsOn {
if _, ok := services[dep]; !ok {
continue
}
deps[name][dep] = struct{}{}
}
if nm := svc.NetworkMode; isServiceNetworkMode(nm) {
peer := nm[len("service:"):]
if _, ok := services[peer]; ok && peer != name {
deps[name][peer] = struct{}{}
for _, dep := range serviceEdges(svc) {
if _, ok := services[dep]; ok && dep != name {
deps[name][dep] = struct{}{}
}
}
}
Expand Down Expand Up @@ -145,6 +138,67 @@ func findCycle(deps map[string]map[string]struct{}, remaining map[string]struct{
// orchestrator surfaces the dep edge here so topo-sort respects the
// ordering even though compose-go doesn't model it under DependsOn.
func isServiceNetworkMode(nm string) bool {
return serviceRefTarget(nm) != ""
}

// serviceRefTarget returns the service name a `service:<name>`
// namespace-mode value points at, or "" when the value is anything
// else (empty, "host", "none", "container:<id>", ...).
func serviceRefTarget(v string) string {
const p = "service:"
return len(nm) > len(p) && nm[:len(p)] == p
if len(v) > len(p) && v[:len(p)] == p {
return v[len(p):]
}
return ""
}

// serviceEdges lists the services svc depends on: depends_on entries
// plus the implicit edges from `network_mode: service:<x>` and the
// pid/ipc equivalents — joining another service's namespace requires
// that service's container to exist first.
func serviceEdges(svc composetypes.ServiceConfig) []string {
var out []string
for dep := range svc.DependsOn {
out = append(out, dep)
}
for _, mode := range []string{svc.NetworkMode, svc.Pid, svc.Ipc} {
if peer := serviceRefTarget(mode); peer != "" {
out = append(out, peer)
}
}
return out
}

// ServiceClosure returns names plus the transitive closure of their
// dependencies (the same edge set TopoSort orders by). `docker
// compose up <names...>` starts the named services AND everything
// they depend on; callers restricting a Plan to a service subset use
// this to reproduce that contract. Names not present in the project
// are kept verbatim (Plan validation surfaces them); the result is
// sorted for determinism.
func ServiceClosure(project *composetypes.Project, names []string) []string {
if project == nil {
return append([]string(nil), names...)
}
seen := map[string]bool{}
queue := append([]string(nil), names...)
for len(queue) > 0 {
name := queue[0]
queue = queue[1:]
if seen[name] {
continue
}
seen[name] = true
svc, ok := project.Services[name]
if !ok {
continue
}
queue = append(queue, serviceEdges(svc)...)
}
out := make([]string, 0, len(seen))
for name := range seen {
out = append(out, name)
}
sort.Strings(out)
return out
}
79 changes: 73 additions & 6 deletions compose/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ func (o *Orchestrator) Up(ctx context.Context, plan *Plan) (UpResult, error) {
// limited which services to bring up.
keep := makeKeepSet(plan)

// resolveContainer maps a service name to its already-started
// container ID, for `service:<x>` namespace-mode references. The
// target is always in an earlier level (serviceEdges makes it a
// TopoSort dependency), but same-level writes to the map are
// concurrent, so reads take the same lock.
var idsMu sync.Mutex
resolveContainer := func(name string) string {
idsMu.Lock()
defer idsMu.Unlock()
return res.ContainerIDs[name]
}

for _, level := range levels {
var started []string
var startMu sync.Mutex
Expand All @@ -188,7 +200,7 @@ func (o *Orchestrator) Up(ctx context.Context, plan *Plan) (UpResult, error) {
if !ok {
return
}
id, err := o.ensureService(ctx, plan, svc, projectLabels)
id, err := o.ensureService(ctx, plan, svc, projectLabels, resolveContainer)
startMu.Lock()
defer startMu.Unlock()
if err != nil {
Expand All @@ -198,7 +210,9 @@ func (o *Orchestrator) Up(ctx context.Context, plan *Plan) (UpResult, error) {
}
return
}
idsMu.Lock()
res.ContainerIDs[svcName] = id
idsMu.Unlock()
started = append(started, svcName)
}()
}
Expand Down Expand Up @@ -449,6 +463,7 @@ func (o *Orchestrator) ensureService(
plan *Plan,
svc composetypes.ServiceConfig,
projectLabels map[string]string,
resolveContainer func(string) string,
) (string, error) {
// Resolve the service's image to its digest before hashing. The
// compose file carries a tag (e.g. "postgres:17-alpine") which is
Expand Down Expand Up @@ -520,7 +535,10 @@ func (o *Orchestrator) ensureService(
}
}

spec := serviceToRunSpec(plan, svc, projectLabels, hash, imageDigest)
spec, err := serviceToRunSpec(plan, svc, projectLabels, hash, imageDigest, resolveContainer)
if err != nil {
return "", err
}
if o.selfProbe {
// The orchestrator probes health itself (see waitFor); explicitly
// DISABLE the backend's native HEALTHCHECK. Nil would mean "inherit
Expand Down Expand Up @@ -788,7 +806,8 @@ func serviceToRunSpec(
svc composetypes.ServiceConfig,
projectLabels map[string]string,
hash, imageDigest string,
) runtime.RunSpec {
resolveContainer func(string) string,
) (runtime.RunSpec, error) {
labels := copyLabels(plan.Labels)
for k, v := range projectLabels {
labels[k] = v
Expand Down Expand Up @@ -824,6 +843,29 @@ func serviceToRunSpec(
})
}

// Namespace modes, resolved to HostConfig syntax. A service that
// joins another container's network namespace (or opts out with
// "none"/"host") must not also get per-network endpoint config —
// Docker rejects the combination — so the project network is
// skipped for it. Service-name DNS is then the joined namespace's
// concern, matching `docker compose` semantics.
networkMode, err := resolveNamespaceMode(svc.NetworkMode, resolveContainer)
if err != nil {
return runtime.RunSpec{}, fmt.Errorf("service %q network_mode: %w", svc.Name, err)
}
pidMode, err := resolveNamespaceMode(svc.Pid, resolveContainer)
if err != nil {
return runtime.RunSpec{}, fmt.Errorf("service %q pid: %w", svc.Name, err)
}
ipcMode, err := resolveNamespaceMode(svc.Ipc, resolveContainer)
if err != nil {
return runtime.RunSpec{}, fmt.Errorf("service %q ipc: %w", svc.Name, err)
}
networks := []string{plan.ProjectName + "_default"}
if networkMode != "" {
networks = nil
}

memBytes, nanoCPUs := resourcesOf(svc)
return runtime.RunSpec{
Image: svc.Image,
Expand All @@ -835,7 +877,10 @@ func serviceToRunSpec(
Env: env,
Labels: labels,
Mounts: mounts,
Networks: []string{plan.ProjectName + "_default"},
Networks: networks,
NetworkMode: networkMode,
PidMode: pidMode,
IpcMode: ipcMode,
Ports: portsOf(svc.Ports),
RestartPolicy: restartPolicyOf(svc.Restart),
HealthCheck: healthCheckOf(svc.HealthCheck),
Expand All @@ -845,7 +890,25 @@ func serviceToRunSpec(
SecurityOpt: svc.SecurityOpt,
MemoryBytes: memBytes,
NanoCPUs: nanoCPUs,
}
}, nil
}

// resolveNamespaceMode translates a compose namespace-mode value into
// Docker HostConfig syntax. `service:<x>` becomes `container:<id>`
// via the dependency's already-started container (serviceEdges makes
// the target a TopoSort dependency, so it lives in an earlier level);
// everything else — "", "host", "none", "container:<id>" — passes
// through verbatim.
func resolveNamespaceMode(v string, resolveContainer func(string) string) (string, error) {
target := serviceRefTarget(v)
if target == "" {
return v, nil
}
id := resolveContainer(target)
if id == "" {
return "", fmt.Errorf("mode %q: service %q has no started container to join", v, target)
}
return "container:" + id, nil
}

// resourcesOf extracts the memory + CPU limits from a compose service.
Expand Down Expand Up @@ -992,7 +1055,11 @@ func makeKeepSet(plan *Plan) map[string]bool {
}
return keep
}
for _, name := range plan.Services {
// `docker compose up <names...>` starts the named services and
// their transitive dependencies; a restricted Plan keeps that
// contract, or a kept service would come up with its depends_on
// (or `service:<x>` namespace target) absent.
for _, name := range ServiceClosure(plan.Project, plan.Services) {
keep[name] = true
}
return keep
Expand Down
110 changes: 109 additions & 1 deletion compose/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,10 @@ func TestServiceToRunSpec_CarriesSecurityFields(t *testing.T) {
CapAdd: []string{"SYS_ADMIN"},
SecurityOpt: []string{"seccomp=unconfined"},
}
spec := serviceToRunSpec(&Plan{ProjectName: "dc-x"}, svc, nil, "hash", "")
spec, err := serviceToRunSpec(&Plan{ProjectName: "dc-x"}, svc, nil, "hash", "", func(string) string { return "" })
if err != nil {
t.Fatalf("serviceToRunSpec: %v", err)
}

if !spec.Privileged {
t.Error("Privileged not carried into RunSpec")
Expand Down Expand Up @@ -967,3 +970,108 @@ func TestUp_AdoptsRunningForeignContainerWithoutStarting(t *testing.T) {
rt.removeCalls, rt.runCalls, rt.startCalls)
}
}

// Restricting a plan to a service subset must keep `docker compose up
// <names...>` semantics: dependencies (including `service:<x>`
// namespace targets) come up too.
func TestUp_RestrictedServicesStartDependencyClosure(t *testing.T) {
rt := newMockRuntime()
orch := NewOrchestrator(rt, "docker")
proj := newProject(t, map[string][]string{
"db": nil,
"app": {"db"},
"unwanted": nil,
})

res, err := orch.Up(context.Background(), &Plan{
Project: proj, ProjectName: "dc-x", Services: []string{"app"},
})
if err != nil {
t.Fatalf("Up: %v", err)
}
if res.ContainerIDs["app"] == "" || res.ContainerIDs["db"] == "" {
t.Errorf("ContainerIDs = %+v, want app AND its dependency db", res.ContainerIDs)
}
if _, started := res.ContainerIDs["unwanted"]; started {
t.Error("service outside the closure was started")
}
}

func TestServiceClosure_FollowsNamespaceEdges(t *testing.T) {
proj := newProject(t, map[string][]string{"proxy": nil, "app": nil, "other": nil})
app := proj.Services["app"]
app.NetworkMode = "service:proxy"
proj.Services["app"] = app

got := ServiceClosure(proj, []string{"app"})
want := []string{"app", "proxy"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Errorf("ServiceClosure = %v, want %v", got, want)
}
}

// network_mode must reach the backend, not be silently replaced by
// the project network — `none` in particular is an isolation request.
func TestUp_NetworkModeCarriedAndProjectNetworkSkipped(t *testing.T) {
rt := newMockRuntime()
orch := NewOrchestrator(rt, "docker")
proj := newProject(t, map[string][]string{"app": nil, "sandboxed": nil})
sandboxed := proj.Services["sandboxed"]
sandboxed.NetworkMode = "none"
sandboxed.Pid = "host"
proj.Services["sandboxed"] = sandboxed

var mu sync.Mutex
specs := map[string]runtime.RunSpec{}
rt.OnRunContainer = func(spec runtime.RunSpec) (*runtime.Container, error) {
mu.Lock()
specs[spec.Labels[LabelComposeService]] = spec
mu.Unlock()
return nil, nil
}

if _, err := orch.Up(context.Background(), &Plan{Project: proj, ProjectName: "dc-x"}); err != nil {
t.Fatalf("Up: %v", err)
}
if got := specs["sandboxed"].NetworkMode; got != "none" {
t.Errorf("sandboxed NetworkMode = %q, want none", got)
}
if got := specs["sandboxed"].PidMode; got != "host" {
t.Errorf("sandboxed PidMode = %q, want host", got)
}
if nets := specs["sandboxed"].Networks; len(nets) != 0 {
t.Errorf("sandboxed Networks = %v; a namespace mode excludes the project network", nets)
}
if nets := specs["app"].Networks; len(nets) != 1 {
t.Errorf("app Networks = %v, want the project network", nets)
}
}

// `service:<x>` resolves to the dependency's container ID, which the
// topo order guarantees exists by the time the dependent is created.
func TestUp_ServiceNetworkModeResolvesToContainer(t *testing.T) {
rt := newMockRuntime()
orch := NewOrchestrator(rt, "docker")
proj := newProject(t, map[string][]string{"proxy": nil, "app": nil})
app := proj.Services["app"]
app.NetworkMode = "service:proxy"
proj.Services["app"] = app

var mu sync.Mutex
specs := map[string]runtime.RunSpec{}
rt.OnRunContainer = func(spec runtime.RunSpec) (*runtime.Container, error) {
mu.Lock()
specs[spec.Labels[LabelComposeService]] = spec
mu.Unlock()
return nil, nil
}

res, err := orch.Up(context.Background(), &Plan{Project: proj, ProjectName: "dc-x"})
if err != nil {
t.Fatalf("Up: %v", err)
}
want := "container:" + res.ContainerIDs["proxy"]
if got := specs["app"].NetworkMode; got != want {
t.Errorf("app NetworkMode = %q, want %q", got, want)
}
}
Loading
Loading