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

## [Unreleased]

### Added

- **`EngineOptions.DisableHostPortPublishing`** — drops the host side of compose
`ports:` entries. Services still start and nothing else about them changes; each
dropped entry raises an `engine.warn` event (`compose_host_port_publish_skipped`)
naming the service and where it is reachable instead. Set it when the daemon's "host"
is a namespace shared with other workloads — the engine running inside a Kubernetes
pod next to sidecars — where a service publishing a common port (`8080`) loses the
race against a sidecar already listening there and the boot fails on a raw
`bind: address already in use`. Nothing in that shape consumes the publish anyway:
service-to-service traffic goes over the compose network by service name, and an
embedder forwarding a port out of the namespace dials the container on that network,
which is why dropping it changes no connectivity. It is an `EngineOptions` field
rather than a `devcontainer.json` one because it describes where the engine is
deployed, not the project. Entries are dropped from the in-memory project, so
`ConfigHash` sees them go and a container created before the option was turned on is
recreated rather than reused with its publishes intact. `ComposeBackendNative` only —
under `ComposeBackendShellout`, `docker compose` owns the publish, so a compose-source
`Up` is refused rather than run with the option silently ignored. `network_mode: host`
has the same collision and is not covered here; it needs a refusal rather than a drop
([#136](https://github.com/crunchloop/devcontainer/issues/136)).

### Removed

- **BREAKING — `runtime.Capabilities` is cut from six fields to three.** The struct
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ field/behavior the library covers. Legend: ✅ acted on · ⚠️ parsed but not
| `forwardPorts` | ⚠️ parsed | Not actuated; [#7](https://github.com/crunchloop/devcontainer/issues/7) |
| `portsAttributes`, `otherPortsAttributes` | ⚠️ parsed | Surfaced on `ResolvedConfig`; not enforced |
| `appPort` (deprecated) | ✅ translated | Folded into `forwardPorts` (skipping container ports already declared); deprecation warning still emitted |
| compose `ports:` | ✅ published | Host publishes come from the compose file, not `devcontainer.json`. `EngineOptions.DisableHostPortPublishing` drops the host side of every entry — for an embedder whose daemon host is a namespace shared with other workloads (the engine inside a Kubernetes pod), where the binding collides with a sidecar and nothing consumes it. Each dropped entry raises an `engine.warn` event. Requires `ComposeBackendNative`. |

**Other**

Expand Down
119 changes: 119 additions & 0 deletions compose/apply_drop_host_ports_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package compose

import (
"testing"

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

func portProject() *composetypes.Project {
return &composetypes.Project{
Services: composetypes.Services{
"app": {
Name: "app",
Image: "app:dev",
Ports: []composetypes.ServicePortConfig{
{Target: 8080, Published: "8080", Protocol: "tcp"},
{Target: 9229, Published: "9229", HostIP: "127.0.0.1", Protocol: "tcp"},
},
Environment: composetypes.MappingWithEquals{},
},
"db": {
Name: "db",
Image: "postgres:17",
Ports: []composetypes.ServicePortConfig{
// `ports: ["5432"]` — no published side; the daemon
// picks an ephemeral host port, in the same namespace.
{Target: 5432},
},
},
"cache": {
Name: "cache",
Image: "redis:7",
},
},
}
}

// Every entry goes, on every service, and the caller learns what was
// taken so it can say so. Service order is sorted: the engine turns
// each entry into a warning, and warning order must not depend on map
// iteration.
func TestApplyDropHostPorts_DropsEveryEntry(t *testing.T) {
proj := portProject()

dropped := ApplyDropHostPorts(proj)

for name, svc := range proj.Services {
if len(svc.Ports) != 0 {
t.Errorf("service %q kept %d ports, want 0", name, len(svc.Ports))
}
}
if len(dropped) != 3 {
t.Fatalf("dropped = %d entries, want 3; got %+v", len(dropped), dropped)
}
want := []string{"app", "app", "db"}
for i, svc := range want {
if dropped[i].Service != svc {
t.Errorf("dropped[%d].Service = %q, want %q (sorted by service name)", i, dropped[i].Service, svc)
}
}
if got := dropped[0].String(); got != "8080:8080/tcp" {
t.Errorf("dropped[0] = %q, want 8080:8080/tcp", got)
}
if got := dropped[1].String(); got != "127.0.0.1:9229:9229/tcp" {
t.Errorf("dropped[1] = %q, want 127.0.0.1:9229:9229/tcp", got)
}
// Published unset: still dropped, and rendered without a host side
// rather than as ":5432/tcp".
if got := dropped[2].String(); got != "5432/tcp" {
t.Errorf("dropped[2] = %q, want 5432/tcp", got)
}
}

// The drop is surgical: nothing but ports: moves. A service that
// declared none is not rewritten at all.
func TestApplyDropHostPorts_LeavesTheRestOfTheServiceAlone(t *testing.T) {
proj := portProject()

ApplyDropHostPorts(proj)

if got := proj.Services["app"].Image; got != "app:dev" {
t.Errorf("app image = %q, want app:dev", got)
}
if got := proj.Services["cache"].Image; got != "redis:7" {
t.Errorf("cache image = %q, want redis:7", got)
}
if len(proj.Services) != 3 {
t.Errorf("services = %d, want 3", len(proj.Services))
}
}

func TestApplyDropHostPorts_NoPortsIsANoOp(t *testing.T) {
proj := &composetypes.Project{
Services: composetypes.Services{"app": {Name: "app", Image: "app:dev"}},
}
if dropped := ApplyDropHostPorts(proj); dropped != nil {
t.Errorf("dropped = %+v, want nil", dropped)
}
if dropped := ApplyDropHostPorts(nil); dropped != nil {
t.Errorf("nil project: dropped = %+v, want nil", dropped)
}
}

// The reason the drop happens here and not inside the orchestrator's
// portsOf: ConfigHash reads the project's ServiceConfig, so removing
// the entries is visible to the recreate check. A container created
// before the option was turned on published the ports and must be
// replaced, not reused.
func TestApplyDropHostPorts_ChangesConfigHash(t *testing.T) {
proj := portProject()
before := ConfigHash("sha256:x", proj.Services["app"])

ApplyDropHostPorts(proj)

after := ConfigHash("sha256:x", proj.Services["app"])
if before == after {
t.Error("ApplyDropHostPorts did not change ConfigHash; a container created with the publishes would be reused with them intact")
}
}
93 changes: 93 additions & 0 deletions compose/apply_override.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package compose

import (
"fmt"
"sort"
"strconv"

composetypes "github.com/compose-spec/compose-go/v2/types"
)
Expand Down Expand Up @@ -135,3 +137,94 @@ func ApplyRunOverride(project *composetypes.Project, primaryService string, ov O
project.Services[primaryService] = svc
return nil
}

// DroppedHostPort records one `ports:` entry removed by
// ApplyDropHostPorts, so the caller can report what it took away.
type DroppedHostPort struct {
// Service is the compose service that declared the entry.
Service string

// HostIP is the address the entry asked to bind, empty for
// compose's all-interfaces default.
HostIP string

// Published is the host-side port, empty when the entry left it
// to the daemon (`ports: ["8080"]` — an ephemeral host port).
Published string

// Target is the container-side port the entry published.
Target int

// Protocol is "tcp" or "udp", empty for compose's tcp default.
Protocol string
}

// String renders the entry in compose's own short syntax
// (`127.0.0.1:8080:8080/tcp`), so a diagnostic can quote the line the
// user wrote rather than a shape only this package knows.
func (d DroppedHostPort) String() string {
proto := d.Protocol
if proto == "" {
proto = "tcp"
}
out := strconv.Itoa(d.Target) + "/" + proto
if d.Published != "" {
out = d.Published + ":" + out
}
if d.HostIP != "" {
out = d.HostIP + ":" + out
}
return out
}

// ApplyDropHostPorts mutates project so no service publishes a host
// port, and returns every entry it removed in service-name order.
//
// For an engine whose daemon's "host" is a namespace shared with
// workloads it does not own — the engine running inside a Kubernetes
// pod next to sidecars — a published port is not the project's to
// take: it collides with whatever else already listens there, and
// nothing consumes it, because service-to-service traffic goes over
// the compose network by service name and an embedder forwarding a
// port out of the namespace dials the container on that network.
//
// Dropping the entries here rather than inside the orchestrator is
// deliberate: ConfigHash reads the project's ServiceConfig, so a
// container created with the bindings drifts from one created
// without them and is recreated, instead of being reused with its
// publishes intact.
//
// Entries are removed whole, including ones that left `published`
// unset — those bind an ephemeral host port, which is the same
// namespace and the same problem.
func ApplyDropHostPorts(project *composetypes.Project) []DroppedHostPort {
if project == nil {
return nil
}

names := make([]string, 0, len(project.Services))
for name := range project.Services {
names = append(names, name)
}
sort.Strings(names)

var dropped []DroppedHostPort
for _, name := range names {
svc := project.Services[name]
if len(svc.Ports) == 0 {
continue
}
for _, p := range svc.Ports {
dropped = append(dropped, DroppedHostPort{
Service: name,
HostIP: p.HostIP,
Published: p.Published,
Target: int(p.Target),
Protocol: p.Protocol,
})
}
svc.Ports = nil
project.Services[name] = svc
}
return dropped
}
32 changes: 32 additions & 0 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,38 @@ type EngineOptions struct {
// Native; then the default flips and the shellout path is
// deleted.
ComposeBackend ComposeBackend

// DisableHostPortPublishing drops the host side of compose
// `ports:` entries. Services still start and nothing else about
// them changes; they are simply not published. Each dropped entry
// is reported as an events.WarnEvent with code
// "compose_host_port_publish_skipped".
//
// Set it when the daemon's "host" is a namespace shared with other
// workloads — the engine running inside a Kubernetes pod next to
// sidecars — where binding a host port is neither useful nor safe:
// a service publishing a common port (8080) loses the race against
// a sidecar already listening there and the boot fails on a raw
// "bind: address already in use". Nothing consumes the publish in
// that shape anyway, so the binding only takes a scarce shared
// port.
//
// Dropping it leaves connectivity intact — service-to-service
// traffic goes over the compose network by service name, and an
// embedder forwarding a port out of the namespace dials the
// container on that network, never the host publish.
//
// It lives here rather than in devcontainer.json because it is a
// property of where the engine is deployed, not of the project: a
// user cannot know what their compose stack is scheduled next to,
// and a config field would let them opt back into a binding that
// cannot work.
//
// ComposeBackendNative only. The shellout backend hands `ports:`
// to `docker compose`, which publishes them regardless, so a
// compose-source Up under ComposeBackendShellout is refused rather
// than run with the option silently ignored.
DisableHostPortPublishing bool
}

// ComposeBackend selects between the legacy shellout and the new
Expand Down
95 changes: 95 additions & 0 deletions test/integration/compose_host_port_publishing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//go:build integration

package integration

import (
"context"
"path/filepath"
"strings"
"testing"
"time"

devcontainer "github.com/crunchloop/devcontainer"
)

// writeComposePortsWorkspace lays out a project whose primary service
// publishes a host port and whose sidecar answers on the compose
// network. The two together are what DisableHostPortPublishing
// claims: the publish is removable, the service-name path is not.
func writeComposePortsWorkspace(t *testing.T) string {
t.Helper()
dir := t.TempDir()

mustWrite(t, filepath.Join(dir, "docker-compose.yml"), `
services:
app:
image: `+testImage+`
command: ["sh", "-c", "while sleep 1000; do :; done"]
ports:
- "39080:8080"
db:
image: `+testImage+`
command: ["sh", "-c", "while true; do echo db-reachable | nc -l -p 5000; done"]
`)

mustWrite(t, filepath.Join(dir, ".devcontainer", "devcontainer.json"), `{
"dockerComposeFile": "../docker-compose.yml",
"service": "app",
"runServices": ["app", "db"],
"workspaceFolder": "/workspaces/proj"
}`)
return dir
}

// The option's claim is that dropping the host side of `ports:` is
// semantically safe. This is the half a fake runtime cannot make: the
// project still boots against a real daemon, and the sidecar is still
// reachable by service name over the compose network — which is how
// an embedder reaches it, and why nothing is lost by not publishing.
//
// What the host binding itself does (or, here, does not do) is
// asserted at the RunSpec boundary in the unit tests; the daemon-side
// translation of RunSpec.Ports is covered in runtime/docker.
func TestComposeSource_DisableHostPortPublishing_BootsAndKeepsServiceDNS(t *testing.T) {
if testing.Short() {
t.Skip()
}

eng, rt := newEngineWith(t, devcontainer.EngineOptions{
ComposeBackend: devcontainer.ComposeBackendNative,
DisableHostPortPublishing: true,
})
defer rt.Close()

ws := writeComposePortsWorkspace(t)

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

wsObj, err := eng.Up(ctx, devcontainer.UpOptions{
LocalWorkspaceFolder: ws,
Recreate: true,
SkipLifecycle: true,
})
if err != nil {
t.Fatalf("Up: %v", err)
}
defer func() {
_ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{
Remove: true,
RemoveVolumes: true,
})
}()

// Retry: nothing orders the sidecar's listener against this exec,
// and busybox nc serves one connection per accept.
res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{
Cmd: []string{"sh", "-c", `for i in $(seq 1 30); do out=$(nc -w 5 db 5000); if [ -n "$out" ]; then echo "$out"; exit 0; fi; sleep 1; done; exit 1`},
})
if err != nil {
t.Fatalf("Exec nc db: %v", err)
}
if !strings.Contains(res.Stdout, "db-reachable") {
t.Errorf("sidecar unreachable by service name: stdout=%q stderr=%q", res.Stdout, res.Stderr)
}
}
Loading