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
24 changes: 24 additions & 0 deletions compose/apply_override.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ func ApplyRunOverride(project *composetypes.Project, primaryService string, ov O
}
}

// Security/entrypoint options from merged feature+image metadata.
// Pointers: nil means "leave the service's own value untouched" so we
// never downgrade a user's `privileged: true` when no feature asked
// for it. Slices: union (dedup) with the service's existing entries.
if ov.Privileged != nil {
svc.Privileged = *ov.Privileged
}
if ov.Init != nil {
svc.Init = ov.Init
}
svc.CapAdd = unionStrings(svc.CapAdd, ov.CapAdd)
svc.SecurityOpt = unionStrings(svc.SecurityOpt, ov.SecurityOpt)

// Feature-entrypoint chaining. Replace the service entrypoint with a
// wrapper that runs each feature entrypoint then execs the original
// entrypoint+command. Not escaped: the in-memory project is consumed
// directly (no compose re-interpolation). The service's `command`
// (svc.Command) is left untouched.
if len(ov.Entrypoints) > 0 {
svc.Entrypoint = composetypes.ShellCommand(
RenderEntrypointWrapper(ov.Entrypoints, ov.OriginalEntrypoint, false),
)
}

project.Services[primaryService] = svc
return nil
}
97 changes: 97 additions & 0 deletions compose/apply_override_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package compose

import (
"strings"
"testing"

composetypes "github.com/compose-spec/compose-go/v2/types"
Expand Down Expand Up @@ -86,6 +87,102 @@ func TestApplyRunOverride_AppendsVolumeAndEnvAndLabels(t *testing.T) {
}
}

func TestApplyRunOverride_AppliesSecurityMetadata(t *testing.T) {
proj := projectForApply()
// Service already declares one cap and a privileged:false implied by zero.
svc := proj.Services["app"]
svc.CapAdd = []string{"NET_ADMIN"}
proj.Services["app"] = svc

priv := true
initT := true
ov := Override{
Service: "app",
Privileged: &priv,
Init: &initT,
CapAdd: []string{"SYS_ADMIN", "NET_ADMIN"}, // NET_ADMIN dup
SecurityOpt: []string{"seccomp=unconfined"},
}
if err := ApplyRunOverride(proj, "app", ov); err != nil {
t.Fatalf("ApplyRunOverride: %v", err)
}
got := proj.Services["app"]

if !got.Privileged {
t.Error("Privileged not set")
}
if got.Init == nil || !*got.Init {
t.Error("Init not set")
}
// Union: existing NET_ADMIN preserved first, SYS_ADMIN appended, no dup.
if want := []string{"NET_ADMIN", "SYS_ADMIN"}; !equalStrings(got.CapAdd, want) {
t.Errorf("CapAdd = %v, want %v", got.CapAdd, want)
}
if want := []string{"seccomp=unconfined"}; !equalStrings(got.SecurityOpt, want) {
t.Errorf("SecurityOpt = %v, want %v", got.SecurityOpt, want)
}
}

func TestApplyRunOverride_AppliesEntrypointWrapper(t *testing.T) {
proj := projectForApply()
ov := Override{
Service: "app",
Entrypoints: []string{"/usr/local/share/docker-init.sh"},
}
if err := ApplyRunOverride(proj, "app", ov); err != nil {
t.Fatalf("ApplyRunOverride: %v", err)
}
ep := []string(proj.Services["app"].Entrypoint)
if len(ep) < 4 || ep[0] != "/bin/sh" || ep[3] != "-" {
t.Fatalf("entrypoint wrapper not applied: %v", ep)
}
// Native path: single-dollar (no compose re-interpolation).
if !strings.Contains(ep[2], `exec "$@"`) || strings.Contains(ep[2], "$$") {
t.Errorf("native entrypoint should use single-dollar exec: %q", ep[2])
}
if !strings.Contains(ep[2], "docker-init.sh") {
t.Errorf("feature entrypoint missing from wrapper: %q", ep[2])
}
}

func TestApplyRunOverride_NoEntrypointWhenNoneDeclared(t *testing.T) {
proj := projectForApply()
if err := ApplyRunOverride(proj, "app", Override{Service: "app"}); err != nil {
t.Fatalf("ApplyRunOverride: %v", err)
}
if ep := proj.Services["app"].Entrypoint; len(ep) != 0 {
t.Errorf("entrypoint should be untouched when no feature entrypoints; got %v", ep)
}
}

