diff --git a/Makefile b/Makefile index ca4c0e6d..14ed5182 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,9 @@ develop: build: scripts/envbuilder-$(GOARCH) ./scripts/build.sh +.PHONY: gen +gen: docs/env-variables.md update-golden-files + .PHONY: update-golden-files update-golden-files: .gen-golden @@ -64,7 +67,7 @@ test-registry-container: .registry-cache # Pulls images referenced in integration tests and pushes them to the local cache. .PHONY: test-images-push -test-images-push: .registry-cache/docker/registry/v2/repositories/envbuilder-test-alpine .registry-cache/docker/registry/v2/repositories/envbuilder-test-ubuntu .registry-cache/docker/registry/v2/repositories/envbuilder-test-codercom-code-server +test-images-push: .registry-cache/docker/registry/v2/repositories/envbuilder-test-alpine .registry-cache/docker/registry/v2/repositories/envbuilder-test-ubuntu .registry-cache/docker/registry/v2/repositories/envbuilder-test-codercom-code-server .registry-cache/docker/registry/v2/repositories/envbuilder-test-blob-unknown .PHONY: test-images-pull test-images-pull: @@ -74,6 +77,7 @@ test-images-pull: docker tag ubuntu:latest localhost:5000/envbuilder-test-ubuntu:latest docker pull codercom/code-server:latest docker tag codercom/code-server:latest localhost:5000/envbuilder-test-codercom-code-server:latest + docker build -t localhost:5000/envbuilder-test-blob-unknown:latest -f integration/testdata/blob-unknown/Dockerfile integration/testdata/blob-unknown .registry-cache: mkdir -p .registry-cache && chmod -R ag+w .registry-cache @@ -85,4 +89,7 @@ test-images-pull: docker push localhost:5000/envbuilder-test-ubuntu:latest .registry-cache/docker/registry/v2/repositories/envbuilder-test-codercom-code-server: - docker push localhost:5000/envbuilder-test-codercom-code-server:latest \ No newline at end of file + docker push localhost:5000/envbuilder-test-codercom-code-server:latest + +.registry-cache/docker/registry/v2/repositories/envbuilder-test-blob-unknown: + docker push localhost:5000/envbuilder-test-blob-unknown:latest diff --git a/README.md b/README.md index af5323de..0a54619e 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ To explore more examples, tips, and advanced usage, check out the following guid - [Git Authentication](./docs/git-auth.md) - [Caching](./docs/caching.md) - [Custom Certificates](./docs/custom-certificates.md) +- [Users](./docs/users.md) ## Setup Script diff --git a/cmd/envbuilder/main.go b/cmd/envbuilder/main.go index e8dc2201..91cde8a3 100644 --- a/cmd/envbuilder/main.go +++ b/cmd/envbuilder/main.go @@ -75,6 +75,10 @@ func envbuilderCmd() serpent.Command { } } + if o.GitSSHPrivateKeyPath != "" && o.GitSSHPrivateKeyBase64 != "" { + return errors.New("cannot have both GIT_SSH_PRIVATE_KEY_PATH and GIT_SSH_PRIVATE_KEY_BASE64 set") + } + if o.GetCachedImage { img, err := envbuilder.RunCacheProbe(inv.Context(), o) if err != nil { diff --git a/devcontainer/devcontainer.go b/devcontainer/devcontainer.go index 6135c0ef..0bf7cc35 100644 --- a/devcontainer/devcontainer.go +++ b/devcontainer/devcontainer.go @@ -400,11 +400,11 @@ func ImageFromDockerfile(dockerfileContent string) (name.Reference, error) { arg = strings.TrimSpace(arg) if strings.Contains(arg, "=") { parts := strings.SplitN(arg, "=", 2) - key, err := lexer.ProcessWord(parts[0], args) + key, _, err := lexer.ProcessWord(parts[0], shell.EnvsFromSlice(args)) if err != nil { return nil, fmt.Errorf("processing %q: %w", line, err) } - val, err := lexer.ProcessWord(parts[1], args) + val, _, err := lexer.ProcessWord(parts[1], shell.EnvsFromSlice(args)) if err != nil { return nil, fmt.Errorf("processing %q: %w", line, err) } @@ -421,7 +421,7 @@ func ImageFromDockerfile(dockerfileContent string) (name.Reference, error) { if imageRef == "" { return nil, fmt.Errorf("no FROM directive found") } - imageRef, err := lexer.ProcessWord(imageRef, args) + imageRef, _, err := lexer.ProcessWord(imageRef, shell.EnvsFromSlice(args)) if err != nil { return nil, fmt.Errorf("processing %q: %w", imageRef, err) } diff --git a/devcontainer/devcontainer_test.go b/devcontainer/devcontainer_test.go index 923680b9..4a475682 100644 --- a/devcontainer/devcontainer_test.go +++ b/devcontainer/devcontainer_test.go @@ -22,7 +22,7 @@ import ( "github.com/stretchr/testify/require" ) -const magicDir = "/.envbuilder" +const workingDir = "/.envbuilder" func stubLookupEnv(string) (string, bool) { return "", false @@ -98,7 +98,7 @@ func TestCompileWithFeatures(t *testing.T) { featureTwoDir := fmt.Sprintf("/.envbuilder/features/two-%x", featureTwoMD5[:4]) t.Run("WithoutBuildContexts", func(t *testing.T) { - params, err := dc.Compile(fs, "", magicDir, "", "", false, stubLookupEnv) + params, err := dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv) require.NoError(t, err) require.Equal(t, `FROM localhost:5000/envbuilder-test-codercom-code-server:latest @@ -116,7 +116,7 @@ USER 1000`, params.DockerfileContent) }) t.Run("WithBuildContexts", func(t *testing.T) { - params, err := dc.Compile(fs, "", magicDir, "", "", true, stubLookupEnv) + params, err := dc.Compile(fs, "", workingDir, "", "", true, stubLookupEnv) require.NoError(t, err) registryHost := strings.TrimPrefix(registry, "http://") @@ -155,10 +155,10 @@ func TestCompileDevContainer(t *testing.T) { dc := &devcontainer.Spec{ Image: "localhost:5000/envbuilder-test-ubuntu:latest", } - params, err := dc.Compile(fs, "", magicDir, "", "", false, stubLookupEnv) + params, err := dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv) require.NoError(t, err) - require.Equal(t, filepath.Join(magicDir, "Dockerfile"), params.DockerfilePath) - require.Equal(t, magicDir, params.BuildContext) + require.Equal(t, filepath.Join(workingDir, "Dockerfile"), params.DockerfilePath) + require.Equal(t, workingDir, params.BuildContext) }) t.Run("WithBuild", func(t *testing.T) { t.Parallel() @@ -181,7 +181,7 @@ func TestCompileDevContainer(t *testing.T) { _, err = io.WriteString(file, "FROM localhost:5000/envbuilder-test-ubuntu:latest") require.NoError(t, err) _ = file.Close() - params, err := dc.Compile(fs, dcDir, magicDir, "", "/var/workspace", false, stubLookupEnv) + params, err := dc.Compile(fs, dcDir, workingDir, "", "/var/workspace", false, stubLookupEnv) require.NoError(t, err) require.Equal(t, "ARG1=value1", params.BuildArgs[0]) require.Equal(t, "ARG2=workspace", params.BuildArgs[1]) diff --git a/docs/container-registry-auth.md b/docs/container-registry-auth.md index e0d7663e..f14a66d4 100644 --- a/docs/container-registry-auth.md +++ b/docs/container-registry-auth.md @@ -14,9 +14,19 @@ After you have a configuration that resembles the following: } ``` -`base64` encode the JSON and provide it to envbuilder as the `ENVBUILDER_DOCKER_CONFIG_BASE64` environment variable. +`base64` encode the JSON and provide it to envbuilder as the +`ENVBUILDER_DOCKER_CONFIG_BASE64` environment variable. -Alternatively, if running `envbuilder` in Kubernetes, you can create an `ImagePullSecret` and +Alternatively, the configuration file can be placed in `/.envbuilder/config.json`. +The `DOCKER_CONFIG` environment variable can be used to define a custom path. The +path must either be the path to a directory containing `config.json` or the full +path to the JSON file itself. + +> [!NOTE] Providing the docker configuration through other means than the +> `ENVBUILDER_DOCKER_CONFIG_BASE64` environment variable will leave the +> configuration file in the container filesystem. This may be a security risk. + +When running `envbuilder` in Kubernetes, you can create an `ImagePullSecret` and pass it into the pod as a volume mount. This example will work for all registries. ```shell diff --git a/docs/env-variables.md b/docs/env-variables.md index 1c80f4fc..427a3f63 100644 --- a/docs/env-variables.md +++ b/docs/env-variables.md @@ -15,9 +15,10 @@ | `--dockerfile-path` | `ENVBUILDER_DOCKERFILE_PATH` | | The relative path to the Dockerfile that will be used to build the workspace. This is an alternative to using a devcontainer that some might find simpler. | | `--build-context-path` | `ENVBUILDER_BUILD_CONTEXT_PATH` | | Can be specified when a DockerfilePath is specified outside the base WorkspaceFolder. This path MUST be relative to the WorkspaceFolder path into which the repo is cloned. | | `--cache-ttl-days` | `ENVBUILDER_CACHE_TTL_DAYS` | | The number of days to use cached layers before expiring them. Defaults to 7 days. | -| `--docker-config-base64` | `ENVBUILDER_DOCKER_CONFIG_BASE64` | | The base64 encoded Docker config file that will be used to pull images from private container registries. | +| `--docker-config-base64` | `ENVBUILDER_DOCKER_CONFIG_BASE64` | | The base64 encoded Docker config file that will be used to pull images from private container registries. When this is set, Docker configuration set via the DOCKER_CONFIG environment variable is ignored. | | `--fallback-image` | `ENVBUILDER_FALLBACK_IMAGE` | | Specifies an alternative image to use when neither an image is declared in the devcontainer.json file nor a Dockerfile is present. If there's a build failure (from a faulty Dockerfile) or a misconfiguration, this image will be the substitute. Set ExitOnBuildFailure to true to halt the container if the build faces an issue. | | `--exit-on-build-failure` | `ENVBUILDER_EXIT_ON_BUILD_FAILURE` | | Terminates the container upon a build failure. This is handy when preferring the FALLBACK_IMAGE in cases where no devcontainer.json or image is provided. However, it ensures that the container stops if the build process encounters an error. | +| `--exit-on-push-failure` | `ENVBUILDER_EXIT_ON_PUSH_FAILURE` | | ExitOnPushFailure terminates the container upon a push failure. This is useful if failure to push the built image should abort execution and result in an error. | | `--force-safe` | `ENVBUILDER_FORCE_SAFE` | | Ignores any filesystem safety checks. This could cause serious harm to your system! This is used in cases where bypass is needed to unblock customers. | | `--insecure` | `ENVBUILDER_INSECURE` | | Bypass TLS verification when cloning and pulling from container registries. | | `--ignore-paths` | `ENVBUILDER_IGNORE_PATHS` | | The comma separated list of paths to ignore when building the workspace. | @@ -27,9 +28,11 @@ | `--git-clone-single-branch` | `ENVBUILDER_GIT_CLONE_SINGLE_BRANCH` | | Clone only a single branch of the Git repository. | | `--git-username` | `ENVBUILDER_GIT_USERNAME` | | The username to use for Git authentication. This is optional. | | `--git-password` | `ENVBUILDER_GIT_PASSWORD` | | The password to use for Git authentication. This is optional. | -| `--git-ssh-private-key-path` | `ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH` | | Path to an SSH private key to be used for Git authentication. | +| `--git-ssh-private-key-path` | `ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH` | | Path to an SSH private key to be used for Git authentication. If this is set, then GIT_SSH_PRIVATE_KEY_BASE64 cannot be set. | +| `--git-ssh-private-key-base64` | `ENVBUILDER_GIT_SSH_PRIVATE_KEY_BASE64` | | Base64 encoded SSH private key to be used for Git authentication. If this is set, then GIT_SSH_PRIVATE_KEY_PATH cannot be set. | | `--git-http-proxy-url` | `ENVBUILDER_GIT_HTTP_PROXY_URL` | | The URL for the HTTP proxy. This is optional. | -| `--workspace-folder` | `ENVBUILDER_WORKSPACE_FOLDER` | | The path to the workspace folder that will be built. This is optional. | +| `--workspace-base-dir` | `ENVBUILDER_WORKSPACE_BASE_DIR` | `/workspaces` | The path under which workspaces will be placed when workspace folder option is not given. | +| `--workspace-folder` | `ENVBUILDER_WORKSPACE_FOLDER` | | The path to the workspace folder that will be built. This is optional. Defaults to `[workspace base dir]/[name]` where name is the name of the repository or `empty`. | | `--ssl-cert-base64` | `ENVBUILDER_SSL_CERT_BASE64` | | The content of an SSL cert file. This is useful for self-signed certificates. | | `--export-env-file` | `ENVBUILDER_EXPORT_ENV_FILE` | | Optional file path to a .env file where envbuilder will dump environment variables from devcontainer.json and the built container image. | | `--post-start-script-path` | `ENVBUILDER_POST_START_SCRIPT_PATH` | | The path to a script that will be created by envbuilder based on the postStartCommand in devcontainer.json, if any is specified (otherwise the script is not created). If this is set, the specified InitCommand should check for the presence of this script and execute it after successful startup. | diff --git a/docs/users.md b/docs/users.md new file mode 100644 index 00000000..6f121cdf --- /dev/null +++ b/docs/users.md @@ -0,0 +1,9 @@ +# Root Privileges + +Envbuilder always expects to be run as `root` in its container, as building an image will most likely require root privileges. Once the image is built, Envbuilder will drop root privileges and `exec` `ENVBUILDER_INIT_COMMAND` / `ENVBUILDER_INIT_SCRIPT` as a non-root user. + +## Choosing a target user + +Envbuilder will first attempt to switch to the `containerUser` defined `devcontainer.json`. +If this is not specified, it will look up the last `USER` directive from the specified `Dockerfile` or image. +If no alternative user is specified, Envbuilder will fallback to `root`. diff --git a/envbuilder.go b/envbuilder.go index 47cc228d..ea7c4436 100644 --- a/envbuilder.go +++ b/envbuilder.go @@ -1,7 +1,6 @@ package envbuilder import ( - "bufio" "bytes" "context" "encoding/base64" @@ -35,12 +34,13 @@ 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/internal/workingdir" "github.com/coder/envbuilder/log" "github.com/containerd/platforms" "github.com/distribution/distribution/v3/configuration" "github.com/distribution/distribution/v3/registry/handlers" _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem" + dockerconfig "github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config/configfile" "github.com/fatih/color" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -56,7 +56,7 @@ import ( var ErrNoFallbackImage = errors.New("no fallback image has been specified") // DockerConfig represents the Docker configuration file. -type DockerConfig configfile.ConfigFile +type DockerConfig = configfile.ConfigFile type runtimeDataStore struct { // Runtime data. @@ -120,7 +120,7 @@ func Run(ctx context.Context, opts options.Options, preExec ...func()) error { func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) error { defer options.UnsetEnv() - magicDir := magicdir.At(opts.MagicDirBase) + workingDir := workingdir.At(opts.MagicDirBase) stageNumber := 0 startStage := func(format string, args ...any) func(format string, args ...any) { @@ -154,13 +154,13 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro 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.Logger, magicDir, opts.DockerConfigBase64) + cleanupDockerConfigOverride, err := initDockerConfigOverride(opts.Filesystem, opts.Logger, workingDir, opts.DockerConfigBase64) if err != nil { return err } defer func() { - if err := cleanupDockerConfigJSON(); err != nil { - opts.Logger(log.LevelError, "failed to cleanup docker config JSON: %w", err) + if err := cleanupDockerConfigOverride(); err != nil { + opts.Logger(log.LevelError, "failed to cleanup docker config override: %w", err) } }() // best effort @@ -168,8 +168,9 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro ContainerEnv: make(map[string]string), RemoteEnv: make(map[string]string), } - if fileExists(opts.Filesystem, magicDir.Image()) { - if err = parseMagicImageFile(opts.Filesystem, magicDir.Image(), &runtimeData); err != nil { + if fileExists(opts.Filesystem, workingDir.Image()) { + opts.Logger(log.LevelInfo, "Found magic image file at %s", workingDir.Image()) + if err = parseMagicImageFile(opts.Filesystem, workingDir.Image(), &runtimeData); err != nil { return fmt.Errorf("parse magic image file: %w", err) } runtimeData.Image = true @@ -186,7 +187,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro opts.ExportEnvFile = "" } } - runtimeData.Built = fileExists(opts.Filesystem, magicDir.Built()) + runtimeData.Built = fileExists(opts.Filesystem, workingDir.Built()) buildTimeWorkspaceFolder := opts.WorkspaceFolder var fallbackErr error @@ -233,7 +234,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro if err != nil { return fmt.Errorf("git clone options: %w", err) } - cloneOpts.Path = magicDir.Join("repo") + cloneOpts.Path = workingDir.Join("repo") endStage := startStage("📦 Remote repo build mode enabled, cloning %s to %s for build context...", newColor(color.FgCyan).Sprintf(opts.GitURL), @@ -259,7 +260,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro if !runtimeData.Image { defaultBuildParams := func() (*devcontainer.Compiled, error) { - dockerfile := magicDir.Join("Dockerfile") + dockerfile := workingDir.Join("Dockerfile") file, err := opts.Filesystem.OpenFile(dockerfile, os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return nil, err @@ -281,12 +282,13 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro return &devcontainer.Compiled{ DockerfilePath: dockerfile, DockerfileContent: content, - BuildContext: magicDir.Path(), + BuildContext: workingDir.Path(), }, nil } var buildParams *devcontainer.Compiled if opts.DockerfilePath == "" { + opts.Logger(log.LevelInfo, "No Dockerfile specified, looking for a devcontainer.json...") // Only look for a devcontainer if a Dockerfile wasn't specified. // devcontainer is a standard, so it's reasonable to be the default. var devcontainerDir string @@ -296,6 +298,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro opts.Logger(log.LevelError, "Failed to locate devcontainer.json: %s", err.Error()) opts.Logger(log.LevelError, "Falling back to the default image...") } else { + opts.Logger(log.LevelInfo, "Building in Devcontainer mode using %s", strings.TrimPrefix(runtimeData.DevcontainerPath, buildTimeWorkspaceFolder)) // We know a devcontainer exists. // Let's parse it and use it! file, err := opts.Filesystem.Open(runtimeData.DevcontainerPath) @@ -318,7 +321,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro 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, magicDir.Path(), fallbackDockerfile, opts.WorkspaceFolder, false, os.LookupEnv) + buildParams, err = devContainer.Compile(opts.Filesystem, devcontainerDir, workingDir.Path(), fallbackDockerfile, opts.WorkspaceFolder, false, os.LookupEnv) if err != nil { return fmt.Errorf("compile devcontainer.json: %w", err) } @@ -334,6 +337,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro } else { // If a Dockerfile was specified, we use that. dockerfilePath := filepath.Join(buildTimeWorkspaceFolder, opts.DockerfilePath) + opts.Logger(log.LevelInfo, "Building in Dockerfile-only mode using %s", opts.DockerfilePath) // If the dockerfilePath is specified and deeper than the base of WorkspaceFolder AND the BuildContextPath is // not defined, show a warning @@ -393,7 +397,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro // 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{ - magicDir.Path(), + workingDir.Path(), opts.WorkspaceFolder, // See: https://github.com/coder/envbuilder/issues/37 "/etc/resolv.conf", @@ -421,18 +425,18 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro if err := util.AddAllowedPathToDefaultIgnoreList(opts.BinaryPath); err != nil { return fmt.Errorf("add envbuilder binary to ignore list: %w", err) } - if err := util.AddAllowedPathToDefaultIgnoreList(magicDir.Image()); err != nil { + if err := util.AddAllowedPathToDefaultIgnoreList(workingDir.Image()); err != nil { return fmt.Errorf("add magic image file to ignore list: %w", err) } - if err := util.AddAllowedPathToDefaultIgnoreList(magicDir.Features()); err != nil { + if err := util.AddAllowedPathToDefaultIgnoreList(workingDir.Features()); err != nil { return fmt.Errorf("add features to ignore list: %w", err) } - magicTempDir := magicdir.At(buildParams.BuildContext, magicdir.TempDir) + magicTempDir := workingdir.At(buildParams.BuildContext, workingdir.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 += magicdir.Directives + buildParams.DockerfileContent += workingdir.Directives envbuilderBinDest := filepath.Join(magicTempDir.Path(), "envbuilder") magicImageDest := magicTempDir.Image() @@ -467,7 +471,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro } // temp move of all ro mounts - tempRemountDest := magicDir.Join("mnt") + tempRemountDest := workingDir.Join("mnt") // ignorePrefixes is a superset of ignorePaths that we pass to kaniko's // IgnoreList. ignorePrefixes := append([]string{"/dev", "/proc", "/sys"}, ignorePaths...) @@ -581,10 +585,23 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro endStage("🏗️ Built image!") if opts.PushImage { endStage = startStage("🏗️ Pushing image...") - if err := executor.DoPush(image, kOpts); err != nil { + + // To debug registry issues, enable logging: + // + // import ( + // stdlog "log" + // reglogs "github.com/google/go-containerregistry/pkg/logs" + // ) + // reglogs.Debug = stdlog.New(os.Stderr, "", 0) + // reglogs.Warn = stdlog.New(os.Stderr, "", 0) + // reglogs.Progress = stdlog.New(os.Stderr, "", 0) + if err := executor.DoPush(image, kOpts); err == nil { + endStage("🏗️ Pushed image!") + } else if !opts.ExitOnPushFailure { + endStage("⚠️️ Failed to push image!") + } else { return nil, xerrors.Errorf("do push: %w", err) } - endStage("🏗️ Pushed image!") } return image, err @@ -711,6 +728,11 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro // Sanitize the environment of any opts! options.UnsetEnv() + // Remove the Docker config secret file! + if err := cleanupDockerConfigOverride(); err != nil { + return err + } + // Set the environment from /etc/environment first, so it can be // overridden by the image and devcontainer settings. err = setEnvFromEtcEnvironment(opts.Logger) @@ -770,11 +792,6 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro exportEnvFile.Close() } - // Remove the Docker config secret file! - if err := cleanupDockerConfigJSON(); err != nil { - return err - } - if runtimeData.ContainerUser == "" { opts.Logger(log.LevelWarn, "#%d: no user specified, using root", stageNumber) } @@ -845,7 +862,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro // Create the magic file to indicate that this build // has already been ran before! if !runtimeData.Built { - file, err := opts.Filesystem.Create(magicDir.Built()) + file, err := opts.Filesystem.Create(workingDir.Built()) if err != nil { return fmt.Errorf("create magic file: %w", err) } @@ -864,7 +881,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro opts.Logger(log.LevelInfo, "=== Running the setup command %q as the root user...", opts.SetupScript) envKey := "ENVBUILDER_ENV" - envFile := magicDir.Join("environ") + envFile := workingDir.Join("environ") file, err := opts.Filesystem.Create(envFile) if err != nil { return fmt.Errorf("create environ file: %w", err) @@ -884,16 +901,8 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin } else { - var buf bytes.Buffer - go func() { - scanner := bufio.NewScanner(&buf) - for scanner.Scan() { - opts.Logger(log.LevelInfo, "%s", scanner.Text()) - } - }() - - cmd.Stdout = &buf - cmd.Stderr = &buf + cmd.Stdout = newWriteLogger(opts.Logger, log.LevelInfo) + cmd.Stderr = newWriteLogger(opts.Logger, log.LevelError) } err = cmd.Run() if err != nil { @@ -962,7 +971,7 @@ 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) + workingDir := workingdir.At(opts.MagicDirBase) stageNumber := 0 startStage := func(format string, args ...any) func(format string, args ...any) { @@ -978,13 +987,13 @@ 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.Logger, magicDir, opts.DockerConfigBase64) + cleanupDockerConfigOverride, err := initDockerConfigOverride(opts.Filesystem, opts.Logger, workingDir, opts.DockerConfigBase64) if err != nil { return nil, err } defer func() { - if err := cleanupDockerConfigJSON(); err != nil { - opts.Logger(log.LevelError, "failed to cleanup docker config JSON: %w", err) + if err := cleanupDockerConfigOverride(); err != nil { + opts.Logger(log.LevelError, "failed to cleanup docker config override: %w", err) } }() // best effort @@ -1031,7 +1040,7 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error) if err != nil { return nil, fmt.Errorf("git clone options: %w", err) } - cloneOpts.Path = magicDir.Join("repo") + cloneOpts.Path = workingDir.Join("repo") endStage := startStage("📦 Remote repo build mode enabled, cloning %s to %s for build context...", newColor(color.FgCyan).Sprintf(opts.GitURL), @@ -1056,7 +1065,7 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error) } defaultBuildParams := func() (*devcontainer.Compiled, error) { - dockerfile := magicDir.Join("Dockerfile") + dockerfile := workingDir.Join("Dockerfile") file, err := opts.Filesystem.OpenFile(dockerfile, os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return nil, err @@ -1078,7 +1087,7 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error) return &devcontainer.Compiled{ DockerfilePath: dockerfile, DockerfileContent: content, - BuildContext: magicDir.Path(), + BuildContext: workingDir.Path(), }, nil } @@ -1118,7 +1127,7 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, 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, magicDir.Path(), fallbackDockerfile, opts.WorkspaceFolder, false, os.LookupEnv) + buildParams, err = devContainer.Compile(opts.Filesystem, devcontainerDir, workingDir.Path(), fallbackDockerfile, opts.WorkspaceFolder, false, os.LookupEnv) if err != nil { return nil, fmt.Errorf("compile devcontainer.json: %w", err) } @@ -1184,7 +1193,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{ - magicDir.Path(), + workingDir.Path(), opts.WorkspaceFolder, // See: https://github.com/coder/envbuilder/issues/37 "/etc/resolv.conf", @@ -1207,10 +1216,10 @@ 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. - // MAGICDIR - buildParams.DockerfileContent += magicdir.Directives + // WORKINGDIR + buildParams.DockerfileContent += workingdir.Directives - magicTempDir := filepath.Join(buildParams.BuildContext, magicdir.TempDir) + magicTempDir := filepath.Join(buildParams.BuildContext, workingdir.TempDir) if err := opts.Filesystem.MkdirAll(magicTempDir, 0o755); err != nil { return nil, fmt.Errorf("create magic temp dir in build context: %w", err) } @@ -1315,7 +1324,7 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error) options.UnsetEnv() // Remove the Docker config secret file! - if err := cleanupDockerConfigJSON(); err != nil { + if err := cleanupDockerConfigOverride(); err != nil { return nil, err } @@ -1397,6 +1406,7 @@ func execOneLifecycleScript( userInfo userInfo, ) error { if s.IsEmpty() { + logf(log.LevelInfo, "=== No %s script specified", scriptName) return nil } logf(log.LevelInfo, "=== Running %s as the %q user...", scriptName, userInfo.user.Username) @@ -1415,6 +1425,7 @@ func execLifecycleScripts( userInfo userInfo, ) error { if options.PostStartScriptPath != "" { + options.Logger(log.LevelDebug, "Removing postStartScriptPath %s", options.PostStartScriptPath) _ = os.Remove(options.PostStartScriptPath) } @@ -1423,6 +1434,8 @@ func execLifecycleScripts( // skip remaining lifecycle commands return nil } + } else { + options.Logger(log.LevelDebug, "Skipping onCreateCommand for subsequent starts...") } if err := execOneLifecycleScript(ctx, options.Logger, scripts.UpdateContentCommand, "updateContentCommand", userInfo); err != nil { // skip remaining lifecycle commands @@ -1546,11 +1559,11 @@ 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("") + // defaultWorkingDir := workingdir.WorkingDir("") kanikoDir, ok := os.LookupEnv("KANIKO_DIR") - if !ok || strings.TrimSpace(kanikoDir) != magicdir.Default.Path() { + if !ok || strings.TrimSpace(kanikoDir) != workingdir.Default.Path() { if !force { - logger(log.LevelError, "KANIKO_DIR is not set to %s. Bailing!\n", magicdir.Default.Path()) + logger(log.LevelError, "KANIKO_DIR is not set to %s. Bailing!\n", workingdir.Default.Path()) logger(log.LevelError, "To bypass this check, set FORCE_SAFE=true.") return errors.New("safety check failed") } @@ -1567,8 +1580,22 @@ func maybeDeleteFilesystem(logger log.Func, force bool) error { } func fileExists(fs billy.Filesystem, path string) bool { - _, err := fs.Stat(path) - return err == nil + fi, err := fs.Stat(path) + return err == nil && !fi.IsDir() +} + +func readFile(fs billy.Filesystem, name string) ([]byte, error) { + f, err := fs.Open(name) + if err != nil { + return nil, fmt.Errorf("open file: %w", err) + } + defer f.Close() + + b, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + return b, nil } func copyFile(fs billy.Filesystem, src, dst string, mode fs.FileMode) error { @@ -1595,6 +1622,21 @@ func copyFile(fs billy.Filesystem, src, dst string, mode fs.FileMode) error { return nil } +func writeFile(fs billy.Filesystem, name string, data []byte, perm fs.FileMode) error { + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + _, err = f.Write(data) + if err != nil { + err = fmt.Errorf("write file: %w", err) + } + if err2 := f.Close(); err2 != nil && err == nil { + err = fmt.Errorf("close file: %w", err2) + } + return err +} + func writeMagicImageFile(fs billy.Filesystem, path string, v any) error { file, err := fs.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) if err != nil { @@ -1627,55 +1669,178 @@ func parseMagicImageFile(fs billy.Filesystem, path string, v any) error { return nil } -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 +const ( + dockerConfigFile = dockerconfig.ConfigFileName + dockerConfigEnvKey = dockerconfig.EnvOverrideConfigDir +) + +// initDockerConfigOverride sets the DOCKER_CONFIG environment variable +// to a path within the working directory. If a base64 encoded Docker +// config is provided, it is written to the path/config.json and the +// DOCKER_CONFIG environment variable is set to the path. If no base64 +// encoded Docker config is provided, the following paths are checked in +// order: +// +// 1. $DOCKER_CONFIG/config.json +// 2. $DOCKER_CONFIG +// 3. /.envbuilder/config.json +// +// If a Docker config file is found, its path is set as DOCKER_CONFIG. +func initDockerConfigOverride(bfs billy.Filesystem, logf log.Func, workingDir workingdir.WorkingDir, dockerConfigBase64 string) (func() error, error) { + // If dockerConfigBase64 is set, it will have priority over file + // detection. + var dockerConfigJSON []byte + var err error + if dockerConfigBase64 != "" { + logf(log.LevelInfo, "Using base64 encoded Docker config") + + dockerConfigJSON, err = base64.StdEncoding.DecodeString(dockerConfigBase64) + if err != nil { + return nil, fmt.Errorf("decode docker config: %w", err) + } + } + + oldDockerConfig := os.Getenv(dockerConfigEnvKey) + var oldDockerConfigFile string + if oldDockerConfig != "" { + oldDockerConfigFile = filepath.Join(oldDockerConfig, dockerConfigFile) + } + for _, path := range []string{ + oldDockerConfigFile, // $DOCKER_CONFIG/config.json + oldDockerConfig, // $DOCKER_CONFIG + workingDir.Join(dockerConfigFile), // /.envbuilder/config.json + } { + if path == "" || !fileExists(bfs, path) { + continue + } + + logf(log.LevelWarn, "Found Docker config at %s, this file will remain after the build", path) + + if dockerConfigJSON == nil { + logf(log.LevelInfo, "Using Docker config at %s", path) + + dockerConfigJSON, err = readFile(bfs, path) + if err != nil { + return nil, fmt.Errorf("read docker config: %w", err) + } + } else { + logf(log.LevelWarn, "Ignoring Docker config at %s, using base64 encoded Docker config instead", path) + } + break + } + + if dockerConfigJSON == nil { + // No user-provided config available. + return func() error { return nil }, nil + } + + dockerConfigJSON, err = hujson.Standardize(dockerConfigJSON) + if err != nil { + return nil, fmt.Errorf("humanize json for docker config: %w", err) + } + + if err = logDockerAuthConfigs(logf, dockerConfigJSON); err != nil { + return nil, fmt.Errorf("log docker auth configs: %w", err) } - cfgPath := magicDir.Join("config.json") - decoded, err := base64.StdEncoding.DecodeString(dockerConfigBase64) + + // We're going to set the DOCKER_CONFIG environment variable to a + // path within the working directory so that Kaniko can pick it up. + // A user should not mount a file directly to this path as we will + // write to the file. + newDockerConfig := workingDir.Join(".docker") + newDockerConfigFile := filepath.Join(newDockerConfig, dockerConfigFile) + err = bfs.MkdirAll(newDockerConfig, 0o700) + if err != nil { + return nil, fmt.Errorf("create docker config dir: %w", err) + } + + if fileExists(bfs, newDockerConfigFile) { + return nil, fmt.Errorf("unable to write Docker config file, file already exists: %s", newDockerConfigFile) + } + + restoreEnv, err := setAndRestoreEnv(logf, dockerConfigEnvKey, newDockerConfig) if err != nil { - return noop, fmt.Errorf("decode docker config: %w", err) + return nil, fmt.Errorf("set docker config override: %w", err) } - var configFile DockerConfig - decoded, err = hujson.Standardize(decoded) + + err = writeFile(bfs, newDockerConfigFile, dockerConfigJSON, 0o600) if err != nil { - return noop, fmt.Errorf("humanize json for docker config: %w", err) + _ = restoreEnv() // Best effort. + return nil, fmt.Errorf("write docker config: %w", err) } - err = json.Unmarshal(decoded, &configFile) + logf(log.LevelInfo, "Wrote Docker config JSON to %s", newDockerConfigFile) + + cleanupFile := onceErrFunc(func() error { + // Remove the Docker config secret file! + if err := bfs.Remove(newDockerConfigFile); err != nil { + logf(log.LevelError, "Failed to remove the Docker config secret file: %s", err) + return fmt.Errorf("remove docker config: %w", err) + } + return nil + }) + return func() error { return errors.Join(cleanupFile(), restoreEnv()) }, nil +} + +func logDockerAuthConfigs(logf log.Func, dockerConfigJSON []byte) error { + dc := new(DockerConfig) + err := dc.LoadFromReader(bytes.NewReader(dockerConfigJSON)) if err != nil { - return noop, fmt.Errorf("parse docker config: %w", err) + return fmt.Errorf("load docker config: %w", err) } - for k := range configFile.AuthConfigs { + for k := range dc.AuthConfigs { logf(log.LevelInfo, "Docker config contains auth for registry %q", k) } - err = os.WriteFile(cfgPath, decoded, 0o644) + return nil +} + +func setAndRestoreEnv(logf log.Func, key, value string) (restore func() error, err error) { + old := os.Getenv(key) + err = os.Setenv(key, value) if err != nil { - return noop, fmt.Errorf("write docker config: %w", err) - } - logf(log.LevelInfo, "Wrote Docker config JSON to %s", cfgPath) - oldDockerConfig := os.Getenv("DOCKER_CONFIG") - _ = os.Setenv("DOCKER_CONFIG", magicDir.Path()) - newDockerConfig := os.Getenv("DOCKER_CONFIG") - logf(log.LevelInfo, "Set DOCKER_CONFIG to %s", newDockerConfig) - cleanup := func() error { - var cleanupErr error - cleanupOnce.Do(func() { - // Restore the old DOCKER_CONFIG value. - os.Setenv("DOCKER_CONFIG", oldDockerConfig) - logf(log.LevelInfo, "Restored DOCKER_CONFIG to %s", oldDockerConfig) - // Remove the Docker config secret file! - if cleanupErr = os.Remove(cfgPath); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - cleanupErr = fmt.Errorf("remove docker config: %w", cleanupErr) - } - logf(log.LevelError, "Failed to remove the Docker config secret file: %s", cleanupErr) + logf(log.LevelError, "Failed to set %s: %s", key, err) + return nil, fmt.Errorf("set %s: %w", key, err) + } + logf(log.LevelInfo, "Set %s to %s", key, value) + return onceErrFunc(func() error { + if err := func() error { + if old == "" { + return os.Unsetenv(key) } + return os.Setenv(key, old) + }(); err != nil { + return fmt.Errorf("restore %s: %w", key, err) + } + logf(log.LevelInfo, "Restored %s to %s", key, old) + return nil + }), nil +} + +func onceErrFunc(f func() error) func() error { + var once sync.Once + return func() error { + var err error + once.Do(func() { + err = f() }) - return cleanupErr + return err + } +} + +type writeLogger struct { + logf log.Func + level log.Level +} + +func newWriteLogger(logf log.Func, level log.Level) io.Writer { + return writeLogger{logf: logf, level: level} +} + +func (l writeLogger) Write(p []byte) (n int, err error) { + lines := bytes.Split(p, []byte("\n")) + for _, line := range lines { + l.logf(l.level, "%s", line) } - return cleanup, err + return len(p), nil } // Allows quick testing of layer caching using a local directory! diff --git a/git/git.go b/git/git.go index 7d132c3a..f37b9682 100644 --- a/git/git.go +++ b/git/git.go @@ -2,6 +2,7 @@ package git import ( "context" + "encoding/base64" "errors" "fmt" "io" @@ -181,6 +182,22 @@ func ReadPrivateKey(path string) (gossh.Signer, error) { return k, nil } +// DecodeBase64PrivateKey attempts to decode a base64 encoded private +// key and returns an ssh.Signer +func DecodeBase64PrivateKey(key string) (gossh.Signer, error) { + bs, err := base64.StdEncoding.DecodeString(key) + if err != nil { + return nil, fmt.Errorf("decode base64: %w", err) + } + + k, err := gossh.ParsePrivateKey(bs) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + + return k, nil +} + // LogHostKeyCallback is a HostKeyCallback that just logs host keys // and does nothing else. func LogHostKeyCallback(logger func(string, ...any)) gossh.HostKeyCallback { @@ -273,6 +290,17 @@ func SetupRepoAuth(logf func(string, ...any), options *options.Options) transpor } } + // If no path was provided, fall back to the environment variable + if options.GitSSHPrivateKeyBase64 != "" { + s, err := DecodeBase64PrivateKey(options.GitSSHPrivateKeyBase64) + if err != nil { + logf("❌ Failed to decode base 64 private key: %s", err.Error()) + } else { + logf("🔑 Using %s key!", s.PublicKey().Type()) + signer = s + } + } + // If no SSH key set, fall back to agent auth. if signer == nil { logf("🔑 No SSH key found, falling back to agent!") diff --git a/git/git_test.go b/git/git_test.go index e7a58f90..0da5a163 100644 --- a/git/git_test.go +++ b/git/git_test.go @@ -3,6 +3,7 @@ package git_test import ( "context" "crypto/ed25519" + "encoding/base64" "fmt" "io" "net/http/httptest" @@ -433,6 +434,22 @@ func TestSetupRepoAuth(t *testing.T) { require.Equal(t, actualSigner, pk.Signer) }) + t.Run("SSH/Base64PrivateKey", func(t *testing.T) { + opts := &options.Options{ + GitURL: "ssh://git@host.tld:repo/path", + GitSSHPrivateKeyBase64: base64EncodeTestPrivateKey(), + } + auth := git.SetupRepoAuth(t.Logf, opts) + + pk, ok := auth.(*gitssh.PublicKeys) + require.True(t, ok) + require.NotNil(t, pk.Signer) + + actualSigner, err := gossh.ParsePrivateKey([]byte(testKey)) + require.NoError(t, err) + require.Equal(t, actualSigner, pk.Signer) + }) + t.Run("SSH/NoAuthMethods", func(t *testing.T) { opts := &options.Options{ GitURL: "ssh://git@host.tld:repo/path", @@ -502,3 +519,7 @@ func writeTestPrivateKey(t *testing.T) string { require.NoError(t, os.WriteFile(kPath, []byte(testKey), 0o600)) return kPath } + +func base64EncodeTestPrivateKey() string { + return base64.StdEncoding.EncodeToString([]byte(testKey)) +} diff --git a/go.mod b/go.mod index 9fa1d696..1283d599 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.22.4 // There are a few options we need added to Kaniko! // See: https://github.com/GoogleContainerTools/kaniko/compare/main...coder:kaniko:main -replace github.com/GoogleContainerTools/kaniko => github.com/coder/kaniko v0.0.0-20240925122543-caa18967f374 +replace github.com/GoogleContainerTools/kaniko => github.com/coder/kaniko v0.0.0-20241120132148-131d6094d781 // Required to import codersdk due to gvisor dependency. replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20240702054557-aa558fbe5374 @@ -19,8 +19,8 @@ require ( github.com/coder/serpent v0.7.0 github.com/containerd/platforms v0.2.1 github.com/distribution/distribution/v3 v3.0.0-alpha.1 - github.com/docker/cli v27.2.0+incompatible - github.com/docker/docker v26.1.5+incompatible + github.com/docker/cli v27.2.1+incompatible + github.com/docker/docker v27.3.1+incompatible github.com/fatih/color v1.17.0 github.com/gliderlabs/ssh v0.3.7 github.com/go-git/go-billy/v5 v5.5.0 @@ -31,7 +31,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/mattn/go-isatty v0.0.20 - github.com/moby/buildkit v0.13.1 + github.com/moby/buildkit v0.16.0 github.com/otiai10/copy v1.14.0 github.com/prometheus/procfs v0.15.1 github.com/sirupsen/logrus v1.9.3 @@ -39,9 +39,9 @@ require ( github.com/stretchr/testify v1.9.0 github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a go.uber.org/mock v0.4.0 - golang.org/x/crypto v0.26.0 + golang.org/x/crypto v0.29.0 golang.org/x/mod v0.21.0 - golang.org/x/sync v0.8.0 + golang.org/x/sync v0.9.0 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 ) @@ -100,14 +100,12 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/charmbracelet/lipgloss v0.8.0 // indirect github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect - github.com/cilium/ebpf v0.12.3 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 // indirect github.com/coder/quartz v0.1.0 // indirect github.com/coder/terraform-provider-coder v0.23.0 // indirect github.com/containerd/cgroups v1.1.0 // indirect - github.com/containerd/cgroups/v3 v3.0.2 // indirect - github.com/containerd/containerd v1.7.19 // indirect + github.com/containerd/containerd v1.7.21 // indirect github.com/containerd/containerd/api v1.7.19 // indirect github.com/containerd/continuity v0.4.3 // indirect github.com/containerd/errdefs v0.1.0 // indirect @@ -115,10 +113,9 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.15.1 // indirect github.com/containerd/ttrpc v1.2.5 // indirect - github.com/containerd/typeurl/v2 v2.1.1 // indirect + github.com/containerd/typeurl/v2 v2.2.0 // indirect github.com/coreos/go-iptables v0.6.0 // indirect github.com/coreos/go-oidc/v3 v3.10.0 // indirect - github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect @@ -146,12 +143,12 @@ require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.2 // indirect github.com/google/nftables v0.2.0 // indirect - github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b // indirect + github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 // indirect github.com/gorilla/handlers v1.5.1 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0 // indirect @@ -164,7 +161,7 @@ require ( github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl/v2 v2.21.0 // indirect github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-plugin-go v0.12.0 // indirect @@ -202,11 +199,12 @@ require ( github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/swarmkit/v2 v2.0.0-20230315203717-e28e8ba9bc83 // indirect github.com/moby/sys/mount v0.3.3 // indirect - github.com/moby/sys/mountinfo v0.7.1 // indirect + github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/sys/sequential v0.5.0 // indirect - github.com/moby/sys/signal v0.7.0 // indirect + github.com/moby/sys/signal v0.7.1 // indirect github.com/moby/sys/symlink v0.2.0 // indirect - github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/sys/user v0.3.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/muesli/reflow v0.3.0 // indirect @@ -245,6 +243,7 @@ require ( github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272 // indirect github.com/tcnksm/go-httpstat v0.2.0 // indirect github.com/tinylib/msgp v1.1.8 // indirect + github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect github.com/twpayne/go-vfs/v5 v5.0.4 // indirect github.com/u-root/uio v0.0.0-20240209044354-b3d14b93376a // indirect github.com/valyala/fasthttp v1.55.0 // indirect @@ -275,9 +274,9 @@ require ( golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect golang.org/x/net v0.26.0 // indirect golang.org/x/oauth2 v0.20.0 // indirect - golang.org/x/sys v0.23.0 // indirect - golang.org/x/term v0.23.0 // indirect - golang.org/x/text v0.17.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/term v0.26.0 // indirect + golang.org/x/text v0.20.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.22.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect diff --git a/go.sum b/go.sum index 07dc01db..96fa5e7c 100644 --- a/go.sum +++ b/go.sum @@ -171,8 +171,8 @@ github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoC github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI= github.com/coder/coder/v2 v2.10.1-0.20240704130443-c2d44d16a352 h1:L/EjCuZxs5tOcqqCaASj/nu65TRYEFcTt8qRQfHZXX0= github.com/coder/coder/v2 v2.10.1-0.20240704130443-c2d44d16a352/go.mod h1:P1KoQSgnKEAG6Mnd3YlGzAophty+yKA9VV48LpfNRvo= -github.com/coder/kaniko v0.0.0-20240925122543-caa18967f374 h1:/cyXf0vTSwFh7evQqeWHXXl14aRfC4CsNIYxOenJytQ= -github.com/coder/kaniko v0.0.0-20240925122543-caa18967f374/go.mod h1:XoTDIhNF0Ll4tLmRYdOn31udU9w5zFrY2PME/crSRCA= +github.com/coder/kaniko v0.0.0-20241120132148-131d6094d781 h1:/4SMdrjLQL1BseLSnMd9nYQSI+E63CXcyFGC7ZHHj8I= +github.com/coder/kaniko v0.0.0-20241120132148-131d6094d781/go.mod h1:3rM/KOQ4LgF8mE+O1P6pLDa/E57mzxIxNdUOMKi1qpg= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= github.com/coder/quartz v0.1.0 h1:cLL+0g5l7xTf6ordRnUMMiZtRE8Sq5LxpghS63vEXrQ= @@ -187,10 +187,8 @@ github.com/coder/terraform-provider-coder v0.23.0 h1:DuNLWxhnGlXyG0g+OCAZRI6xd8+ github.com/coder/terraform-provider-coder v0.23.0/go.mod h1:wMun9UZ9HT2CzF6qPPBup1odzBpVUc0/xSFoXgdI3tk= github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM= github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= -github.com/containerd/cgroups/v3 v3.0.2 h1:f5WFqIVSgo5IZmtTT3qVBo6TzI1ON6sycSBKkymb9L0= -github.com/containerd/cgroups/v3 v3.0.2/go.mod h1:JUgITrzdFqp42uI2ryGA+ge0ap/nxzYgkGmIcetmErE= -github.com/containerd/containerd v1.7.19 h1:/xQ4XRJ0tamDkdzrrBAUy/LE5nCcxFKdBm4EcPrSMEE= -github.com/containerd/containerd v1.7.19/go.mod h1:h4FtNYUUMB4Phr6v+xG89RYKj9XccvbNSCKjdufCrkc= +github.com/containerd/containerd v1.7.21 h1:USGXRK1eOC/SX0L195YgxTHb0a00anxajOzgfN0qrCA= +github.com/containerd/containerd v1.7.21/go.mod h1:e3Jz1rYRUZ2Lt51YrH9Rz0zPyJBOlSvB3ghr2jbVD8g= github.com/containerd/containerd/api v1.7.19 h1:VWbJL+8Ap4Ju2mx9c9qS1uFSB1OVYr5JJrW2yT5vFoA= github.com/containerd/containerd/api v1.7.19/go.mod h1:fwGavl3LNwAV5ilJ0sbrABL44AQxmNjDRcwheXDb6Ig= github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8= @@ -207,15 +205,13 @@ github.com/containerd/stargz-snapshotter/estargz v0.15.1 h1:eXJjw9RbkLFgioVaTG+G github.com/containerd/stargz-snapshotter/estargz v0.15.1/go.mod h1:gr2RNwukQ/S9Nv33Lt6UC7xEx58C+LHRdoqbEKjz1Kk= github.com/containerd/ttrpc v1.2.5 h1:IFckT1EFQoFBMG4c3sMdT8EP3/aKfumK1msY+Ze4oLU= github.com/containerd/ttrpc v1.2.5/go.mod h1:YCXHsb32f+Sq5/72xHubdiJRQY9inL4a4ZQrAbN1q9o= -github.com/containerd/typeurl/v2 v2.1.1 h1:3Q4Pt7i8nYwy2KmQWIw2+1hTvwTE/6w9FqcttATPO/4= -github.com/containerd/typeurl/v2 v2.1.1/go.mod h1:IDp2JFvbwZ31H8dQbEIY7sDl2L3o3HZj1hsSQlywkQ0= +github.com/containerd/typeurl/v2 v2.2.0 h1:6NBDbQzr7I5LHgp34xAXYF5DOTQDn05X58lsPEmzLso= +github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= github.com/coreos/go-iptables v0.6.0 h1:is9qnZMPYjLd8LYqmm/qlE+wwEgJIkTYdhV3rfZo4jk= github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU= github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -236,12 +232,12 @@ github.com/distribution/distribution/v3 v3.0.0-alpha.1 h1:jn7I1gvjOvmLztH1+1cLiU github.com/distribution/distribution/v3 v3.0.0-alpha.1/go.mod h1:LCp4JZp1ZalYg0W/TN05jarCQu+h4w7xc7ZfQF4Y/cY= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/cli v27.2.0+incompatible h1:yHD1QEB1/0vr5eBNpu8tncu8gWxg8EydFPOSKHzXSMM= -github.com/docker/cli v27.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v27.2.1+incompatible h1:U5BPtiD0viUzjGAjV1p0MGB8eVA3L3cbIrnyWmSJI70= +github.com/docker/cli v27.2.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g= -github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= @@ -344,8 +340,9 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68= github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= @@ -394,8 +391,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/nftables v0.2.0 h1:PbJwaBmbVLzpeldoeUKGkE2RjstrjPKMl6oLrfEJ6/8= github.com/google/nftables v0.2.0/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= -github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b h1:h9U78+dx9a4BKdQkBBos92HalKpaGKHrp+3Uo6yTodo= -github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -439,8 +436,8 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= -github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= -github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hc-install v0.7.0 h1:Uu9edVqjKQxxuD28mR5TikkKDd/p55S8vzPC1659aBk= github.com/hashicorp/hc-install v0.7.0/go.mod h1:ELmmzZlGnEcqoUMKUuykHaPCIR1sYLYX+KSggWSKZuA= github.com/hashicorp/hcl/v2 v2.21.0 h1:lve4q/o/2rqwYOgUg3y3V2YPyD1/zkCLGjIV74Jit14= @@ -556,8 +553,8 @@ github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374 github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/moby/buildkit v0.13.1 h1:L8afOFhPq2RPJJSr/VyzbufwID7jquZVB7oFHbPRcPE= -github.com/moby/buildkit v0.13.1/go.mod h1:aNmNQKLBFYAOFuzQjR3VA27/FijlvtBD1pjNwTSN37k= +github.com/moby/buildkit v0.16.0 h1:wOVBj1o5YNVad/txPQNXUXdelm7Hs/i0PUFjzbK0VKE= +github.com/moby/buildkit v0.16.0/go.mod h1:Xqx/5GlrqE1yIRORk0NSCVDFpQAU1WjlT6KHYZdisIQ= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= @@ -569,16 +566,18 @@ github.com/moby/swarmkit/v2 v2.0.0-20230315203717-e28e8ba9bc83/go.mod h1:GvjR7mC github.com/moby/sys/mount v0.3.3 h1:fX1SVkXFJ47XWDoeFW4Sq7PdQJnV2QIDZAqjNqgEjUs= github.com/moby/sys/mount v0.3.3/go.mod h1:PBaEorSNTLG5t/+4EgukEQVlAvVEc6ZjTySwKdqp5K0= github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= -github.com/moby/sys/mountinfo v0.7.1 h1:/tTvQaSJRr2FshkhXiIpux6fQ2Zvc4j7tAhMTStAG2g= -github.com/moby/sys/mountinfo v0.7.1/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= -github.com/moby/sys/signal v0.7.0 h1:25RW3d5TnQEoKvRbEKUGay6DCQ46IxAVTT9CUMgmsSI= -github.com/moby/sys/signal v0.7.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg= +github.com/moby/sys/signal v0.7.1 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0= +github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8= github.com/moby/sys/symlink v0.2.0 h1:tk1rOM+Ljp0nFmfOIBtlV3rTDlWOwFRhjEeAhZB0nZc= github.com/moby/sys/symlink v0.2.0/go.mod h1:7uZVF2dqJjG/NsClqul95CqKOBRQyYSNnJ6BMgR/gFs= -github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= -github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -729,6 +728,8 @@ github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ0 github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= +github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 h1:7I5c2Ig/5FgqkYOh/N87NzoyI9U15qUPXhDD8uCupv8= +github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4/go.mod h1:278M4p8WsNh3n4a1eqiFcV2FGk7wE5fwUpUom9mK9lE= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twpayne/go-vfs/v5 v5.0.4 h1:/ne3h+rW7f5YOyOFguz+3ztfUwzOLR0Vts3y0mMAitg= @@ -835,8 +836,8 @@ golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY= golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= @@ -884,8 +885,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -924,15 +925,15 @@ golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= -golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= -golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= +golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -942,8 +943,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/integration/integration_test.go b/integration/integration_test.go index b7332c04..e7fbc959 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "crypto/ed25519" "encoding/base64" "encoding/json" "encoding/pem" @@ -27,11 +28,13 @@ import ( "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/envbuilder" "github.com/coder/envbuilder/devcontainer/features" - "github.com/coder/envbuilder/internal/magicdir" + "github.com/coder/envbuilder/internal/workingdir" "github.com/coder/envbuilder/options" "github.com/coder/envbuilder/testutil/gittest" "github.com/coder/envbuilder/testutil/mwtest" "github.com/coder/envbuilder/testutil/registrytest" + "github.com/go-git/go-billy/v5/osfs" + gossh "golang.org/x/crypto/ssh" clitypes "github.com/docker/cli/cli/config/types" "github.com/docker/docker/api/types" @@ -55,9 +58,20 @@ import ( ) const ( - testContainerLabel = "envbox-integration-test" - testImageAlpine = "localhost:5000/envbuilder-test-alpine:latest" - testImageUbuntu = "localhost:5000/envbuilder-test-ubuntu:latest" + testContainerLabel = "envbox-integration-test" + testImageAlpine = "localhost:5000/envbuilder-test-alpine:latest" + testImageUbuntu = "localhost:5000/envbuilder-test-ubuntu:latest" + testImageBlobUnknown = "localhost:5000/envbuilder-test-blob-unknown:latest" + + // nolint:gosec // Throw-away key for testing. DO NOT REUSE. + testSSHKey = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBXOGgAge/EbcejqASqZa6s8PFXZle56DiGEt0VYnljuwAAAKgM05mUDNOZ +lAAAAAtzc2gtZWQyNTUxOQAAACBXOGgAge/EbcejqASqZa6s8PFXZle56DiGEt0VYnljuw +AAAEDCawwtjrM4AGYXD1G6uallnbsgMed4cfkFsQ+mLZtOkFc4aACB78Rtx6OoBKplrqzw +8VdmV7noOIYS3RVieWO7AAAAHmNpYW5AY2RyLW1icC1mdmZmdzBuOHEwNXAuaG9tZQECAw +QFBgc= +-----END OPENSSH PRIVATE KEY-----` ) func TestLogs(t *testing.T) { @@ -110,10 +124,12 @@ func TestLogs(t *testing.T) { "Dockerfile": fmt.Sprintf(`FROM %s`, testImageUbuntu), }, }) - _, err := runEnvbuilder(t, runOpts{env: []string{ + ctrID, err := runEnvbuilder(t, runOpts{env: []string{ envbuilderEnv("GIT_URL", srv.URL), "CODER_AGENT_URL=" + logSrv.URL, "CODER_AGENT_TOKEN=" + token, + "ENVBUILDER_SETUP_SCRIPT=/bin/sh -c 'echo MY${NO_MATCH_ENV}_SETUP_SCRIPT_OUT; echo MY${NO_MATCH_ENV}_SETUP_SCRIPT_ERR' 1>&2", + "ENVBUILDER_INIT_SCRIPT=env", }}) require.NoError(t, err) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) @@ -123,6 +139,30 @@ func TestLogs(t *testing.T) { t.Fatal("timed out waiting for logs") case <-logsDone: } + + // Wait for the container to exit + client, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + require.NoError(t, err) + require.Eventually(t, func() bool { + status, err := client.ContainerInspect(ctx, ctrID) + if !assert.NoError(t, err) { + return false + } + return !status.State.Running + }, 10*time.Second, time.Second, "container never exited") + + // Check the expected log output + logReader, err := client.ContainerLogs(ctx, ctrID, container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + }) + require.NoError(t, err) + logBytes, err := io.ReadAll(logReader) + require.NoError(t, err) + logs := string(logBytes) + require.Contains(t, logs, "CODER_AGENT_SUBSYSTEM=envbuilder") + require.Contains(t, logs, "MY_SETUP_SCRIPT_OUT") + require.Contains(t, logs, "MY_SETUP_SCRIPT_ERR") } func TestInitScriptInitCommand(t *testing.T) { @@ -378,6 +418,54 @@ func TestSucceedsGitAuth(t *testing.T) { require.Contains(t, gitConfig, srv.URL) } +func TestGitSSHAuth(t *testing.T) { + t.Parallel() + + base64Key := base64.StdEncoding.EncodeToString([]byte(testSSHKey)) + + t.Run("Base64/Success", func(t *testing.T) { + signer, err := gossh.ParsePrivateKey([]byte(testSSHKey)) + require.NoError(t, err) + require.NotNil(t, signer) + + tmpDir := t.TempDir() + srvFS := osfs.New(tmpDir, osfs.WithChrootOS()) + + _ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "Dockerfile", "FROM "+testImageAlpine, "Initial commit")) + tr := gittest.NewServerSSH(t, srvFS, signer.PublicKey()) + + _, err = runEnvbuilder(t, runOpts{env: []string{ + envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"), + envbuilderEnv("GIT_URL", tr.String()+"."), + envbuilderEnv("GIT_SSH_PRIVATE_KEY_BASE64", base64Key), + }}) + // TODO: Ensure it actually clones but this does mean we have + // successfully authenticated. + require.ErrorContains(t, err, "repository not found") + }) + + t.Run("Base64/Failure", func(t *testing.T) { + _, randomKey, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + signer, err := gossh.NewSignerFromKey(randomKey) + require.NoError(t, err) + require.NotNil(t, signer) + + tmpDir := t.TempDir() + srvFS := osfs.New(tmpDir, osfs.WithChrootOS()) + + _ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "Dockerfile", "FROM "+testImageAlpine, "Initial commit")) + tr := gittest.NewServerSSH(t, srvFS, signer.PublicKey()) + + _, err = runEnvbuilder(t, runOpts{env: []string{ + envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"), + envbuilderEnv("GIT_URL", tr.String()+"."), + envbuilderEnv("GIT_SSH_PRIVATE_KEY_BASE64", base64Key), + }}) + require.ErrorContains(t, err, "handshake failed") + }) +} + func TestSucceedsGitAuthInURL(t *testing.T) { t.Parallel() srv := gittest.CreateGitServer(t, gittest.Options{ @@ -484,27 +572,142 @@ func TestBuildFromDevcontainerWithFeatures(t *testing.T) { require.Equal(t, "hello from test 3!", strings.TrimSpace(test3Output)) } -func TestBuildFromDockerfile(t *testing.T) { - // Ensures that a Git repository with a Dockerfile is cloned and built. - srv := gittest.CreateGitServer(t, gittest.Options{ - Files: map[string]string{ - "Dockerfile": "FROM " + testImageAlpine, +func TestBuildFromDockerfileAndConfig(t *testing.T) { + t.Parallel() + + type configFile struct { + name string + data string + } + type testCase struct { + name string + env []string + configFile configFile + configBase64 string + validate func(t *testing.T, tc testCase, ctrID, logs string) + } + + validateDockerConfig := func(t *testing.T, tc testCase, ctrID, logs string) { + t.Helper() + + // Ensure that the config matches the expected value, base64 is + // always prioritized over a file. + got := execContainer(t, ctrID, "cat /docker_config_json") + got = strings.TrimSpace(got) + want := tc.configBase64 + if want == "" { + want = tc.configFile.data + } + if want != "" { + require.Contains(t, logs, "Set DOCKER_CONFIG to /.envbuilder/.docker") + require.Equal(t, want, got) + } + + // Ensure that a warning message is printed if config secrets + // will remain in the container after build. + warningMessage := "this file will remain after the build" + if tc.configFile.name != "" { + require.Contains(t, logs, warningMessage) + } else { + require.NotContains(t, logs, warningMessage) + } + } + + configJSONContainerPath := workingdir.Default.Join(".docker", "config.json") + defaultConfigJSON := `{"experimental": "enabled"}` + + tests := []testCase{ + { + name: "Plain", + validate: func(t *testing.T, tc testCase, ctrID, logs string) { + output := execContainer(t, ctrID, "echo hello") + require.Equal(t, "hello", strings.TrimSpace(output)) + }, }, - }) - ctr, err := runEnvbuilder(t, runOpts{env: []string{ - envbuilderEnv("GIT_URL", srv.URL), - envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"), - envbuilderEnv("DOCKER_CONFIG_BASE64", base64.StdEncoding.EncodeToString([]byte(`{"experimental": "enabled"}`))), - }}) - require.NoError(t, err) + { + name: "ConfigBase64", + configBase64: defaultConfigJSON, + validate: validateDockerConfig, + }, + { + name: "BindConfigToKnownLocation", + configFile: configFile{"/.envbuilder/config.json", defaultConfigJSON}, + validate: validateDockerConfig, + }, + { + name: "BindConfigToPath", + env: []string{"DOCKER_CONFIG=/secret"}, + configFile: configFile{"/secret/config.json", defaultConfigJSON}, + validate: validateDockerConfig, + }, + { + name: "BindConfigToCustomFile", + env: []string{"DOCKER_CONFIG=/secret/my.json"}, + configFile: configFile{"/secret/my.json", defaultConfigJSON}, + validate: validateDockerConfig, + }, + { + name: "ConfigBase64AndBindUsesBase64", + configFile: configFile{"/.envbuilder/config.json", `{"experimental": "disabled"}`}, + configBase64: defaultConfigJSON, + validate: validateDockerConfig, + }, + { + name: "ConfigBase64AndCustomConfigPath", + env: []string{"DOCKER_CONFIG=/secret"}, + configBase64: defaultConfigJSON, + validate: validateDockerConfig, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - output := execContainer(t, ctr, "echo hello") - require.Equal(t, "hello", strings.TrimSpace(output)) + // Ensures that a Git repository with a Dockerfile is cloned and built. + srv := gittest.CreateGitServer(t, gittest.Options{ + Files: map[string]string{ + "Dockerfile": fmt.Sprintf(` + FROM %[1]s + RUN if [ -f %[2]q ]; then cat %[2]q > /docker_config_json; fi + `, testImageAlpine, configJSONContainerPath), + }, + }) + + logbuf := new(bytes.Buffer) + opts := runOpts{ + env: []string{ + envbuilderEnv("GIT_URL", srv.URL), + envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"), + }, + logbuf: logbuf, + } + + if tt.configFile.name != "" { + dir := t.TempDir() + configFile := filepath.Join(dir, filepath.Base(tt.configFile.name)) + err := os.WriteFile(configFile, []byte(tt.configFile.data), 0o600) + require.NoError(t, err, "failed to write config") + + opts.privileged = true + opts.binds = []string{fmt.Sprintf("%s:%s:rw", configFile, tt.configFile.name)} + } + if tt.configBase64 != "" { + enc := base64.StdEncoding.EncodeToString([]byte(tt.configBase64)) + tt.env = append(tt.env, envbuilderEnv("DOCKER_CONFIG_BASE64", enc)) + } + + opts.env = append(opts.env, tt.env...) + + ctrID, err := runEnvbuilder(t, opts) + require.NoError(t, err) - // Verify that the Docker configuration secret file is removed - configJSONContainerPath := magicdir.Default.Join("config.json") - output = execContainer(t, ctr, "stat "+configJSONContainerPath) - require.Contains(t, output, "No such file or directory") + tt.validate(t, tt, ctrID, logbuf.String()) + + // Always verify that the Docker configuration secret file is removed. + output := execContainer(t, ctrID, "stat "+configJSONContainerPath) + require.Contains(t, output, "No such file or directory") + }) + } } func TestBuildPrintBuildOutput(t *testing.T) { @@ -619,6 +822,28 @@ func TestBuildFromDevcontainerInCustomPath(t *testing.T) { require.Equal(t, "hello", strings.TrimSpace(output)) } +func TestBuildFromCustomWorkspaceBaseDir(t *testing.T) { + t.Parallel() + + // Ensures that a Git repository with a devcontainer.json is cloned and built. + srv := gittest.CreateGitServer(t, gittest.Options{ + Files: map[string]string{ + "Dockerfile": "FROM " + testImageUbuntu, + }, + }) + ctr, err := runEnvbuilder(t, runOpts{ + env: []string{ + envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"), + envbuilderEnv("WORKSPACE_BASE_DIR", "/foo"), + envbuilderEnv("GIT_URL", srv.URL), + }, + }) + require.NoError(t, err) + + output := execContainer(t, ctr, "readlink /proc/1/cwd") + require.Contains(t, output, "/foo/") +} + func TestBuildFromDevcontainerInSubfolder(t *testing.T) { t.Parallel() @@ -844,15 +1069,17 @@ func TestContainerEnv(t *testing.T) { require.NoError(t, err) output := execContainer(t, ctr, "cat /env") - require.Contains(t, strings.TrimSpace(output), - `DEVCONTAINER=true + want := `DEVCONTAINER=true DEVCONTAINER_CONFIG=/workspaces/empty/.devcontainer/devcontainer.json ENVBUILDER=true FROM_CONTAINER_ENV=bar FROM_DOCKERFILE=foo FROM_REMOTE_ENV=baz PATH=/usr/local/bin:/bin:/go/bin:/opt -REMOTE_BAR=bar`) +REMOTE_BAR=bar` + if diff := cmp.Diff(want, strings.TrimSpace(output)); diff != "" { + require.Failf(t, "env mismatch", "diff (-want +got):\n%s", diff) + } } func TestUnsetOptionsEnv(t *testing.T) { @@ -898,37 +1125,88 @@ func TestUnsetOptionsEnv(t *testing.T) { func TestLifecycleScripts(t *testing.T) { t.Parallel() - // Ensures that a Git repository with a devcontainer.json is cloned and built. - srv := gittest.CreateGitServer(t, gittest.Options{ - Files: map[string]string{ - ".devcontainer/devcontainer.json": `{ - "name": "Test", - "build": { - "dockerfile": "Dockerfile" - }, - "onCreateCommand": "echo create > /tmp/out", - "updateContentCommand": ["sh", "-c", "echo update >> /tmp/out"], - "postCreateCommand": "(echo -n postCreate. ; id -un) >> /tmp/out", - "postStartCommand": { - "parallel1": "echo parallel1 > /tmp/parallel1", - "parallel2": ["sh", "-c", "echo parallel2 > /tmp/parallel2"] - } - }`, - ".devcontainer/Dockerfile": "FROM " + testImageAlpine + "\nUSER nobody", + for _, tt := range []struct { + name string + files map[string]string + outputCmd string + expectOutput string + }{ + { + name: "build", + files: map[string]string{ + ".devcontainer/devcontainer.json": `{ + "name": "Test", + "build": { + "dockerfile": "Dockerfile" + }, + "onCreateCommand": "echo create > /tmp/out", + "updateContentCommand": ["sh", "-c", "echo update >> /tmp/out"], + "postCreateCommand": "(echo -n postCreate. ; id -un) >> /tmp/out", + "postStartCommand": { + "parallel1": "echo parallel1 > /tmp/parallel1", + "parallel2": ["sh", "-c", "echo parallel2 > /tmp/parallel2"] + } + }`, + ".devcontainer/Dockerfile": "FROM " + testImageAlpine + "\nUSER nobody", + }, + outputCmd: "cat /tmp/out /tmp/parallel1 /tmp/parallel2", + expectOutput: "create\nupdate\npostCreate.nobody\nparallel1\nparallel2", }, - }) - ctr, err := runEnvbuilder(t, runOpts{env: []string{ - envbuilderEnv("GIT_URL", srv.URL), - }}) - require.NoError(t, err) - - output := execContainer(t, ctr, "cat /tmp/out /tmp/parallel1 /tmp/parallel2") - require.Equal(t, - `create -update -postCreate.nobody -parallel1 -parallel2`, strings.TrimSpace(output)) + { + name: "image", + files: map[string]string{ + ".devcontainer/devcontainer.json": fmt.Sprintf(`{ + "name": "Test", + "image": %q, + "containerUser": "nobody", + "onCreateCommand": "echo create > /tmp/out", + "updateContentCommand": ["sh", "-c", "echo update >> /tmp/out"], + "postCreateCommand": "(echo -n postCreate. ; id -un) >> /tmp/out", + "postStartCommand": { + "parallel1": "echo parallel1 > /tmp/parallel1", + "parallel2": ["sh", "-c", "echo parallel2 > /tmp/parallel2"] + } + }`, testImageAlpine), + }, + outputCmd: "cat /tmp/out /tmp/parallel1 /tmp/parallel2", + expectOutput: "create\nupdate\npostCreate.nobody\nparallel1\nparallel2", + }, + { + name: "label", + files: map[string]string{ + ".devcontainer/Dockerfile": fmt.Sprintf(`FROM %s + LABEL devcontainer.metadata='[{ \ + "onCreateCommand": "echo create > /tmp/out", \ + "updateContentCommand": ["sh", "-c", "echo update >> /tmp/out"], \ + "postCreateCommand": "(echo -n postCreate. ; id -un) >> /tmp/out", \ + "postStartCommand": { \ + "parallel1": "echo parallel1 > /tmp/parallel1", \ + "parallel2": ["sh", "-c", "echo parallel2 > /tmp/parallel2"] \ + } \ + }]' + USER nobody`, testImageAlpine), + }, + outputCmd: "cat /tmp/out /tmp/parallel1 /tmp/parallel2", + expectOutput: "create\nupdate\npostCreate.nobody\nparallel1\nparallel2", + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + srv := gittest.CreateGitServer(t, gittest.Options{ + Files: tt.files, + }) + env := []string{ + envbuilderEnv("GIT_URL", srv.URL), + } + if _, ok := tt.files[".devcontainer/devcontainer.json"]; !ok { + env = append(env, envbuilderEnv("DOCKERFILE_PATH", ".devcontainer/Dockerfile")) + } + ctr, err := runEnvbuilder(t, runOpts{env: env}) + require.NoError(t, err, "failed to run envbuilder") + output := execContainer(t, ctr, tt.outputCmd) + require.Equal(t, tt.expectOutput, strings.TrimSpace(output)) + }) + } } func TestPostStartScript(t *testing.T) { @@ -1678,9 +1956,10 @@ RUN date --utc > /root/date.txt`, testImageAlpine), _, err = remote.Image(ref, remoteAuthOpt) require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push") - // When: we run envbuilder with PUSH_IMAGE set + // When: we run envbuilder with PUSH_IMAGE and EXIT_ON_PUSH_FAILURE set _, err = runEnvbuilder(t, runOpts{env: append(opts, envbuilderEnv("PUSH_IMAGE", "1"), + envbuilderEnv("EXIT_ON_PUSH_FAILURE", "1"), )}) // Then: it should fail with an Unauthorized error require.ErrorContains(t, err, "401 Unauthorized", "expected unauthorized error using no auth when cache repo requires it") @@ -1873,7 +2152,7 @@ RUN date --utc > /root/date.txt`, testImageAlpine), require.ErrorContains(t, err, "--cache-repo must be set when using --push-image") }) - t.Run("PushErr", func(t *testing.T) { + t.Run("PushErr/ExitOnPushFail", func(t *testing.T) { t.Parallel() srv := gittest.CreateGitServer(t, gittest.Options{ @@ -1903,12 +2182,50 @@ RUN date --utc > /root/date.txt`, testImageAlpine), envbuilderEnv("GIT_URL", srv.URL), envbuilderEnv("CACHE_REPO", notRegURL), envbuilderEnv("PUSH_IMAGE", "1"), + envbuilderEnv("EXIT_ON_PUSH_FAILURE", "1"), }}) // Then: envbuilder should fail with a descriptive error require.ErrorContains(t, err, "failed to push to destination") }) + t.Run("PushErr/NoExitOnPushFail", func(t *testing.T) { + t.Parallel() + + srv := gittest.CreateGitServer(t, gittest.Options{ + Files: map[string]string{ + ".devcontainer/Dockerfile": fmt.Sprintf(`FROM %s +USER root +ARG WORKDIR=/ +WORKDIR $WORKDIR +ENV FOO=bar +RUN echo $FOO > /root/foo.txt +RUN date --utc > /root/date.txt`, testImageAlpine), + ".devcontainer/devcontainer.json": `{ + "name": "Test", + "build": { + "dockerfile": "Dockerfile" + }, + }`, + }, + }) + + // Given: registry is not set up (in this case, not a registry) + notRegSrv := httptest.NewServer(http.NotFoundHandler()) + notRegURL := strings.TrimPrefix(notRegSrv.URL, "http://") + "/test" + + // When: we run envbuilder with PUSH_IMAGE set + _, err := runEnvbuilder(t, runOpts{env: []string{ + envbuilderEnv("GIT_URL", srv.URL), + envbuilderEnv("CACHE_REPO", notRegURL), + envbuilderEnv("PUSH_IMAGE", "1"), + envbuilderEnv("EXIT_ON_PUSH_FAILURE", "0"), + }}) + + // Then: envbuilder should not fail + require.NoError(t, err) + }) + t.Run("CacheAndPushDevcontainerFeatures", func(t *testing.T) { t.Parallel() @@ -2022,6 +2339,38 @@ USER devalot } require.Fail(t, "expected pid 1 to be running as devalot") }) + + t.Run("PushDuplicateLayersNoBlobUnknown", func(t *testing.T) { + t.Parallel() + + srv := gittest.CreateGitServer(t, gittest.Options{ + Files: map[string]string{ + ".devcontainer/Dockerfile": fmt.Sprintf(`FROM %s +USER root +RUN echo "hi i r empty" +RUN echo "who u" +`, testImageBlobUnknown), + ".devcontainer/devcontainer.json": `{ + "name": "Test", + "build": { + "dockerfile": "Dockerfile" + }, + }`, + }, + }) + + // NOTE(mafredri): The in-memory registry doesn't catch this error so we + // have to use registry:2. + ref, err := name.ParseReference(fmt.Sprintf("localhost:5000/test-blob-unknown-%s", uuid.NewString())) + require.NoError(t, err) + opts := []string{ + envbuilderEnv("GIT_URL", srv.URL), + envbuilderEnv("CACHE_REPO", ref.String()), + envbuilderEnv("VERBOSE", "1"), + } + + _ = pushImage(t, ref, nil, opts...) + }) } func TestChownHomedir(t *testing.T) { @@ -2150,8 +2499,13 @@ func pushImage(t *testing.T, ref name.Reference, remoteOpt remote.Option, env .. if remoteOpt != nil { remoteOpts = append(remoteOpts, remoteOpt) } - - _, err := runEnvbuilder(t, runOpts{env: append(env, envbuilderEnv("PUSH_IMAGE", "1"))}) + opts := runOpts{ + env: append(env, + envbuilderEnv("PUSH_IMAGE", "1"), + envbuilderEnv("EXIT_ON_PUSH_FAILURE", "1"), + ), + } + _, err := runEnvbuilder(t, opts) require.NoError(t, err, "envbuilder push image failed") img, err := remote.Image(ref, remoteOpts...) @@ -2195,6 +2549,8 @@ func getCachedImage(ctx context.Context, t *testing.T, cli *client.Client, env . } func startContainerFromRef(ctx context.Context, t *testing.T, cli *client.Client, ref name.Reference) container.CreateResponse { + t.Helper() + // Ensure that we can pull the image. rc, err := cli.ImagePull(ctx, ref.String(), image.PullOptions{}) require.NoError(t, err) @@ -2229,10 +2585,12 @@ func startContainerFromRef(ctx context.Context, t *testing.T, cli *client.Client } type runOpts struct { - image string - binds []string - env []string - volumes map[string]string + image string + privileged bool // Required for remounting. + binds []string + env []string + volumes map[string]string + logbuf *bytes.Buffer } // runEnvbuilder starts the envbuilder container with the given environment @@ -2270,17 +2628,22 @@ func runEnvbuilder(t *testing.T, opts runOpts) (string, error) { require.NoError(t, err, "failed to read image pull response") img = opts.image } + hostConfig := &container.HostConfig{ + NetworkMode: container.NetworkMode("host"), + Binds: opts.binds, + Mounts: mounts, + } + if opts.privileged { + hostConfig.CapAdd = append(hostConfig.CapAdd, "SYS_ADMIN") + hostConfig.Privileged = true + } ctr, err := cli.ContainerCreate(ctx, &container.Config{ Image: img, Env: opts.env, Labels: map[string]string{ testContainerLabel: "true", }, - }, &container.HostConfig{ - NetworkMode: container.NetworkMode("host"), - Binds: opts.binds, - Mounts: mounts, - }, nil, nil, "") + }, hostConfig, nil, nil, "") require.NoError(t, err) t.Cleanup(func() { _ = cli.ContainerRemove(ctx, ctr.ID, container.RemoveOptions{ @@ -2294,6 +2657,9 @@ func runEnvbuilder(t *testing.T, opts runOpts) (string, error) { logChan, errChan := streamContainerLogs(t, cli, ctr.ID) go func() { for log := range logChan { + if opts.logbuf != nil { + opts.logbuf.WriteString(log + "\n") + } if strings.HasPrefix(log, "=== Running init command") { errChan <- nil return diff --git a/integration/testdata/blob-unknown/Dockerfile b/integration/testdata/blob-unknown/Dockerfile new file mode 100644 index 00000000..fffcc574 --- /dev/null +++ b/integration/testdata/blob-unknown/Dockerfile @@ -0,0 +1,7 @@ +FROM alpine:latest + +# This will produce an empty layer via Docker. It will allow us to test for a +# conflicting empty layer produced by Kaniko. This is to check against the +# BLOB_UNKNOWN error when trying to upload the built image to a registry and +# Kaniko having overwritten this blob with its own. +WORKDIR /home diff --git a/internal/magicdir/magicdir_internal_test.go b/internal/magicdir/magicdir_internal_test.go deleted file mode 100644 index 43b66ba0..00000000 --- a/internal/magicdir/magicdir_internal_test.go +++ /dev/null @@ -1,38 +0,0 @@ -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/internal/magicdir/magicdir.go b/internal/workingdir/workingdir.go similarity index 67% rename from internal/magicdir/magicdir.go rename to internal/workingdir/workingdir.go index 5e062514..5df05234 100644 --- a/internal/magicdir/magicdir.go +++ b/internal/workingdir/workingdir.go @@ -1,4 +1,4 @@ -package magicdir +package workingdir import ( "fmt" @@ -6,10 +6,10 @@ import ( ) const ( - // defaultMagicDirBase is the default working location for envbuilder. + // defaultWorkingDirBase 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" + defaultWorkingDirBase = "/.envbuilder" // TempDir is a directory inside the build context inside which // we place files referenced by MagicDirectives. @@ -20,7 +20,7 @@ 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 + Default WorkingDir // Directives are directives automatically appended to Dockerfiles // when pushing the image. These directives allow the built image to be // 're-used'. @@ -30,33 +30,33 @@ COPY --chmod=0644 %[1]s/image %[2]s/image USER root WORKDIR / ENTRYPOINT ["%[2]s/bin/envbuilder"] -`, TempDir, defaultMagicDirBase) +`, TempDir, defaultWorkingDirBase) ) -// MagicDir is a working directory for envbuilder. It +// WorkingDir is a working directory for envbuilder. It // will also be present in images built by envbuilder. -type MagicDir struct { +type WorkingDir struct { base string } -// At returns a MagicDir rooted at filepath.Join(paths...) -func At(paths ...string) MagicDir { +// At returns a WorkingDir rooted at filepath.Join(paths...) +func At(paths ...string) WorkingDir { if len(paths) == 0 { - return MagicDir{} + return WorkingDir{} } - return MagicDir{base: filepath.Join(paths...)} + return WorkingDir{base: filepath.Join(paths...)} } // Join returns the result of filepath.Join([m.Path, paths...]). -func (m MagicDir) Join(paths ...string) string { +func (m WorkingDir) 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. +// String returns the string representation of the WorkingDir. +func (m WorkingDir) Path() string { + // Instead of the zero value, use defaultWorkingDir. if m.base == "" { - return defaultMagicDirBase + return defaultWorkingDirBase } return m.base } @@ -65,7 +65,7 @@ func (m MagicDir) Path() string { // 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 { +func (m WorkingDir) Built() string { return m.Join("built") } @@ -73,11 +73,11 @@ func (m MagicDir) Built() string { // 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 { +func (m WorkingDir) Image() string { return m.Join("image") } // Features is a directory that contains feature files. -func (m MagicDir) Features() string { +func (m WorkingDir) Features() string { return m.Join("features") } diff --git a/internal/workingdir/workingdir_internal_test.go b/internal/workingdir/workingdir_internal_test.go new file mode 100644 index 00000000..5e1dfc01 --- /dev/null +++ b/internal/workingdir/workingdir_internal_test.go @@ -0,0 +1,38 @@ +package workingdir + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_WorkingDir(t *testing.T) { + t.Parallel() + + t.Run("Default", func(t *testing.T) { + t.Parallel() + require.Equal(t, defaultWorkingDirBase+"/foo", Default.Join("foo")) + require.Equal(t, defaultWorkingDirBase, Default.Path()) + require.Equal(t, defaultWorkingDirBase+"/built", Default.Built()) + require.Equal(t, defaultWorkingDirBase+"/image", Default.Image()) + }) + + t.Run("ZeroValue", func(t *testing.T) { + t.Parallel() + var md WorkingDir + require.Equal(t, defaultWorkingDirBase+"/foo", md.Join("foo")) + require.Equal(t, defaultWorkingDirBase, md.Path()) + require.Equal(t, defaultWorkingDirBase+"/built", md.Built()) + require.Equal(t, defaultWorkingDirBase+"/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.go b/options/defaults.go index 220480d8..a6fd145b 100644 --- a/options/defaults.go +++ b/options/defaults.go @@ -2,36 +2,39 @@ package options import ( "fmt" + "path" "strings" "github.com/go-git/go-billy/v5/osfs" giturls "github.com/chainguard-dev/git-urls" "github.com/coder/envbuilder/internal/chmodfs" - "github.com/coder/envbuilder/internal/magicdir" + "github.com/coder/envbuilder/internal/workingdir" ) -// 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 { +func DefaultWorkspaceFolder(workspacesFolder, repoURL string) string { + // emptyWorkspaceDir is the path to a workspace that has + // nothing going on... it's empty! + emptyWorkspaceDir := workspacesFolder + "/empty" + if repoURL == "" { - return EmptyWorkspaceDir + return emptyWorkspaceDir } parsed, err := giturls.Parse(repoURL) if err != nil { - return EmptyWorkspaceDir + return emptyWorkspaceDir } - name := strings.Split(parsed.Path, "/") - hasOwnerAndRepo := len(name) >= 2 - if !hasOwnerAndRepo { - return EmptyWorkspaceDir + repo := path.Base(parsed.Path) + // Giturls parsing never actually fails since ParseLocal never + // errors and places the entire URL in the Path field. This check + // ensures it's at least a Unix path containing forwardslash. + if repo == repoURL || repo == "/" || repo == "." || repo == "" { + return emptyWorkspaceDir } - repo := strings.TrimSuffix(name[len(name)-1], ".git") - return fmt.Sprintf("/workspaces/%s", repo) + repo = strings.TrimSuffix(repo, ".git") + return fmt.Sprintf("%s/%s", workspacesFolder, repo) } func (o *Options) SetDefaults() { @@ -56,13 +59,16 @@ func (o *Options) SetDefaults() { if o.Filesystem == nil { o.Filesystem = chmodfs.New(osfs.New("/")) } + if o.WorkspaceBaseDir == "" { + o.WorkspaceBaseDir = "/workspaces" + } if o.WorkspaceFolder == "" { - o.WorkspaceFolder = DefaultWorkspaceFolder(o.GitURL) + o.WorkspaceFolder = DefaultWorkspaceFolder(o.WorkspaceBaseDir, o.GitURL) } if o.BinaryPath == "" { o.BinaryPath = "/.envbuilder/bin/envbuilder" } if o.MagicDirBase == "" { - o.MagicDirBase = magicdir.Default.Path() + o.MagicDirBase = workingdir.Default.Path() } } diff --git a/options/defaults_test.go b/options/defaults_test.go index 4387c084..3efd8f25 100644 --- a/options/defaults_test.go +++ b/options/defaults_test.go @@ -17,38 +17,110 @@ func TestDefaultWorkspaceFolder(t *testing.T) { successTests := []struct { name string + baseDir string gitURL string expected string }{ { name: "HTTP", + baseDir: "/workspaces", gitURL: "https://github.com/coder/envbuilder.git", expected: "/workspaces/envbuilder", }, { name: "SSH", + baseDir: "/workspaces", gitURL: "git@github.com:coder/envbuilder.git", expected: "/workspaces/envbuilder", }, { name: "username and password", + baseDir: "/workspaces", gitURL: "https://username:password@github.com/coder/envbuilder.git", expected: "/workspaces/envbuilder", }, + { + name: "trailing", + baseDir: "/workspaces", + gitURL: "https://github.com/coder/envbuilder.git/", + expected: "/workspaces/envbuilder", + }, + { + name: "trailing-x2", + baseDir: "/workspaces", + gitURL: "https://github.com/coder/envbuilder.git//", + expected: "/workspaces/envbuilder", + }, + { + name: "no .git", + baseDir: "/workspaces", + gitURL: "https://github.com/coder/envbuilder", + expected: "/workspaces/envbuilder", + }, + { + name: "trailing no .git", + baseDir: "/workspaces", + gitURL: "https://github.com/coder/envbuilder/", + expected: "/workspaces/envbuilder", + }, { name: "fragment", + baseDir: "/workspaces", gitURL: "https://github.com/coder/envbuilder.git#feature-branch", expected: "/workspaces/envbuilder", }, + { + name: "fragment-trailing", + baseDir: "/workspaces", + gitURL: "https://github.com/coder/envbuilder.git/#refs/heads/feature-branch", + expected: "/workspaces/envbuilder", + }, + { + name: "fragment-trailing no .git", + baseDir: "/workspaces", + gitURL: "https://github.com/coder/envbuilder/#refs/heads/feature-branch", + expected: "/workspaces/envbuilder", + }, + { + name: "space", + baseDir: "/workspaces", + gitURL: "https://github.com/coder/env%20builder.git", + expected: "/workspaces/env builder", + }, + { + name: "Unix path", + baseDir: "/workspaces", + gitURL: "/repo", + expected: "/workspaces/repo", + }, + { + name: "Unix subpath", + baseDir: "/workspaces", + gitURL: "/path/to/repo", + expected: "/workspaces/repo", + }, { name: "empty", + baseDir: "/workspaces", + gitURL: "", + expected: "/workspaces/empty", + }, + { + name: "non default workspaces folder", + baseDir: "/foo", + gitURL: "https://github.com/coder/envbuilder.git", + expected: "/foo/envbuilder", + }, + { + name: "non default workspaces folder empty git URL", + baseDir: "/foo", gitURL: "", - expected: options.EmptyWorkspaceDir, + expected: "/foo/empty", }, } for _, tt := range successTests { t.Run(tt.name, func(t *testing.T) { - dir := options.DefaultWorkspaceFolder(tt.gitURL) + dir := options.DefaultWorkspaceFolder(tt.baseDir, tt.gitURL) require.Equal(t, tt.expected, dir) }) } @@ -65,11 +137,23 @@ func TestDefaultWorkspaceFolder(t *testing.T) { name: "website URL", invalidURL: "www.google.com", }, + { + name: "Unix root", + invalidURL: "/", + }, + { + name: "Path consists entirely of slash", + invalidURL: "//", + }, + { + name: "Git URL with no path", + invalidURL: "http://127.0.0.1:41073", + }, } for _, tt := range invalidTests { t.Run(tt.name, func(t *testing.T) { - dir := options.DefaultWorkspaceFolder(tt.invalidURL) - require.Equal(t, options.EmptyWorkspaceDir, dir) + dir := options.DefaultWorkspaceFolder("/workspaces", tt.invalidURL) + require.Equal(t, "/workspaces/empty", dir) }) } } @@ -78,14 +162,15 @@ func TestOptions_SetDefaults(t *testing.T) { t.Parallel() expected := options.Options{ - InitScript: "sleep infinity", - InitCommand: "/bin/sh", - IgnorePaths: []string{"/var/run", "/product_uuid", "/product_name"}, - Filesystem: chmodfs.New(osfs.New("/")), - GitURL: "", - WorkspaceFolder: options.EmptyWorkspaceDir, - MagicDirBase: "/.envbuilder", - BinaryPath: "/.envbuilder/bin/envbuilder", + InitScript: "sleep infinity", + InitCommand: "/bin/sh", + IgnorePaths: []string{"/var/run", "/product_uuid", "/product_name"}, + Filesystem: chmodfs.New(osfs.New("/")), + GitURL: "", + WorkspaceBaseDir: "/workspaces", + WorkspaceFolder: "/workspaces/empty", + MagicDirBase: "/.envbuilder", + BinaryPath: "/.envbuilder/bin/envbuilder", } var actual options.Options diff --git a/options/options.go b/options/options.go index 18bd56d1..9b9edda2 100644 --- a/options/options.go +++ b/options/options.go @@ -78,6 +78,10 @@ type Options struct { // devcontainer.json or image is provided. However, it ensures that the // container stops if the build process encounters an error. ExitOnBuildFailure bool + // ExitOnPushFailure terminates the container upon a push failure. This is + // useful if failure to push the built image should abort execution + // and result in an error. + ExitOnPushFailure bool // ForceSafe ignores any filesystem safety checks. This could cause serious // harm to your system! This is used in cases where bypass is needed to // unblock customers. @@ -108,10 +112,17 @@ type Options struct { // GitSSHPrivateKeyPath is the path to an SSH private key to be used for // Git authentication. GitSSHPrivateKeyPath string + // GitSSHPrivateKeyBase64 is the content of an SSH private key to be used + // for Git authentication. + GitSSHPrivateKeyBase64 string // GitHTTPProxyURL is the URL for the HTTP proxy. This is optional. GitHTTPProxyURL string + // WorkspaceBaseDir is the path under which workspaces will be placed when + // workspace folder option is not given. + WorkspaceBaseDir string // WorkspaceFolder is the path to the workspace folder that will be built. - // This is optional. + // This is optional. Defaults to `[workspace base dir]/[name]` where name is + // the name of the repository or "empty". WorkspaceFolder string // SSLCertBase64 is the content of an SSL cert file. This is useful for // self-signed certificates. @@ -274,7 +285,9 @@ func (o *Options) CLI() serpent.OptionSet { Env: WithEnvPrefix("DOCKER_CONFIG_BASE64"), Value: serpent.StringOf(&o.DockerConfigBase64), Description: "The base64 encoded Docker config file that " + - "will be used to pull images from private container registries.", + "will be used to pull images from private container registries. " + + "When this is set, Docker configuration set via the DOCKER_CONFIG " + + "environment variable is ignored.", }, { Flag: "fallback-image", @@ -296,6 +309,14 @@ func (o *Options) CLI() serpent.OptionSet { "no devcontainer.json or image is provided. However, it ensures " + "that the container stops if the build process encounters an error.", }, + { + Flag: "exit-on-push-failure", + Env: WithEnvPrefix("EXIT_ON_PUSH_FAILURE"), + Value: serpent.BoolOf(&o.ExitOnPushFailure), + Description: "ExitOnPushFailure terminates the container upon a push failure. " + + "This is useful if failure to push the built image should abort execution " + + "and result in an error.", + }, { Flag: "force-safe", Env: WithEnvPrefix("FORCE_SAFE"), @@ -358,10 +379,18 @@ func (o *Options) CLI() serpent.OptionSet { Description: "The password to use for Git authentication. This is optional.", }, { - Flag: "git-ssh-private-key-path", - Env: WithEnvPrefix("GIT_SSH_PRIVATE_KEY_PATH"), - Value: serpent.StringOf(&o.GitSSHPrivateKeyPath), - Description: "Path to an SSH private key to be used for Git authentication.", + Flag: "git-ssh-private-key-path", + Env: WithEnvPrefix("GIT_SSH_PRIVATE_KEY_PATH"), + Value: serpent.StringOf(&o.GitSSHPrivateKeyPath), + Description: "Path to an SSH private key to be used for Git authentication." + + " If this is set, then GIT_SSH_PRIVATE_KEY_BASE64 cannot be set.", + }, + { + Flag: "git-ssh-private-key-base64", + Env: WithEnvPrefix("GIT_SSH_PRIVATE_KEY_BASE64"), + Value: serpent.StringOf(&o.GitSSHPrivateKeyBase64), + Description: "Base64 encoded SSH private key to be used for Git authentication." + + " If this is set, then GIT_SSH_PRIVATE_KEY_PATH cannot be set.", }, { Flag: "git-http-proxy-url", @@ -369,12 +398,21 @@ func (o *Options) CLI() serpent.OptionSet { Value: serpent.StringOf(&o.GitHTTPProxyURL), Description: "The URL for the HTTP proxy. This is optional.", }, + { + Flag: "workspace-base-dir", + Env: WithEnvPrefix("WORKSPACE_BASE_DIR"), + Value: serpent.StringOf(&o.WorkspaceBaseDir), + Default: "/workspaces", + Description: "The path under which workspaces will be placed when " + + "workspace folder option is not given.", + }, { Flag: "workspace-folder", Env: WithEnvPrefix("WORKSPACE_FOLDER"), Value: serpent.StringOf(&o.WorkspaceFolder), - Description: "The path to the workspace folder that will " + - "be built. This is optional.", + Description: "The path to the workspace folder that will be built. " + + "This is optional. Defaults to `[workspace base dir]/[name]` where " + + "name is the name of the repository or `empty`.", }, { Flag: "ssl-cert-base64", diff --git a/options/testdata/options.golden b/options/testdata/options.golden index 0bfbd64a..203c197b 100644 --- a/options/testdata/options.golden +++ b/options/testdata/options.golden @@ -47,7 +47,9 @@ OPTIONS: --docker-config-base64 string, $ENVBUILDER_DOCKER_CONFIG_BASE64 The base64 encoded Docker config file that will be used to pull images - from private container registries. + from private container registries. When this is set, Docker + configuration set via the DOCKER_CONFIG environment variable is + ignored. --dockerfile-path string, $ENVBUILDER_DOCKERFILE_PATH The relative path to the Dockerfile that will be used to build the @@ -60,6 +62,11 @@ OPTIONS: image is provided. However, it ensures that the container stops if the build process encounters an error. + --exit-on-push-failure bool, $ENVBUILDER_EXIT_ON_PUSH_FAILURE + ExitOnPushFailure terminates the container upon a push failure. This + is useful if failure to push the built image should abort execution + and result in an error. + --export-env-file string, $ENVBUILDER_EXPORT_ENV_FILE Optional file path to a .env file where envbuilder will dump environment variables from devcontainer.json and the built container @@ -94,8 +101,13 @@ OPTIONS: --git-password string, $ENVBUILDER_GIT_PASSWORD The password to use for Git authentication. This is optional. + --git-ssh-private-key-base64 string, $ENVBUILDER_GIT_SSH_PRIVATE_KEY_BASE64 + Base64 encoded SSH private key to be used for Git authentication. If + this is set, then GIT_SSH_PRIVATE_KEY_PATH cannot be set. + --git-ssh-private-key-path string, $ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH - Path to an SSH private key to be used for Git authentication. + Path to an SSH private key to be used for Git authentication. If this + is set, then GIT_SSH_PRIVATE_KEY_BASE64 cannot be set. --git-url string, $ENVBUILDER_GIT_URL The URL of a Git repository containing a Devcontainer or Docker image @@ -165,6 +177,12 @@ OPTIONS: --verbose bool, $ENVBUILDER_VERBOSE Enable verbose logging. + --workspace-base-dir string, $ENVBUILDER_WORKSPACE_BASE_DIR (default: /workspaces) + The path under which workspaces will be placed when workspace folder + option is not given. + --workspace-folder string, $ENVBUILDER_WORKSPACE_FOLDER The path to the workspace folder that will be built. This is optional. + Defaults to `[workspace base dir]/[name]` where name is the name of + the repository or `empty`. diff --git a/scripts/docsgen/main.go b/scripts/docsgen/main.go index b61de096..deb308f8 100644 --- a/scripts/docsgen/main.go +++ b/scripts/docsgen/main.go @@ -16,5 +16,5 @@ func main() { if err != nil { panic(err) } - fmt.Printf("%s updated successfully with the latest flags!", path) + fmt.Printf("%s updated successfully with the latest flags!\n", path) }