Skip to content

Commit 628a140

Browse files
aledbfclaude
andcommitted
fix(up): pull the Dockerfile base image before reading its metadata
up's Dockerfile path computed devcontainer metadata from the FROM image BEFORE the build, but (unlike the `build` command) never pulled it when absent. On a fresh run the base image is not cached yet, so InspectImage failed and the base's baked metadata — e.g. `remoteUser=node` on the mcr devcontainers images — was silently dropped. The CLI then stamped a metadata label without a remoteUser, so exec/lifecycle fell back to root instead of node. Extract the inspect→pull→inspect fallback into EngineClient.ImageLabelsEnsuringPresent (shared by up and build, DRYing the two paths) and unit-test all three branches (present / absent-then-pulled / unpullable) against a full ImagePullResponse fake that streams like the real client. End-to-end this fixes exec.workspace-dockerfile-with-features-hello-success (TS `howdy, node!` vs Go `howdy, root!`). Also adds the previously-missing unit tests for parsePlatform (#1241) and buildSecretIDs (#1078). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2f2ae79 commit 628a140

5 files changed

Lines changed: 153 additions & 12 deletions

File tree

internal/cli/build.go

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -338,14 +338,7 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai
338338

339339
// Generate base-image metadata. Ensure the base image is available locally so
340340
// metadata from the published image label is preserved.
341-
var baseLabels map[string]string
342-
if baseInspect, inspErr := engine.InspectImage(ctx, baseImage); inspErr == nil && baseInspect.Config != nil {
343-
baseLabels = baseInspect.Config.Labels
344-
} else if pullErr := engine.PullImage(ctx, baseImage); pullErr == nil {
345-
if baseInspect, inspErr := engine.InspectImage(ctx, baseImage); inspErr == nil && baseInspect.Config != nil {
346-
baseLabels = baseInspect.Config.Labels
347-
}
348-
}
341+
baseLabels := engine.ImageLabelsEnsuringPresent(ctx, baseImage)
349342
baseMetadata := imagemeta.ReadMetadataFromLabels(baseLabels, logger)
350343
metadata := append([]imagemeta.Entry{}, baseMetadata...)
351344
// Preserve a `LABEL devcontainer.metadata` declared in the user's Dockerfile

internal/cli/pure_helpers_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,3 +444,21 @@ func TestFindPlatformArg(t *testing.T) {
444444
}
445445
}
446446
}
447+
448+
// TestBuildSecretIDs covers #1078: extracting the KEY from each "KEY=VALUE"
449+
// build secret for mounting as --mount=type=secret,id=KEY.
450+
func TestBuildSecretIDs(t *testing.T) {
451+
got := buildSecretIDs([]string{"NPM_TOKEN=abc", "GH_TOKEN=xyz=with=eq", "=novalue", "noeq"})
452+
want := []string{"NPM_TOKEN", "GH_TOKEN"}
453+
if len(got) != len(want) {
454+
t.Fatalf("buildSecretIDs = %v, want %v", got, want)
455+
}
456+
for i := range want {
457+
if got[i] != want[i] {
458+
t.Errorf("id[%d] = %q, want %q", i, got[i], want[i])
459+
}
460+
}
461+
if len(buildSecretIDs(nil)) != 0 {
462+
t.Error("buildSecretIDs(nil) should be empty")
463+
}
464+
}

internal/cli/up.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -763,9 +763,12 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer,
763763
baseImage := docker.FindBaseImage(prep.Parsed, buildArgsFromConfig(cfg), findTarget)
764764