// TestApplyRunOverride_NilPrivilegedLeavesServiceValue confirms a nil
// Override.Privileged does not downgrade a user's `privileged: true`.
func TestApplyRunOverride_NilPrivilegedLeavesServiceValue(t *testing.T) {
proj := projectForApply()
svc := proj.Services["app"]
svc.Privileged = true
proj.Services["app"] = svc

if err := ApplyRunOverride(proj, "app", Override{Service: "app"}); err != nil {
t.Fatalf("ApplyRunOverride: %v", err)
}
if !proj.Services["app"].Privileged {
t.Error("nil Override.Privileged clobbered the service's privileged:true")
}
}

func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

func TestApplyRunOverride_HandlesNilMaps(t *testing.T) {
proj := &composetypes.Project{
Services: composetypes.Services{
Expand Down
14 changes: 8 additions & 6 deletions compose/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -655,12 +655,12 @@ func (o *Orchestrator) waitFor(
}

// serviceToRunSpec is the in-memory translation from compose's
// ServiceConfig to runtime.RunSpec. This is intentionally minimal
// for C6 — env / labels / mounts / command / entrypoint / user /
// workdir / init / cap_add. RunArgs / Privileged / SecurityOpt are
// not in compose's typed model (those are docker-cli concepts), so
// they stay at their zero values. Ports / restart / healthcheck
// are RunSpec gaps to fix in a later PR.
// ServiceConfig to runtime.RunSpec — env / labels / mounts / command /
// entrypoint / user / workdir / init / cap_add / privileged /
// security_opt. The security fields are populated by ApplyRunOverride
// from merged feature+image metadata (e.g. docker-in-docker's
// privileged/init/capAdd), mirroring the flags the image path applies.
// RunArgs has no compose-typed equivalent and stays zero.
func serviceToRunSpec(
plan *Plan,
svc composetypes.ServiceConfig,
Expand Down Expand Up @@ -719,6 +719,8 @@ func serviceToRunSpec(
HealthCheck: healthCheckOf(svc.HealthCheck),
Init: svc.Init != nil && *svc.Init,
CapAdd: svc.CapAdd,
Privileged: svc.Privileged,
SecurityOpt: svc.SecurityOpt,
MemoryBytes: memBytes,
NanoCPUs: nanoCPUs,
}
Expand Down
26 changes: 26 additions & 0 deletions compose/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,32 @@ func TestUp_SingleService(t *testing.T) {
}
}

func TestServiceToRunSpec_CarriesSecurityFields(t *testing.T) {
initT := true
svc := composetypes.ServiceConfig{
Name: "app",
Image: "alpine",
Init: &initT,
Privileged: true,
CapAdd: []string{"SYS_ADMIN"},
SecurityOpt: []string{"seccomp=unconfined"},
}
spec := serviceToRunSpec(&Plan{ProjectName: "dc-x"}, svc, nil, "hash", "")

if !spec.Privileged {
t.Error("Privileged not carried into RunSpec")
}
if !spec.Init {
t.Error("Init not carried into RunSpec")
}
if len(spec.CapAdd) != 1 || spec.CapAdd[0] != "SYS_ADMIN" {
t.Errorf("CapAdd = %v, want [SYS_ADMIN]", spec.CapAdd)
}
if len(spec.SecurityOpt) != 1 || spec.SecurityOpt[0] != "seccomp=unconfined" {
t.Errorf("SecurityOpt = %v, want [seccomp=unconfined]", spec.SecurityOpt)
}
}

func TestUp_DependencyOrder(t *testing.T) {
rt := newMockRuntime()
orch := NewOrchestrator(rt, "docker")
Expand Down
115 changes: 114 additions & 1 deletion compose/override.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,65 @@ type Override struct {
// Labels written on the primary service so Engine.Attach's
// label-based lookup works (`dev.containers.id`, etc.).
Labels map[string]string

// Security/entrypoint options merged from feature + image metadata
// (config.ResolvedConfig.{Privileged,Init,CapAdd,SecurityOpt}).
// These mirror the `docker run --privileged/--init/--cap-add/
// --security-opt` flags the image/Dockerfile path applies; on the
// compose path upstream devcontainers/cli emits the equivalent
// service fields into its generated override compose file. Without
// them a feature like docker-in-docker (which declares
// privileged/init/capAdd) silently fails on compose-source
// devcontainers.
//
// Privileged / Init are pointers so nil means "no change" — we must
// not clobber a user's `privileged: true` in their compose file when
// no feature requested it. CapAdd / SecurityOpt are unioned with the
// service's existing entries (dedup, user entries preserved).
Privileged *bool
Init *bool
CapAdd []string
SecurityOpt []string

// Entrypoints is the ordered chain of feature/image-metadata
// entrypoint scripts (config.ResolvedConfig.Entrypoints). When
// non-empty the primary service's entrypoint is replaced with a
// generated wrapper that runs each in sequence then execs the
// original entrypoint+command — mirroring devcontainers/cli's
// generated compose override. Required for features like
// docker-in-docker whose dockerd is launched by docker-init.sh.
Entrypoints []string

// OriginalEntrypoint is the entrypoint to preserve underneath the
// wrapper: the service's own `entrypoint:` if it declared one, else
// the image's ENTRYPOINT. The service's `command` is left untouched
// (the wrapper execs entrypoint+command together). Only consulted
// when Entrypoints is non-empty.
OriginalEntrypoint []string
}

// RenderEntrypointWrapper builds the wrapper entrypoint array that runs
// each feature entrypoint in order, then execs the original
// entrypoint+command (`exec "$@"`), then falls back to a keep-alive
// loop. Mirrors devcontainers/cli's generated compose override entrypoint.
//
// escapeDollar controls $-escaping: the shellout path writes a YAML file
// that `docker compose` re-interpolates, so `$` must be doubled to `$$`
// to survive as a literal; the native path mutates the in-memory project
// (no interpolation) and must keep a single `$`.
func RenderEntrypointWrapper(entrypoints, original []string, escapeDollar bool) []string {
script := "echo Container started\n" +
"trap \"exit 0\" 15\n" +
strings.Join(entrypoints, "\n") + "\n" +
"exec \"$@\"\n" +
"while sleep 1 & wait $!; do :; done"
arr := append([]string{"/bin/sh", "-c", script, "-"}, original...)
if escapeDollar {
for i := range arr {
arr[i] = strings.ReplaceAll(arr[i], "$", "$$")
}
}
return arr
}

// BindMount describes one bind volume in the override.
Expand Down Expand Up @@ -99,9 +158,20 @@ func WriteRunOverride(dst string, project *composetypes.Project, ov Override) er
return err
}

// cap_add / security_opt use compose v2's sequence-REPLACE merge, so
// (like volumes) we union with the user's existing service entries
// and re-emit the full list rather than risk dropping them.
emit := ov
if project != nil {
if svc, err := PrimaryService(project, ov.Service); err == nil {
emit.CapAdd = unionStrings(svc.CapAdd, ov.CapAdd)
emit.SecurityOpt = unionStrings(svc.SecurityOpt, ov.SecurityOpt)
}
}

doc := map[string]any{
"services": map[string]any{
ov.Service: buildServiceOverride(merged, ov),
ov.Service: buildServiceOverride(merged, emit),
},
}
body, err := yaml.Marshal(doc)
Expand Down Expand Up @@ -178,9 +248,52 @@ func buildServiceOverride(volumes []any, ov Override) map[string]any {
}
svc["labels"] = labels
}
if ov.Privileged != nil {
svc["privileged"] = *ov.Privileged
}
if ov.Init != nil {
svc["init"] = *ov.Init
}
if len(ov.CapAdd) > 0 {
svc["cap_add"] = append([]string(nil), ov.CapAdd...)
}
if len(ov.SecurityOpt) > 0 {
svc["security_opt"] = append([]string(nil), ov.SecurityOpt...)
}
if len(ov.Entrypoints) > 0 {
// Escaped: this YAML is re-interpolated by `docker compose`.
svc["entrypoint"] = RenderEntrypointWrapper(ov.Entrypoints, ov.OriginalEntrypoint, true)
}
return svc
}

// unionStrings returns existing followed by any entries in add not
// already present, preserving order and dropping duplicates. Used to
// merge feature-contributed cap_add / security_opt into a service's
// own entries without clobbering either.
func unionStrings(existing, add []string) []string {
if len(add) == 0 {
return existing
}
seen := make(map[string]struct{}, len(existing)+len(add))
out := make([]string, 0, len(existing)+len(add))
for _, s := range existing {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
for _, s := range add {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}

// yamlScalar quotes a value if it contains characters that would
// require quoting in YAML. Conservative: any non-alphanumeric / dot /
// dash / underscore / slash / colon / @ character triggers quoting.
Expand Down
Loading
Loading