From a9a7919d0370ae97e4e6a7e5eb1ab0f99f9e94ab Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 19 Sep 2026 17:10:01 +0000 Subject: [PATCH 1/7] fix(podman): preserve variables in build labels --- pkg/driver/docker/build.go | 31 +++++++++++--- pkg/driver/docker/build_test.go | 71 ++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/pkg/driver/docker/build.go b/pkg/driver/docker/build.go index a75bad9d0..8d1aaf7a5 100644 --- a/pkg/driver/docker/build.go +++ b/pkg/driver/docker/build.go @@ -75,7 +75,8 @@ func (s *dockerBuildxStrategy) build( platform string, options *build.BuildOptions, ) error { - args := buildDockerBuildxArgs(options, platform) + runtimeName := s.driver.Docker.GetRuntime().Name() + args := buildDockerBuildxArgs(options, platform, runtimeName) secretEnv, secretArgs, err := buildxSecretArgs(options.BuildSecrets) if err != nil { return err @@ -131,7 +132,11 @@ func (c *tailBuffer) Write(p []byte) (int, error) { func (c *tailBuffer) Len() int { return len(c.buf) } func (c *tailBuffer) String() string { return string(c.buf) } -func buildDockerBuildxArgs(options *build.BuildOptions, platform string) []string { +func buildDockerBuildxArgs( + options *build.BuildOptions, + platform string, + runtimeName docker.RuntimeName, +) []string { args := []string{"buildx", "build", "-f", options.Dockerfile} args = appendBuildFlags(args, options.Load, options.Push) if options.NoCache { @@ -142,7 +147,7 @@ func buildDockerBuildxArgs(options *build.BuildOptions, platform string) []strin } args = appendImageTags(args, options.Images) args = appendBuildArgsAndContexts(args, options.BuildArgs, options.Contexts) - args = appendLabels(args, options.Labels) + args = appendLabels(args, options.Labels, runtimeName) args = appendTargetAndPlatform(args, options.Target, platform) args = appendCacheOptions(args, options.CacheFrom, options.CacheTo) args = append(args, options.CliOpts...) @@ -206,7 +211,11 @@ func appendBuildArgsAndContexts(args []string, buildArgs, contexts map[string]st return args } -func appendLabels(args []string, labels map[string]string) []string { +func appendLabels( + args []string, + labels map[string]string, + runtimeName docker.RuntimeName, +) []string { keys := make([]string, 0, len(labels)) for k := range labels { keys = append(keys, k) @@ -214,11 +223,23 @@ func appendLabels(args []string, labels map[string]string) []string { sort.Strings(keys) for _, k := range keys { - args = append(args, "--label", k+"="+labels[k]) + value := encodeBuildLabelValue(runtimeName, labels[k]) + args = append(args, "--label", k+"="+value) } return args } +func encodeBuildLabelValue( + runtimeName docker.RuntimeName, + value string, +) string { + if runtimeName != docker.RuntimePodman { + return value + } + + return strings.ReplaceAll(value, "$", `\$`) +} + func appendTargetAndPlatform(args []string, target, platform string) []string { if target != "" { args = append(args, "--target", target) diff --git a/pkg/driver/docker/build_test.go b/pkg/driver/docker/build_test.go index bdbd9aa03..cc201477b 100644 --- a/pkg/driver/docker/build_test.go +++ b/pkg/driver/docker/build_test.go @@ -136,13 +136,82 @@ func TestBuildDockerBuildxArgs_PullAndNoCache(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - args := buildDockerBuildxArgs(tt.opts, "") + args := buildDockerBuildxArgs(tt.opts, "", docker.RuntimeDocker) assert.Equal(t, tt.wantPull, slices.Contains(args, "--pull"), "args=%v", args) assert.Equal(t, tt.wantNoCach, slices.Contains(args, "--no-cache"), "args=%v", args) }) } } +const devcontainerMetadataLabel = "devcontainer.metadata" + +func TestBuildDockerBuildxArgs_PodmanEscapesDollarInLabels(t *testing.T) { + opts := &build.BuildOptions{ + Labels: map[string]string{ + devcontainerMetadataLabel: `[{"mounts":[{"source":"${localEnv:HOME}/.cache","target":"/cache","type":"bind"}]}]`, + }, + } + + args := buildDockerBuildxArgs( + opts, + "", + docker.RuntimePodman, + ) + + expected := devcontainerMetadataLabel + + `=[{"mounts":[{"source":"\${localEnv:HOME}/.cache","target":"/cache","type":"bind"}]}]` + var found string + for i := range len(args) - 1 { + if args[i] == "--label" { + found = args[i+1] + break + } + } + assert.Equal(t, expected, found) +} + +func TestBuildDockerBuildxArgs_DockerPreservesDollarInLabels(t *testing.T) { + opts := &build.BuildOptions{ + Labels: map[string]string{ + devcontainerMetadataLabel: `[{"mounts":[{"source":"${localEnv:HOME}/.cache","target":"/cache","type":"bind"}]}]`, + }, + } + + args := buildDockerBuildxArgs( + opts, + "", + docker.RuntimeDocker, + ) + + expected := devcontainerMetadataLabel + + `=[{"mounts":[{"source":"${localEnv:HOME}/.cache","target":"/cache","type":"bind"}]}]` + var found string + for i := range len(args) - 1 { + if args[i] == "--label" { + found = args[i+1] + break + } + } + assert.Equal(t, expected, found) +} + +func TestBuildDockerBuildxArgs_PodmanDoesNotMutateLabels(t *testing.T) { + const raw = `[{"mounts":[{"source":"${localEnv:HOME}/.cache","target":"/cache","type":"bind"}]}]` + opts := &build.BuildOptions{ + Labels: map[string]string{ + devcontainerMetadataLabel: raw, + }, + } + + _ = buildDockerBuildxArgs( + opts, + "", + docker.RuntimePodman, + ) + + assert.Equal(t, raw, opts.Labels[devcontainerMetadataLabel]) +} + func TestBuildxSecretArgs(t *testing.T) { env, args, err := buildxSecretArgs([]string{"NPM_TOKEN=abc123", "CONN=user=admin"}) require.NoError(t, err) From 0a38e19a13b2f4ba6b4c9ec91ba0b00924bc9d01 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 19 Sep 2026 17:10:12 +0000 Subject: [PATCH 2/7] test(devcontainer): lock raw metadata substitution semantics --- pkg/devcontainer/config_test.go | 29 +++++++++++ pkg/devcontainer/metadata/metadata_test.go | 60 ++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/pkg/devcontainer/config_test.go b/pkg/devcontainer/config_test.go index 7617a94a7..66b6037ab 100644 --- a/pkg/devcontainer/config_test.go +++ b/pkg/devcontainer/config_test.go @@ -362,6 +362,35 @@ func (s *SubstituteTestSuite) TestSubstitute_CLIMountsEmpty() { s.Equal("/existing-target", substitutedConfig.Config.Mounts[0].Target) } +func (s *SubstituteTestSuite) TestSubstitute_LocalEnvInMountSource() { + s.T().Setenv("HOME", "/home/test") + + rawConfig := &config.DevContainerConfig{ + ImageContainer: config.ImageContainer{Image: "alpine:latest"}, + NonComposeBase: config.NonComposeBase{ + Mounts: []*config.Mount{ + { + Type: mountTypeBind, + Source: "${localEnv:HOME}/.cache/example", + Target: "/cache/example", + }, + }, + }, + } + options := provider2.CLIOptions{} + + result, _, err := s.runner.substitute(options, rawConfig) + + s.NoError(err) + s.Require().NotNil(result) + s.Require().NotNil(result.Raw) + s.Require().NotNil(result.Config) + s.Require().Len(result.Raw.Mounts, 1) + s.Require().Len(result.Config.Mounts, 1) + s.Equal("${localEnv:HOME}/.cache/example", result.Raw.Mounts[0].Source) + s.Equal("/home/test/.cache/example", result.Config.Mounts[0].Source) +} + func ptr(s string) *string { return new(s) } func TestWorkspaceMountFolderWarning(t *testing.T) { diff --git a/pkg/devcontainer/metadata/metadata_test.go b/pkg/devcontainer/metadata/metadata_test.go index 37693a9ad..b56e1498a 100644 --- a/pkg/devcontainer/metadata/metadata_test.go +++ b/pkg/devcontainer/metadata/metadata_test.go @@ -111,3 +111,63 @@ func TestMarshalImageMetadata_WarnsWhenLarge(t *testing.T) { t.Fatalf("test data should exceed threshold, got %d bytes", len(data)) } } + +func TestGetDevContainerMetadata_PreservesRawLocalEnvMount(t *testing.T) { + raw := &config.DevContainerConfig{ + NonComposeBase: config.NonComposeBase{ + Mounts: []*config.Mount{ + { + Type: "bind", + Source: "${localEnv:HOME}/.cache/example", + Target: "/cache/example", + }, + }, + }, + } + + effective := &config.DevContainerConfig{ + NonComposeBase: config.NonComposeBase{ + Mounts: []*config.Mount{ + { + Type: "bind", + Source: "/home/test/.cache/example", + Target: "/cache/example", + }, + }, + }, + } + + substituted := &config.SubstitutedConfig{ + Raw: raw, + Config: effective, + } + + got, err := GetDevContainerMetadata( + &config.SubstitutionContext{}, + &config.ImageMetadataConfig{}, + substituted, + nil, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got.Raw) == 0 || len(got.Raw[len(got.Raw)-1].Mounts) == 0 { + t.Fatal("expected non-empty got.Raw mounts") + } + if len(got.Config) == 0 || len(got.Config[len(got.Config)-1].Mounts) == 0 { + t.Fatal("expected non-empty got.Config mounts") + } + + rawSource := got.Raw[len(got.Raw)-1].Mounts[0].Source + expectedRaw := "${localEnv:HOME}/.cache/example" + if rawSource != expectedRaw { + t.Errorf("got raw source %q, want %q", rawSource, expectedRaw) + } + + configSource := got.Config[len(got.Config)-1].Mounts[0].Source + expectedConfig := "/home/test/.cache/example" + if configSource != expectedConfig { + t.Errorf("got config source %q, want %q", configSource, expectedConfig) + } +} From 84a6aca6bf6d934ed41ca7e07e83e3e2da27020e Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 19 Sep 2026 17:10:29 +0000 Subject: [PATCH 3/7] test(e2e): cover localEnv metadata with podman --- .../up/provider_podman_rootful_config.go | 45 ++++++++++++++++++ .../up/provider_podman_rootless_config.go | 46 +++++++++++++++++++ .../.devcontainer.json | 10 ++++ 3 files changed, 101 insertions(+) create mode 100644 e2e/tests/up/testdata/podman-local-env-metadata/.devcontainer.json diff --git a/e2e/tests/up/provider_podman_rootful_config.go b/e2e/tests/up/provider_podman_rootful_config.go index ebc4f1a7c..8031af977 100644 --- a/e2e/tests/up/provider_podman_rootful_config.go +++ b/e2e/tests/up/provider_podman_rootful_config.go @@ -261,6 +261,51 @@ var _ = ginkgo.Describe( err = f.DevsyWorkspaceDelete(ctx, tempDir) framework.ExpectNoError(err) }, ginkgo.SpecTimeout(framework.TimeoutModerate())) + + ginkgo.It( + "should preserve localEnv expressions in build metadata", + func(ctx context.Context) { + homeDir, err := os.UserHomeDir() + framework.ExpectNoError(err) + + sourceDir := filepath.Join( + homeDir, + ".devsy-e2e-local-env-metadata", + ) + + err = os.MkdirAll(sourceDir, 0o750) + framework.ExpectNoError(err) + + ginkgo.DeferCleanup(func() { + _ = os.RemoveAll(sourceDir) + }) + + err = os.WriteFile( + filepath.Join(sourceDir, "probe.txt"), + []byte("devsy-local-env-metadata-ok\n"), + 0o600, + ) + framework.ExpectNoError(err) + + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/podman-local-env-metadata", + initialDir, + f, + ) + framework.ExpectNoError(err) + + out := eventuallySSH( + f, + ctx, + tempDir, + "cat /tmp/devsy-local-env-metadata/probe.txt", + ) + + gomega.Expect(out).To(gomega.Equal("devsy-local-env-metadata-ok")) + }, + ginkgo.SpecTimeout(framework.TimeoutModerate()), + ) }) }) }, diff --git a/e2e/tests/up/provider_podman_rootless_config.go b/e2e/tests/up/provider_podman_rootless_config.go index 3abb7cb12..67636d9ef 100644 --- a/e2e/tests/up/provider_podman_rootless_config.go +++ b/e2e/tests/up/provider_podman_rootless_config.go @@ -226,6 +226,52 @@ var _ = ginkgo.Describe( err = f.DevsyWorkspaceDelete(ctx, tempDir) framework.ExpectNoError(err) }, ginkgo.SpecTimeout(framework.TimeoutModerate())) + + ginkgo.It( + "should preserve localEnv expressions in build metadata", + func(ctx context.Context) { + homeDir, err := os.UserHomeDir() + framework.ExpectNoError(err) + + sourceDir := filepath.Join( + homeDir, + ".devsy-e2e-local-env-metadata", + ) + + err = os.MkdirAll(sourceDir, 0o750) + framework.ExpectNoError(err) + + ginkgo.DeferCleanup(func() { + _ = os.RemoveAll(sourceDir) + }) + + err = os.WriteFile( + filepath.Join(sourceDir, "probe.txt"), + []byte("devsy-local-env-metadata-ok\n"), + 0o600, + ) + framework.ExpectNoError(err) + + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/podman-local-env-metadata", + initialDir, + f, + ) + framework.ExpectNoError(err) + + out, err := f.DevsySSH( + ctx, + tempDir, + "cat /tmp/devsy-local-env-metadata/probe.txt", + ) + framework.ExpectNoError(err) + + gomega.Expect(strings.TrimSpace(out)). + To(gomega.Equal("devsy-local-env-metadata-ok")) + }, + ginkgo.SpecTimeout(framework.TimeoutModerate()), + ) }) }) }, diff --git a/e2e/tests/up/testdata/podman-local-env-metadata/.devcontainer.json b/e2e/tests/up/testdata/podman-local-env-metadata/.devcontainer.json new file mode 100644 index 000000000..b8b5db788 --- /dev/null +++ b/e2e/tests/up/testdata/podman-local-env-metadata/.devcontainer.json @@ -0,0 +1,10 @@ +{ + "image": "ghcr.io/devsy-org/test-images/base:ubuntu", + "mounts": [ + { + "type": "bind", + "source": "${localEnv:HOME}/.devsy-e2e-local-env-metadata", + "target": "/tmp/devsy-local-env-metadata" + } + ] +} From e31fb83aa96f96e378689ca5068099394ab0fd43 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 19 Sep 2026 17:28:14 +0000 Subject: [PATCH 4/7] refactor(runtime): encapsulate build label encoding --- pkg/compose/helper_test.go | 11 +++--- pkg/docker/runtime.go | 9 +++++ pkg/docker/runtime_test.go | 62 +++++++++++++++++++++++++++++++++ pkg/driver/docker/build.go | 23 ++++-------- pkg/driver/docker/build_test.go | 25 ++++++++----- 5 files changed, 100 insertions(+), 30 deletions(-) diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index 9e403e121..11f1b4d6f 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -197,11 +197,12 @@ type stubRuntime struct { name docker.RuntimeName } -func (r stubRuntime) Name() docker.RuntimeName { return r.name } -func (r stubRuntime) SupportsInternalBuildKit() bool { return false } -func (r stubRuntime) SupportsSignalProxy() bool { return false } -func (r stubRuntime) SupportsMountConsistency() bool { return false } -func (r stubRuntime) NeedsUserNamespaceArgs() bool { return false } +func (r stubRuntime) Name() docker.RuntimeName { return r.name } +func (r stubRuntime) SupportsInternalBuildKit() bool { return false } +func (r stubRuntime) SupportsSignalProxy() bool { return false } +func (r stubRuntime) SupportsMountConsistency() bool { return false } +func (r stubRuntime) NeedsUserNamespaceArgs() bool { return false } +func (r stubRuntime) EncodeBuildLabelValue(value string) string { return value } func (r stubRuntime) GPUAvailable(_ context.Context, _ *docker.DockerHelper) (bool, error) { return false, nil } diff --git a/pkg/docker/runtime.go b/pkg/docker/runtime.go index 376d6476a..067a737a1 100644 --- a/pkg/docker/runtime.go +++ b/pkg/docker/runtime.go @@ -27,6 +27,7 @@ type ContainerRuntime interface { SupportsSignalProxy() bool SupportsMountConsistency() bool NeedsUserNamespaceArgs() bool + EncodeBuildLabelValue(value string) string GPUAvailable(ctx context.Context, helper *DockerHelper) (bool, error) } @@ -38,6 +39,8 @@ func (dockerRuntime) SupportsSignalProxy() bool { return true } func (dockerRuntime) SupportsMountConsistency() bool { return true } func (dockerRuntime) NeedsUserNamespaceArgs() bool { return false } +func (dockerRuntime) EncodeBuildLabelValue(value string) string { return value } + func (dockerRuntime) GPUAvailable(ctx context.Context, h *DockerHelper) (bool, error) { out, err := h.buildCmd(ctx, "info", "-f", "{{.Runtimes.nvidia}}").Output() if err != nil { @@ -54,6 +57,10 @@ func (podmanRuntime) SupportsSignalProxy() bool { return true } func (podmanRuntime) SupportsMountConsistency() bool { return true } func (podmanRuntime) NeedsUserNamespaceArgs() bool { return true } +func (podmanRuntime) EncodeBuildLabelValue(value string) string { + return strings.ReplaceAll(value, "$", `${_:-$}`) +} + func (podmanRuntime) GPUAvailable(ctx context.Context, h *DockerHelper) (bool, error) { out, err := h.buildCmd(ctx, "info", "-f", "{{.Host.CDIDevices}}").Output() if err != nil { @@ -70,6 +77,8 @@ func (nerdctlRuntime) SupportsSignalProxy() bool { return false } func (nerdctlRuntime) SupportsMountConsistency() bool { return false } func (nerdctlRuntime) NeedsUserNamespaceArgs() bool { return false } +func (nerdctlRuntime) EncodeBuildLabelValue(value string) string { return value } + func (nerdctlRuntime) GPUAvailable(ctx context.Context, h *DockerHelper) (bool, error) { out, err := h.buildCmd(ctx, "info", "-f", "{{.Runtimes.nvidia}}").Output() if err != nil { diff --git a/pkg/docker/runtime_test.go b/pkg/docker/runtime_test.go index f399eb6cb..df7073f70 100644 --- a/pkg/docker/runtime_test.go +++ b/pkg/docker/runtime_test.go @@ -105,3 +105,65 @@ func TestDetectRuntimeCaching(t *testing.T) { rt2 := DetectRuntime("nonexistent-binary-abc") assert.Equal(t, rt1, rt2, "same binary should return same cached runtime") } + +func TestRuntimeEncodeBuildLabelValue(t *testing.T) { + dockerRt, err := RuntimeFromName(string(RuntimeDocker)) + require.NoError(t, err) + nerdctlRt, err := RuntimeFromName(string(RuntimeNerdctl)) + require.NoError(t, err) + podmanRt, err := RuntimeFromName(string(RuntimePodman)) + require.NoError(t, err) + + const localEnvInput = "${localEnv:HOME}/foo" + + tests := []struct { + name string + runtime ContainerRuntime + input string + want string + }{ + { + name: "docker preserves value", + runtime: dockerRt, + input: localEnvInput, + want: localEnvInput, + }, + { + name: "nerdctl preserves value", + runtime: nerdctlRt, + input: localEnvInput, + want: localEnvInput, + }, + { + name: "podman escapes dollar in localEnv expression", + runtime: podmanRt, + input: localEnvInput, + want: `${_:-$}{localEnv:HOME}/foo`, + }, + { + name: "podman preserves plain text", + runtime: podmanRt, + input: "plain text", + want: "plain text", + }, + { + name: "podman escapes bare dollar variable", + runtime: podmanRt, + input: "$HOME", + want: `${_:-$}HOME`, + }, + { + name: "podman escapes multiple variable expressions", + runtime: podmanRt, + input: "${A}/$B/${C}", + want: `${_:-$}{A}/${_:-$}B/${_:-$}{C}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.runtime.EncodeBuildLabelValue(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/driver/docker/build.go b/pkg/driver/docker/build.go index 8d1aaf7a5..2ec34e575 100644 --- a/pkg/driver/docker/build.go +++ b/pkg/driver/docker/build.go @@ -75,8 +75,8 @@ func (s *dockerBuildxStrategy) build( platform string, options *build.BuildOptions, ) error { - runtimeName := s.driver.Docker.GetRuntime().Name() - args := buildDockerBuildxArgs(options, platform, runtimeName) + runtime := s.driver.Docker.GetRuntime() + args := buildDockerBuildxArgs(options, platform, runtime) secretEnv, secretArgs, err := buildxSecretArgs(options.BuildSecrets) if err != nil { return err @@ -135,7 +135,7 @@ func (c *tailBuffer) String() string { return string(c.buf) } func buildDockerBuildxArgs( options *build.BuildOptions, platform string, - runtimeName docker.RuntimeName, + runtime docker.ContainerRuntime, ) []string { args := []string{"buildx", "build", "-f", options.Dockerfile} args = appendBuildFlags(args, options.Load, options.Push) @@ -147,7 +147,7 @@ func buildDockerBuildxArgs( } args = appendImageTags(args, options.Images) args = appendBuildArgsAndContexts(args, options.BuildArgs, options.Contexts) - args = appendLabels(args, options.Labels, runtimeName) + args = appendLabels(args, options.Labels, runtime) args = appendTargetAndPlatform(args, options.Target, platform) args = appendCacheOptions(args, options.CacheFrom, options.CacheTo) args = append(args, options.CliOpts...) @@ -214,7 +214,7 @@ func appendBuildArgsAndContexts(args []string, buildArgs, contexts map[string]st func appendLabels( args []string, labels map[string]string, - runtimeName docker.RuntimeName, + runtime docker.ContainerRuntime, ) []string { keys := make([]string, 0, len(labels)) for k := range labels { @@ -223,23 +223,12 @@ func appendLabels( sort.Strings(keys) for _, k := range keys { - value := encodeBuildLabelValue(runtimeName, labels[k]) + value := runtime.EncodeBuildLabelValue(labels[k]) args = append(args, "--label", k+"="+value) } return args } -func encodeBuildLabelValue( - runtimeName docker.RuntimeName, - value string, -) string { - if runtimeName != docker.RuntimePodman { - return value - } - - return strings.ReplaceAll(value, "$", `\$`) -} - func appendTargetAndPlatform(args []string, target, platform string) []string { if target != "" { args = append(args, "--target", target) diff --git a/pkg/driver/docker/build_test.go b/pkg/driver/docker/build_test.go index cc201477b..506ff7ec5 100644 --- a/pkg/driver/docker/build_test.go +++ b/pkg/driver/docker/build_test.go @@ -134,9 +134,12 @@ func TestBuildDockerBuildxArgs_PullAndNoCache(t *testing.T) { wantNoCach: true, }, } + dockerRt, err := docker.RuntimeFromName(string(docker.RuntimeDocker)) + require.NoError(t, err) + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - args := buildDockerBuildxArgs(tt.opts, "", docker.RuntimeDocker) + args := buildDockerBuildxArgs(tt.opts, "", dockerRt) assert.Equal(t, tt.wantPull, slices.Contains(args, "--pull"), "args=%v", args) assert.Equal(t, tt.wantNoCach, slices.Contains(args, "--no-cache"), "args=%v", args) }) @@ -146,6 +149,9 @@ func TestBuildDockerBuildxArgs_PullAndNoCache(t *testing.T) { const devcontainerMetadataLabel = "devcontainer.metadata" func TestBuildDockerBuildxArgs_PodmanEscapesDollarInLabels(t *testing.T) { + podmanRt, err := docker.RuntimeFromName(string(docker.RuntimePodman)) + require.NoError(t, err) + opts := &build.BuildOptions{ Labels: map[string]string{ devcontainerMetadataLabel: `[{"mounts":[{"source":"${localEnv:HOME}/.cache","target":"/cache","type":"bind"}]}]`, @@ -155,11 +161,10 @@ func TestBuildDockerBuildxArgs_PodmanEscapesDollarInLabels(t *testing.T) { args := buildDockerBuildxArgs( opts, "", - docker.RuntimePodman, + podmanRt, ) - expected := devcontainerMetadataLabel + - `=[{"mounts":[{"source":"\${localEnv:HOME}/.cache","target":"/cache","type":"bind"}]}]` + `=[{"mounts":[{"source":"${_:-$}{localEnv:HOME}/.cache","target":"/cache","type":"bind"}]}]` var found string for i := range len(args) - 1 { if args[i] == "--label" { @@ -171,6 +176,9 @@ func TestBuildDockerBuildxArgs_PodmanEscapesDollarInLabels(t *testing.T) { } func TestBuildDockerBuildxArgs_DockerPreservesDollarInLabels(t *testing.T) { + dockerRt, err := docker.RuntimeFromName(string(docker.RuntimeDocker)) + require.NoError(t, err) + opts := &build.BuildOptions{ Labels: map[string]string{ devcontainerMetadataLabel: `[{"mounts":[{"source":"${localEnv:HOME}/.cache","target":"/cache","type":"bind"}]}]`, @@ -180,9 +188,8 @@ func TestBuildDockerBuildxArgs_DockerPreservesDollarInLabels(t *testing.T) { args := buildDockerBuildxArgs( opts, "", - docker.RuntimeDocker, + dockerRt, ) - expected := devcontainerMetadataLabel + `=[{"mounts":[{"source":"${localEnv:HOME}/.cache","target":"/cache","type":"bind"}]}]` var found string @@ -196,6 +203,9 @@ func TestBuildDockerBuildxArgs_DockerPreservesDollarInLabels(t *testing.T) { } func TestBuildDockerBuildxArgs_PodmanDoesNotMutateLabels(t *testing.T) { + podmanRt, err := docker.RuntimeFromName(string(docker.RuntimePodman)) + require.NoError(t, err) + const raw = `[{"mounts":[{"source":"${localEnv:HOME}/.cache","target":"/cache","type":"bind"}]}]` opts := &build.BuildOptions{ Labels: map[string]string{ @@ -206,9 +216,8 @@ func TestBuildDockerBuildxArgs_PodmanDoesNotMutateLabels(t *testing.T) { _ = buildDockerBuildxArgs( opts, "", - docker.RuntimePodman, + podmanRt, ) - assert.Equal(t, raw, opts.Labels[devcontainerMetadataLabel]) } From d577dc2f272000dbe5dceb0748e7619027553cef Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 19 Sep 2026 17:37:55 +0000 Subject: [PATCH 5/7] test(e2e): verify podman metadata label round trip --- .../up/provider_podman_rootful_config.go | 93 ++++++++++++++++++- .../up/provider_podman_rootless_config.go | 93 ++++++++++++++++++- .../.devcontainer.json | 4 +- .../podman-local-env-metadata/Dockerfile | 1 + 4 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 e2e/tests/up/testdata/podman-local-env-metadata/Dockerfile diff --git a/e2e/tests/up/provider_podman_rootful_config.go b/e2e/tests/up/provider_podman_rootful_config.go index 8031af977..71cac0659 100644 --- a/e2e/tests/up/provider_podman_rootful_config.go +++ b/e2e/tests/up/provider_podman_rootful_config.go @@ -2,6 +2,8 @@ package up import ( "context" + "encoding/json" + "fmt" "os" "path" "path/filepath" @@ -9,6 +11,9 @@ import ( "time" "github.com/devsy-org/devsy/e2e/framework" + pkgconfig "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + docker "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" @@ -273,17 +278,31 @@ var _ = ginkgo.Describe( ".devsy-e2e-local-env-metadata", ) - err = os.MkdirAll(sourceDir, 0o750) + if _, statErr := os.Stat(sourceDir); statErr == nil { + probePath := filepath.Join(sourceDir, "probe.txt") + _, probeErr := os.Stat(probePath) + gomega.Expect(probeErr).NotTo( + gomega.HaveOccurred(), + "fixture directory %s already exists without probe.txt; aborting to prevent data loss", + sourceDir, + ) + } + // #nosec G301 -- fixture must be traversable by container user + err = os.MkdirAll( + sourceDir, + 0o755, + ) framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { _ = os.RemoveAll(sourceDir) }) + // #nosec G306 -- fixture must be readable by container user err = os.WriteFile( filepath.Join(sourceDir, "probe.txt"), []byte("devsy-local-env-metadata-ok\n"), - 0o600, + 0o644, ) framework.ExpectNoError(err) @@ -303,6 +322,76 @@ var _ = ginkgo.Describe( ) gomega.Expect(out).To(gomega.Equal("devsy-local-env-metadata-ok")) + + workspace, err := f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + + dockerHelper := &docker.DockerHelper{ + DockerCommand: initialDir + "/bin/podman-rootful", + } + + container, err := dockerHelper.FindDevContainer(ctx, []string{ + fmt.Sprintf("%s=%s", pkgconfig.DevcontainerIDLabel, workspace.UID), + }) + framework.ExpectNoError(err) + gomega.Expect(container).NotTo(gomega.BeNil()) + + imageRef := container.Config.LegacyImage + if imageRef == "" { + var rawInspect []struct { + Image string `json:"Image"` + } + inspectErr := dockerHelper.Inspect( + ctx, + []string{container.ID}, + "container", + &rawInspect, + ) + framework.ExpectNoError(inspectErr) + if len(rawInspect) > 0 { + imageRef = rawInspect[0].Image + } + } + gomega.Expect(imageRef).NotTo(gomega.BeEmpty()) + + imageDetails, err := dockerHelper.InspectImage(ctx, imageRef, false) + framework.ExpectNoError(err) + gomega.Expect(imageDetails.Config.Labels).NotTo(gomega.BeNil()) + + metadataValue, ok := imageDetails.Config.Labels[pkgconfig.DevcontainerMetadataLabel] + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(metadataValue).NotTo(gomega.BeEmpty()) + + var metadataList []*config.ImageMetadata + err = json.Unmarshal([]byte(metadataValue), &metadataList) + framework.ExpectNoError(err) + gomega.Expect(metadataList).NotTo(gomega.BeEmpty()) + + var foundSource string + for _, item := range metadataList { + for _, m := range item.Mounts { + if strings.Contains(m.Source, ".devsy-e2e-local-env-metadata") { + foundSource = m.Source + break + } + } + } + gomega.Expect(foundSource). + To(gomega.Equal("${localEnv:HOME}/.devsy-e2e-local-env-metadata")) + + gomega.Expect(metadataValue).To( + gomega.ContainSubstring( + "${localEnv:HOME}/.devsy-e2e-local-env-metadata", + ), + ) + gomega.Expect(metadataValue).NotTo( + gomega.ContainSubstring( + `\${localEnv:HOME}/.devsy-e2e-local-env-metadata`, + ), + ) + gomega.Expect(metadataValue).NotTo( + gomega.ContainSubstring(sourceDir), + ) }, ginkgo.SpecTimeout(framework.TimeoutModerate()), ) diff --git a/e2e/tests/up/provider_podman_rootless_config.go b/e2e/tests/up/provider_podman_rootless_config.go index 67636d9ef..08fb6bf5c 100644 --- a/e2e/tests/up/provider_podman_rootless_config.go +++ b/e2e/tests/up/provider_podman_rootless_config.go @@ -2,12 +2,17 @@ package up import ( "context" + "encoding/json" + "fmt" "os" "path" "path/filepath" "strings" "github.com/devsy-org/devsy/e2e/framework" + pkgconfig "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + docker "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" @@ -237,18 +242,32 @@ var _ = ginkgo.Describe( homeDir, ".devsy-e2e-local-env-metadata", ) - - err = os.MkdirAll(sourceDir, 0o750) + if _, statErr := os.Stat(sourceDir); statErr == nil { + probePath := filepath.Join(sourceDir, "probe.txt") + _, probeErr := os.Stat(probePath) + gomega.Expect(probeErr).NotTo( + gomega.HaveOccurred(), + "fixture directory %s already exists without probe.txt; aborting to prevent data loss", + sourceDir, + ) + } + + // #nosec G301 -- fixture must be traversable by container user + err = os.MkdirAll( + sourceDir, + 0o755, + ) framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { _ = os.RemoveAll(sourceDir) }) + // #nosec G306 -- fixture must be readable by container user err = os.WriteFile( filepath.Join(sourceDir, "probe.txt"), []byte("devsy-local-env-metadata-ok\n"), - 0o600, + 0o644, ) framework.ExpectNoError(err) @@ -269,6 +288,74 @@ var _ = ginkgo.Describe( gomega.Expect(strings.TrimSpace(out)). To(gomega.Equal("devsy-local-env-metadata-ok")) + + workspace, err := f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + + dockerHelper := &docker.DockerHelper{DockerCommand: podmanBinName} + + container, err := dockerHelper.FindDevContainer(ctx, []string{ + fmt.Sprintf("%s=%s", pkgconfig.DevcontainerIDLabel, workspace.UID), + }) + framework.ExpectNoError(err) + gomega.Expect(container).NotTo(gomega.BeNil()) + + imageRef := container.Config.LegacyImage + if imageRef == "" { + var rawInspect []struct { + Image string `json:"Image"` + } + inspectErr := dockerHelper.Inspect( + ctx, + []string{container.ID}, + "container", + &rawInspect, + ) + framework.ExpectNoError(inspectErr) + if len(rawInspect) > 0 { + imageRef = rawInspect[0].Image + } + } + gomega.Expect(imageRef).NotTo(gomega.BeEmpty()) + + imageDetails, err := dockerHelper.InspectImage(ctx, imageRef, false) + framework.ExpectNoError(err) + gomega.Expect(imageDetails.Config.Labels).NotTo(gomega.BeNil()) + + metadataValue, ok := imageDetails.Config.Labels[pkgconfig.DevcontainerMetadataLabel] + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(metadataValue).NotTo(gomega.BeEmpty()) + + var metadataList []*config.ImageMetadata + err = json.Unmarshal([]byte(metadataValue), &metadataList) + framework.ExpectNoError(err) + gomega.Expect(metadataList).NotTo(gomega.BeEmpty()) + + var foundSource string + for _, item := range metadataList { + for _, m := range item.Mounts { + if strings.Contains(m.Source, ".devsy-e2e-local-env-metadata") { + foundSource = m.Source + break + } + } + } + gomega.Expect(foundSource). + To(gomega.Equal("${localEnv:HOME}/.devsy-e2e-local-env-metadata")) + + gomega.Expect(metadataValue).To( + gomega.ContainSubstring( + "${localEnv:HOME}/.devsy-e2e-local-env-metadata", + ), + ) + gomega.Expect(metadataValue).NotTo( + gomega.ContainSubstring( + `\${localEnv:HOME}/.devsy-e2e-local-env-metadata`, + ), + ) + gomega.Expect(metadataValue).NotTo( + gomega.ContainSubstring(sourceDir), + ) }, ginkgo.SpecTimeout(framework.TimeoutModerate()), ) diff --git a/e2e/tests/up/testdata/podman-local-env-metadata/.devcontainer.json b/e2e/tests/up/testdata/podman-local-env-metadata/.devcontainer.json index b8b5db788..75599971e 100644 --- a/e2e/tests/up/testdata/podman-local-env-metadata/.devcontainer.json +++ b/e2e/tests/up/testdata/podman-local-env-metadata/.devcontainer.json @@ -1,5 +1,7 @@ { - "image": "ghcr.io/devsy-org/test-images/base:ubuntu", + "build": { + "dockerfile": "Dockerfile" + }, "mounts": [ { "type": "bind", diff --git a/e2e/tests/up/testdata/podman-local-env-metadata/Dockerfile b/e2e/tests/up/testdata/podman-local-env-metadata/Dockerfile new file mode 100644 index 000000000..4391f8f14 --- /dev/null +++ b/e2e/tests/up/testdata/podman-local-env-metadata/Dockerfile @@ -0,0 +1 @@ +FROM ghcr.io/devsy-org/test-images/base:ubuntu From a25994273b38cf97aa6175ec985c9b31358fc810 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 19 Sep 2026 16:47:39 -0600 Subject: [PATCH 6/7] test(e2e): avoid deleting existing metadata fixtures Signed-off-by: Samuel K --- e2e/tests/up/provider_podman_rootless_config.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/e2e/tests/up/provider_podman_rootless_config.go b/e2e/tests/up/provider_podman_rootless_config.go index 08fb6bf5c..8d5cc2eca 100644 --- a/e2e/tests/up/provider_podman_rootless_config.go +++ b/e2e/tests/up/provider_podman_rootless_config.go @@ -242,15 +242,12 @@ var _ = ginkgo.Describe( homeDir, ".devsy-e2e-local-env-metadata", ) - if _, statErr := os.Stat(sourceDir); statErr == nil { - probePath := filepath.Join(sourceDir, "probe.txt") - _, probeErr := os.Stat(probePath) - gomega.Expect(probeErr).NotTo( - gomega.HaveOccurred(), - "fixture directory %s already exists without probe.txt; aborting to prevent data loss", - sourceDir, - ) - } + _, statErr := os.Stat(sourceDir) + gomega.Expect(os.IsNotExist(statErr)).To( + gomega.BeTrue(), + "fixture directory %s already exists; aborting to prevent data loss", + sourceDir, + ) // #nosec G301 -- fixture must be traversable by container user err = os.MkdirAll( From 4a06cc4b24376c8d555fbdd59292939ea3584ca8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 19 Sep 2026 16:47:59 -0600 Subject: [PATCH 7/7] test(e2e): protect rootful metadata fixture cleanup Signed-off-by: Samuel K --- e2e/tests/up/provider_podman_rootful_config.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/e2e/tests/up/provider_podman_rootful_config.go b/e2e/tests/up/provider_podman_rootful_config.go index 71cac0659..e82c47fdf 100644 --- a/e2e/tests/up/provider_podman_rootful_config.go +++ b/e2e/tests/up/provider_podman_rootful_config.go @@ -278,15 +278,12 @@ var _ = ginkgo.Describe( ".devsy-e2e-local-env-metadata", ) - if _, statErr := os.Stat(sourceDir); statErr == nil { - probePath := filepath.Join(sourceDir, "probe.txt") - _, probeErr := os.Stat(probePath) - gomega.Expect(probeErr).NotTo( - gomega.HaveOccurred(), - "fixture directory %s already exists without probe.txt; aborting to prevent data loss", - sourceDir, - ) - } + _, statErr := os.Stat(sourceDir) + gomega.Expect(os.IsNotExist(statErr)).To( + gomega.BeTrue(), + "fixture directory %s already exists; aborting to prevent data loss", + sourceDir, + ) // #nosec G301 -- fixture must be traversable by container user err = os.MkdirAll( sourceDir,