765765
metadata := []imagemeta.Entry{}
766-
if baseInspect, inspErr := engine.InspectImage(ctx, baseImage); inspErr == nil && baseInspect.Config != nil {
767-
metadata = append(metadata, imagemeta.ReadMetadataFromLabels(baseInspect.Config.Labels, logger)...)
768-
}
766+
// Read the FROM image's baked devcontainer.metadata (e.g. remoteUser=node on
767+
// the mcr devcontainers images). The metadata is computed before the build, so
768+
// on a fresh run the base image is otherwise absent and its metadata would be
769+
// silently lost — making exec/lifecycle fall back to root.
770+
baseLabels := engine.ImageLabelsEnsuringPresent(ctx, baseImage)
771+
metadata = append(metadata, imagemeta.ReadMetadataFromLabels(baseLabels, logger)...)
769772
// Preserve a `LABEL devcontainer.metadata` from the user's Dockerfile (#1225).
770773
metadata = append(metadata, imagemeta.ReadMetadataFromLabels(prep.Parsed.StageLabels(stageName), logger)...)
771774
metadata = append(metadata, imagemeta.Entry{RemoteUser: cfg.RemoteUser, ContainerUser: cfg.ContainerUser})

internal/docker/engine.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,23 @@ func (e *EngineClient) PullImage(ctx context.Context, ref string) error {
223223
return e.PullImagePlatform(ctx, ref, "")
224224
}
225225

