Skip to content

fix(compose): reattach existing projects without fetching features - #144

Merged
bilby91 merged 1 commit into
mainfrom
fix/compose-reattach
Oct 28, 2026
Merged

bilby91 merged 1 commit into
mainfrom
fix/compose-reattach

Conversation

@bilby91

@bilby91 bilby91 commented Oct 28, 2026

Copy link
Copy Markdown
Member

Problem

For compose-source devcontainers, Up always ran createFreshCompose → layerFeatures. It fetched every feature from its registry and rebuilt dc-go-final-<id>, even when the project's containers already existed from a previous run. The image path's attachExisting instead reuses the existing container and reads the merged metadata from its image's devcontainer.metadata label, with no network. The OCI feature cache also asked the registry for the manifest before it checked itself.

Consequences:

  • every start after a stop of a compose workspace with features re-fetches features and rebuilds the final image;
  • with no route to the registry, the start fails. DCVM's lab fixture found this: a docker-in-docker compose workspace failed every start after a stop with fetch feature ghcr.io/devcontainers/features/docker-in-docker:2.17.0: … connect: connection refused.

Fix

1. Compose reattach (up_compose_reattach.go)

  • Label. The full path stamps dev.containers.compose-inputs-hash (LabelComposeInputsHash) on the primary container. It is a sha256 over the inputs that are fixed before any feature is fetched:

    • the container-shaping part of the freshly resolved devcontainer.json: workspace folder and mount, mounts, users, updateRemoteUserUID, containerEnv, runArgs, init, privileged, capAdd, securityOpt, overrideCommand, and the features as requested with their options, plus a content digest of each local feature;
    • the loaded compose project: services, networks, volumes, secrets and configs;
    • the local image ID of the primary service's base image;
    • ComposeBackend, DisableHostPortPublishing and StrictFeatureVersionMatch;
    • the workspace folder's owner.

    Lifecycle commands, remoteEnv, ports attributes, customizations and the name are left out. Up re-reads them on every call and they never reach the container or its image. The hash also has a version field.

  • Reattach. On an Up with an existing container, unless Recreate, PullAlways or an Engine.Build override applies, the engine finds the primary container by project, service and devcontainer id. It reattaches when:

    • the label equals the freshly computed hash, and
    • the image tag the container was created from (ContainerDetails.ImageRef, new) still names the image the container runs.

    It then merges the image label into cfg as attachExisting does. It rebuilds the run override through the same helper as the full path: entrypoint wrapper, all mount types including the fix(compose): carry volume and tmpfs mounts onto the primary service #143 volume/tmpfs fix, env, privileged, capAdd and labels. Finally it brings the project up against the existing images:

    • native: Orchestrator.Up under the caller's AdoptExisting. Sidecar builds are skipped when their image is present.
    • shell-out: docker compose up -d --no-recreate.

    No feature is fetched and no image is built. Because the image is unchanged, the orchestrator's per-service config hash also matches, so a native Up without AdoptExisting reuses the containers. The integration test checks the container IDs.

  • Miss. No container, no label, a different hash, a moved tag, Recreate or PullAlways all take the full path, unchanged. cfg is not touched on a miss.

  • AdoptExisting keeps its meaning: reuse a container whose config drifted. Reattach only removes the fetch and the rebuild in front of it.

  • Upgrade. Containers created before this change have no label, so they take the full path. Native without AdoptExisting recreates them on the first Up, which adds the label. With AdoptExisting, or on shell-out, they are kept unlabelled until they are recreated.

2. Metadata label $ escaping (found by the new test)

GenerateBuildContext wrote the devcontainer.metadata LABEL with strconv.Quote only. The Dockerfile parser expands ${…} inside double quotes, so docker-in-docker's dind-var-lib-docker-${devcontainerId} was stored as dind-var-lib-docker-. A reattach that read the label therefore mounted a different, empty volume. The image path's attachExisting has the same latent issue in ws.Config.Mounts. Every $ is now escaped (dockerfileLiteral).

3. Feature cache policy (feature/oci.go, design/features.md §8.1)

Reference Policy
name@sha256:… Local first: a cached extraction is used with no registry call.
name:X.Y.Z Local first: the digest recorded the last time this tag resolved is used if its content is cached. Published versions are immutable by convention.
name:X, name:X.Y, latest, other tags Online first. If the registry is unreachable (no HTTP response), the recorded resolution is used if its content is cached.
  • A registry that answers with an error (404/401/403) is authoritative.
  • With nothing recorded, the fetch fails as before.
  • Tag records live under oci/tags/ and are written atomically.
  • Extraction now goes to a temporary directory and is renamed into place.
  • Adds DiskStoreOptions.OCITransport.

