diff --git a/e2e/tests/up/provider_kubernetes_dockerless_path.go b/e2e/tests/up/provider_kubernetes_dockerless_path.go new file mode 100644 index 000000000..18ec366df --- /dev/null +++ b/e2e/tests/up/provider_kubernetes_dockerless_path.go @@ -0,0 +1,294 @@ +package up + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/devsy-org/devsy/pkg/docker" + "github.com/devsy-org/devsy/pkg/driver/kubernetes" + "github.com/devsy-org/devsy/pkg/flags/names" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" +) + +const ( + dockerlessPathRoot = "/opt/devsy-dockerless-path" + dockerlessImagePath = dockerlessPathRoot + "/bin:/usr/local/sbin:/usr/local/bin:" + + "/usr/sbin:/usr/bin:/sbin:/bin" + dockerlessImageDockerConfig = dockerlessPathRoot + "/docker-config" + dockerlessPathCommand = "devsy-dockerless-path-check" +) + +type dockerlessPathFixture struct { + framework *framework.Framework + id string + dir string + expectedPath string + credentialsDirs []string + containerName string +} + +var _ = ginkgo.Describe( + "Kubernetes Dockerless image environment", + ginkgo.Label("up-provider-kubernetes", "up-provider-kubernetes-dockerless-path"), + func() { + ginkgo.DescribeTable( + "preserves image PATH after credentials cleanup", + runDockerlessPathFixture, + ginkgo.Entry("default remote PATH", false, + ginkgo.SpecTimeout(framework.TimeoutLong())), + ginkgo.Entry("remoteEnv appends to image PATH", true, + ginkgo.SpecTimeout(framework.TimeoutLong())), + ) + }, +) + +func runDockerlessPathFixture(ctx ginkgo.SpecContext, appendRemotePath bool) { + fixture := newDockerlessPathFixture(ctx, appendRemotePath) + fixture.up(ctx, "create", true, fixture.dir, "--provider", fixture.id, "--id", fixture.id) + firstPod := fixture.verify(ctx, "create") + // Repeated setup currently substitutes containerEnv from the persisted + // remote environment, so appending PATH is checked on a fresh build. + if appendRemotePath { + return + } + fixture.up(ctx, "repeat", false, fixture.id) + repeatedPod := fixture.verify(ctx, "repeat") + gomega.Expect(repeatedPod.UID). + To(gomega.Equal(firstPod.UID), "repeat up must keep the pod") + fixture.up(ctx, "recreate", true, fixture.id, "--recreate") + recreatedPod := fixture.verify(ctx, "recreate") + gomega.Expect(recreatedPod.UID). + NotTo(gomega.Equal(firstPod.UID), "recreate must replace the pod") + framework.ExpectNoError(fixture.framework.DevsyWorkspaceStop(ctx, fixture.id)) + waitForPodCount(ctx, fixture.id, 0, "stop must remove the workspace pod") + fixture.up(ctx, "restart", false, fixture.id) + restartedPod := fixture.verify(ctx, "restart") + gomega.Expect(restartedPod.UID). + NotTo(gomega.Equal(recreatedPod.UID), "restart must create a new pod") +} + +func newDockerlessPathFixture(ctx context.Context, appendRemotePath bool) *dockerlessPathFixture { + initialDir, err := os.Getwd() + framework.ExpectNoError(err) + fixtureDir, err := framework.CopyToTempDir("tests/up/testdata/kubernetes-dockerless-path") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, fixtureDir) + fixture := &dockerlessPathFixture{ + framework: framework.NewDefaultFramework(filepath.Join(initialDir, "bin")), + id: fmt.Sprintf("dlpath-%d", time.Now().UnixNano()), + dir: fixtureDir, + expectedPath: dockerlessImagePath, + } + if appendRemotePath { + fixture.expectedPath += ":" + dockerlessPathRoot + "/remote-bin" + addDockerlessRemotePath(fixtureDir) + } + dockerlessPathKubectl(ctx, "create", "namespace", fixture.id) + ginkgo.DeferCleanup(fixture.deleteNamespace) + // Keep the built-in provider's Dockerless credentials enabled. + // Debug output records the helper created by the real callback. + framework.ExpectNoError(fixture.framework.DevsyProviderAdd(ctx, "kubernetes", + "--name", fixture.id, "-o", "KUBERNETES_NAMESPACE="+fixture.id, + "-o", "LABELS=issue-1446="+fixture.id)) + ginkgo.DeferCleanup(fixture.deleteProvider) + ginkgo.DeferCleanup(func(specCtx ginkgo.SpecContext) { + framework.ExpectNoError(fixture.framework.CleanupWorkspace(specCtx, fixture.id)) + }) + return fixture +} + +func (fixture *dockerlessPathFixture) deleteNamespace(specCtx ginkgo.SpecContext) { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(specCtx), time.Minute) + defer cancel() + dockerlessPathKubectl(cleanupCtx, "delete", "namespace", fixture.id, "--wait=false") +} + +func (fixture *dockerlessPathFixture) deleteProvider(specCtx ginkgo.SpecContext) { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(specCtx), time.Minute) + defer cancel() + framework.ExpectNoError(fixture.framework.DevsyProviderDelete(cleanupCtx, fixture.id)) +} + +func (fixture *dockerlessPathFixture) up( + ctx context.Context, + phase string, + mustBuild bool, + args ...string, +) { + commandCtx, cancel := context.WithTimeout(ctx, framework.TimeoutModerate()) + defer cancel() + // Single CLI attempt: a second up must not repair first-up failure. + baseArgs := []string{ + cmdWorkspace, + "up", + names.Flag(names.Debug), + names.Flag(names.IDE), + kubernetesIgnoreIDE, + } + stdout, stderr, upErr := fixture.framework.ExecCommandCapture( + commandCtx, + append(baseArgs, args...), + ) + gomega.Expect(upErr).NotTo(gomega.HaveOccurred(), + "%s first CLI attempt: stdout=%s stderr=%s", phase, stdout, stderr) + output := stdout + stderr + gomega.Expect(output).NotTo(gomega.ContainSubstring("failed to configure docker credentials")) + gomega.Expect(output).NotTo(gomega.ContainSubstring("docker credentials disabled")) + helperPattern := regexp.MustCompile( + `Wrote docker credentials helper to ` + + `(/\.dockerless/\.docker/docker-credentials-[a-zA-Z0-9]{12})/`, + ) + helpers := helperPattern.FindAllStringSubmatch(output, -1) + for _, helper := range helpers { + fixture.credentialsDirs = append(fixture.credentialsDirs, helper[1]) + } + if mustBuild { + gomega.Expect(helpers). + NotTo(gomega.BeEmpty(), "%s must configure Dockerless credentials", phase) + gomega.Expect(output).To(gomega.ContainSubstring("starting dockerless build"), phase) + gomega.Expect(output).To(gomega.ContainSubstring("dockerless build completed"), phase) + } +} + +func (fixture *dockerlessPathFixture) verify(ctx context.Context, phase string) corev1.Pod { + selector := "devsy.sh/created=true,issue-1446=" + fixture.id + pod := dockerlessPathWorkspacePod(ctx, fixture.id, selector) + fixture.containerName = "" + for _, container := range pod.Spec.Containers { + if container.Name == kubernetes.DevContainerName { + fixture.containerName = container.Name + } + } + gomega.Expect(fixture.containerName). + NotTo(gomega.BeEmpty(), "workspace container in pod %s", pod.Name) + fixture.verifyEnvironment(ctx, pod.Name, phase) + return pod +} + +func addDockerlessRemotePath(fixtureDir string) { + configPath := filepath.Join(fixtureDir, ".devcontainer", "devcontainer.json") + // #nosec G304 -- fixed filename in a test-owned temporary fixture directory. + raw, err := os.ReadFile(configPath) + framework.ExpectNoError(err) + var cfg map[string]any + framework.ExpectNoError(json.Unmarshal(raw, &cfg)) + remoteEnv, ok := cfg["remoteEnv"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue()) + remoteEnv["PATH"] = "${containerEnv:PATH}:" + dockerlessPathRoot + "/remote-bin" + raw, err = json.MarshalIndent(cfg, "", " ") + framework.ExpectNoError(err) + framework.ExpectNoError(os.WriteFile(configPath, raw, 0o600)) +} + +func (fixture *dockerlessPathFixture) verifyEnvironment( + ctx context.Context, + podName, phase string, +) { + commandCtx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + expectedOutput := fmt.Sprintf( + "command=%s/bin/%s\nPATH=%s\nDOCKER_CONFIG=%s", + dockerlessPathRoot, + dockerlessPathCommand, + fixture.expectedPath, + dockerlessImageDockerConfig, + ) + // No login/profile probe, no command retry, and no absolute executable path: + // SSH must find the executable solely through the image/remote environment. + out, err := fixture.framework.DevsySSHOnce(commandCtx, fixture.id, dockerlessPathCommand) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "%s bare-name SSH command", phase) + gomega.Expect(strings.TrimSpace(out)). + To(gomega.Equal(expectedOutput), "%s SSH environment", phase) + gomega.Expect(out).NotTo(gomega.ContainSubstring("/.dockerless")) + + // Raw kubectl exec reads files without treating the pod's original builder + // environment as the post-setup agent/SSH environment. + execInPod := func(args ...string) string { + baseArgs := []string{"exec", "-n", fixture.id, podName, "-c", fixture.containerName, "--"} + return dockerlessPathKubectl(commandCtx, append(baseArgs, args...)...) + } + readFile := func(name string) string { + return execInPod("/bin/cat", name) + } + gomega.Expect(strings.TrimSpace(readFile(dockerlessPathRoot+"/post-start.env"))). + To(gomega.Equal(expectedOutput), "%s postStartCommand environment", phase) + var image v1.ConfigFile + framework.ExpectNoError(json.Unmarshal([]byte(readFile("/.dockerless/image.json")), &image)) + gomega.Expect(image.Config.Env).To(gomega.ContainElement("PATH=" + dockerlessImagePath)) + gomega.Expect(image.Config.Env). + To(gomega.ContainElement("DOCKER_CONFIG=" + dockerlessImageDockerConfig)) + var persisted struct { + Env map[string]string `json:"env"` + } + framework.ExpectNoError(json.Unmarshal([]byte(readFile("/etc/envfile.json")), &persisted)) + gomega.Expect(persisted.Env["PATH"]). + To(gomega.Equal(fixture.expectedPath), "%s persisted remote PATH", phase) + gomega.Expect(persisted.Env["DOCKER_CONFIG"]). + To(gomega.Equal(dockerlessImageDockerConfig), phase) + for _, credentialsDir := range fixture.credentialsDirs { + execInPod("/bin/test", "!", "-e", credentialsDir) + } + execInPod("/bin/sh", "-c", + `for dir in /.dockerless/.docker/docker-credentials-*; do test ! -e "$dir" || exit 1; done`) +} + +func dockerlessPathWorkspacePod(ctx context.Context, namespace, selector string) corev1.Pod { + var pod corev1.Pod + gomega.Eventually(func(g gomega.Gomega) { + pollCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + out, err := runDockerlessPathKubectl( + pollCtx, + "get", + "pods", + "-n", + namespace, + "-l", + selector, + "-o", + "json", + ) + g.Expect(err).NotTo(gomega.HaveOccurred(), "workspace pod discovery: %s", out) + var pods corev1.PodList + g.Expect(json.Unmarshal([]byte(out), &pods)).To(gomega.Succeed()) + g.Expect(pods.Items).To(gomega.HaveLen(1)) + pod = pods.Items[0] + g.Expect(pod.UID).NotTo(gomega.BeEmpty()) + ready := false + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodReady && condition.Status == corev1.ConditionTrue { + ready = true + } + } + g.Expect(ready).To(gomega.BeTrue(), "workspace pod %s must be ready", pod.Name) + }).WithTimeout(30 * time.Second).WithPolling(time.Second).Should(gomega.Succeed()) + return pod +} + +func runDockerlessPathKubectl(ctx context.Context, args ...string) (string, error) { + commandCtx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + // #nosec G204 -- kubectl arguments are controlled by this test fixture. + cmd := exec.CommandContext(commandCtx, "kubectl", args...) + docker.PrepareForGroupCancellation(cmd) + cmd.WaitDelay = 5 * time.Second + out, err := cmd.CombinedOutput() + return string(out), err +} + +func dockerlessPathKubectl(ctx context.Context, args ...string) string { + out, err := runDockerlessPathKubectl(ctx, args...) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "kubectl %v: %s", args, out) + return out +} diff --git a/e2e/tests/up/testdata/kubernetes-dockerless-path/.devcontainer/Dockerfile b/e2e/tests/up/testdata/kubernetes-dockerless-path/.devcontainer/Dockerfile new file mode 100644 index 000000000..cc55a6742 --- /dev/null +++ b/e2e/tests/up/testdata/kubernetes-dockerless-path/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +FROM ghcr.io/devsy-org/test-images/base:ubuntu + +RUN mkdir -p /opt/devsy-dockerless-path/bin /opt/devsy-dockerless-path/docker-config + +COPY devsy-dockerless-path-check /opt/devsy-dockerless-path/bin/devsy-dockerless-path-check +RUN chmod 0755 /opt/devsy-dockerless-path/bin/devsy-dockerless-path-check + +ENV PATH=/opt/devsy-dockerless-path/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ENV DOCKER_CONFIG=/opt/devsy-dockerless-path/docker-config diff --git a/e2e/tests/up/testdata/kubernetes-dockerless-path/.devcontainer/devcontainer.json b/e2e/tests/up/testdata/kubernetes-dockerless-path/.devcontainer/devcontainer.json new file mode 100644 index 000000000..6364ac681 --- /dev/null +++ b/e2e/tests/up/testdata/kubernetes-dockerless-path/.devcontainer/devcontainer.json @@ -0,0 +1,14 @@ +{ + "name": "Kubernetes Dockerless PATH regression", + "build": { + "dockerfile": "Dockerfile", + "context": "." + }, + "userEnvProbe": "none", + "remoteEnv": { + "DOCKER_CONFIG": "${containerEnv:DOCKER_CONFIG}" + }, + "waitFor": "postStartCommand", + "postStartCommand": "devsy-dockerless-path-check > /opt/devsy-dockerless-path/post-start.env", + "remoteUser": "root" +} diff --git a/e2e/tests/up/testdata/kubernetes-dockerless-path/.devcontainer/devsy-dockerless-path-check b/e2e/tests/up/testdata/kubernetes-dockerless-path/.devcontainer/devsy-dockerless-path-check new file mode 100644 index 000000000..5fd87d363 --- /dev/null +++ b/e2e/tests/up/testdata/kubernetes-dockerless-path/.devcontainer/devsy-dockerless-path-check @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +printf 'command=%s\nPATH=%s\nDOCKER_CONFIG=%s\n' \ + "$(command -v devsy-dockerless-path-check)" "$PATH" "${DOCKER_CONFIG-}" diff --git a/pkg/agent/dockerless.go b/pkg/agent/dockerless.go index c4a8f7513..24711d991 100644 --- a/pkg/agent/dockerless.go +++ b/pkg/agent/dockerless.go @@ -77,17 +77,38 @@ func executeBuild(opts DockerlessBuildOptions) error { } cleanup := setupDockerCredentials(opts) - if cleanup != nil { - defer cleanup() - } + return buildAndApplyContainerEnv(cleanup, func() error { + args := buildDockerlessArgs(binaryPath, opts) + return runDockerlessBuild(opts.Context, args, opts.Debug) + }, func() error { + return applyContainerEnv(opts.ImageConfigOutput) + }) +} - args := buildDockerlessArgs(binaryPath, opts) +func buildAndApplyContainerEnv( + cleanup func(), + build func() error, + applyImageEnv func() error, +) error { + // Keep a fallback for a panic while building, without restoring the builder's + // environment a second time after the image environment has been applied. + defer func() { + if cleanup != nil { + cleanup() + } + }() - if err := runDockerlessBuild(opts.Context, args, opts.Debug); err != nil { - return err + buildErr := build() + if cleanup != nil { + finishCleanup := cleanup + cleanup = nil + finishCleanup() + } + if buildErr != nil { + return buildErr } - return applyContainerEnv(opts.ImageConfigOutput) + return applyImageEnv() } func validateBuildOptions(opts DockerlessBuildOptions) error { diff --git a/pkg/agent/dockerless_test.go b/pkg/agent/dockerless_test.go new file mode 100644 index 000000000..f9f557997 --- /dev/null +++ b/pkg/agent/dockerless_test.go @@ -0,0 +1,393 @@ +package agent + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/stretchr/testify/require" +) + +const ( + dockerlessBuilderConfig = "/builder/docker" + dockerlessParseFailure = "parse" +) + +type dockerlessEnvironmentCase struct { + name string + builderConfig string + builderConfigSet bool + imageConfig string + imageConfigSet bool +} + +func TestBuildAndApplyContainerEnv_ImageEnvironmentWins(t *testing.T) { + for _, tc := range []dockerlessEnvironmentCase{ + { + name: "replace builder config", + builderConfig: dockerlessBuilderConfig, + builderConfigSet: true, + imageConfig: "/image/docker", + imageConfigSet: true, + }, + {name: "set image config", imageConfig: "/image/docker", imageConfigSet: true}, + {name: "keep builder config when image omits it", builderConfig: dockerlessBuilderConfig, builderConfigSet: true}, + {name: "keep config absent when image omits it"}, + { + name: "empty image config wins", + builderConfig: dockerlessBuilderConfig, + builderConfigSet: true, + imageConfigSet: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + assertDockerlessImageEnvironment(t, tc) + }) + } +} + +type dockerlessEnvironmentFixture struct { + builderPath, imagePath, toolName, toolPath, credentialsDir string + credentialsDone <-chan struct{} + cleanup func() +} + +func setupDockerlessEnvironmentTest( + t *testing.T, + tc dockerlessEnvironmentCase, +) dockerlessEnvironmentFixture { + t.Helper() + builderPath := filepath.Join(t.TempDir(), "builder-bin") + imageBin := t.TempDir() + imagePath := strings.Join( + []string{imageBin, "/usr/bin", "/bin"}, + string(os.PathListSeparator), + ) + t.Setenv("PATH", builderPath) + setDockerlessTestConfig(t, tc.builderConfig, tc.builderConfigSet) + + toolName, toolPath := createDockerlessImageExecutable(t, imageBin) + _, lookupErr := exec.LookPath(toolName) + require.Error(t, lookupErr, "image-only executable unexpectedly found in builder PATH") + + credentialsDir := filepath.Join(t.TempDir(), "credentials") + var credentialsDone <-chan struct{} + cleanup := setupDockerCredentials(DockerlessBuildOptions{ + Context: context.Background(), + DockerlessOptions: &provider2.ProviderDockerlessOptions{}, + ConfigureCredentialsFunc: func(ctx context.Context) (string, error) { + credentialsDone = ctx.Done() + require.NoError(t, os.Mkdir(credentialsDir, 0o700)) + require.NoError( + t, + os.Setenv("PATH", builderPath+string(os.PathListSeparator)+credentialsDir), + ) + require.NoError(t, os.Setenv("DOCKER_CONFIG", credentialsDir)) + return credentialsDir, nil + }, + }) + require.NotNil(t, cleanup, "expected credentials cleanup") + return dockerlessEnvironmentFixture{ + builderPath: builderPath, + imagePath: imagePath, + toolName: toolName, + toolPath: toolPath, + credentialsDir: credentialsDir, + credentialsDone: credentialsDone, + cleanup: cleanup, + } +} + +func createDockerlessImageExecutable(t *testing.T, imageBin string) (string, string) { + t.Helper() + // This executable exists only in the image's PATH, never the builder's. + toolName := "devsy-image-only-tool" + if runtime.GOOS == "windows" { + toolName += ".exe" + } + toolPath := filepath.Join(imageBin, toolName) + require.NoError(t, os.WriteFile( + toolPath, + []byte("#!/bin/sh\nprintf 'image-only-tool\\n'\n"), + 0o600, + )) + // #nosec G302 -- owner-only executable fixture in a test-owned temporary directory. + require.NoError(t, os.Chmod(toolPath, 0o700)) + return toolName, toolPath +} + +func assertDockerlessImageEnvironment(t *testing.T, tc dockerlessEnvironmentCase) { + t.Helper() + fixture := setupDockerlessEnvironmentTest(t, tc) + cleanupCalls := 0 + var sequence []string + err := buildAndApplyContainerEnv(func() { + cleanupCalls++ + sequence = append(sequence, "cleanup") + fixture.cleanup() + }, func() error { + sequence = append(sequence, "build") + require.Equal( + t, + fixture.builderPath+string(os.PathListSeparator)+fixture.credentialsDir, + os.Getenv("PATH"), + "build PATH", + ) + require.Equal(t, fixture.credentialsDir, os.Getenv("DOCKER_CONFIG"), "build DOCKER_CONFIG") + assertDockerlessCredentialsCanceled(t, fixture.credentialsDone, false) + return nil + }, func() error { + sequence = append(sequence, "apply") + require.Equal(t, fixture.builderPath, os.Getenv("PATH"), "PATH before image environment") + assertDockerlessTestConfig(t, tc.builderConfig, tc.builderConfigSet) + assertDockerlessCredentialsCanceled(t, fixture.credentialsDone, true) + _, statErr := os.Stat(fixture.credentialsDir) + require.ErrorIs(t, statErr, os.ErrNotExist) + if err := os.Setenv("PATH", fixture.imagePath); err != nil { + return err + } + if tc.imageConfigSet { + return os.Setenv("DOCKER_CONFIG", tc.imageConfig) + } + return nil + }) + require.NoError(t, err) + require.Equal(t, "build,cleanup,apply", strings.Join(sequence, ",")) + require.Equal(t, 1, cleanupCalls) + require.Equal(t, fixture.imagePath, os.Getenv("PATH"), "runtime PATH") + require.NotContains(t, os.Getenv("PATH"), fixture.credentialsDir) + if tc.imageConfigSet { + assertDockerlessTestConfig(t, tc.imageConfig, true) + } else { + assertDockerlessTestConfig(t, tc.builderConfig, tc.builderConfigSet) + } + got, lookupErr := exec.LookPath(fixture.toolName) + require.NoError(t, lookupErr) + require.Equal(t, fixture.toolPath, got) +} + +func TestBuildAndApplyContainerEnv_RuntimePathSurvivesCleanup(t *testing.T) { + t.Setenv("PATH", "/builder/bin") + setDockerlessTestConfig(t, dockerlessBuilderConfig, true) + credentialsDir := t.TempDir() + cleanup := setupDockerCredentials(DockerlessBuildOptions{ + Context: context.Background(), + DockerlessOptions: &provider2.ProviderDockerlessOptions{}, + ConfigureCredentialsFunc: func(context.Context) (string, error) { + if err := os.Setenv("PATH", "/temporary/helper"); err != nil { + return "", err + } + return credentialsDir, os.Setenv("DOCKER_CONFIG", credentialsDir) + }, + }) + if cleanup == nil { + t.Fatal("expected credentials cleanup") + } + err := buildAndApplyContainerEnv(cleanup, func() error { return nil }, func() error { + if err := os.Setenv("PATH", "/image/bin:/usr/bin:/bin"); err != nil { + return err + } + return os.Setenv("DOCKER_CONFIG", "/image/docker") + }) + if err != nil { + t.Fatal(err) + } + if got := os.Getenv("PATH"); got != "/image/bin:/usr/bin:/bin" { + t.Fatalf("runtime PATH = %q, want image PATH", got) + } + assertDockerlessTestConfig(t, "/image/docker", true) +} + +func TestBuildAndApplyContainerEnv_BuildError(t *testing.T) { + for _, buildErr := range []error{errors.New("build failed"), context.Canceled} { + t.Run(buildErr.Error(), func(t *testing.T) { + t.Setenv("PATH", "/builder/bin") + setDockerlessTestConfig(t, dockerlessBuilderConfig, true) + credentialsDir := t.TempDir() + var credentialsDone <-chan struct{} + cleanup := setupDockerCredentials(DockerlessBuildOptions{ + Context: context.Background(), + DockerlessOptions: &provider2.ProviderDockerlessOptions{}, + ConfigureCredentialsFunc: func(ctx context.Context) (string, error) { + credentialsDone = ctx.Done() + _ = os.Setenv("PATH", "/temporary/helper") + _ = os.Setenv("DOCKER_CONFIG", credentialsDir) + return credentialsDir, nil + }, + }) + cleanupCalls, applyCalls := 0, 0 + err := buildAndApplyContainerEnv(func() { + cleanupCalls++ + cleanup() + }, func() error { + return buildErr + }, func() error { + applyCalls++ + return nil + }) + if err != buildErr || cleanupCalls != 1 || applyCalls != 0 { + t.Fatalf( + "error = %v, cleanup calls = %d, apply calls = %d", + err, + cleanupCalls, + applyCalls, + ) + } + if got := os.Getenv("PATH"); got != "/builder/bin" { + t.Fatalf("PATH after failed build = %q", got) + } + assertDockerlessTestConfig(t, dockerlessBuilderConfig, true) + assertDockerlessCredentialsCanceled(t, credentialsDone, true) + if _, err := os.Stat(credentialsDir); !os.IsNotExist(err) { + t.Fatalf("credentials directory still present after failed build: %v", err) + } + }) + } +} + +func TestBuildAndApplyContainerEnv_ImageError(t *testing.T) { + for _, failure := range []string{"read", dockerlessParseFailure} { + t.Run(failure, func(t *testing.T) { + imageConfig := filepath.Join(t.TempDir(), "image.json") + if failure == dockerlessParseFailure { + if err := os.WriteFile( + imageConfig, + []byte("invalid image config"), + 0o600, + ); err != nil { + t.Fatal(err) + } + } + cleanupCalls := 0 + err := buildAndApplyContainerEnv(func() { cleanupCalls++ }, func() error { + return nil + }, func() error { + require.Equal(t, 1, cleanupCalls, "image application started before cleanup") + return applyContainerEnv(imageConfig) + }) + require.Error(t, err) + require.Equal(t, 1, cleanupCalls) + if failure == "read" { + require.ErrorIs(t, err, os.ErrNotExist) + } + if failure == dockerlessParseFailure { + require.ErrorContains(t, err, "parse container config") + } + }) + } +} + +func TestBuildAndApplyContainerEnv_NoCleanup(t *testing.T) { + applyCalls := 0 + if err := buildAndApplyContainerEnv(nil, func() error { return nil }, func() error { + applyCalls++ + return nil + }); err != nil || applyCalls != 1 { + t.Fatalf("error = %v, apply calls = %d", err, applyCalls) + } +} + +func TestBuildAndApplyContainerEnv_PanicCleanup(t *testing.T) { + for _, stage := range []string{"build", "cleanup", "apply"} { + t.Run(stage, func(t *testing.T) { + cleanupCalls := 0 + defer func() { + if got := recover(); got != stage { + t.Fatalf("panic = %v, want %q", got, stage) + } + if cleanupCalls != 1 { + t.Fatalf("cleanup calls = %d, want 1", cleanupCalls) + } + }() + _ = buildAndApplyContainerEnv(func() { + cleanupCalls++ + if stage == "cleanup" { + panic(stage) + } + }, func() error { + if stage == "build" { + panic(stage) + } + return nil + }, func() error { + if stage == "apply" { + panic(stage) + } + return nil + }) + }) + } +} + +func TestSetupDockerCredentials_NoCleanup(t *testing.T) { + for _, mode := range []string{"disabled", "missing callback", "configuration failed"} { + t.Run(mode, func(t *testing.T) { + t.Setenv("PATH", "/builder/bin") + setDockerlessTestConfig(t, "", false) + opts := DockerlessBuildOptions{ + Context: context.Background(), + DockerlessOptions: &provider2.ProviderDockerlessOptions{}, + } + var credentialsDone <-chan struct{} + switch mode { + case "disabled": + opts.DockerlessOptions.DisableDockerCredentials = trueValue + opts.ConfigureCredentialsFunc = func(context.Context) (string, error) { + t.Fatal("disabled credentials callback called") + return "", nil + } + case "configuration failed": + opts.ConfigureCredentialsFunc = func(ctx context.Context) (string, error) { + credentialsDone = ctx.Done() + _ = os.Setenv("PATH", "/temporary/helper") + _ = os.Setenv("DOCKER_CONFIG", "/temporary/config") + return "", errors.New("configuration failed") + } + } + if cleanup := setupDockerCredentials(opts); cleanup != nil { + t.Fatal("unexpected cleanup") + } + if got := os.Getenv("PATH"); got != "/builder/bin" { + t.Fatalf("PATH = %q", got) + } + assertDockerlessTestConfig(t, "", false) + if credentialsDone != nil { + assertDockerlessCredentialsCanceled(t, credentialsDone, true) + } + }) + } +} + +func setDockerlessTestConfig(t *testing.T, value string, present bool) { + t.Helper() + t.Setenv("DOCKER_CONFIG", value) + if !present { + if err := os.Unsetenv("DOCKER_CONFIG"); err != nil { + t.Fatal(err) + } + } +} + +func assertDockerlessTestConfig(t *testing.T, value string, present bool) { + t.Helper() + if got, set := os.LookupEnv("DOCKER_CONFIG"); got != value || set != present { + t.Fatalf("DOCKER_CONFIG = %q, present %v; want %q, present %v", got, set, value, present) + } +} + +func assertDockerlessCredentialsCanceled(t *testing.T, done <-chan struct{}, want bool) { + t.Helper() + canceled := false + select { + case <-done: + canceled = true + default: + } + require.Equal(t, want, canceled, "credentials context cancellation") +}