226+
// ImageLabelsEnsuringPresent returns ref's image labels, pulling ref first when
227+
// it is not present locally so its baked metadata (e.g. the devcontainer.metadata
228+
// label carrying remoteUser) is not lost when read before a build. Returns nil
229+
// when the image can't be inspected even after a pull.
230+
func (e *EngineClient) ImageLabelsEnsuringPresent(ctx context.Context, ref string) map[string]string {
231+
if info, err := e.InspectImage(ctx, ref); err == nil && info.Config != nil {
232+
return info.Config.Labels
233+
}
234+
if err := e.PullImage(ctx, ref); err != nil {
235+
return nil
236+
}
237+
if info, err := e.InspectImage(ctx, ref); err == nil && info.Config != nil {
238+
return info.Config.Labels
239+
}
240+
return nil
241+
}
242+
226243
// parsePlatform splits an "os/arch[/variant]" platform string into an OCI
227244
// platform. Malformed input yields a best-effort value (the daemon rejects it).
228245
func parsePlatform(s string) ocispec.Platform {

internal/docker/engine_test.go

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@ import (
55
"errors"
66
"testing"
77

8+
dockerspec "github.com/moby/docker-image-spec/specs-go/v1"
89
"github.com/moby/moby/api/types/container"
910
"github.com/moby/moby/api/types/events"
1011
"github.com/moby/moby/api/types/image"
12+
"github.com/moby/moby/api/types/jsonstream"
1113
mobyclient "github.com/moby/moby/client"
14+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
15+
"io"
16+
"iter"
17+
"strings"
1218

1319
"github.com/devcontainers/cli/internal/log"
1420
)
@@ -84,7 +90,7 @@ func (m *mockAPI) ImagePull(ctx context.Context, ref string, opts mobyclient.Ima
8490
if m.imagePullFn != nil {
8591
return m.imagePullFn(ctx, ref, opts)
8692
}
87-
return nil, nil
93+
return fakePullResponse{strings.NewReader("")}, nil
8894
}
8995

9096
func (m *mockAPI) ContainerInspect(ctx context.Context, id string, _ mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
@@ -395,3 +401,107 @@ func TestEvents(t *testing.T) {
395401
t.Errorf("got %d messages, want 2", len(msgs))
396402
}
397403
}
404+
405+
// TestImageLabelsEnsuringPresent guards the inspect→pull→inspect fallback: the
406+
// base image's baked metadata (e.g. devcontainer.metadata remoteUser) must be
407+
// read even on a fresh run where the image is not cached yet. Without the pull
408+
// the metadata is lost and exec/lifecycle fall back to root.
409+
func TestImageLabelsEnsuringPresent(t *testing.T) {
410+
labels := map[string]string{"devcontainer.metadata": `[{"remoteUser":"node"}]`}
411+
withLabels := func() (image.InspectResponse, error) {
412+
return image.InspectResponse{Config: &dockerspec.DockerOCIImageConfig{
413+
ImageConfig: ocispec.ImageConfig{Labels: labels},
414+
}}, nil
415+
}
416+
notFound := func() (image.InspectResponse, error) {
417+
return image.InspectResponse{}, errors.New("no such image")
418+
}
419+
420+
t.Run("present: returns labels without pulling", func(t *testing.T) {
421+
pulled := false
422+
api := &mockAPI{
423+
imageInspectFn: func(context.Context, string, ...mobyclient.ImageInspectOption) (image.InspectResponse, error) {
424+
return withLabels()
425+
},
426+
imagePullFn: func(context.Context, string, mobyclient.ImagePullOptions) (mobyclient.ImagePullResponse, error) {
427+
pulled = true
428+
return fakePullResponse{strings.NewReader("pulling\n")}, nil
429+
},
430+
}
431+
got := newTestEngine(api).ImageLabelsEnsuringPresent(t.Context(), "img")
432+
if got["devcontainer.metadata"] != labels["devcontainer.metadata"] {
433+
t.Errorf("labels = %v", got)
434+
}
435+
if pulled {
436+
t.Error("pulled despite the image already being present")
437+
}
438+
})
439+
440+
t.Run("absent: pulls then returns labels", func(t *testing.T) {
441+
n, pulled := 0, false
442+
api := &mockAPI{
443+
imageInspectFn: func(context.Context, string, ...mobyclient.ImageInspectOption) (image.InspectResponse, error) {
444+
n++
445+
if n == 1 {
446+
return notFound()
447+
}
448+
return withLabels()
449+
},
450+
imagePullFn: func(context.Context, string, mobyclient.ImagePullOptions) (mobyclient.ImagePullResponse, error) {
451+
pulled = true
452+
return fakePullResponse{strings.NewReader("pulling\n")}, nil
453+
},
454+
}
455+
got := newTestEngine(api).ImageLabelsEnsuringPresent(t.Context(), "img")
456+
if !pulled {
457+
t.Error("did not pull the missing image")
458+
}
459+
if got["devcontainer.metadata"] != labels["devcontainer.metadata"] {
460+
t.Errorf("labels after pull = %v", got)
461+
}
462+
})
463+
464+
t.Run("absent and unpullable: nil", func(t *testing.T) {
465+
api := &mockAPI{
466+
imageInspectFn: func(context.Context, string, ...mobyclient.ImageInspectOption) (image.InspectResponse, error) {
467+
return notFound()
468+
},
469+
imagePullFn: func(context.Context, string, mobyclient.ImagePullOptions) (mobyclient.ImagePullResponse, error) {
470+
return nil, errors.New("pull failed")
471+
},
472+
}
473+
if got := newTestEngine(api).ImageLabelsEnsuringPresent(t.Context(), "img"); got != nil {
474+
t.Errorf("labels = %v, want nil", got)
475+
}
476+
})
477+
}
478+
479+
// TestParsePlatform covers #1241 platform parsing into an OCI platform.
480+
func TestParsePlatform(t *testing.T) {
481+
cases := []struct {
482+
in string
483+
os, arch, var_ string
484+
}{
485+
{"linux/amd64", "linux", "amd64", ""},
486+
{"linux/arm64/v8", "linux", "arm64", "v8"},
487+
{"linux", "linux", "", ""},
488+
}
489+
for _, c := range cases {
490+
p := parsePlatform(c.in)
491+
if p.OS != c.os || p.Architecture != c.arch || p.Variant != c.var_ {
492+
t.Errorf("parsePlatform(%q) = %+v", c.in, p)
493+
}
494+
}
495+
}
496+
497+
// fakePullResponse is a minimal but complete mobyclient.ImagePullResponse for
498+
// tests: it streams the wrapped reader (so io.Copy drains it exactly like the
499+
// real client) and satisfies the JSONMessages/Wait/Close surface.
500+
type fakePullResponse struct{ r io.Reader }
501+
502+
func (f fakePullResponse) Read(p []byte) (int, error) { return f.r.Read(p) }
503+
func (f fakePullResponse) Close() error { return nil }
504+
func (f fakePullResponse) Wait(context.Context) error { return nil }
505+
func (f fakePullResponse) JSONMessages(context.Context) iter.Seq2[jsonstream.Message, error] {
506+
return func(func(jsonstream.Message, error) bool) {}
507+
}

0 commit comments

Comments
 (0)