diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec43d1..0d3c773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.1] - 2026-10-27 + +### Fixed + +- **compose** — volume and tmpfs mounts from `devcontainer.json` and from Dev + Container Feature / image metadata (`ResolvedConfig.Mounts`) now reach the + primary compose service on both backends. Only bind mounts did before: + `composeBindMounts` dropped every other type, so **docker-in-docker failed on + compose-source devcontainers** — its + `dind-var-lib-docker-${devcontainerId}` → `/var/lib/docker` volume never + mounted, the inner dockerd's data root stayed on the container's overlay root, + and `docker run` failed with `EINVAL` (overlay on overlay). The image path + already passed every type through. Named volumes are also declared as + top-level project volumes, so they are created as `_` with the + project labels — `ensureNamedVolumes` on the native backend, the generated + override's `volumes:` on the shell-out backend — the same project-scoped name + the reference `devcontainers/cli` gives them. Where the user's compose service + already mounts something at the same target, the user's entry is kept and the + metadata mount is skipped (the reference CLI's override would replace it); + this matches how the workspace bind was already deduplicated. A top-level + volume the user already declares under the same name is left as declared. + Adds `compose.Mount` and `compose.Override.ExtraMounts`; `ExtraBindMounts` is + unchanged. Recreate note: on the first `Up` after upgrading, a compose + workspace whose features declare such a mount has a new entry in its + service volumes, so its config hash drifts and the primary container is + recreated unless `UpOptions.AdoptExisting` is set — the same drift the + per-boot feature image rebuild already causes. + ## [0.5.0] - 2026-09-16 ### Added @@ -585,7 +613,8 @@ shelling out to `@devcontainers/cli`. - `events` is doc-tagged **experimental** until v1.0.0 — type shapes may evolve without a SemVer-major bump. -[Unreleased]: https://github.com/crunchloop/devcontainer/compare/v0.5.0...HEAD +[Unreleased]: https://github.com/crunchloop/devcontainer/compare/v0.5.1...HEAD +[0.5.1]: https://github.com/crunchloop/devcontainer/compare/v0.5.0...v0.5.1 [0.5.0]: https://github.com/crunchloop/devcontainer/compare/v0.4.3...v0.5.0 [0.4.3]: https://github.com/crunchloop/devcontainer/compare/v0.4.2...v0.4.3 [0.4.2]: https://github.com/crunchloop/devcontainer/compare/v0.4.1...v0.4.2 diff --git a/compose/apply_override.go b/compose/apply_override.go index 8753bf5..ab9ffc4 100644 --- a/compose/apply_override.go +++ b/compose/apply_override.go @@ -49,13 +49,17 @@ func ApplyBuildOverride(project *composetypes.Project, service, imageRef string) } // ApplyRunOverride mutates project so the primary service has the -// workspace bind mount, container env, and labels merged in. Existing +// workspace bind mount, the other devcontainer mounts (binds, volumes, +// tmpfs), container env, and labels merged in. Named volumes from +// ov.ExtraMounts are also declared on project.Volumes so the +// orchestrator's ensureNamedVolumes creates them. Existing // user-declared volumes / environment / labels are preserved (we // append, we don't replace) — unlike the YAML write path, which // re-emits everything to dodge compose's sequence-replace merge, // mutating in memory is naturally additive. // -// Returns an error if the primary service is missing. +// Returns an error if the primary service is missing or an ExtraMounts +// entry has an unsupported type. func ApplyRunOverride(project *composetypes.Project, primaryService string, ov Override) error { if project == nil { return fmt.Errorf("ApplyRunOverride: nil project") @@ -67,6 +71,9 @@ func ApplyRunOverride(project *composetypes.Project, primaryService string, ov O if !ok { return fmt.Errorf("ApplyRunOverride: primary service %q not found in project", primaryService) } + if err := validateMounts(ov.ExtraMounts); err != nil { + return fmt.Errorf("ApplyRunOverride: %w", err) + } // Dedupe by target. The user's compose file may already declare // the workspace bind mount (a common idiom for compose-source @@ -90,6 +97,34 @@ func ApplyRunOverride(project *composetypes.Project, primaryService string, ov O }) existingTargets[b.Target] = struct{}{} } + // Volumes and tmpfs follow the same target dedupe. A named volume + // also needs a top-level declaration: ensureNamedVolumes creates + // only declared volumes, and an undeclared one would be left for + // the daemon to create implicitly, without the project labels + // `Down --volumes` finds it by. A declaration the user's project + // already has is kept as-is. + for _, m := range ov.ExtraMounts { + if _, exists := existingTargets[m.Target]; exists { + continue + } + svc.Volumes = append(svc.Volumes, m.serviceVolume()) + existingTargets[m.Target] = struct{}{} + if !m.namedVolume() { + continue + } + if _, declared := project.Volumes[m.Source]; declared { + continue + } + if project.Volumes == nil { + project.Volumes = composetypes.Volumes{} + } + vol := composetypes.VolumeConfig{} + if project.Name != "" { + // Same name the loader normalizes a declared volume to. + vol.Name = project.Name + "_" + m.Source + } + project.Volumes[m.Source] = vol + } if len(ov.ExtraEnvironment) > 0 { if svc.Environment == nil { diff --git a/compose/override.go b/compose/override.go index a01fc5a..f9d1903 100644 --- a/compose/override.go +++ b/compose/override.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" "strings" @@ -26,8 +27,9 @@ type Override struct { Image string // ExtraBindMounts are added to the primary service's volumes. - // Engine adds the workspace folder bind here, plus any mounts - // declared in devcontainer.json (cfg.Mounts). + // Engine adds the workspace folder bind here, plus the bind + // mounts declared in devcontainer.json and feature metadata + // (cfg.Mounts). Other mount types go in ExtraMounts. // // IMPORTANT: compose v2's default merge strategy for sequence // fields is REPLACEMENT, not concatenation. WriteRunOverride @@ -36,6 +38,22 @@ type Override struct { // declarations. ExtraBindMounts []BindMount + // ExtraMounts are the non-bind mounts — named or anonymous volumes + // and tmpfs — added to the primary service's volumes after + // ExtraBindMounts. Engine fills it from devcontainer.json and + // feature metadata (cfg.Mounts): docker-in-docker's + // `dind-var-lib-docker-${devcontainerId}` → /var/lib/docker volume + // is the case that motivated it, because without it the inner + // dockerd's data root lands on the container's overlay root and + // overlay-on-overlay fails every `docker run`. + // + // A named volume (Type volume, non-empty Source) is also declared + // as a top-level project volume, so it is created as + // `_` the same way a user-declared one is. A + // mount whose target the user's compose service already declares + // is skipped: the user's entry wins. + ExtraMounts []Mount + // ExtraEnvironment is merged into the primary service's // environment map. Compose merges maps natively, so we only // need to emit our additions. @@ -112,6 +130,85 @@ type BindMount struct { ReadOnly bool } +// Mount describes one non-bind mount in the override: a named or +// anonymous volume, or a tmpfs. Bind mounts keep using BindMount. +type Mount struct { + // Type is composetypes.VolumeTypeVolume or + // composetypes.VolumeTypeTmpfs. Any other value is rejected by + // ApplyRunOverride and WriteRunOverride. + Type string + + // Source is the volume name, unscoped (the project prefix is added + // the way compose adds it for a top-level volume). Empty means an + // anonymous volume. Must be empty for tmpfs. + Source string + + Target string + ReadOnly bool + + // NoCopy disables copying image content into a fresh volume + // (volume only). + NoCopy bool + + // TmpfsSize (bytes) and TmpfsMode (permission bits) size and mode + // a tmpfs mount; zero leaves the daemon default (tmpfs only). + TmpfsSize int64 + TmpfsMode uint32 +} + +// validateMounts rejects mounts the override cannot express, so a +// caller's mistake fails loudly instead of producing a bind or a +// mount with the wrong semantics. +func validateMounts(mounts []Mount) error { + for _, m := range mounts { + if m.Target == "" { + return fmt.Errorf("compose override: %s mount with source %q has no target", m.Type, m.Source) + } + switch m.Type { + case composetypes.VolumeTypeVolume: + case composetypes.VolumeTypeTmpfs: + if m.Source != "" { + return fmt.Errorf("compose override: tmpfs mount at %q must not have a source (got %q)", m.Target, m.Source) + } + default: + return fmt.Errorf("compose override: mount at %q has unsupported type %q (want %q or %q)", + m.Target, m.Type, composetypes.VolumeTypeVolume, composetypes.VolumeTypeTmpfs) + } + } + return nil +} + +// serviceVolume converts m to the compose-go service volume it +// declares. +func (m Mount) serviceVolume() composetypes.ServiceVolumeConfig { + v := composetypes.ServiceVolumeConfig{ + Type: m.Type, + Source: m.Source, + Target: m.Target, + ReadOnly: m.ReadOnly, + } + switch m.Type { + case composetypes.VolumeTypeVolume: + if m.NoCopy { + v.Volume = &composetypes.ServiceVolumeVolume{NoCopy: true} + } + case composetypes.VolumeTypeTmpfs: + if m.TmpfsSize != 0 || m.TmpfsMode != 0 { + v.Tmpfs = &composetypes.ServiceVolumeTmpfs{ + Size: composetypes.UnitBytes(m.TmpfsSize), + Mode: m.TmpfsMode, + } + } + } + return v +} + +// namedVolume reports whether m is a named volume, which needs a +// top-level project volume declaration. +func (m Mount) namedVolume() bool { + return m.Type == composetypes.VolumeTypeVolume && m.Source != "" +} + // WriteBuildOverride writes dst with the build override. Pins the // primary service's image to ov.Image and clears any inherited // build: directive via the YAML !reset tag (compose v2 idiom). @@ -153,7 +250,10 @@ func WriteRunOverride(dst string, project *composetypes.Project, ov Override) er return fmt.Errorf("WriteRunOverride: ov.Service required") } - merged, err := mergedVolumes(project, ov) + if err := validateMounts(ov.ExtraMounts); err != nil { + return err + } + merged, named, err := mergedVolumes(project, ov) if err != nil { return err } @@ -174,6 +274,24 @@ func WriteRunOverride(dst string, project *composetypes.Project, ov Override) er ov.Service: buildServiceOverride(merged, emit), }, } + // Top-level declarations for the named volumes we added, so + // `docker compose` creates them as `_`. A volume the + // user's project already declares is left to their declaration; + // compose merges top-level maps, so omitting it keeps theirs intact. + if len(named) > 0 { + vols := make(map[string]any, len(named)) + for _, name := range named { + if project != nil { + if _, declared := project.Volumes[name]; declared { + continue + } + } + vols[name] = map[string]any{} + } + if len(vols) > 0 { + doc["volumes"] = vols + } + } body, err := yaml.Marshal(doc) if err != nil { return fmt.Errorf("marshal run override: %w", err) @@ -182,10 +300,13 @@ func WriteRunOverride(dst string, project *composetypes.Project, ov Override) er } // mergedVolumes combines existing volumes from the user's primary -// service with ov.ExtraBindMounts. Existing entries are preserved as- -// is (string CSV form or full object form, whichever the user used). -// New mounts are appended in the order supplied. -func mergedVolumes(project *composetypes.Project, ov Override) ([]any, error) { +// service with ov.ExtraBindMounts and ov.ExtraMounts. Existing entries +// are preserved as-is (string CSV form or full object form, whichever +// the user used). New mounts are appended in the order supplied; an +// ExtraMounts entry whose target is already declared is skipped (the +// user's entry wins, matching ApplyRunOverride). Returns the merged +// list and the names of the named volumes it added. +func mergedVolumes(project *composetypes.Project, ov Override) ([]any, []string, error) { var existing []composetypes.ServiceVolumeConfig if project != nil { svc, err := PrimaryService(project, ov.Service) @@ -194,9 +315,11 @@ func mergedVolumes(project *composetypes.Project, ov Override) ([]any, error) { } } - out := make([]any, 0, len(existing)+len(ov.ExtraBindMounts)) + out := make([]any, 0, len(existing)+len(ov.ExtraBindMounts)+len(ov.ExtraMounts)) + targets := make(map[string]struct{}, cap(out)) for _, v := range existing { out = append(out, serializeServiceVolume(v)) + targets[v.Target] = struct{}{} } for _, b := range ov.ExtraBindMounts { out = append(out, map[string]any{ @@ -205,8 +328,20 @@ func mergedVolumes(project *composetypes.Project, ov Override) ([]any, error) { "target": b.Target, "read_only": b.ReadOnly, }) + targets[b.Target] = struct{}{} } - return out, nil + var named []string + for _, m := range ov.ExtraMounts { + if _, exists := targets[m.Target]; exists { + continue + } + targets[m.Target] = struct{}{} + out = append(out, serializeServiceVolume(m.serviceVolume())) + if m.namedVolume() && !slices.Contains(named, m.Source) { + named = append(named, m.Source) + } + } + return out, named, nil } // serializeServiceVolume converts a compose-go ServiceVolumeConfig @@ -219,9 +354,29 @@ func serializeServiceVolume(v composetypes.ServiceVolumeConfig) map[string]any { "source": v.Source, "target": v.Target, } + if v.Source == "" { + // Anonymous volume / tmpfs: an empty source is not a valid + // long-syntax value. + delete(out, "source") + } if v.ReadOnly { out["read_only"] = true } + if v.Volume != nil && v.Volume.NoCopy { + out["volume"] = map[string]any{"nocopy": true} + } + if v.Tmpfs != nil { + tmpfs := map[string]any{} + if v.Tmpfs.Size != 0 { + tmpfs["size"] = int64(v.Tmpfs.Size) + } + if v.Tmpfs.Mode != 0 { + tmpfs["mode"] = v.Tmpfs.Mode + } + if len(tmpfs) > 0 { + out["tmpfs"] = tmpfs + } + } return out } diff --git a/compose/override_mounts_test.go b/compose/override_mounts_test.go new file mode 100644 index 0000000..3562122 --- /dev/null +++ b/compose/override_mounts_test.go @@ -0,0 +1,414 @@ +package compose + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + composetypes "github.com/compose-spec/compose-go/v2/types" + + "github.com/crunchloop/devcontainer/runtime" +) + +// Tests for Override.ExtraMounts — the volume and tmpfs mounts from +// devcontainer.json and feature metadata (cfg.Mounts) — on both +// compose backends: ApplyRunOverride (native) and WriteRunOverride +// (shell-out). The motivating mount is docker-in-docker's +// /var/lib/docker volume; without it the inner dockerd's data root +// sits on overlay and `docker run` fails with EINVAL. + +// dindMount is docker-in-docker's feature mount after +// ${devcontainerId} substitution. +var dindMount = Mount{ + Type: composetypes.VolumeTypeVolume, + Source: "dind-var-lib-docker-abc123", + Target: "/var/lib/docker", +} + +func mountsProject() *composetypes.Project { + p := projectForApply() + p.Name = "dc-x" + return p +} + +func volumeAt(vols []composetypes.ServiceVolumeConfig, target string) []composetypes.ServiceVolumeConfig { + var out []composetypes.ServiceVolumeConfig + for _, v := range vols { + if v.Target == target { + out = append(out, v) + } + } + return out +} + +// ---- native: ApplyRunOverride --------------------------------------- + +func TestApplyRunOverride_FeatureVolumeMountAddsServiceAndProjectVolume(t *testing.T) { + proj := mountsProject() + if err := ApplyRunOverride(proj, "app", Override{ + Service: "app", + ExtraMounts: []Mount{dindMount}, + }); err != nil { + t.Fatalf("ApplyRunOverride: %v", err) + } + + got := volumeAt(proj.Services["app"].Volumes, "/var/lib/docker") + if len(got) != 1 { + t.Fatalf("/var/lib/docker entries = %+v, want exactly 1", got) + } + if got[0].Type != composetypes.VolumeTypeVolume || got[0].Source != "dind-var-lib-docker-abc123" || got[0].ReadOnly { + t.Errorf("service volume = %+v, want rw volume dind-var-lib-docker-abc123", got[0]) + } + + vol, ok := proj.Volumes["dind-var-lib-docker-abc123"] + if !ok { + t.Fatalf("project.Volumes = %+v, want a top-level declaration so ensureNamedVolumes creates it", proj.Volumes) + } + if vol.Name != "dc-x_dind-var-lib-docker-abc123" { + t.Errorf("project volume Name = %q, want the loader-normalized dc-x_dind-var-lib-docker-abc123", vol.Name) + } +} + +func TestApplyRunOverride_TmpfsPassesThrough(t *testing.T) { + proj := mountsProject() + if err := ApplyRunOverride(proj, "app", Override{ + Service: "app", + ExtraMounts: []Mount{{ + Type: composetypes.VolumeTypeTmpfs, + Target: "/scratch", + TmpfsSize: 64 << 20, + TmpfsMode: 0o1777, + }}, + }); err != nil { + t.Fatalf("ApplyRunOverride: %v", err) + } + got := volumeAt(proj.Services["app"].Volumes, "/scratch") + if len(got) != 1 { + t.Fatalf("/scratch entries = %+v, want 1", got) + } + v := got[0] + if v.Type != composetypes.VolumeTypeTmpfs || v.Source != "" { + t.Errorf("tmpfs entry = %+v, want type tmpfs with no source", v) + } + if v.Tmpfs == nil || v.Tmpfs.Size != 64<<20 || v.Tmpfs.Mode != 0o1777 { + t.Errorf("tmpfs options = %+v, want size 64MiB mode 1777", v.Tmpfs) + } + if len(proj.Volumes) != 0 { + t.Errorf("project.Volumes = %+v, want none for a tmpfs", proj.Volumes) + } +} + +func TestApplyRunOverride_VolumeOptions(t *testing.T) { + proj := mountsProject() + if err := ApplyRunOverride(proj, "app", Override{ + Service: "app", + ExtraMounts: []Mount{ + {Type: composetypes.VolumeTypeVolume, Source: "cache", Target: "/cache", ReadOnly: true, NoCopy: true}, + {Type: composetypes.VolumeTypeVolume, Target: "/anon"}, + }, + }); err != nil { + t.Fatalf("ApplyRunOverride: %v", err) + } + vols := proj.Services["app"].Volumes + ro := volumeAt(vols, "/cache") + if len(ro) != 1 || !ro[0].ReadOnly || ro[0].Volume == nil || !ro[0].Volume.NoCopy { + t.Errorf("/cache = %+v, want read-only nocopy volume", ro) + } + anon := volumeAt(vols, "/anon") + if len(anon) != 1 || anon[0].Source != "" { + t.Errorf("/anon = %+v, want anonymous volume", anon) + } + if _, ok := proj.Volumes[""]; ok { + t.Error("anonymous volume must not get a top-level declaration") + } + if _, ok := proj.Volumes["cache"]; !ok { + t.Error("named volume cache not declared") + } +} + +// Binds keep their existing path and shape when ExtraMounts are added. +func TestApplyRunOverride_BindsUnchangedByExtraMounts(t *testing.T) { + binds := []BindMount{ + {Source: "/host/proj", Target: "/workspaces/proj"}, + {Source: "/host/ro", Target: "/ro", ReadOnly: true}, + } + without := mountsProject() + if err := ApplyRunOverride(without, "app", Override{Service: "app", ExtraBindMounts: binds}); err != nil { + t.Fatal(err) + } + with := mountsProject() + if err := ApplyRunOverride(with, "app", Override{Service: "app", ExtraBindMounts: binds, ExtraMounts: []Mount{dindMount}}); err != nil { + t.Fatal(err) + } + a, b := without.Services["app"].Volumes, with.Services["app"].Volumes + if len(b) != len(a)+1 { + t.Fatalf("volumes with ExtraMounts = %+v, want the bind-only list plus one", b) + } + for i := range a { + if a[i].Type != b[i].Type || a[i].Source != b[i].Source || a[i].Target != b[i].Target || a[i].ReadOnly != b[i].ReadOnly { + t.Errorf("entry %d changed: %+v -> %+v", i, a[i], b[i]) + } + } + if b[len(b)-1].Target != "/var/lib/docker" { + t.Errorf("volume mount not appended after the binds: %+v", b) + } +} + +// The user's compose already mounts something at the metadata target: +// the user's entry is kept, ours is not added, and no project volume +// is declared for the skipped mount. +func TestApplyRunOverride_UserTargetWinsOverMetadataMount(t *testing.T) { + proj := mountsProject() + svc := proj.Services["app"] + svc.Volumes = append(svc.Volumes, composetypes.ServiceVolumeConfig{ + Type: composetypes.VolumeTypeVolume, Source: "my-docker", Target: "/var/lib/docker", + }) + proj.Services["app"] = svc + proj.Volumes = composetypes.Volumes{"my-docker": {Name: "dc-x_my-docker", Labels: composetypes.Labels{"user": "kept"}}} + + if err := ApplyRunOverride(proj, "app", Override{Service: "app", ExtraMounts: []Mount{dindMount}}); err != nil { + t.Fatalf("ApplyRunOverride: %v", err) + } + got := volumeAt(proj.Services["app"].Volumes, "/var/lib/docker") + if len(got) != 1 || got[0].Source != "my-docker" { + t.Errorf("/var/lib/docker = %+v, want only the user's my-docker entry", got) + } + if _, ok := proj.Volumes["dind-var-lib-docker-abc123"]; ok { + t.Error("skipped metadata volume must not be declared") + } + if proj.Volumes["my-docker"].Labels["user"] != "kept" { + t.Error("user's top-level volume declaration changed") + } +} + +// A top-level volume the user already declares under the same name is +// left exactly as declared (driver, labels, external). +func TestApplyRunOverride_KeepsUserTopLevelVolumeDeclaration(t *testing.T) { + proj := mountsProject() + proj.Volumes = composetypes.Volumes{ + "dind-var-lib-docker-abc123": {Name: "custom", Driver: "local", Labels: composetypes.Labels{"k": "v"}}, + } + if err := ApplyRunOverride(proj, "app", Override{Service: "app", ExtraMounts: []Mount{dindMount}}); err != nil { + t.Fatal(err) + } + vol := proj.Volumes["dind-var-lib-docker-abc123"] + if vol.Name != "custom" || vol.Driver != "local" || vol.Labels["k"] != "v" { + t.Errorf("user declaration overwritten: %+v", vol) + } +} + +func TestApplyRunOverride_RejectsUnsupportedMountType(t *testing.T) { + for _, m := range []Mount{ + {Type: composetypes.VolumeTypeBind, Source: "/h", Target: "/c"}, + {Type: "", Source: "x", Target: "/c"}, + {Type: composetypes.VolumeTypeTmpfs, Source: "x", Target: "/c"}, + {Type: composetypes.VolumeTypeVolume, Source: "x"}, + } { + proj := mountsProject() + if err := ApplyRunOverride(proj, "app", Override{Service: "app", ExtraMounts: []Mount{m}}); err == nil { + t.Errorf("ApplyRunOverride(%+v) = nil, want error", m) + } + } +} + +// Round trip through the native orchestrator: the override's volume +// and tmpfs reach the RunSpec the backend receives (named volume with +// its project-scoped name), and the named volume is pre-created with +// the project labels `Down --volumes` uses. +func TestApplyRunOverride_MountsReachRunSpecAndCreateVolume(t *testing.T) { + proj := mountsProject() + if err := ApplyRunOverride(proj, "app", Override{ + Service: "app", + ExtraBindMounts: []BindMount{ + {Source: "/host/proj", Target: "/workspaces/proj"}, + }, + ExtraMounts: []Mount{ + dindMount, + {Type: composetypes.VolumeTypeTmpfs, Target: "/scratch"}, + }, + }); err != nil { + t.Fatalf("ApplyRunOverride: %v", err) + } + svc := proj.Services["app"] + svc.Build = nil + proj.Services["app"] = svc + + rt := newMockRuntime() + var seen runtime.RunSpec + rt.OnRunContainer = func(spec runtime.RunSpec) (*runtime.Container, error) { + seen = spec + return nil, nil + } + if _, err := NewOrchestrator(rt).Up(context.Background(), &Plan{Project: proj, ProjectName: "dc-x"}); err != nil { + t.Fatalf("Up: %v", err) + } + + want := map[string]runtime.MountSpec{ + "/app": {Type: runtime.MountBind, Source: "./code", Target: "/app"}, + "/workspaces/proj": {Type: runtime.MountBind, Source: "/host/proj", Target: "/workspaces/proj"}, + "/var/lib/docker": {Type: runtime.MountVolume, Source: "dc-x_dind-var-lib-docker-abc123", Target: "/var/lib/docker"}, + "/scratch": {Type: runtime.MountTmpfs, Source: "", Target: "/scratch"}, + } + if len(seen.Mounts) != len(want) { + t.Fatalf("RunSpec.Mounts = %+v, want %d entries", seen.Mounts, len(want)) + } + for _, m := range seen.Mounts { + w, ok := want[m.Target] + if !ok || m.Type != w.Type || m.Source != w.Source || m.ReadOnly != w.ReadOnly { + t.Errorf("mount %+v, want %+v", m, w) + } + } + + labels, ok := rt.volumes["dc-x_dind-var-lib-docker-abc123"] + if !ok { + t.Fatalf("CreateVolume not called for the dind volume; created %v", rt.volumes) + } + if labels[LabelComposeProject] != "dc-x" || labels["com.docker.compose.volume"] != "dind-var-lib-docker-abc123" { + t.Errorf("volume labels = %v, want project + compose volume labels", labels) + } + if rt.createVolumeCalls != 1 { + t.Errorf("CreateVolume calls = %d, want 1 (tmpfs and binds are not volumes)", rt.createVolumeCalls) + } +} + +// ---- shell-out: WriteRunOverride ------------------------------------ + +// loadWithOverride loads the user's compose file plus the generated +// run override through compose-go — the same merge `docker compose` +// applies to `-f user.yml -f dc-run.yaml`. +func loadWithOverride(t *testing.T, userYAML string, ov Override) (*composetypes.Project, string) { + t.Helper() + dir := t.TempDir() + userFile := filepath.Join(dir, "compose.yml") + if err := os.WriteFile(userFile, []byte(userYAML), 0o644); err != nil { + t.Fatal(err) + } + user, err := Load(context.Background(), LoadOptions{Files: []string{userFile}, WorkingDir: dir, ProjectName: "dc-x"}) + if err != nil { + t.Fatalf("Load user: %v", err) + } + runFile := filepath.Join(dir, "dc-run.yaml") + if err := WriteRunOverride(runFile, user, ov); err != nil { + t.Fatalf("WriteRunOverride: %v", err) + } + body, _ := os.ReadFile(runFile) + merged, err := Load(context.Background(), LoadOptions{Files: []string{userFile, runFile}, WorkingDir: dir, ProjectName: "dc-x"}) + if err != nil { + t.Fatalf("Load merged: %v\n%s", err, body) + } + return merged, string(body) +} + +const plainUserCompose = ` +services: + app: + image: alpine + volumes: + - type: bind + source: /user/host + target: /user/container +` + +func TestWriteRunOverride_FeatureVolumeMountAddsServiceAndProjectVolume(t *testing.T) { + proj, body := loadWithOverride(t, plainUserCompose, Override{ + Service: "app", + ExtraBindMounts: []BindMount{ + {Source: "/host/proj", Target: "/workspaces/proj"}, + }, + ExtraMounts: []Mount{dindMount}, + }) + got := volumeAt(proj.Services["app"].Volumes, "/var/lib/docker") + if len(got) != 1 || got[0].Type != composetypes.VolumeTypeVolume || got[0].Source != "dind-var-lib-docker-abc123" { + t.Fatalf("/var/lib/docker = %+v, want the dind volume\n%s", got, body) + } + vol, ok := proj.Volumes["dind-var-lib-docker-abc123"] + if !ok { + t.Fatalf("top-level volume missing; compose would refuse an undeclared named volume\n%s", body) + } + if vol.Name != "dc-x_dind-var-lib-docker-abc123" { + t.Errorf("volume name = %q, want dc-x_dind-var-lib-docker-abc123", vol.Name) + } + // Binds untouched. + if b := volumeAt(proj.Services["app"].Volumes, "/workspaces/proj"); len(b) != 1 || b[0].Type != composetypes.VolumeTypeBind || b[0].Source != "/host/proj" { + t.Errorf("workspace bind = %+v", b) + } + if b := volumeAt(proj.Services["app"].Volumes, "/user/container"); len(b) != 1 || b[0].Source != "/user/host" { + t.Errorf("user bind = %+v", b) + } +} + +func TestWriteRunOverride_TmpfsPassesThrough(t *testing.T) { + proj, body := loadWithOverride(t, plainUserCompose, Override{ + Service: "app", + ExtraMounts: []Mount{{ + Type: composetypes.VolumeTypeTmpfs, Target: "/scratch", TmpfsSize: 64 << 20, TmpfsMode: 0o1777, + }}, + }) + got := volumeAt(proj.Services["app"].Volumes, "/scratch") + if len(got) != 1 || got[0].Type != composetypes.VolumeTypeTmpfs || got[0].Source != "" { + t.Fatalf("/scratch = %+v, want a tmpfs\n%s", got, body) + } + if got[0].Tmpfs == nil || got[0].Tmpfs.Size != 64<<20 || got[0].Tmpfs.Mode != 0o1777 { + t.Errorf("tmpfs options = %+v\n%s", got[0].Tmpfs, body) + } + if strings.Contains(body, "\nvolumes:") { + t.Errorf("tmpfs must not declare a top-level volume:\n%s", body) + } +} + +func TestWriteRunOverride_UserTargetWinsOverMetadataMount(t *testing.T) { + proj, body := loadWithOverride(t, ` +services: + app: + image: alpine + volumes: + - my-docker:/var/lib/docker +volumes: + my-docker: + labels: + user: kept +`, Override{Service: "app", ExtraMounts: []Mount{dindMount}}) + got := volumeAt(proj.Services["app"].Volumes, "/var/lib/docker") + if len(got) != 1 || got[0].Source != "my-docker" { + t.Fatalf("/var/lib/docker = %+v, want only the user's my-docker\n%s", got, body) + } + if strings.Contains(body, "dind-var-lib-docker") { + t.Errorf("skipped metadata mount leaked into the override:\n%s", body) + } + if proj.Volumes["my-docker"].Labels["user"] != "kept" { + t.Errorf("user's volume declaration changed: %+v", proj.Volumes["my-docker"]) + } +} + +func TestWriteRunOverride_KeepsUserTopLevelVolumeDeclaration(t *testing.T) { + proj, body := loadWithOverride(t, ` +services: + app: + image: alpine +volumes: + dind-var-lib-docker-abc123: + name: custom + labels: + k: v +`, Override{Service: "app", ExtraMounts: []Mount{dindMount}}) + vol := proj.Volumes["dind-var-lib-docker-abc123"] + if vol.Name != "custom" || vol.Labels["k"] != "v" { + t.Errorf("user declaration changed: %+v\n%s", vol, body) + } + if got := volumeAt(proj.Services["app"].Volumes, "/var/lib/docker"); len(got) != 1 { + t.Errorf("/var/lib/docker = %+v, want the dind service volume", got) + } +} + +func TestWriteRunOverride_RejectsUnsupportedMountType(t *testing.T) { + dst := filepath.Join(t.TempDir(), "dc-run.yaml") + err := WriteRunOverride(dst, nil, Override{ + Service: "app", + ExtraMounts: []Mount{{Type: composetypes.VolumeTypeBind, Source: "/h", Target: "/c"}}, + }) + if err == nil { + t.Fatal("want error for a bind in ExtraMounts") + } +} diff --git a/test/integration/compose_dind_test.go b/test/integration/compose_dind_test.go new file mode 100644 index 0000000..71d764f --- /dev/null +++ b/test/integration/compose_dind_test.go @@ -0,0 +1,141 @@ +//go:build integration + +package integration + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + devcontainer "github.com/crunchloop/devcontainer" +) + +// dindFeatureRef is the upstream docker-in-docker feature. Its metadata +// declares the mount this test is about — +// {"source":"dind-var-lib-docker-${devcontainerId}","target":"/var/lib/docker","type":"volume"} +// — alongside privileged and the docker-init.sh entrypoint. +const dindFeatureRef = "ghcr.io/devcontainers/features/docker-in-docker:2" + +// dindBaseImage is a Debian base the feature's install script supports. +const dindBaseImage = "debian:bookworm-slim" + +// TestComposeSource_DockerInDockerRunsContainers is the end-to-end +// check for feature volume mounts on compose-source devcontainers. +// Before the fix only bind mounts from cfg.Mounts reached the compose +// service, so the feature's /var/lib/docker volume was dropped, the +// inner dockerd's data root sat on the container's overlay root, and +// `docker run` failed (overlay-on-overlay, EINVAL). Runs on both +// compose backends. +func TestComposeSource_DockerInDockerRunsContainers(t *testing.T) { + if testing.Short() { + t.Skip() + } + + backends := []struct { + name string + backend devcontainer.ComposeBackend + }{ + {"native", devcontainer.ComposeBackendNative}, + {"shellout", devcontainer.ComposeBackendShellout}, + } + + for _, bk := range backends { + t.Run(bk.name, func(t *testing.T) { + eng, rt := newEngineWith(t, devcontainer.EngineOptions{ + ComposeBackend: bk.backend, + }) + defer rt.Close() + + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "docker-compose.yml"), ` +services: + app: + image: `+dindBaseImage+` + command: ["sh", "-c", "while sleep 1000; do :; done"] +`) + mustWrite(t, filepath.Join(dir, ".devcontainer", "devcontainer.json"), `{ + "dockerComposeFile": "../docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/proj", + "features": { "`+dindFeatureRef+`": {} } + }`) + + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute) + defer cancel() + + wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ + LocalWorkspaceFolder: dir, + Recreate: true, + SkipLifecycle: true, + }) + if err != nil { + t.Fatalf("Up: %v", err) + } + + // Both backends create the feature's named volume as a + // project volume, labelled the way compose labels one. + volKey := "dind-var-lib-docker-" + string(wsObj.ID) + out, lsErr := exec.Command("docker", "volume", "ls", "-q", + "--filter", "label=com.docker.compose.volume="+volKey).Output() + vols := strings.Fields(string(out)) + t.Cleanup(func() { + _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{ + Remove: true, + RemoveVolumes: true, + }) + // Down does not remove every named volume on every + // backend; remove the feature's volume explicitly. + for _, v := range vols { + _ = exec.Command("docker", "volume", "rm", "-f", v).Run() + } + }) + if lsErr != nil || len(vols) != 1 || !strings.HasSuffix(vols[0], "_"+volKey) { + t.Fatalf("project volume for %s = %v (err %v), want exactly one _%s", volKey, vols, lsErr, volKey) + } + + var dockerMount bool + for _, m := range wsObj.Container.Mounts { + if m.Target == "/var/lib/docker" { + dockerMount = true + if m.Type != "volume" || !strings.Contains(m.Source, vols[0]) { + t.Errorf("/var/lib/docker mount = %+v, want volume %s", m, vols[0]) + } + } + } + if !dockerMount { + t.Fatalf("no mount at /var/lib/docker: %+v", wsObj.Container.Mounts) + } + + // dockerd is started in the background by the feature's + // entrypoint; wait for it, then run a container. + deadline := time.Now().Add(2 * time.Minute) + for { + res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ + Cmd: []string{"docker", "info", "--format", "{{.Driver}}"}, + }) + if err == nil && res.ExitCode == 0 { + t.Logf("inner dockerd storage driver: %s", strings.TrimSpace(res.Stdout)) + break + } + if time.Now().After(deadline) { + t.Fatalf("inner dockerd never became ready: err=%v res=%+v", err, res) + } + time.Sleep(2 * time.Second) + } + + res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ + Cmd: []string{"docker", "run", "--rm", "hello-world"}, + }) + if err != nil { + t.Fatalf("Exec docker run: %v", err) + } + if res.ExitCode != 0 || !strings.Contains(res.Stdout, "Hello from Docker!") { + t.Fatalf("docker run hello-world failed: exit=%d stdout=%q stderr=%q", + res.ExitCode, res.Stdout, res.Stderr) + } + }) + } +} diff --git a/up.go b/up.go index 9f87d7b..bf51e9d 100644 --- a/up.go +++ b/up.go @@ -610,6 +610,7 @@ func (e *Engine) createFreshCompose(ctx context.Context, cfg *config.ResolvedCon runOverride := compose.Override{ Service: src.Service, ExtraBindMounts: bindMounts, + ExtraMounts: composeMounts(cfg, opts), ExtraEnvironment: overrideEnv, Labels: overrideLabels, Privileged: cfg.Privileged, @@ -813,11 +814,10 @@ func (e *Engine) dropHostPortPublishes(project *composetypes.Project, opts UpOpt } } -// composeBindMounts assembles the workspace + cfg + extra mounts in -// the order the engine has always used them, kept as a helper so -// both compose backends share the exact same set. Only bind mounts -// are expressible in compose overrides; other types are silently -// dropped — same as the legacy path. +// composeBindMounts assembles the workspace + cfg + extra bind mounts +// in the order the engine has always used them, kept as a helper so +// both compose backends share the exact same set. Volume and tmpfs +// mounts from the same sources are collected by composeMounts. func composeBindMounts(cfg *config.ResolvedConfig, opts UpOptions) []compose.BindMount { out := []compose.BindMount{ {Source: cfg.LocalWorkspaceFolder, Target: cfg.ContainerWorkspaceFolder}, @@ -843,6 +843,58 @@ func composeBindMounts(cfg *config.ResolvedConfig, opts UpOptions) []compose.Bin return out } +// composeMounts collects the volume and tmpfs mounts from cfg.Mounts +// (devcontainer.json plus feature and image metadata, with +// ${devcontainerId} and friends already substituted by +// applyMetadataMerge) and opts.ExtraMounts, in that order — the same +// mounts the image path's buildRunSpec passes to the container. +// Without them a feature like docker-in-docker, whose +// /var/lib/docker volume keeps the inner dockerd off the overlay +// root, fails on every compose-source devcontainer. +func composeMounts(cfg *config.ResolvedConfig, opts UpOptions) []compose.Mount { + var out []compose.Mount + for _, m := range cfg.Mounts { + if m.Target == "" { + continue + } + cm := compose.Mount{Source: m.Source, Target: m.Target, ReadOnly: m.ReadOnly} + switch m.Type { + case config.MountVolume: + cm.Type = composetypes.VolumeTypeVolume + if m.VolumeOptions != nil { + cm.NoCopy = m.VolumeOptions.NoCopy + } + case config.MountTmpfs: + cm.Type = composetypes.VolumeTypeTmpfs + cm.Source = "" + if m.TmpfsOptions != nil { + cm.TmpfsSize = m.TmpfsOptions.SizeBytes + cm.TmpfsMode = m.TmpfsOptions.Mode + } + default: + continue + } + out = append(out, cm) + } + for _, m := range opts.ExtraMounts { + if m.Target == "" { + continue + } + cm := compose.Mount{Source: m.Source, Target: m.Target, ReadOnly: m.ReadOnly} + switch m.Type { + case runtime.MountVolume: + cm.Type = composetypes.VolumeTypeVolume + case runtime.MountTmpfs: + cm.Type = composetypes.VolumeTypeTmpfs + cm.Source = "" + default: + continue + } + out = append(out, cm) + } + return out +} + // buildComposeSidecarImages builds every selected non-primary service // that declares `build:`. The shellout backend delegated these to // `docker compose up`, which builds missing images implicitly; the diff --git a/up_compose_mounts_test.go b/up_compose_mounts_test.go new file mode 100644 index 0000000..bc85120 --- /dev/null +++ b/up_compose_mounts_test.go @@ -0,0 +1,229 @@ +package devcontainer + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + composetypes "github.com/compose-spec/compose-go/v2/types" + + "github.com/crunchloop/devcontainer/compose" + "github.com/crunchloop/devcontainer/config" + "github.com/crunchloop/devcontainer/events" + "github.com/crunchloop/devcontainer/runtime" +) + +// Compose-source devcontainers must carry cfg.Mounts of every type +// onto the primary service, as the image path's buildRunSpec does. +// Before this, composeBindMounts kept binds only, so a feature's +// volume mount — docker-in-docker's /var/lib/docker — was dropped and +// the inner dockerd ran on overlay-on-overlay, failing `docker run`. + +// dindCfg resolves a config the way Up does: user mounts plus feature +// metadata merged with ${devcontainerId} substitution. +func dindCfg(t *testing.T) *config.ResolvedConfig { + t.Helper() + cfg := &config.ResolvedConfig{ + DevcontainerID: "abc123", + LocalWorkspaceFolder: t.TempDir(), + ContainerWorkspaceFolder: "/workspaces/test", + Mounts: []config.Mount{ + {Type: config.MountBind, Source: "/host/cache", Target: "/cache", ReadOnly: true}, + {Type: config.MountTmpfs, Target: "/scratch"}, + }, + } + config.MergeMetadata(cfg, config.SubstitutionContext{ + LocalWorkspaceFolder: cfg.LocalWorkspaceFolder, + DevcontainerID: cfg.DevcontainerID, + }, []config.FeatureMetadata{{ + ID: "docker-in-docker", + Mounts: []config.Mount{{ + Type: config.MountVolume, + Source: "dind-var-lib-docker-${devcontainerId}", + Target: "/var/lib/docker", + }}, + }}) + return cfg +} + +// mountsRunOverride builds the mount part of the run override exactly +// as createFreshCompose does. +func mountsRunOverride(cfg *config.ResolvedConfig, opts UpOptions) compose.Override { + return compose.Override{ + Service: "app", + ExtraBindMounts: composeBindMounts(cfg, opts), + ExtraMounts: composeMounts(cfg, opts), + } +} + +func TestComposeMounts_CarriesVolumeAndTmpfsFromMetadata(t *testing.T) { + cfg := dindCfg(t) + opts := UpOptions{ExtraMounts: []runtime.MountSpec{ + {Type: runtime.MountVolume, Source: "extra-vol", Target: "/extra"}, + {Type: runtime.MountBind, Source: "/host/extra", Target: "/extra-bind"}, + }} + + got := composeMounts(cfg, opts) + // cfg.Mounts order after MergeMetadata: feature mounts, then the + // user's. + want := []compose.Mount{ + {Type: composetypes.VolumeTypeVolume, Source: "dind-var-lib-docker-abc123", Target: "/var/lib/docker"}, + {Type: composetypes.VolumeTypeTmpfs, Target: "/scratch"}, + {Type: composetypes.VolumeTypeVolume, Source: "extra-vol", Target: "/extra"}, + } + if len(got) != len(want) { + t.Fatalf("composeMounts = %+v, want %+v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("composeMounts[%d] = %+v, want %+v", i, got[i], want[i]) + } + } + + // Binds are unchanged: workspace, cfg bind, extra bind — nothing else. + binds := composeBindMounts(cfg, opts) + wantBinds := []compose.BindMount{ + {Source: cfg.LocalWorkspaceFolder, Target: "/workspaces/test"}, + {Source: "/host/cache", Target: "/cache", ReadOnly: true}, + {Source: "/host/extra", Target: "/extra-bind"}, + } + if len(binds) != len(wantBinds) { + t.Fatalf("composeBindMounts = %+v, want %+v", binds, wantBinds) + } + for i := range wantBinds { + if binds[i] != wantBinds[i] { + t.Errorf("composeBindMounts[%d] = %+v, want %+v", i, binds[i], wantBinds[i]) + } + } +} + +// volumeRecorder is the native-backend fake with CreateVolume recorded. +type volumeRecorder struct { + *composeFake + volumes []runtime.VolumeSpec +} + +func (v *volumeRecorder) CreateVolume(ctx context.Context, spec runtime.VolumeSpec) (string, error) { + v.volumes = append(v.volumes, spec) + return spec.Name, nil +} + +func TestUpComposeNative_CarriesFeatureVolumeMount(t *testing.T) { + rec := &buildRecorder{fakeRuntime: newFakeRuntime()} + rt := &volumeRecorder{composeFake: &composeFake{buildRecorder: rec}} + eng, err := New(EngineOptions{Runtime: rt, ComposeBackend: ComposeBackendNative}) + if err != nil { + t.Fatalf("New: %v", err) + } + cfg := dindCfg(t) + opts := UpOptions{LocalEnv: map[string]string{}} + opts.bus = newEventBus(events.NewEmitter(nil), nil) + + project := sidecarProject(map[string]composetypes.ServiceConfig{"app": {Image: "app:dev"}}) + project.Name = "dc-test" + if _, err := eng.upComposeNative(context.Background(), cfg, opts, project, + &config.ComposeSource{Service: "app"}, "dc-test", t.TempDir(), "app:dev", + mountsRunOverride(cfg, opts)); err != nil { + t.Fatalf("upComposeNative: %v", err) + } + + want := map[string]runtime.MountSpec{ + "/workspaces/test": {Type: runtime.MountBind, Source: cfg.LocalWorkspaceFolder}, + "/cache": {Type: runtime.MountBind, Source: "/host/cache", ReadOnly: true}, + "/scratch": {Type: runtime.MountTmpfs}, + "/var/lib/docker": {Type: runtime.MountVolume, Source: "dc-test_dind-var-lib-docker-abc123"}, + } + mounts := rec.createdSpec.Mounts + if len(mounts) != len(want) { + t.Fatalf("RunSpec.Mounts = %+v, want %d entries", mounts, len(want)) + } + for _, m := range mounts { + w, ok := want[m.Target] + if !ok || m.Type != w.Type || m.Source != w.Source || m.ReadOnly != w.ReadOnly { + t.Errorf("mount %+v, want %+v", m, w) + } + } + if len(rt.volumes) != 1 || rt.volumes[0].Name != "dc-test_dind-var-lib-docker-abc123" { + t.Errorf("CreateVolume calls = %+v, want the dind volume pre-created", rt.volumes) + } +} + +// shelloutRecorder is a ComposeRuntime that loads the files it is +// handed through compose-go — the merge `docker compose -f ... -f ...` +// performs — and stops the Up there. +type shelloutRecorder struct { + *fakeRuntime + project *composetypes.Project +} + +var errStopAfterComposeUp = errors.New("stop after ComposeUp") + +func (s *shelloutRecorder) ComposeUp(ctx context.Context, spec runtime.ComposeUpSpec, _ chan<- runtime.BuildEvent) error { + p, err := compose.Load(ctx, compose.LoadOptions{ + Files: spec.Files, WorkingDir: spec.WorkingDir, ProjectName: spec.ProjectName, + }) + if err != nil { + return err + } + s.project = p + return errStopAfterComposeUp +} + +func (s *shelloutRecorder) ComposeDown(context.Context, runtime.ComposeDownSpec) error { return nil } + +func (s *shelloutRecorder) ComposeContainerID(context.Context, runtime.ComposePsSpec, string) (string, error) { + return "", nil +} + +func TestUpComposeShellout_CarriesFeatureVolumeMount(t *testing.T) { + rt := &shelloutRecorder{fakeRuntime: newFakeRuntime()} + eng, err := New(EngineOptions{Runtime: rt, ComposeBackend: ComposeBackendShellout}) + if err != nil { + t.Fatalf("New: %v", err) + } + cfg := dindCfg(t) + opts := UpOptions{LocalEnv: map[string]string{}} + opts.bus = newEventBus(events.NewEmitter(nil), nil) + + dir := t.TempDir() + userFile := filepath.Join(dir, "compose.yml") + if err := os.WriteFile(userFile, []byte("services:\n app:\n image: app:dev\n"), 0o644); err != nil { + t.Fatal(err) + } + project, err := compose.Load(context.Background(), compose.LoadOptions{ + Files: []string{userFile}, WorkingDir: dir, ProjectName: "dc-test", + }) + if err != nil { + t.Fatal(err) + } + + _, err = eng.upComposeShellout(context.Background(), cfg, opts, project, + &config.ComposeSource{Service: "app", Files: []string{userFile}}, "dc-test", dir, "app:dev", + mountsRunOverride(cfg, opts), false) + if !errors.Is(err, errStopAfterComposeUp) { + t.Fatalf("upComposeShellout: %v, want the recorder's stop", err) + } + + svc := rt.project.Services["app"] + byTarget := map[string]composetypes.ServiceVolumeConfig{} + for _, v := range svc.Volumes { + byTarget[v.Target] = v + } + if v := byTarget["/var/lib/docker"]; v.Type != composetypes.VolumeTypeVolume || v.Source != "dind-var-lib-docker-abc123" { + t.Errorf("/var/lib/docker = %+v, want the dind volume", v) + } + if v := byTarget["/scratch"]; v.Type != composetypes.VolumeTypeTmpfs { + t.Errorf("/scratch = %+v, want tmpfs", v) + } + if v := byTarget["/cache"]; v.Type != composetypes.VolumeTypeBind || v.Source != "/host/cache" || !v.ReadOnly { + t.Errorf("/cache = %+v, want the read-only bind", v) + } + if v := byTarget["/workspaces/test"]; v.Type != composetypes.VolumeTypeBind { + t.Errorf("workspace = %+v, want bind", v) + } + if vol, ok := rt.project.Volumes["dind-var-lib-docker-abc123"]; !ok || vol.Name != "dc-test_dind-var-lib-docker-abc123" { + t.Errorf("project volume = %+v (declared=%v), want dc-test_dind-var-lib-docker-abc123", vol, ok) + } +}