The changelog cuts 0.5.2. The change only adds API, following the v0.4.3/v0.5.1 precedent.

Tests

  • Unit (up_compose_reattach_test.go):
    • the hash is deterministic;
    • it changes with the base image ID, a feature option, a feature version, containerEnv, mounts, the primary service, a sidecar service, an engine option, and local feature content;
    • it ignores name, lifecycle, remoteEnv, ports and customizations;
    • every reattach miss falls back without mutating cfg: no label, changed inputs, moved or missing base, moved final tag, no container, PullAlways. A control case commits.
  • Unit (feature/oci_cache_test.go, in-process registry with a gated transport):
    • a digest is local-first;
    • an uncached digest goes online;
    • an exact version makes 0 registry calls when cached;
    • a floating tag is online-first and picks up a moved tag;
    • a floating tag offline uses the recorded resolution;
    • offline without a record fails;
    • 403 is authoritative;
    • a record whose content was evicted re-fetches.
  • Unit: the LABEL keeps \${devcontainerId}.
  • Integration, both backends (test/integration/compose_reattach_test.go). An in-process registry serves a small feature that declares env, capAdd, a volume, an entrypoint and postStartCommand.
    • RestartsWithoutRegistry, for native, native+adopt, shellout and shellout+adopt. Up, stop every service, block the registry, up again:

      • 0 fetches, 0 registry requests, 0 feature builds;
      • same primary ID, primary and sidecar running;
      • env, capAdd and volume applied;
      • the entrypoint and postStartCommand ran again into the same volume.

      The first Up is checked unchanged: 1 fetch, 1 build, label present.

    • ConfigChangeRebuilds: after a feature option change, the next Up fetches and builds again. Native recreates with a new label and the new option.

  • Integration TestComposeSource_DockerInDockerRestartsWithoutRegistry, both backends, with the real docker-in-docker:2:
    1. up, then docker pull hello-world in the inner dockerd;
    2. stop every service and block the registry;
    3. up again: 0 fetches, 0 registry requests, same container, privileged, /var/lib/docker still a volume;
    4. docker run --pull never hello-world prints "Hello from Docker!".

Local results (Linux, Docker 29.1.3, Compose 2.40.3, Go 1.25.3):

  • make lint (golangci-lint v2.5.0): 0 issues.
  • go vet ./... passes, including -tags=integration.
  • make test passes.
  • The full integration suite passes: go test -race -tags=integration ./test/integration/..., 839 s.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UMTBosSzdEvXqCZ9c7VyLk

Every compose-source Up ran createFreshCompose -> layerFeatures, which
fetched each feature from its registry and rebuilt dc-go-final-<id>
even when the project's containers already existed. A restart of a
compose workspace with features therefore needed registry egress and
paid a final-image rebuild; without egress (a docker-in-docker
workspace in a guest with no route to ghcr.io) it could not start at
all. The image path's attachExisting never had this cost.

- Stamp dev.containers.compose-inputs-hash on the primary container:
  a digest of the container-shaping part of the resolved config
  (features as requested, with options; local feature content), the
  loaded compose project, the primary base image ID and the engine
  options that reshape the project. On a later Up with a matching
  label and an unmoved image tag, reattachCompose merges the metadata
  from the existing image's label and brings the project up against
  the existing images (native under the caller's AdoptExisting with
  present sidecar images not rebuilt; shell-out with --no-recreate).
  Any miss takes the full path unchanged.
- Escape $ in the devcontainer.metadata LABEL: the Dockerfile parser
  expanded ${devcontainerId}, so docker-in-docker's volume source was
  recorded as "dind-var-lib-docker-" and every read of the label lost
  the per-workspace volume name.
- OCI feature cache is local-first for digest refs and exact X.Y.Z
  tags; floating tags stay online-first and fall back to the recorded
  resolution only when the registry is unreachable. Extraction is
  atomic. Adds DiskStoreOptions.OCITransport and
  ContainerDetails.ImageRef.

Cut 0.5.2 in the changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMTBosSzdEvXqCZ9c7VyLk
@bilby91
bilby91 merged commit 53eeecf into main Oct 28, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant