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
31 changes: 30 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<project>_<name>` 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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 37 additions & 2 deletions compose/apply_override.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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 {
Expand Down
173 changes: 164 additions & 9 deletions compose/override.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"

Expand All @@ -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
Expand All @@ -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
// `<project>_<source>` 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.
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
}
Expand All @@ -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 `<project>_<name>`. 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)
Expand All @@ -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)
Expand All @@ -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{
Expand All @@ -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
Expand All @@ -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
}

Expand Down
Loading
Loading