Repository: coder/envbuilder
Pull request #337 (2024-09-06 16:13:12)

fix: allow setting MagicDir in Options
Relates to https://github.com/coder/terraform-provider-envbuilder/issues/43

This PR unexports `constants.MagicDir` and replaces it with a different implementation that supports dynamically changing `MagicDir` at runtime.
There is also some more "strategic" hard-coding of certain values to ensure they do not change when `MagicDir` changes.

See also: https://github.com/coder/envbuilder/pull/336 

A subsequent PR will update the Terraform provider.


johnstcn: > I feel the `constants` package is becoming pretty useless with the dynamic type. What do you think about moving more of the logic to options instead?

I moved some of the logic to options, and the rest to `internal/magicdir`.

Patch:
diff --git a/constants/constants.go b/constants/constants.go
deleted file mode 100644
index fefa139..0000000
--- a/constants/constants.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package constants
-
-import (
-	"errors"
-	"fmt"
-	"path/filepath"
-)
-
-const (
-	// WorkspacesDir is the path to the directory where
-	// all workspaces are stored by default.
-	WorkspacesDir = "/workspaces"
-
-	// EmptyWorkspaceDir is the path to a workspace that has
-	// nothing going on... it's empty!
-	EmptyWorkspaceDir = WorkspacesDir + "/empty"
-
-	// MagicDir is where all envbuilder related files are stored.
-	// This is a special directory that must not be modified
-	// by the user or images.
-	MagicDir = "/.envbuilder"
-)
-
-var (
-	ErrNoFallbackImage = errors.New("no fallback image has been specified")
-
-	// MagicFile is a file that is created in the workspace
-	// when envbuilder has already been run. This is used
-	// to skip building when a container is restarting.
-	// e.g. docker stop -> docker start
-	MagicFile = filepath.Join(MagicDir, "built")
-
-	// MagicFile is the location of the build context when
-	// using remote build mode.
-	MagicRemoteRepoDir = filepath.Join(MagicDir, "repo")
-
-	// MagicBinaryLocation is the expected location of the envbuilder binary
-	// inside a builder image.
-	MagicBinaryLocation = filepath.Join(MagicDir, "bin", "envbuilder")
-
-	// MagicImage is a file that is created in the image when
-	// envbuilder has already been run. This is used to skip
-	// the destructive initial build step when 'resuming' envbuilder
-	// from a previously built image.
-	MagicImage = filepath.Join(MagicDir, "image")
-
-	// MagicTempDir is a directory inside the build context inside which
-	// we place files referenced by MagicDirectives.
-	MagicTempDir = ".envbuilder.tmp"
-
-	// MagicDirectives are directives automatically appended to Dockerfiles
-	// when pushing the image. These directives allow the built image to be
-	// 're-used'.
-	MagicDirectives = fmt.Sprintf(`
-COPY --chmod=0755 %[1]s %[2]s
-COPY --chmod=0644 %[3]s %[4]s
-USER root
-WORKDIR /
-ENTRYPOINT [%[2]q]
-`,
-		".envbuilder.tmp/envbuilder", MagicBinaryLocation,
-		".envbuilder.tmp/image", MagicImage,
-	)
-)
diff --git a/envbuilder.go b/envbuilder.go
index 7f3c983..ac64bee 100644
--- a/envbuilder.go
+++ b/envbuilder.go
@@ -25,7 +25,6 @@ import (
 	"time"
 
 	"github.com/coder/envbuilder/buildinfo"
-	"github.com/coder/envbuilder/constants"
 	"github.com/coder/envbuilder/git"
 	"github.com/coder/envbuilder/options"
 	"github.com/go-git/go-billy/v5"
@@ -36,6 +35,7 @@ import (
 	"github.com/GoogleContainerTools/kaniko/pkg/util"
 	"github.com/coder/envbuilder/devcontainer"
 	"github.com/coder/envbuilder/internal/ebutil"
+	"github.com/coder/envbuilder/internal/magicdir"
 	"github.com/coder/envbuilder/log"
 	"github.com/containerd/platforms"
 	"github.com/distribution/distribution/v3/configuration"
@@ -52,6 +52,9 @@ import (
 	"golang.org/x/xerrors"
 )
 
+// ErrNoFallbackImage is returned when no fallback image has been specified.
+var ErrNoFallbackImage = errors.New("no fallback image has been specified")
+
 // DockerConfig represents the Docker configuration file.
 type DockerConfig configfile.ConfigFile
 
@@ -68,6 +71,9 @@ func Run(ctx context.Context, opts options.Options) error {
 	if opts.CacheRepo == "" && opts.PushImage {
 		return fmt.Errorf("--cache-repo must be set when using --push-image")
 	}
+
+	magicDir := magicdir.At(opts.MagicDirBase)
+
 	// Default to the shell!
 	initArgs := []string{"-c", opts.InitScript}
 	if opts.InitArgs != "" {
@@ -92,7 +98,7 @@ func Run(ctx context.Context, opts options.Options) error {
 
 	opts.Logger(log.LevelInfo, "%s %s - Build development environments from repositories in a container", newColor(color.Bold).Sprintf("envbuilder"), buildinfo.Version())
 
-	cleanupDockerConfigJSON, err := initDockerConfigJSON(opts.DockerConfigBase64)
+	cleanupDockerConfigJSON, err := initDockerConfigJSON(opts.Logger, magicDir, opts.DockerConfigBase64)
 	if err != nil {
 		return err
 	}
@@ -168,7 +174,7 @@ func Run(ctx context.Context, opts options.Options) error {
 	}
 
 	defaultBuildParams := func() (*devcontainer.Compiled, error) {
-		dockerfile := filepath.Join(constants.MagicDir, "Dockerfile")
+		dockerfile := magicDir.Join("Dockerfile")
 		file, err := opts.Filesystem.OpenFile(dockerfile, os.O_CREATE|os.O_WRONLY, 0o644)
 		if err != nil {
 			return nil, err
@@ -176,11 +182,11 @@ func Run(ctx context.Context, opts options.Options) error {
 		defer file.Close()
 		if opts.FallbackImage == "" {
 			if fallbackErr != nil {
-				return nil, xerrors.Errorf("%s: %w", fallbackErr.Error(), constants.ErrNoFallbackImage)
+				return nil, xerrors.Errorf("%s: %w", fallbackErr.Error(), ErrNoFallbackImage)
 			}
 			// We can't use errors.Join here because our tests
 			// don't support parsing a multiline error.
-			return nil, constants.ErrNoFallbackImage
+			return nil, ErrNoFallbackImage
 		}
 		content := "FROM " + opts.FallbackImage
 		_, err = file.Write([]byte(content))
@@ -190,7 +196,7 @@ func Run(ctx context.Context, opts options.Options) error {
 		return &devcontainer.Compiled{
 			DockerfilePath:    dockerfile,
 			DockerfileContent: content,
-			BuildContext:      constants.MagicDir,
+			BuildContext:      magicDir.Path(),
 		}, nil
 	}
 
@@ -232,7 +238,7 @@ func Run(ctx context.Context, opts options.Options) error {
 					opts.Logger(log.LevelInfo, "No Dockerfile or image specified; falling back to the default image...")
 					fallbackDockerfile = defaultParams.DockerfilePath
 				}
-				buildParams, err = devContainer.Compile(opts.Filesystem, devcontainerDir, constants.MagicDir, fallbackDockerfile, opts.WorkspaceFolder, false, os.LookupEnv)
+				buildParams, err = devContainer.Compile(opts.Filesystem, devcontainerDir, magicDir.Path(), fallbackDockerfile, opts.WorkspaceFolder, false, os.LookupEnv)
 				if err != nil {
 					return fmt.Errorf("compile devcontainer.json: %w", err)
 				}
@@ -304,7 +310,7 @@ func Run(ctx context.Context, opts options.Options) error {
 	// So we add them to the default ignore list. See:
 	// https://github.com/GoogleContainerTools/kaniko/blob/63be4990ca5a60bdf06ddc4d10aa4eca0c0bc714/cmd/executor/cmd/root.go#L136
 	ignorePaths := append([]string{
-		constants.MagicDir,
+		magicDir.Path(),
 		opts.WorkspaceFolder,
 		// See: https://github.com/coder/envbuilder/issues/37
 		"/etc/resolv.conf",
@@ -332,31 +338,25 @@ func Run(ctx context.Context, opts options.Options) error {
 		if err := util.AddAllowedPathToDefaultIgnoreList(opts.BinaryPath); err != nil {
 			return fmt.Errorf("add envbuilder binary to ignore list: %w", err)
 		}
-		if err := util.AddAllowedPathToDefaultIgnoreList(constants.MagicImage); err != nil {
+		if err := util.AddAllowedPathToDefaultIgnoreList(magicDir.Image()); err != nil {
 			return fmt.Errorf("add magic image file to ignore list: %w", err)
 		}
-		magicTempDir := filepath.Join(buildParams.BuildContext, constants.MagicTempDir)
-		if err := opts.Filesystem.MkdirAll(magicTempDir, 0o755); err != nil {
+		magicTempDir := magicdir.At(buildParams.BuildContext, magicdir.TempDir)
+		if err := opts.Filesystem.MkdirAll(magicTempDir.Path(), 0o755); err != nil {
 			return fmt.Errorf("create magic temp dir in build context: %w", err)
 		}
 		// Add the magic directives that embed the binary into the built image.
-		buildParams.DockerfileContent += constants.MagicDirectives
+		buildParams.DockerfileContent += magicdir.Directives
 		// Copy the envbuilder binary into the build context.
 		// External callers will need to specify the path to the desired envbuilder binary.
-		envbuilderBinDest := filepath.Join(
-			magicTempDir,
-			filepath.Base(constants.MagicBinaryLocation),
-		)
+		envbuilderBinDest := filepath.Join(magicTempDir.Path(), "envbuilder")
 		// Also touch the magic file that signifies the image has been built!
-		magicImageDest := filepath.Join(
-			magicTempDir,
-			filepath.Base(constants.MagicImage),
-		)
+		magicImageDest := magicTempDir.Image()
 		// Clean up after build!
 		var cleanupOnce sync.Once
 		cleanupBuildContext = func() {
 			cleanupOnce.Do(func() {
-				for _, path := range []string{magicImageDest, envbuilderBinDest, magicTempDir} {
+				for _, path := range []string{magicImageDest, envbuilderBinDest, magicTempDir.Path()} {
 					if err := opts.Filesystem.Remove(path); err != nil {
 						opts.Logger(log.LevelWarn, "failed to clean up magic temp dir from build context: %w", err)
 					}
@@ -370,15 +370,14 @@ func Run(ctx context.Context, opts options.Options) error {
 			return fmt.Errorf("copy envbuilder binary to build context: %w", err)
 		}
 
-		opts.Logger(log.LevelDebug, "touching magic image file at %q in build context %q", magicImageDest, buildParams.BuildContext)
+		opts.Logger(log.LevelDebug, "touching magic image file at %q in build context %q", magicImageDest, magicTempDir)
 		if err := touchFile(opts.Filesystem, magicImageDest, 0o755); err != nil {
 			return fmt.Errorf("touch magic image file in build context: %w", err)
 		}
-
 	}
 
 	// temp move of all ro mounts
-	tempRemountDest := filepath.Join("/", constants.MagicDir, "mnt")
+	tempRemountDest := magicDir.Join("mnt")
 	// ignorePrefixes is a superset of ignorePaths that we pass to kaniko's
 	// IgnoreList.
 	ignorePrefixes := append([]string{"/dev", "/proc", "/sys"}, ignorePaths...)
@@ -399,8 +398,8 @@ func Run(ctx context.Context, opts options.Options) error {
 	defer closeStderr()
 	build := func() (v1.Image, error) {
 		defer cleanupBuildContext()
-		_, alreadyBuiltErr := opts.Filesystem.Stat(constants.MagicFile)
-		_, isImageErr := opts.Filesystem.Stat(constants.MagicImage)
+		_, alreadyBuiltErr := opts.Filesystem.Stat(magicDir.Built())
+		_, isImageErr := opts.Filesystem.Stat(magicDir.Image())
 		if (alreadyBuiltErr == nil && opts.SkipRebuild) || isImageErr == nil {
 			endStage := startStage("🏗️ Skipping build because of cache...")
 			imageRef, err := devcontainer.ImageFromDockerfile(buildParams.DockerfileContent)
@@ -545,7 +544,7 @@ func Run(ctx context.Context, opts options.Options) error {
 
 	// Create the magic file to indicate that this build
 	// has already been ran before!
-	file, err := opts.Filesystem.Create(constants.MagicFile)
+	file, err := opts.Filesystem.Create(magicDir.Built())
 	if err != nil {
 		return fmt.Errorf("create magic file: %w", err)
 	}
@@ -752,7 +751,7 @@ func Run(ctx context.Context, opts options.Options) error {
 		opts.Logger(log.LevelInfo, "=== Running the setup command %q as the root user...", opts.SetupScript)
 
 		envKey := "ENVBUILDER_ENV"
-		envFile := filepath.Join("/", constants.MagicDir, "environ")
+		envFile := magicDir.Join("environ")
 		file, err := os.Create(envFile)
 		if err != nil {
 			return fmt.Errorf("create environ file: %w", err)
@@ -862,6 +861,8 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error)
 		return nil, fmt.Errorf("--cache-repo must be set when using --get-cached-image")
 	}
 
+	magicDir := magicdir.At(opts.MagicDirBase)
+
 	stageNumber := 0
 	startStage := func(format string, args ...any) func(format string, args ...any) {
 		now := time.Now()
@@ -876,7 +877,7 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error)
 
 	opts.Logger(log.LevelInfo, "%s %s - Build development environments from repositories in a container", newColor(color.Bold).Sprintf("envbuilder"), buildinfo.Version())
 
-	cleanupDockerConfigJSON, err := initDockerConfigJSON(opts.DockerConfigBase64)
+	cleanupDockerConfigJSON, err := initDockerConfigJSON(opts.Logger, magicDir, opts.DockerConfigBase64)
 	if err != nil {
 		return nil, err
 	}
@@ -960,11 +961,11 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error)
 		defer file.Close()
 		if opts.FallbackImage == "" {
 			if fallbackErr != nil {
-				return nil, fmt.Errorf("%s: %w", fallbackErr.Error(), constants.ErrNoFallbackImage)
+				return nil, fmt.Errorf("%s: %w", fallbackErr.Error(), ErrNoFallbackImage)
 			}
 			// We can't use errors.Join here because our tests
 			// don't support parsing a multiline error.
-			return nil, constants.ErrNoFallbackImage
+			return nil, ErrNoFallbackImage
 		}
 		content := "FROM " + opts.FallbackImage
 		_, err = file.Write([]byte(content))
@@ -1080,7 +1081,7 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error)
 	// So we add them to the default ignore list. See:
 	// https://github.com/GoogleContainerTools/kaniko/blob/63be4990ca5a60bdf06ddc4d10aa4eca0c0bc714/cmd/executor/cmd/root.go#L136
 	ignorePaths := append([]string{
-		constants.MagicDir,
+		magicDir.Path(),
 		opts.WorkspaceFolder,
 		// See: https://github.com/coder/envbuilder/issues/37
 		"/etc/resolv.conf",
@@ -1103,29 +1104,25 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error)
 	// build via executor.RunCacheProbe we need to have the *exact* copy of the
 	// envbuilder binary available used to build the image and we also need to
 	// add the magic directives to the Dockerfile content.
-	buildParams.DockerfileContent += constants.MagicDirectives
-	magicTempDir := filepath.Join(buildParams.BuildContext, constants.MagicTempDir)
+	// MAGICDIR
+	buildParams.DockerfileContent += magicdir.Directives
+	magicTempDir := filepath.Join(buildParams.BuildContext, magicdir.TempDir)
 	if err := opts.Filesystem.MkdirAll(magicTempDir, 0o755); err != nil {
 		return nil, fmt.Errorf("create magic temp dir in build context: %w", err)
 	}
-	envbuilderBinDest := filepath.Join(
-		magicTempDir,
-		filepath.Base(constants.MagicBinaryLocation),
-	)
+	envbuilderBinDest := filepath.Join(magicTempDir, "envbuilder")
 
 	// Copy the envbuilder binary into the build context.
-	opts.Logger(log.LevelDebug, "copying envbuilder binary at %q to build context %q", opts.BinaryPath, buildParams.BuildContext)
+	opts.Logger(log.LevelDebug, "copying envbuilder binary at %q to build context %q", opts.BinaryPath, envbuilderBinDest)
 	if err := copyFile(opts.Filesystem, opts.BinaryPath, envbuilderBinDest, 0o755); err != nil {
 		return nil, xerrors.Errorf("copy envbuilder binary to build context: %w", err)
 	}
 
-	// Also touch the magic file that signifies the image has been built!
-	magicImageDest := filepath.Join(
-		magicTempDir,
-		filepath.Base(constants.MagicImage),
-	)
+	// Also touch the magic file that signifies the image has been built!A
+	magicImageDest := filepath.Join(magicTempDir, "image")
+	opts.Logger(log.LevelDebug, "touching magic image file at %q in build context %q", magicImageDest, magicTempDir)
 	if err := touchFile(opts.Filesystem, magicImageDest, 0o755); err != nil {
-		return nil, fmt.Errorf("touch magic image file in build context: %w", err)
+		return nil, fmt.Errorf("touch magic image file at %q: %w", magicImageDest, err)
 	}
 	defer func() {
 		// Clean up after we're done!
@@ -1417,21 +1414,24 @@ func findDevcontainerJSON(workspaceFolder string, options options.Options) (stri
 // maybeDeleteFilesystem wraps util.DeleteFilesystem with a guard to hopefully stop
 // folks from unwittingly deleting their entire root directory.
 func maybeDeleteFilesystem(logger log.Func, force bool) error {
+	// We always expect the magic directory to be set to the default, signifying that
+	// the user is running envbuilder in a container.
+	// If this is set to anything else we should bail out to prevent accidental data loss.
+	// defaultMagicDir := magicdir.MagicDir("")
 	kanikoDir, ok := os.LookupEnv("KANIKO_DIR")
-	if !ok || strings.TrimSpace(kanikoDir) != constants.MagicDir {
-		if force {
-			bailoutSecs := 10
-			logger(log.LevelWarn, "WARNING! BYPASSING SAFETY CHECK! THIS WILL DELETE YOUR ROOT FILESYSTEM!")
-			logger(log.LevelWarn, "You have %d seconds to bail out!", bailoutSecs)
-			for i := bailoutSecs; i > 0; i-- {
-				logger(log.LevelWarn, "%d...", i)
-				<-time.After(time.Second)
-			}
-		} else {
-			logger(log.LevelError, "KANIKO_DIR is not set to %s. Bailing!\n", constants.MagicDir)
+	if !ok || strings.TrimSpace(kanikoDir) != magicdir.Default.Path() {
+		if !force {
+			logger(log.LevelError, "KANIKO_DIR is not set to %s. Bailing!\n", magicdir.Default.Path())
 			logger(log.LevelError, "To bypass this check, set FORCE_SAFE=true.")
 			return errors.New("safety check failed")
 		}
+		bailoutSecs := 10
+		logger(log.LevelWarn, "WARNING! BYPASSING SAFETY CHECK! THIS WILL DELETE YOUR ROOT FILESYSTEM!")
+		logger(log.LevelWarn, "You have %d seconds to bail out!", bailoutSecs)
+		for i := bailoutSecs; i > 0; i-- {
+			logger(log.LevelWarn, "%d...", i)
+			<-time.After(time.Second)
+		}
 	}
 
 	return util.DeleteFilesystem()
@@ -1469,13 +1469,13 @@ func touchFile(fs billy.Filesystem, dst string, mode fs.FileMode) error {
 	return f.Close()
 }
 
-func initDockerConfigJSON(dockerConfigBase64 string) (func() error, error) {
+func initDockerConfigJSON(logf log.Func, magicDir magicdir.MagicDir, dockerConfigBase64 string) (func() error, error) {
 	var cleanupOnce sync.Once
 	noop := func() error { return nil }
 	if dockerConfigBase64 == "" {
 		return noop, nil
 	}
-	cfgPath := filepath.Join(constants.MagicDir, "config.json")
+	cfgPath := filepath.Join(magicDir.Path(), "config.json")
 	decoded, err := base64.StdEncoding.DecodeString(dockerConfigBase64)
 	if err != nil {
 		return noop, fmt.Errorf("decode docker config: %w", err)
@@ -1489,10 +1489,14 @@ func initDockerConfigJSON(dockerConfigBase64 string) (func() error, error) {
 	if err != nil {
 		return noop, fmt.Errorf("parse docker config: %w", err)
 	}
+	for k := range configFile.AuthConfigs {
+		logf(log.LevelInfo, "Docker config contains auth for registry %q", k)
+	}
 	err = os.WriteFile(cfgPath, decoded, 0o644)
 	if err != nil {
 		return noop, fmt.Errorf("write docker config: %w", err)
 	}
+	logf(log.LevelInfo, "Wrote Docker config JSON to %s", cfgPath)
 	cleanup := func() error {
 		var cleanupErr error
 		cleanupOnce.Do(func() {
@@ -1501,7 +1505,7 @@ func initDockerConfigJSON(dockerConfigBase64 string) (func() error, error) {
 				if !errors.Is(err, fs.ErrNotExist) {
 					cleanupErr = fmt.Errorf("remove docker config: %w", cleanupErr)
 				}
-				_, _ = fmt.Fprintf(os.Stderr, "failed to remove the Docker config secret file: %s\n", cleanupErr)
+				logf(log.LevelError, "Failed to remove the Docker config secret file: %s", cleanupErr)
 			}
 		})
 		return cleanupErr
diff --git a/internal/magicdir/magicdir.go b/internal/magicdir/magicdir.go
new file mode 100644
index 0000000..31bcd7c
--- /dev/null
+++ b/internal/magicdir/magicdir.go
@@ -0,0 +1,78 @@
+package magicdir
+
+import (
+	"fmt"
+	"path/filepath"
+)
+
+const (
+	// defaultMagicDirBase is the default working location for envbuilder.
+	// This is a special directory that must not be modified by the user
+	// or images. This is intentionally unexported.
+	defaultMagicDirBase = "/.envbuilder"
+
+	// TempDir is a directory inside the build context inside which
+	// we place files referenced by MagicDirectives.
+	TempDir = ".envbuilder.tmp"
+)
+
+var (
+	// Default is the default working directory for Envbuilder.
+	// This defaults to /.envbuilder. It should only be used when Envbuilder
+	// is known to be running as root inside a container.
+	Default MagicDir
+	// Directives are directives automatically appended to Dockerfiles
+	// when pushing the image. These directives allow the built image to be
+	// 're-used'.
+	Directives = fmt.Sprintf(`
+COPY --chmod=0755 %[1]s/envbuilder %[2]s/bin/envbuilder
+COPY --chmod=0644 %[1]s/image %[2]s/image
+USER root
+WORKDIR /
+ENTRYPOINT ["%[2]s/bin/envbuilder"]
+`, TempDir, defaultMagicDirBase)
+)
+
+// MagicDir is a working directory for envbuilder. It
+// will also be present in images built by envbuilder.
+type MagicDir struct {
+	base string
+}
+
+// At returns a MagicDir rooted at filepath.Join(paths...)
+func At(paths ...string) MagicDir {
+	if len(paths) == 0 {
+		return MagicDir{}
+	}
+	return MagicDir{base: filepath.Join(paths...)}
+}
+
+// Join returns the result of filepath.Join([m.Path, paths...]).
+func (m MagicDir) Join(paths ...string) string {
+	return filepath.Join(append([]string{m.Path()}, paths...)...)
+}
+
+// String returns the string representation of the MagicDir.
+func (m MagicDir) Path() string {
+	// Instead of the zero value, use defaultMagicDir.
+	if m.base == "" {
+		return defaultMagicDirBase
+	}
+	return m.base
+}
+
+// Built is a file that is created in the workspace
+// when envbuilder has already been run. This is used
+// to skip building when a container is restarting.
+// e.g. docker stop -> docker start
+func (m MagicDir) Built() string {
+	return m.Join("built")
+}
+
+// Image is a file that is created in the image when
+// envbuilder has already been run. This is used to skip
+// the destructive initial build step when 'resuming' envbuilder
+// from a previously built image.
+func (m MagicDir) Image() string {
+	return m.Join("image")
+}
diff --git a/options/defaults.go b/options/defaults.go
index 42e4806..df3d436 100644
--- a/options/defaults.go
+++ b/options/defaults.go
@@ -7,24 +7,28 @@ import (
 	"github.com/go-git/go-billy/v5/osfs"
 
 	giturls "github.com/chainguard-dev/git-urls"
-	"github.com/coder/envbuilder/constants"
 	"github.com/coder/envbuilder/internal/chmodfs"
+	"github.com/coder/envbuilder/internal/magicdir"
 )
 
+// EmptyWorkspaceDir is the path to a workspace that has
+// nothing going on... it's empty!
+var EmptyWorkspaceDir = "/workspaces/empty"
+
 // DefaultWorkspaceFolder returns the default workspace folder
 // for a given repository URL.
 func DefaultWorkspaceFolder(repoURL string) string {
 	if repoURL == "" {
-		return constants.EmptyWorkspaceDir
+		return EmptyWorkspaceDir
 	}
 	parsed, err := giturls.Parse(repoURL)
 	if err != nil {
-		return constants.EmptyWorkspaceDir
+		return EmptyWorkspaceDir
 	}
 	name := strings.Split(parsed.Path, "/")
 	hasOwnerAndRepo := len(name) >= 2
 	if !hasOwnerAndRepo {
-		return constants.EmptyWorkspaceDir
+		return EmptyWorkspaceDir
 	}
 	repo := strings.TrimSuffix(name[len(name)-1], ".git")
 	return fmt.Sprintf("/workspaces/%s", repo)
@@ -55,7 +59,13 @@ func (o *Options) SetDefaults() {
 	if o.WorkspaceFolder == "" {
 		o.WorkspaceFolder = DefaultWorkspaceFolder(o.GitURL)
 	}
+	if o.RemoteRepoDir == "" {
+		o.RemoteRepoDir = magicdir.Default.Join("repo")
+	}
 	if o.BinaryPath == "" {
 		o.BinaryPath = "/.envbuilder/bin/envbuilder"
 	}
+	if o.MagicDirBase == "" {
+		o.MagicDirBase = magicdir.Default.Path()
+	}
 }
diff --git a/options/options.go b/options/options.go
index d7bd66b..a0058cd 100644
--- a/options/options.go
+++ b/options/options.go
@@ -7,7 +7,6 @@ import (
 	"os"
 	"strings"
 
-	"github.com/coder/envbuilder/constants"
 	"github.com/coder/envbuilder/log"
 	"github.com/coder/serpent"
 	"github.com/go-git/go-billy/v5"
@@ -166,6 +165,11 @@ type Options struct {
 	// attempting to probe the build cache. This is only relevant when
 	// GetCachedImage is true.
 	BinaryPath string
+
+	// MagicDirBase is the path to the directory where all envbuilder files should be
+	// stored. By default, this is set to `/.envbuilder`. This is intentionally
+	// excluded from the CLI options.
+	MagicDirBase string
 }
 
 const envPrefix = "ENVBUILDER_"
@@ -456,10 +460,10 @@ func (o *Options) CLI() serpent.OptionSet {
 				"working on the same repository.",
 		},
 		{
-			Flag:        "remote-repo-dir",
-			Env:         WithEnvPrefix("REMOTE_REPO_DIR"),
-			Value:       serpent.StringOf(&o.RemoteRepoDir),
-			Default:     constants.MagicRemoteRepoDir,
+			Flag:  "remote-repo-dir",
+			Env:   WithEnvPrefix("REMOTE_REPO_DIR"),
+			Value: serpent.StringOf(&o.RemoteRepoDir),
+			// Default:     magicdir.Default.Join("repo"), // TODO: reinstate once legacy opts are removed.
 			Hidden:      true,
 			Description: "Specify the destination directory for the cloned repo when using remote repo build mode.",
 		},


Test patch:
diff --git a/integration/integration_test.go b/integration/integration_test.go
index 43d728c..e0e012b 100644
--- a/integration/integration_test.go
+++ b/integration/integration_test.go
@@ -22,8 +22,8 @@ import (
 	"time"
 
 	"github.com/coder/envbuilder"
-	"github.com/coder/envbuilder/constants"
 	"github.com/coder/envbuilder/devcontainer/features"
+	"github.com/coder/envbuilder/internal/magicdir"
 	"github.com/coder/envbuilder/options"
 	"github.com/coder/envbuilder/testutil/gittest"
 	"github.com/coder/envbuilder/testutil/mwtest"
@@ -365,7 +365,8 @@ func TestBuildFromDockerfile(t *testing.T) {
 	require.Equal(t, "hello", strings.TrimSpace(output))
 
 	// Verify that the Docker configuration secret file is removed
-	output = execContainer(t, ctr, "stat "+filepath.Join(constants.MagicDir, "config.json"))
+	configJSONContainerPath := magicdir.Default.Join("config.json")
+	output = execContainer(t, ctr, "stat "+configJSONContainerPath)
 	require.Contains(t, output, "No such file or directory")
 }
 
@@ -591,7 +592,7 @@ func TestCloneFailsFallback(t *testing.T) {
 		_, err := runEnvbuilder(t, runOpts{env: []string{
 			envbuilderEnv("GIT_URL", "bad-value"),
 		}})
-		require.ErrorContains(t, err, constants.ErrNoFallbackImage.Error())
+		require.ErrorContains(t, err, envbuilder.ErrNoFallbackImage.Error())
 	})
 }
 
@@ -609,7 +610,7 @@ func TestBuildFailsFallback(t *testing.T) {
 			envbuilderEnv("GIT_URL", srv.URL),
 			envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"),
 		}})
-		require.ErrorContains(t, err, constants.ErrNoFallbackImage.Error())
+		require.ErrorContains(t, err, envbuilder.ErrNoFallbackImage.Error())
 		require.ErrorContains(t, err, "dockerfile parse error")
 	})
 	t.Run("FailsBuild", func(t *testing.T) {
@@ -625,7 +626,7 @@ RUN exit 1`,
 			envbuilderEnv("GIT_URL", srv.URL),
 			envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"),
 		}})
-		require.ErrorContains(t, err, constants.ErrNoFallbackImage.Error())
+		require.ErrorContains(t, err, envbuilder.ErrNoFallbackImage.Error())
 	})
 	t.Run("BadDevcontainer", func(t *testing.T) {
 		t.Parallel()
@@ -638,7 +639,7 @@ RUN exit 1`,
 		_, err := runEnvbuilder(t, runOpts{env: []string{
 			envbuilderEnv("GIT_URL", srv.URL),
 		}})
-		require.ErrorContains(t, err, constants.ErrNoFallbackImage.Error())
+		require.ErrorContains(t, err, envbuilder.ErrNoFallbackImage.Error())
 	})
 	t.Run("NoImageOrDockerfile", func(t *testing.T) {
 		t.Parallel()
@@ -971,7 +972,7 @@ func setupPassthroughRegistry(t *testing.T, image string, opts *setupPassthrough
 
 func TestNoMethodFails(t *testing.T) {
 	_, err := runEnvbuilder(t, runOpts{env: []string{}})
-	require.ErrorContains(t, err, constants.ErrNoFallbackImage.Error())
+	require.ErrorContains(t, err, envbuilder.ErrNoFallbackImage.Error())
 }
 
 func TestDockerfileBuildContext(t *testing.T) {
@@ -1157,6 +1158,7 @@ RUN date --utc > /root/date.txt`, testImageAlpine),
 			envbuilderEnv("GIT_URL", srv.URL),
 			envbuilderEnv("CACHE_REPO", testRepo),
 			envbuilderEnv("GET_CACHED_IMAGE", "1"),
+			envbuilderEnv("VERBOSE", "1"),
 		}})
 		require.ErrorContains(t, err, "error probing build cache: uncached RUN command")
 		// Then: it should fail to build the image and nothing should be pushed
@@ -1168,6 +1170,7 @@ RUN date --utc > /root/date.txt`, testImageAlpine),
 			envbuilderEnv("GIT_URL", srv.URL),
 			envbuilderEnv("CACHE_REPO", testRepo),
 			envbuilderEnv("PUSH_IMAGE", "1"),
+			envbuilderEnv("VERBOSE", "1"),
 		}})
 		require.NoError(t, err)
 
diff --git a/internal/magicdir/magicdir_internal_test.go b/internal/magicdir/magicdir_internal_test.go
new file mode 100644
index 0000000..43b66ba
--- /dev/null
+++ b/internal/magicdir/magicdir_internal_test.go
@@ -0,0 +1,38 @@
+package magicdir
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func Test_MagicDir(t *testing.T) {
+	t.Parallel()
+
+	t.Run("Default", func(t *testing.T) {
+		t.Parallel()
+		require.Equal(t, defaultMagicDirBase+"/foo", Default.Join("foo"))
+		require.Equal(t, defaultMagicDirBase, Default.Path())
+		require.Equal(t, defaultMagicDirBase+"/built", Default.Built())
+		require.Equal(t, defaultMagicDirBase+"/image", Default.Image())
+	})
+
+	t.Run("ZeroValue", func(t *testing.T) {
+		t.Parallel()
+		var md MagicDir
+		require.Equal(t, defaultMagicDirBase+"/foo", md.Join("foo"))
+		require.Equal(t, defaultMagicDirBase, md.Path())
+		require.Equal(t, defaultMagicDirBase+"/built", md.Built())
+		require.Equal(t, defaultMagicDirBase+"/image", md.Image())
+	})
+
+	t.Run("At", func(t *testing.T) {
+		t.Parallel()
+		tmpDir := t.TempDir()
+		md := At(tmpDir)
+		require.Equal(t, tmpDir+"/foo", md.Join("foo"))
+		require.Equal(t, tmpDir, md.Path())
+		require.Equal(t, tmpDir+"/built", md.Built())
+		require.Equal(t, tmpDir+"/image", md.Image())
+	})
+}
diff --git a/options/defaults_test.go b/options/defaults_test.go
index 4878358..8c9946f 100644
--- a/options/defaults_test.go
+++ b/options/defaults_test.go
@@ -8,7 +8,6 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
-	"github.com/coder/envbuilder/constants"
 	"github.com/coder/envbuilder/options"
 	"github.com/stretchr/testify/require"
 )
@@ -44,7 +43,7 @@ func TestDefaultWorkspaceFolder(t *testing.T) {
 		{
 			name:     "empty",
 			gitURL:   "",
-			expected: constants.EmptyWorkspaceDir,
+			expected: options.EmptyWorkspaceDir,
 		},
 	}
 	for _, tt := range successTests {
@@ -70,7 +69,7 @@ func TestDefaultWorkspaceFolder(t *testing.T) {
 	for _, tt := range invalidTests {
 		t.Run(tt.name, func(t *testing.T) {
 			dir := options.DefaultWorkspaceFolder(tt.invalidURL)
-			require.Equal(t, constants.EmptyWorkspaceDir, dir)
+			require.Equal(t, options.EmptyWorkspaceDir, dir)
 		})
 	}
 }
@@ -84,7 +83,9 @@ func TestOptions_SetDefaults(t *testing.T) {
 		IgnorePaths:     []string{"/var/run", "/product_uuid", "/product_name"},
 		Filesystem:      chmodfs.New(osfs.New("/")),
 		GitURL:          "",
-		WorkspaceFolder: constants.EmptyWorkspaceDir,
+		WorkspaceFolder: options.EmptyWorkspaceDir,
+		MagicDirBase:    "/.envbuilder",
+		RemoteRepoDir:   "/.envbuilder/repo",
 		BinaryPath:      "/.envbuilder/bin/envbuilder",
 	}