From 23f2cf5f48b4cc9ecc379bccacb197c0f6f0f8f5 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 4 Sep 2024 11:19:25 +0100 Subject: [PATCH 1/3] fix(internal/provider): correctly override from extra_env (#44) Relates to #43 Our previous logic did not pass options from extra_env to envbuilder.RunCacheProbe. This fixes the logic and adds more comprehensive tests around the overriding logic. Future commits will refactor this logic some more. --- internal/provider/cached_image_resource.go | 367 ++++++++++++------ .../provider/cached_image_resource_test.go | 134 +++++-- internal/provider/provider_internal_test.go | 359 +++++++++++++++++ internal/provider/provider_test.go | 48 ++- 4 files changed, 741 insertions(+), 167 deletions(-) create mode 100644 internal/provider/provider_internal_test.go diff --git a/internal/provider/cached_image_resource.go b/internal/provider/cached_image_resource.go index dbd5500..c3f9378 100644 --- a/internal/provider/cached_image_resource.go +++ b/internal/provider/cached_image_resource.go @@ -16,12 +16,14 @@ import ( "github.com/coder/envbuilder/constants" eblog "github.com/coder/envbuilder/log" eboptions "github.com/coder/envbuilder/options" + "github.com/coder/serpent" "github.com/go-git/go-billy/v5/osfs" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/uuid" + "github.com/spf13/pflag" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -293,128 +295,246 @@ func (r *CachedImageResource) Configure(ctx context.Context, req resource.Config r.client = client } -// setComputedEnv sets data.Env and data.EnvMap based on the values of the -// other fields in the model. -func (data *CachedImageResourceModel) setComputedEnv(ctx context.Context) diag.Diagnostics { - env := make(map[string]string) +func optionsFromDataModel(data CachedImageResourceModel) (eboptions.Options, diag.Diagnostics) { + var diags diag.Diagnostics + var opts eboptions.Options - env["ENVBUILDER_CACHE_REPO"] = tfValueToString(data.CacheRepo) - env["ENVBUILDER_GIT_URL"] = tfValueToString(data.GitURL) + // Required options. Cannot be overridden by extra_env. + opts.CacheRepo = data.CacheRepo.ValueString() + opts.GitURL = data.GitURL.ValueString() + + // Other options can be overridden by extra_env, with a warning. + // Keep track of which options are overridden. + overrides := make(map[string]struct{}) if !data.BaseImageCacheDir.IsNull() { - env["ENVBUILDER_BASE_IMAGE_CACHE_DIR"] = tfValueToString(data.BaseImageCacheDir) + overrides["ENVBUILDER_BASE_IMAGE_CACHE_DIR"] = struct{}{} + opts.BaseImageCacheDir = data.BaseImageCacheDir.ValueString() } if !data.BuildContextPath.IsNull() { - env["ENVBUILDER_BUILD_CONTEXT_PATH"] = tfValueToString(data.BuildContextPath) + overrides["ENVBUILDER_BUILD_CONTEXT_PATH"] = struct{}{} + opts.BuildContextPath = data.BuildContextPath.ValueString() } if !data.CacheTTLDays.IsNull() { - env["ENVBUILDER_CACHE_TTL_DAYS"] = tfValueToString(data.CacheTTLDays) + overrides["ENVBUILDER_CACHE_TTL_DAYS"] = struct{}{} + opts.CacheTTLDays = data.CacheTTLDays.ValueInt64() } if !data.DevcontainerDir.IsNull() { - env["ENVBUILDER_DEVCONTAINER_DIR"] = tfValueToString(data.DevcontainerDir) + overrides["ENVBUILDER_DEVCONTAINER_DIR"] = struct{}{} + opts.DevcontainerDir = data.DevcontainerDir.ValueString() } if !data.DevcontainerJSONPath.IsNull() { - env["ENVBUILDER_DEVCONTAINER_JSON_PATH"] = tfValueToString(data.DevcontainerJSONPath) + overrides["ENVBUILDER_DEVCONTAINER_JSON_PATH"] = struct{}{} + opts.DevcontainerJSONPath = data.DevcontainerJSONPath.ValueString() } if !data.DockerfilePath.IsNull() { - env["ENVBUILDER_DOCKERFILE_PATH"] = tfValueToString(data.DockerfilePath) + overrides["ENVBUILDER_DOCKERFILE_PATH"] = struct{}{} + opts.DockerfilePath = data.DockerfilePath.ValueString() } if !data.DockerConfigBase64.IsNull() { - env["ENVBUILDER_DOCKER_CONFIG_BASE64"] = tfValueToString(data.DockerConfigBase64) + overrides["ENVBUILDER_DOCKER_CONFIG_BASE64"] = struct{}{} + opts.DockerConfigBase64 = data.DockerConfigBase64.ValueString() } if !data.ExitOnBuildFailure.IsNull() { - env["ENVBUILDER_EXIT_ON_BUILD_FAILURE"] = tfValueToString(data.ExitOnBuildFailure) + overrides["ENVBUILDER_EXIT_ON_BUILD_FAILURE"] = struct{}{} + opts.ExitOnBuildFailure = data.ExitOnBuildFailure.ValueBool() } if !data.FallbackImage.IsNull() { - env["ENVBUILDER_FALLBACK_IMAGE"] = tfValueToString(data.FallbackImage) + overrides["ENVBUILDER_FALLBACK_IMAGE"] = struct{}{} + opts.FallbackImage = data.FallbackImage.ValueString() } if !data.GitCloneDepth.IsNull() { - env["ENVBUILDER_GIT_CLONE_DEPTH"] = tfValueToString(data.GitCloneDepth) + overrides["ENVBUILDER_GIT_CLONE_DEPTH"] = struct{}{} + opts.GitCloneDepth = data.GitCloneDepth.ValueInt64() } if !data.GitCloneSingleBranch.IsNull() { - env["ENVBUILDER_GIT_CLONE_SINGLE_BRANCH"] = tfValueToString(data.GitCloneSingleBranch) + overrides["ENVBUILDER_GIT_CLONE_SINGLE_BRANCH"] = struct{}{} + opts.GitCloneSingleBranch = data.GitCloneSingleBranch.ValueBool() } if !data.GitHTTPProxyURL.IsNull() { - env["ENVBUILDER_GIT_HTTP_PROXY_URL"] = tfValueToString(data.GitHTTPProxyURL) + overrides["ENVBUILDER_GIT_HTTP_PROXY_URL"] = struct{}{} + opts.GitHTTPProxyURL = data.GitHTTPProxyURL.ValueString() } if !data.GitSSHPrivateKeyPath.IsNull() { - env["ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH"] = tfValueToString(data.GitSSHPrivateKeyPath) + overrides["ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH"] = struct{}{} + opts.GitSSHPrivateKeyPath = data.GitSSHPrivateKeyPath.ValueString() } if !data.GitUsername.IsNull() { - env["ENVBUILDER_GIT_USERNAME"] = tfValueToString(data.GitUsername) + overrides["ENVBUILDER_GIT_USERNAME"] = struct{}{} + opts.GitUsername = data.GitUsername.ValueString() } if !data.GitPassword.IsNull() { - env["ENVBUILDER_GIT_PASSWORD"] = tfValueToString(data.GitPassword) + overrides["ENVBUILDER_GIT_PASSWORD"] = struct{}{} + opts.GitPassword = data.GitPassword.ValueString() } if !data.IgnorePaths.IsNull() { - env["ENVBUILDER_IGNORE_PATHS"] = strings.Join(tfListToStringSlice(data.IgnorePaths), ",") + overrides["ENVBUILDER_IGNORE_PATHS"] = struct{}{} + opts.IgnorePaths = tfListToStringSlice(data.IgnorePaths) } if !data.Insecure.IsNull() { - env["ENVBUILDER_INSECURE"] = tfValueToString(data.Insecure) + overrides["ENVBUILDER_INSECURE"] = struct{}{} + opts.Insecure = data.Insecure.ValueBool() } - // Default to remote build mode. if data.RemoteRepoBuildMode.IsNull() { - env["ENVBUILDER_REMOTE_REPO_BUILD_MODE"] = "true" + opts.RemoteRepoBuildMode = true } else { - env["ENVBUILDER_REMOTE_REPO_BUILD_MODE"] = tfValueToString(data.RemoteRepoBuildMode) + overrides["ENVBUILDER_REMOTE_REPO_BUILD_MODE"] = struct{}{} + opts.RemoteRepoBuildMode = data.RemoteRepoBuildMode.ValueBool() } if !data.SSLCertBase64.IsNull() { - env["ENVBUILDER_SSL_CERT_BASE64"] = tfValueToString(data.SSLCertBase64) + overrides["ENVBUILDER_SSL_CERT_BASE64"] = struct{}{} + opts.SSLCertBase64 = data.SSLCertBase64.ValueString() } if !data.Verbose.IsNull() { - env["ENVBUILDER_VERBOSE"] = tfValueToString(data.Verbose) + overrides["ENVBUILDER_VERBOSE"] = struct{}{} + opts.Verbose = data.Verbose.ValueBool() } if !data.WorkspaceFolder.IsNull() { - env["ENVBUILDER_WORKSPACE_FOLDER"] = tfValueToString(data.WorkspaceFolder) + overrides["ENVBUILDER_WORKSPACE_FOLDER"] = struct{}{} + opts.WorkspaceFolder = data.WorkspaceFolder.ValueString() } - // Do ExtraEnv last so that it can override any other values. - // With one exception: ENVBUILDER_CACHE_REPO and ENVBUILDER_GIT_URL are required and should not be overridden. - // Other values set by the provider may be overridden, but will generate a warning. - var diag, ds diag.Diagnostics - if !data.ExtraEnv.IsNull() { - for key, elem := range data.ExtraEnv.Elements() { - switch key { - // These are required and should not be overridden. - case "ENVBUILDER_CACHE_REPO", "ENVBUILDER_GIT_URL": - diag.AddAttributeWarning(path.Root("extra_env"), - "Cannot override required environment variable", - fmt.Sprintf("The key %q in extra_env cannot be overridden.", key), + // convert extraEnv to a map for ease of use. + extraEnv := make(map[string]string) + for k, v := range data.ExtraEnv.Elements() { + extraEnv[k] = tfValueToString(v) + } + diags = append(diags, overrideOptionsFromExtraEnv(&opts, extraEnv, overrides)...) + + return opts, diags +} + +func overrideOptionsFromExtraEnv(opts *eboptions.Options, extraEnv map[string]string, overrides map[string]struct{}) diag.Diagnostics { + var diags diag.Diagnostics + // Make a map of the options for easy lookup. + optsMap := make(map[string]pflag.Value) + for _, opt := range opts.CLI() { + optsMap[opt.Env] = opt.Value + } + for key, val := range extraEnv { + switch key { + + // These options may not be overridden. + case "ENVBUILDER_CACHE_REPO", "ENVBUILDER_GIT_URL": + diags.AddAttributeWarning(path.Root("extra_env"), + "Cannot override required environment variable", + fmt.Sprintf("The key %q in extra_env cannot be overridden.", key), + ) + continue + + default: + // Check if the option was set on the provider data model and generate a warning if so. + if _, overridden := overrides[key]; overridden { + diags.AddAttributeWarning(path.Root("extra_env"), + "Overriding provider environment variable", + fmt.Sprintf("The key %q in extra_env overrides an option set on the provider.", key), + ) + } + + // XXX: workaround for serpent behaviour where calling Set() on a + // string slice will append instead of replace: set to empty first. + if key == "ENVBUILDER_IGNORE_PATHS" { + _ = optsMap[key].Set("") + } + + opt, found := optsMap[key] + if !found { + // ignore unknown keys + continue + } + + if err := opt.Set(val); err != nil { + diags.AddAttributeError(path.Root("extra_env"), + "Invalid value for environment variable", + fmt.Sprintf("The key %q in extra_env has an invalid value: %s", key, err), ) - default: - if _, ok := env[key]; ok { - // This is a warning because it's possible that the user wants to override - // a value set by the provider. - diag.AddAttributeWarning(path.Root("extra_env"), - "Overriding provider environment variable", - fmt.Sprintf("The key %q in extra_env overrides an environment variable set by the provider.", key), - ) - } - env[key] = tfValueToString(elem) } } } + return diags +} + +func computeEnvFromOptions(opts eboptions.Options, extraEnv map[string]string) map[string]string { + allEnvKeys := make(map[string]struct{}) + for _, opt := range opts.CLI() { + if opt.Env == "" { + continue + } + allEnvKeys[opt.Env] = struct{}{} + } + + // Only set the environment variables from opts that are not legacy options. + // Legacy options are those that are not prefixed with ENVBUILDER_. + // While we can detect when a legacy option is set, overriding it becomes + // problematic. Erring on the side of caution, we will not override legacy options. + isEnvbuilderOption := func(key string) bool { + switch key { + case "CODER_AGENT_URL", "CODER_AGENT_TOKEN", "CODER_AGENT_SUBSYSTEM": + return true // kinda + default: + return strings.HasPrefix(key, "ENVBUILDER_") + } + } + + computed := make(map[string]string) + for _, opt := range opts.CLI() { + if opt.Env == "" { + continue + } + // TODO: remove this check once support for legacy options is removed. + if !isEnvbuilderOption(opt.Env) { + continue + } + var val string + if sa, ok := opt.Value.(*serpent.StringArray); ok { + val = strings.Join(sa.GetSlice(), ",") + } else { + val = opt.Value.String() + } + + switch val { + case "", "false", "0": + // Skip zero values. + continue + } + computed[opt.Env] = val + } + + // Merge in extraEnv, which may override values from opts. + // Skip any keys that are envbuilder options. + for key, val := range extraEnv { + if isEnvbuilderOption(key) { + continue + } + computed[key] = val + } + return computed +} +// setComputedEnv sets data.Env and data.EnvMap based on the values of the +// other fields in the model. +func (data *CachedImageResourceModel) setComputedEnv(ctx context.Context, env map[string]string) diag.Diagnostics { + var diag, ds diag.Diagnostics data.EnvMap, ds = basetypes.NewMapValueFrom(ctx, types.StringType, env) diag = append(diag, ds...) data.Env, ds = basetypes.NewListValueFrom(ctx, types.StringType, sortedKeyValues(env)) @@ -431,6 +551,16 @@ func (r *CachedImageResource) Read(ctx context.Context, req resource.ReadRequest return } + // Get the options from the data model. + opts, diags := optionsFromDataModel(data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + // Set the expected environment variables. + computedEnv := computeEnvFromOptions(opts, tfMapToStringMap(data.ExtraEnv)) + resp.Diagnostics.Append(data.setComputedEnv(ctx, computedEnv)...) + // If the previous state is that Image == BuilderImage, then we previously did // not find the image. We will need to run another cache probe. if data.Image.Equal(data.BuilderImage) { @@ -478,9 +608,6 @@ func (r *CachedImageResource) Read(ctx context.Context, req resource.ReadRequest data.Image = types.StringValue(fmt.Sprintf("%s@%s", data.CacheRepo.ValueString(), digest)) data.Exists = types.BoolValue(true) - // Set the expected environment variables. - resp.Diagnostics.Append(data.setComputedEnv(ctx)...) - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -494,7 +621,18 @@ func (r *CachedImageResource) Create(ctx context.Context, req resource.CreateReq return } - cachedImg, err := r.runCacheProbe(ctx, data) + // Get the options from the data model. + opts, diags := optionsFromDataModel(data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Set the expected environment variables. + computedEnv := computeEnvFromOptions(opts, tfMapToStringMap(data.ExtraEnv)) + resp.Diagnostics.Append(data.setComputedEnv(ctx, computedEnv)...) + + cachedImg, err := runCacheProbe(ctx, data.BuilderImage.ValueString(), opts) data.ID = types.StringValue(uuid.Nil.String()) data.Exists = types.BoolValue(err == nil) if err != nil { @@ -517,9 +655,6 @@ func (r *CachedImageResource) Create(ctx context.Context, req resource.CreateReq data.ID = types.StringValue(digest.String()) } - // Set the expected environment variables. - resp.Diagnostics.Append(data.setComputedEnv(ctx)...) - // Save data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -553,7 +688,7 @@ func (r *CachedImageResource) Delete(ctx context.Context, req resource.DeleteReq // runCacheProbe performs a 'fake build' of the requested image and ensures that // all of the resulting layers of the image are present in the configured cache // repo. Otherwise, returns an error. -func (r *CachedImageResource) runCacheProbe(ctx context.Context, data CachedImageResourceModel) (v1.Image, error) { +func runCacheProbe(ctx context.Context, builderImage string, opts eboptions.Options) (v1.Image, error) { tmpDir, err := os.MkdirTemp(os.TempDir(), "envbuilder-provider-cached-image-data-source") if err != nil { return nil, fmt.Errorf("unable to create temp directory: %s", err.Error()) @@ -581,69 +716,53 @@ func (r *CachedImageResource) runCacheProbe(ctx context.Context, data CachedImag // In order to correctly reproduce the final layer of the cached image, we // need the envbuilder binary used to originally build the image! envbuilderPath := filepath.Join(tmpDir, "envbuilder") - if err := extractEnvbuilderFromImage(ctx, data.BuilderImage.ValueString(), envbuilderPath); err != nil { + if err := extractEnvbuilderFromImage(ctx, builderImage, envbuilderPath); err != nil { tflog.Error(ctx, "failed to fetch envbuilder binary from builder image", map[string]any{"err": err}) return nil, fmt.Errorf("failed to fetch the envbuilder binary from the builder image: %s", err.Error()) } - - workspaceFolder := data.WorkspaceFolder.ValueString() - if workspaceFolder == "" { - workspaceFolder = filepath.Join(tmpDir, "workspace") - tflog.Debug(ctx, "workspace_folder not specified, using temp dir", map[string]any{"workspace_folder": workspaceFolder}) - } - - opts := eboptions.Options{ - // These options are always required - CacheRepo: data.CacheRepo.ValueString(), - Filesystem: osfs.New("/"), - ForceSafe: false, // This should never be set to true, as this may be running outside of a container! - GetCachedImage: true, // always! - Logger: tfLogFunc(ctx), - Verbose: data.Verbose.ValueBool(), - WorkspaceFolder: workspaceFolder, - - // Options related to compiling the devcontainer - BuildContextPath: data.BuildContextPath.ValueString(), - DevcontainerDir: data.DevcontainerDir.ValueString(), - DevcontainerJSONPath: data.DevcontainerJSONPath.ValueString(), - DockerfilePath: data.DockerfilePath.ValueString(), - DockerConfigBase64: data.DockerConfigBase64.ValueString(), - FallbackImage: data.FallbackImage.ValueString(), - - // These options are required for cloning the Git repo - CacheTTLDays: data.CacheTTLDays.ValueInt64(), - GitURL: data.GitURL.ValueString(), - GitCloneDepth: data.GitCloneDepth.ValueInt64(), - GitCloneSingleBranch: data.GitCloneSingleBranch.ValueBool(), - GitUsername: data.GitUsername.ValueString(), - GitPassword: data.GitPassword.ValueString(), - GitSSHPrivateKeyPath: data.GitSSHPrivateKeyPath.ValueString(), - GitHTTPProxyURL: data.GitHTTPProxyURL.ValueString(), - RemoteRepoBuildMode: data.RemoteRepoBuildMode.ValueBool(), - RemoteRepoDir: filepath.Join(tmpDir, "repo"), - SSLCertBase64: data.SSLCertBase64.ValueString(), - - // Other options - BaseImageCacheDir: data.BaseImageCacheDir.ValueString(), - BinaryPath: envbuilderPath, // needed to reproduce the final layer. - ExitOnBuildFailure: data.ExitOnBuildFailure.ValueBool(), // may wish to do this instead of fallback image? - Insecure: data.Insecure.ValueBool(), // might have internal CAs? - IgnorePaths: tfListToStringSlice(data.IgnorePaths), // may need to be specified? - // The below options are not relevant and are set to their zero value explicitly. - // They must be set by extra_env. - CoderAgentSubsystem: nil, - CoderAgentToken: "", - CoderAgentURL: "", - ExportEnvFile: "", - InitArgs: "", - InitCommand: "", - InitScript: "", - LayerCacheDir: "", - PostStartScriptPath: "", - PushImage: false, // This is only relevant when building. - SetupScript: "", - SkipRebuild: false, - } + opts.BinaryPath = envbuilderPath + + // We need a filesystem to work with. + opts.Filesystem = osfs.New("/") + // This should never be set to true, as this may be running outside of a container! + opts.ForceSafe = false + // We always want to get the cached image. + opts.GetCachedImage = true + // Log to the Terraform logger. + opts.Logger = tfLogFunc(ctx) + + // We don't require users to set a workspace folder, but maybe there's a + // reason someone may need to. + if opts.WorkspaceFolder == "" { + opts.WorkspaceFolder = filepath.Join(tmpDir, "workspace") + if err := os.MkdirAll(opts.WorkspaceFolder, 0o755); err != nil { + return nil, fmt.Errorf("failed to create workspace folder: %w", err) + } + tflog.Debug(ctx, "workspace_folder not specified, using temp dir", map[string]any{"workspace_folder": opts.WorkspaceFolder}) + } + + // We need a place to clone the repo. + repoDir := filepath.Join(tmpDir, "repo") + if err := os.MkdirAll(repoDir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create repo dir: %w", err) + } + opts.RemoteRepoDir = repoDir + + // The below options are not relevant and are set to their zero value + // explicitly. + // They must be set by extra_env to be used in the final builder image. + opts.CoderAgentSubsystem = nil + opts.CoderAgentToken = "" + opts.CoderAgentURL = "" + opts.ExportEnvFile = "" + opts.InitArgs = "" + opts.InitCommand = "" + opts.InitScript = "" + opts.LayerCacheDir = "" + opts.PostStartScriptPath = "" + opts.PushImage = false + opts.SetupScript = "" + opts.SkipRebuild = false return envbuilder.RunCacheProbe(ctx, opts) } @@ -764,6 +883,16 @@ func tfListToStringSlice(l types.List) []string { return ss } +// tfMapToStringMap converts a types.Map to a map[string]string by calling +// tfValueToString on each element. +func tfMapToStringMap(m types.Map) map[string]string { + res := make(map[string]string) + for k, v := range m.Elements() { + res[k] = tfValueToString(v) + } + return res +} + // tfLogFunc is an adapter to envbuilder/log.Func. func tfLogFunc(ctx context.Context) eblog.Func { return func(level eblog.Level, format string, args ...any) { diff --git a/internal/provider/cached_image_resource_test.go b/internal/provider/cached_image_resource_test.go index c4f3f9a..b5fcb1d 100644 --- a/internal/provider/cached_image_resource_test.go +++ b/internal/provider/cached_image_resource_test.go @@ -20,8 +20,10 @@ func TestAccCachedImageResource(t *testing.T) { defer cancel() for _, tc := range []struct { - name string - files map[string]string + name string + files map[string]string + extraEnv map[string]string + assertEnv func(t *testing.T, deps testDependencies) resource.TestCheckFunc }{ { // This test case is the simplest possible case: a devcontainer.json. @@ -31,6 +33,23 @@ func TestAccCachedImageResource(t *testing.T) { files: map[string]string{ ".devcontainer/devcontainer.json": `{"image": "localhost:5000/test-ubuntu:latest"}`, }, + extraEnv: map[string]string{ + "FOO": testEnvValue, + "ENVBUILDER_GIT_URL": "https://not.the.real.git/url", + "ENVBUILDER_CACHE_REPO": "not-the-real-cache-repo", + }, + assertEnv: func(t *testing.T, deps testDependencies) resource.TestCheckFunc { + return resource.ComposeAggregateTestCheckFunc( + assertEnv(t, + "ENVBUILDER_CACHE_REPO", deps.CacheRepo, + "ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH", deps.Repo.Key, + "ENVBUILDER_GIT_URL", deps.Repo.URL, + "ENVBUILDER_REMOTE_REPO_BUILD_MODE", "true", + "ENVBUILDER_VERBOSE", "true", + "FOO", "bar\nbaz", + ), + ) + }, }, { // This test case includes a Dockerfile in addition to the devcontainer.json. @@ -42,14 +61,61 @@ func TestAccCachedImageResource(t *testing.T) { ".devcontainer/Dockerfile": `FROM localhost:5000/test-ubuntu:latest RUN date > /date.txt`, }, + extraEnv: map[string]string{ + "FOO": testEnvValue, + "ENVBUILDER_GIT_URL": "https://not.the.real.git/url", + "ENVBUILDER_CACHE_REPO": "not-the-real-cache-repo", + }, + assertEnv: func(t *testing.T, deps testDependencies) resource.TestCheckFunc { + return resource.ComposeAggregateTestCheckFunc( + assertEnv(t, + "ENVBUILDER_CACHE_REPO", deps.CacheRepo, + "ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH", deps.Repo.Key, + "ENVBUILDER_GIT_URL", deps.Repo.URL, + "ENVBUILDER_REMOTE_REPO_BUILD_MODE", "true", + "ENVBUILDER_VERBOSE", "true", + "FOO", "bar\nbaz", + ), + ) + }, + }, + { + // This test case ensures that parameters passed via extra_env are + // handled correctly. + name: "extra_env", + files: map[string]string{ + "path/to/.devcontainer/devcontainer.json": `{"build": { "dockerfile": "Dockerfile" }}`, + "path/to/.devcontainer/Dockerfile": `FROM localhost:5000/test-ubuntu:latest + RUN date > /date.txt`, + }, + extraEnv: map[string]string{ + "FOO": testEnvValue, + "ENVBUILDER_GIT_URL": "https://not.the.real.git/url", + "ENVBUILDER_CACHE_REPO": "not-the-real-cache-repo", + "ENVBUILDER_DEVCONTAINER_DIR": "path/to/.devcontainer", + "ENVBUILDER_DEVCONTAINER_JSON_PATH": "path/to/.devcontainer/devcontainer.json", + "ENVBUILDER_DOCKERFILE_PATH": "path/to/.devcontainer/Dockerfile", + }, + assertEnv: func(t *testing.T, deps testDependencies) resource.TestCheckFunc { + return resource.ComposeAggregateTestCheckFunc( + assertEnv(t, + "ENVBUILDER_CACHE_REPO", deps.CacheRepo, + "ENVBUILDER_DEVCONTAINER_DIR", "path/to/.devcontainer", + "ENVBUILDER_DEVCONTAINER_JSON_PATH", "path/to/.devcontainer/devcontainer.json", + "ENVBUILDER_DOCKERFILE_PATH", "path/to/.devcontainer/Dockerfile", + "ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH", deps.Repo.Key, + "ENVBUILDER_GIT_URL", deps.Repo.URL, + "ENVBUILDER_REMOTE_REPO_BUILD_MODE", "true", + "ENVBUILDER_VERBOSE", "true", + "FOO", "bar\nbaz", + ), + ) + }, }, } { t.Run(tc.name, func(t *testing.T) { //nolint: paralleltest - deps := setup(ctx, t, tc.files) - deps.ExtraEnv["FOO"] = testEnvValue - deps.ExtraEnv["ENVBUILDER_GIT_URL"] = "https://not.the.real.git/url" - deps.ExtraEnv["ENVBUILDER_CACHE_REPO"] = "not-the-real-cache-repo" + deps := setup(ctx, t, tc.extraEnv, tc.files) resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, @@ -71,14 +137,13 @@ RUN date > /date.txt`, resource.TestCheckResourceAttr("envbuilder_cached_image.test", "image", deps.BuilderImage), // Inputs should still be present. resource.TestCheckResourceAttr("envbuilder_cached_image.test", "cache_repo", deps.CacheRepo), - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "extra_env.FOO", "bar\nbaz"), resource.TestCheckResourceAttr("envbuilder_cached_image.test", "git_url", deps.Repo.URL), // Should be empty resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_username"), resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_password"), resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "cache_ttl_days"), // Environment variables - assertEnv(t, deps), + tc.assertEnv(t, deps), ), ExpectNonEmptyPlan: true, // TODO: check the plan. }, @@ -93,14 +158,13 @@ RUN date > /date.txt`, resource.TestCheckResourceAttr("envbuilder_cached_image.test", "image", deps.BuilderImage), // Inputs should still be present. resource.TestCheckResourceAttr("envbuilder_cached_image.test", "cache_repo", deps.CacheRepo), - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "extra_env.FOO", "bar\nbaz"), resource.TestCheckResourceAttr("envbuilder_cached_image.test", "git_url", deps.Repo.URL), // Should be empty resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_username"), resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_password"), resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "cache_ttl_days"), // Environment variables - assertEnv(t, deps), + tc.assertEnv(t, deps), ), ExpectNonEmptyPlan: true, // TODO: check the plan. }, @@ -113,7 +177,6 @@ RUN date > /date.txt`, Check: resource.ComposeAggregateTestCheckFunc( // Inputs should still be present. resource.TestCheckResourceAttr("envbuilder_cached_image.test", "cache_repo", deps.CacheRepo), - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "extra_env.FOO", "bar\nbaz"), resource.TestCheckResourceAttr("envbuilder_cached_image.test", "git_url", deps.Repo.URL), // Should be empty resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_username"), @@ -125,7 +188,7 @@ RUN date > /date.txt`, resource.TestCheckResourceAttrSet("envbuilder_cached_image.test", "image"), resource.TestCheckResourceAttrWith("envbuilder_cached_image.test", "image", quotedPrefix(deps.CacheRepo)), // Environment variables - assertEnv(t, deps), + tc.assertEnv(t, deps), ), }, // 5) Should produce an empty plan after apply @@ -144,28 +207,31 @@ RUN date > /date.txt`, } } -// assertEnv is a test helper that checks the environment variables set on the -// cached image resource based on the provided test dependencies. -func assertEnv(t *testing.T, deps testDependencies) resource.TestCheckFunc { +// assertEnv is a test helper that checks the environment variables, in order, +// on both the env and env_map attributes of the cached image resource. +func assertEnv(t *testing.T, kvs ...string) resource.TestCheckFunc { t.Helper() - return resource.ComposeAggregateTestCheckFunc( - // Check that the environment variables are set correctly. - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.0", fmt.Sprintf("ENVBUILDER_CACHE_REPO=%s", deps.CacheRepo)), - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.1", fmt.Sprintf("ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH=%s", deps.Repo.Key)), - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.2", fmt.Sprintf("ENVBUILDER_GIT_URL=%s", deps.Repo.URL)), - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.3", "ENVBUILDER_REMOTE_REPO_BUILD_MODE=true"), - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.4", "ENVBUILDER_VERBOSE=true"), - // Check that the extra environment variables are set correctly. - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.5", "FOO=bar\nbaz"), - // We should not have any other environment variables set. - resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "env.6"), + if len(kvs)%2 != 0 { + t.Fatalf("assertEnv: expected an even number of key-value pairs, got %d", len(kvs)) + } + + funcs := make([]resource.TestCheckFunc, 0) + for i := 0; i < len(kvs); i += 2 { + resKey := fmt.Sprintf("env.%d", len(funcs)) + resVal := fmt.Sprintf("%s=%s", kvs[i], kvs[i+1]) + fn := resource.TestCheckResourceAttr("envbuilder_cached_image.test", resKey, resVal) + funcs = append(funcs, fn) + } + + lastKey := fmt.Sprintf("env.%d", len(funcs)) + lastFn := resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", lastKey) + funcs = append(funcs, lastFn) + + for i := 0; i < len(kvs); i += 2 { + resKey := fmt.Sprintf("env_map.%s", kvs[i]) + fn := resource.TestCheckResourceAttr("envbuilder_cached_image.test", resKey, kvs[i+1]) + funcs = append(funcs, fn) + } - // Check that the same values are set in env_map. - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env_map.ENVBUILDER_CACHE_REPO", deps.CacheRepo), - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env_map.ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH", deps.Repo.Key), - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env_map.ENVBUILDER_GIT_URL", deps.Repo.URL), - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env_map.ENVBUILDER_REMOTE_REPO_BUILD_MODE", "true"), - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env_map.ENVBUILDER_VERBOSE", "true"), - resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env_map.FOO", "bar\nbaz"), - ) + return resource.ComposeAggregateTestCheckFunc(funcs...) } diff --git a/internal/provider/provider_internal_test.go b/internal/provider/provider_internal_test.go new file mode 100644 index 0000000..5601832 --- /dev/null +++ b/internal/provider/provider_internal_test.go @@ -0,0 +1,359 @@ +package provider + +import ( + "testing" + + eboptions "github.com/coder/envbuilder/options" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/stretchr/testify/assert" +) + +func Test_optionsFromDataModel(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + data CachedImageResourceModel + expectOpts eboptions.Options + expectNumErrorDiags int + expectNumWarningDiags int + }{ + { + name: "required only", + data: CachedImageResourceModel{ + BuilderImage: basetypes.NewStringValue("envbuilder:latest"), + CacheRepo: basetypes.NewStringValue("localhost:5000/cache"), + GitURL: basetypes.NewStringValue("git@git.local/devcontainer.git"), + }, + expectOpts: eboptions.Options{ + CacheRepo: "localhost:5000/cache", + GitURL: "git@git.local/devcontainer.git", + RemoteRepoBuildMode: true, + }, + }, + { + name: "all options without extra_env", + data: CachedImageResourceModel{ + BuilderImage: basetypes.NewStringValue("envbuilder:latest"), + CacheRepo: basetypes.NewStringValue("localhost:5000/cache"), + GitURL: basetypes.NewStringValue("git@git.local/devcontainer.git"), + BaseImageCacheDir: basetypes.NewStringValue("/tmp/cache"), + BuildContextPath: basetypes.NewStringValue("."), + CacheTTLDays: basetypes.NewInt64Value(7), + DevcontainerDir: basetypes.NewStringValue(".devcontainer"), + DevcontainerJSONPath: basetypes.NewStringValue(".devcontainer/devcontainer.json"), + DockerfilePath: basetypes.NewStringValue("Dockerfile"), + DockerConfigBase64: basetypes.NewStringValue("some base64"), + ExitOnBuildFailure: basetypes.NewBoolValue(true), + // ExtraEnv: map[string]basetypes.Value{}, + FallbackImage: basetypes.NewStringValue("fallback"), + GitCloneDepth: basetypes.NewInt64Value(1), + GitCloneSingleBranch: basetypes.NewBoolValue(true), + GitHTTPProxyURL: basetypes.NewStringValue("http://proxy"), + GitPassword: basetypes.NewStringValue("password"), + GitSSHPrivateKeyPath: basetypes.NewStringValue("/tmp/id_rsa"), + GitUsername: basetypes.NewStringValue("user"), + IgnorePaths: listValue("ignore", "paths"), + Insecure: basetypes.NewBoolValue(true), + RemoteRepoBuildMode: basetypes.NewBoolValue(false), + SSLCertBase64: basetypes.NewStringValue("cert"), + Verbose: basetypes.NewBoolValue(true), + WorkspaceFolder: basetypes.NewStringValue("workspace"), + }, + expectOpts: eboptions.Options{ + CacheRepo: "localhost:5000/cache", + GitURL: "git@git.local/devcontainer.git", + BaseImageCacheDir: "/tmp/cache", + BuildContextPath: ".", + CacheTTLDays: 7, + DevcontainerDir: ".devcontainer", + DevcontainerJSONPath: ".devcontainer/devcontainer.json", + DockerfilePath: "Dockerfile", + DockerConfigBase64: "some base64", + ExitOnBuildFailure: true, + FallbackImage: "fallback", + GitCloneDepth: 1, + GitCloneSingleBranch: true, + GitHTTPProxyURL: "http://proxy", + GitPassword: "password", + GitSSHPrivateKeyPath: "/tmp/id_rsa", + GitUsername: "user", + IgnorePaths: []string{"ignore", "paths"}, + Insecure: true, + RemoteRepoBuildMode: false, + SSLCertBase64: "cert", + Verbose: true, + WorkspaceFolder: "workspace", + }, + }, + { + name: "extra env override", + data: CachedImageResourceModel{ + BuilderImage: basetypes.NewStringValue("envbuilder:latest"), + CacheRepo: basetypes.NewStringValue("localhost:5000/cache"), + GitURL: basetypes.NewStringValue("git@git.local/devcontainer.git"), + ExtraEnv: extraEnvMap(t, + "CODER_AGENT_TOKEN", "token", + "CODER_AGENT_URL", "http://coder", + "FOO", "bar", + ), + }, + expectOpts: eboptions.Options{ + CacheRepo: "localhost:5000/cache", + GitURL: "git@git.local/devcontainer.git", + RemoteRepoBuildMode: true, + CoderAgentToken: "token", + CoderAgentURL: "http://coder", + }, + }, + { + name: "extra_env override warnings", + data: CachedImageResourceModel{ + BuilderImage: basetypes.NewStringValue("envbuilder:latest"), + CacheRepo: basetypes.NewStringValue("localhost:5000/cache"), + GitURL: basetypes.NewStringValue("git@git.local/devcontainer.git"), + BaseImageCacheDir: basetypes.NewStringValue("/tmp/cache"), + BuildContextPath: basetypes.NewStringValue("."), + CacheTTLDays: basetypes.NewInt64Value(7), + DevcontainerDir: basetypes.NewStringValue(".devcontainer"), + DevcontainerJSONPath: basetypes.NewStringValue(".devcontainer/devcontainer.json"), + DockerfilePath: basetypes.NewStringValue("Dockerfile"), + DockerConfigBase64: basetypes.NewStringValue("some base64"), + ExitOnBuildFailure: basetypes.NewBoolValue(true), + // ExtraEnv: map[string]basetypes.Value{}, + FallbackImage: basetypes.NewStringValue("fallback"), + GitCloneDepth: basetypes.NewInt64Value(1), + GitCloneSingleBranch: basetypes.NewBoolValue(true), + GitHTTPProxyURL: basetypes.NewStringValue("http://proxy"), + GitPassword: basetypes.NewStringValue("password"), + GitSSHPrivateKeyPath: basetypes.NewStringValue("/tmp/id_rsa"), + GitUsername: basetypes.NewStringValue("user"), + IgnorePaths: listValue("ignore", "paths"), + Insecure: basetypes.NewBoolValue(true), + RemoteRepoBuildMode: basetypes.NewBoolValue(false), + SSLCertBase64: basetypes.NewStringValue("cert"), + Verbose: basetypes.NewBoolValue(true), + WorkspaceFolder: basetypes.NewStringValue("workspace"), + ExtraEnv: extraEnvMap(t, + "ENVBUILDER_CACHE_REPO", "override", + "ENVBUILDER_GIT_URL", "override", + "ENVBUILDER_BASE_IMAGE_CACHE_DIR", "override", + "ENVBUILDER_BUILD_CONTEXT_PATH", "override", + "ENVBUILDER_CACHE_TTL_DAYS", "8", + "ENVBUILDER_DEVCONTAINER_DIR", "override", + "ENVBUILDER_DEVCONTAINER_JSON_PATH", "override", + "ENVBUILDER_DOCKERFILE_PATH", "override", + "ENVBUILDER_DOCKER_CONFIG_BASE64", "override", + "ENVBUILDER_EXIT_ON_BUILD_FAILURE", "false", + "ENVBUILDER_FALLBACK_IMAGE", "override", + "ENVBUILDER_GIT_CLONE_DEPTH", "2", + "ENVBUILDER_GIT_CLONE_SINGLE_BRANCH", "false", + "ENVBUILDER_GIT_HTTP_PROXY_URL", "override", + "ENVBUILDER_GIT_PASSWORD", "override", + "ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH", "override", + "ENVBUILDER_GIT_USERNAME", "override", + "ENVBUILDER_IGNORE_PATHS", "override", + "ENVBUILDER_INSECURE", "false", + "ENVBUILDER_REMOTE_REPO_BUILD_MODE", "true", + "ENVBUILDER_SSL_CERT_BASE64", "override", + "ENVBUILDER_VERBOSE", "false", + "ENVBUILDER_WORKSPACE_FOLDER", "override", + "FOO", "bar", + ), + }, + expectOpts: eboptions.Options{ + // not overridden + CacheRepo: "localhost:5000/cache", + GitURL: "git@git.local/devcontainer.git", + // overridden + BaseImageCacheDir: "override", + BuildContextPath: "override", + CacheTTLDays: 8, + DevcontainerDir: "override", + DevcontainerJSONPath: "override", + DockerfilePath: "override", + DockerConfigBase64: "override", + ExitOnBuildFailure: false, + FallbackImage: "override", + GitCloneDepth: 2, + GitCloneSingleBranch: false, + GitHTTPProxyURL: "override", + GitPassword: "override", + GitSSHPrivateKeyPath: "override", + GitUsername: "override", + IgnorePaths: []string{"override"}, + Insecure: false, + RemoteRepoBuildMode: true, + SSLCertBase64: "override", + Verbose: false, + WorkspaceFolder: "override", + }, + expectNumWarningDiags: 23, + }, + { + name: "extra_env override errors", + data: CachedImageResourceModel{ + BuilderImage: basetypes.NewStringValue("envbuilder:latest"), + CacheRepo: basetypes.NewStringValue("localhost:5000/cache"), + GitURL: basetypes.NewStringValue("git@git.local/devcontainer.git"), + ExtraEnv: extraEnvMap(t, + "ENVBUILDER_CACHE_TTL_DAYS", "not a number", + "ENVBUILDER_VERBOSE", "not a bool", + "FOO", "bar", + ), + }, + expectOpts: eboptions.Options{ + // not overridden + CacheRepo: "localhost:5000/cache", + GitURL: "git@git.local/devcontainer.git", + RemoteRepoBuildMode: true, + }, + expectNumErrorDiags: 2, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + actual, diags := optionsFromDataModel(tc.data) + assert.Equal(t, tc.expectNumErrorDiags, diags.ErrorsCount()) + assert.Equal(t, tc.expectNumWarningDiags, diags.WarningsCount()) + assert.EqualValues(t, tc.expectOpts, actual) + }) + } +} + +func Test_computeEnvFromOptions(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + opts eboptions.Options + extraEnv map[string]string + expectEnv map[string]string + }{ + { + name: "empty", + opts: eboptions.Options{}, + expectEnv: map[string]string{}, + }, + { + name: "all options", + opts: eboptions.Options{ + BaseImageCacheDir: "string", + BinaryPath: "string", + BuildContextPath: "string", + CacheRepo: "string", + CacheTTLDays: 1, + CoderAgentSubsystem: []string{"one", "two"}, + CoderAgentToken: "string", + CoderAgentURL: "string", + DevcontainerDir: "string", + DevcontainerJSONPath: "string", + DockerConfigBase64: "string", + DockerfilePath: "string", + ExitOnBuildFailure: true, + ExportEnvFile: "string", + FallbackImage: "string", + ForceSafe: true, + GetCachedImage: true, + GitCloneDepth: 1, + GitCloneSingleBranch: true, + GitHTTPProxyURL: "string", + GitPassword: "string", + GitSSHPrivateKeyPath: "string", + GitURL: "string", + GitUsername: "string", + IgnorePaths: []string{"one", "two"}, + InitArgs: "string", + InitCommand: "string", + InitScript: "string", + Insecure: true, + LayerCacheDir: "string", + PostStartScriptPath: "string", + PushImage: true, + RemoteRepoBuildMode: true, + RemoteRepoDir: "string", + SetupScript: "string", + SkipRebuild: true, + SSLCertBase64: "string", + Verbose: true, + WorkspaceFolder: "string", + }, + extraEnv: map[string]string{ + "ENVBUILDER_SOMETHING": "string", // should be ignored + "FOO": "bar", // should be included + }, + expectEnv: map[string]string{ + "CODER_AGENT_SUBSYSTEM": "one,two", + "CODER_AGENT_TOKEN": "string", + "CODER_AGENT_URL": "string", + "ENVBUILDER_BASE_IMAGE_CACHE_DIR": "string", + "ENVBUILDER_BINARY_PATH": "string", + "ENVBUILDER_BUILD_CONTEXT_PATH": "string", + "ENVBUILDER_CACHE_REPO": "string", + "ENVBUILDER_CACHE_TTL_DAYS": "1", + "ENVBUILDER_DEVCONTAINER_DIR": "string", + "ENVBUILDER_DEVCONTAINER_JSON_PATH": "string", + "ENVBUILDER_DOCKER_CONFIG_BASE64": "string", + "ENVBUILDER_DOCKERFILE_PATH": "string", + "ENVBUILDER_EXIT_ON_BUILD_FAILURE": "true", + "ENVBUILDER_EXPORT_ENV_FILE": "string", + "ENVBUILDER_FALLBACK_IMAGE": "string", + "ENVBUILDER_FORCE_SAFE": "true", + "ENVBUILDER_GET_CACHED_IMAGE": "true", + "ENVBUILDER_GIT_CLONE_DEPTH": "1", + "ENVBUILDER_GIT_CLONE_SINGLE_BRANCH": "true", + "ENVBUILDER_GIT_HTTP_PROXY_URL": "string", + "ENVBUILDER_GIT_PASSWORD": "string", + "ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH": "string", + "ENVBUILDER_GIT_URL": "string", + "ENVBUILDER_GIT_USERNAME": "string", + "ENVBUILDER_IGNORE_PATHS": "one,two", + "ENVBUILDER_INIT_ARGS": "string", + "ENVBUILDER_INIT_COMMAND": "string", + "ENVBUILDER_INIT_SCRIPT": "string", + "ENVBUILDER_INSECURE": "true", + "ENVBUILDER_LAYER_CACHE_DIR": "string", + "ENVBUILDER_POST_START_SCRIPT_PATH": "string", + "ENVBUILDER_PUSH_IMAGE": "true", + "ENVBUILDER_REMOTE_REPO_BUILD_MODE": "true", + "ENVBUILDER_REMOTE_REPO_DIR": "string", + "ENVBUILDER_SETUP_SCRIPT": "string", + "ENVBUILDER_SKIP_REBUILD": "true", + "ENVBUILDER_SSL_CERT_BASE64": "string", + "ENVBUILDER_VERBOSE": "true", + "ENVBUILDER_WORKSPACE_FOLDER": "string", + "FOO": "bar", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if tc.extraEnv == nil { + tc.extraEnv = map[string]string{} + } + actual := computeEnvFromOptions(tc.opts, tc.extraEnv) + assert.EqualValues(t, tc.expectEnv, actual) + }) + } +} + +func listValue(vs ...string) basetypes.ListValue { + vals := make([]attr.Value, len(vs)) + for i, s := range vs { + vals[i] = basetypes.NewStringValue(s) + } + return basetypes.NewListValueMust(basetypes.StringType{}, vals) +} + +func extraEnvMap(t *testing.T, kvs ...string) basetypes.MapValue { + t.Helper() + if len(kvs)%2 != 0 { + t.Fatalf("extraEnvMap: expected even number of key-value pairs, got %d", len(kvs)) + } + vals := make(map[string]attr.Value) + for i := 0; i < len(kvs); i += 2 { + vals[kvs[i]] = basetypes.NewStringValue(kvs[i+1]) + } + return basetypes.NewMapValueMust(basetypes.StringType{}, vals) +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index cfe32a9..de9ad8c 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -49,14 +49,14 @@ func (d *testDependencies) Config(t testing.TB) string { resource "envbuilder_cached_image" "test" { builder_image = {{ quote .BuilderImage }} cache_repo = {{ quote .CacheRepo }} + git_url = {{ quote .Repo.URL }} extra_env = { + "ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH": {{ quote .Repo.Key }} + "ENVBUILDER_VERBOSE": true {{ range $k, $v := .ExtraEnv }} {{ quote $k }}: {{ quote $v }} {{ end }} } - git_url = {{ quote .Repo.URL }} - git_ssh_private_key_path = {{ quote .Repo.Key }} - verbose = true }` fm := template.FuncMap{"quote": quote} @@ -71,7 +71,7 @@ func quote(s string) string { return fmt.Sprintf("%q", s) } -func setup(ctx context.Context, t testing.TB, files map[string]string) testDependencies { +func setup(ctx context.Context, t testing.TB, extraEnv, files map[string]string) testDependencies { t.Helper() envbuilderImage := getEnvOrDefault("ENVBUILDER_IMAGE", "localhost:5000/envbuilder") @@ -89,7 +89,7 @@ func setup(ctx context.Context, t testing.TB, files map[string]string) testDepen return testDependencies{ BuilderImage: envbuilderImageRef, CacheRepo: reg + "/test", - ExtraEnv: make(map[string]string), + ExtraEnv: extraEnv, Repo: gitRepo, } } @@ -106,18 +106,38 @@ func seedCache(ctx context.Context, t testing.TB, deps testDependencies) { ensureImage(ctx, t, cli, deps.BuilderImage) + // Set up env for envbuilder + seedEnv := map[string]string{ + "ENVBUILDER_CACHE_REPO": deps.CacheRepo, + "ENVBUILDER_EXIT_ON_BUILD_FAILURE": "true", + "ENVBUILDER_INIT_SCRIPT": "exit", + "ENVBUILDER_PUSH_IMAGE": "true", + "ENVBUILDER_VERBOSE": "true", + "ENVBUILDER_GIT_URL": deps.Repo.URL, + "ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH": "/id_ed25519", + } + + for k, v := range deps.ExtraEnv { + if !strings.HasPrefix(k, "ENVBUILDER_") { + continue + } + if _, ok := seedEnv[k]; ok { + continue + } + seedEnv[k] = v + } + + seedDockerEnv := make([]string, 0) + for k, v := range seedEnv { + seedDockerEnv = append(seedDockerEnv, k+"="+v) + } + + t.Logf("running envbuilder to seed cache with args: %v", seedDockerEnv) + // Run envbuilder using this dir as a local layer cache ctr, err := cli.ContainerCreate(ctx, &container.Config{ Image: deps.BuilderImage, - Env: []string{ - "ENVBUILDER_CACHE_REPO=" + deps.CacheRepo, - "ENVBUILDER_EXIT_ON_BUILD_FAILURE=true", - "ENVBUILDER_INIT_SCRIPT=exit", - "ENVBUILDER_PUSH_IMAGE=true", - "ENVBUILDER_VERBOSE=true", - "ENVBUILDER_GIT_URL=" + deps.Repo.URL, - "ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH=/id_ed25519", - }, + Env: seedDockerEnv, Labels: map[string]string{ testContainerLabel: "true", }, From 482a446eb376fe9861291b8a07dbf94e26c30625 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 4 Sep 2024 16:29:25 +0100 Subject: [PATCH 2/3] chore(internal/provider): refactor cached_image_resource (#46) Addresses some non-blocking comments from #44: - Extracts some of the functions in cached_image_resource.go to separate internal packages tfutil and imgutil. - Some other functions are extracted to helpers.go. - Extracts non-overridable flags to a package-level variable. - Pre-allocates some slices where possible. - Removes some unused code and renames some existing code for readability --- internal/imgutil/imgutil.go | 103 +++++ internal/provider/cached_image_resource.go | 423 +----------------- .../provider/cached_image_resource_test.go | 14 +- internal/provider/helpers.go | 255 +++++++++++ internal/provider/provider_internal_test.go | 3 - internal/provider/provider_test.go | 2 +- internal/tfutil/tfutil.go | 92 ++++ 7 files changed, 472 insertions(+), 420 deletions(-) create mode 100644 internal/imgutil/imgutil.go create mode 100644 internal/provider/helpers.go create mode 100644 internal/tfutil/tfutil.go diff --git a/internal/imgutil/imgutil.go b/internal/imgutil/imgutil.go new file mode 100644 index 0000000..5c2d96a --- /dev/null +++ b/internal/imgutil/imgutil.go @@ -0,0 +1,103 @@ +package imgutil + +import ( + "archive/tar" + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/coder/envbuilder/constants" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// GetRemoteImage fetches the image manifest of the image. +func GetRemoteImage(imgRef string) (v1.Image, error) { + ref, err := name.ParseReference(imgRef) + if err != nil { + return nil, fmt.Errorf("parse reference: %w", err) + } + + img, err := remote.Image(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return nil, fmt.Errorf("check remote image: %w", err) + } + + return img, nil +} + +// ExtractEnvbuilderFromImage reads the image located at imgRef and extracts +// MagicBinaryLocation to destPath. +func ExtractEnvbuilderFromImage(ctx context.Context, imgRef, destPath string) error { + needle := filepath.Clean(constants.MagicBinaryLocation)[1:] // skip leading '/' + img, err := GetRemoteImage(imgRef) + if err != nil { + return fmt.Errorf("check remote image: %w", err) + } + + layers, err := img.Layers() + if err != nil { + return fmt.Errorf("get image layers: %w", err) + } + + // Check the layers in reverse order. The last layers are more likely to + // include the binary. + for i := len(layers) - 1; i >= 0; i-- { + ul, err := layers[i].Uncompressed() + if err != nil { + return fmt.Errorf("get uncompressed layer: %w", err) + } + + tr := tar.NewReader(ul) + for { + th, err := tr.Next() + if err == io.EOF { + break + } + + if err != nil { + return fmt.Errorf("read tar header: %w", err) + } + + name := filepath.Clean(th.Name) + if th.Typeflag != tar.TypeReg { + tflog.Debug(ctx, "skip non-regular file", map[string]any{"name": name, "layer_idx": i + 1}) + continue + } + + if name != needle { + tflog.Debug(ctx, "skip file", map[string]any{"name": name, "layer_idx": i + 1}) + continue + } + + tflog.Debug(ctx, "found file", map[string]any{"name": name, "layer_idx": i + 1}) + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return fmt.Errorf("create parent directories: %w", err) + } + destF, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("create dest file for writing: %w", err) + } + defer destF.Close() + _, err = io.Copy(destF, tr) + if err != nil { + return fmt.Errorf("copy dest file from image: %w", err) + } + if err := destF.Close(); err != nil { + return fmt.Errorf("close dest file: %w", err) + } + + if err := os.Chmod(destPath, 0o755); err != nil { + return fmt.Errorf("chmod file: %w", err) + } + return nil + } + } + + return fmt.Errorf("extract envbuilder binary from image %q: %w", imgRef, os.ErrNotExist) +} diff --git a/internal/provider/cached_image_resource.go b/internal/provider/cached_image_resource.go index c3f9378..27adc98 100644 --- a/internal/provider/cached_image_resource.go +++ b/internal/provider/cached_image_resource.go @@ -1,33 +1,24 @@ package provider import ( - "archive/tar" "context" "fmt" - "io" "net/http" "os" "path/filepath" - "sort" "strings" kconfig "github.com/GoogleContainerTools/kaniko/pkg/config" "github.com/coder/envbuilder" "github.com/coder/envbuilder/constants" - eblog "github.com/coder/envbuilder/log" eboptions "github.com/coder/envbuilder/options" - "github.com/coder/serpent" + "github.com/coder/terraform-provider-envbuilder/internal/imgutil" + "github.com/coder/terraform-provider-envbuilder/internal/tfutil" "github.com/go-git/go-billy/v5/osfs" - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/uuid" - "github.com/spf13/pflag" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" @@ -295,249 +286,13 @@ func (r *CachedImageResource) Configure(ctx context.Context, req resource.Config r.client = client } -func optionsFromDataModel(data CachedImageResourceModel) (eboptions.Options, diag.Diagnostics) { - var diags diag.Diagnostics - var opts eboptions.Options - - // Required options. Cannot be overridden by extra_env. - opts.CacheRepo = data.CacheRepo.ValueString() - opts.GitURL = data.GitURL.ValueString() - - // Other options can be overridden by extra_env, with a warning. - // Keep track of which options are overridden. - overrides := make(map[string]struct{}) - - if !data.BaseImageCacheDir.IsNull() { - overrides["ENVBUILDER_BASE_IMAGE_CACHE_DIR"] = struct{}{} - opts.BaseImageCacheDir = data.BaseImageCacheDir.ValueString() - } - - if !data.BuildContextPath.IsNull() { - overrides["ENVBUILDER_BUILD_CONTEXT_PATH"] = struct{}{} - opts.BuildContextPath = data.BuildContextPath.ValueString() - } - - if !data.CacheTTLDays.IsNull() { - overrides["ENVBUILDER_CACHE_TTL_DAYS"] = struct{}{} - opts.CacheTTLDays = data.CacheTTLDays.ValueInt64() - } - - if !data.DevcontainerDir.IsNull() { - overrides["ENVBUILDER_DEVCONTAINER_DIR"] = struct{}{} - opts.DevcontainerDir = data.DevcontainerDir.ValueString() - } - - if !data.DevcontainerJSONPath.IsNull() { - overrides["ENVBUILDER_DEVCONTAINER_JSON_PATH"] = struct{}{} - opts.DevcontainerJSONPath = data.DevcontainerJSONPath.ValueString() - } - - if !data.DockerfilePath.IsNull() { - overrides["ENVBUILDER_DOCKERFILE_PATH"] = struct{}{} - opts.DockerfilePath = data.DockerfilePath.ValueString() - } - - if !data.DockerConfigBase64.IsNull() { - overrides["ENVBUILDER_DOCKER_CONFIG_BASE64"] = struct{}{} - opts.DockerConfigBase64 = data.DockerConfigBase64.ValueString() - } - - if !data.ExitOnBuildFailure.IsNull() { - overrides["ENVBUILDER_EXIT_ON_BUILD_FAILURE"] = struct{}{} - opts.ExitOnBuildFailure = data.ExitOnBuildFailure.ValueBool() - } - - if !data.FallbackImage.IsNull() { - overrides["ENVBUILDER_FALLBACK_IMAGE"] = struct{}{} - opts.FallbackImage = data.FallbackImage.ValueString() - } - - if !data.GitCloneDepth.IsNull() { - overrides["ENVBUILDER_GIT_CLONE_DEPTH"] = struct{}{} - opts.GitCloneDepth = data.GitCloneDepth.ValueInt64() - } - - if !data.GitCloneSingleBranch.IsNull() { - overrides["ENVBUILDER_GIT_CLONE_SINGLE_BRANCH"] = struct{}{} - opts.GitCloneSingleBranch = data.GitCloneSingleBranch.ValueBool() - } - - if !data.GitHTTPProxyURL.IsNull() { - overrides["ENVBUILDER_GIT_HTTP_PROXY_URL"] = struct{}{} - opts.GitHTTPProxyURL = data.GitHTTPProxyURL.ValueString() - } - - if !data.GitSSHPrivateKeyPath.IsNull() { - overrides["ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH"] = struct{}{} - opts.GitSSHPrivateKeyPath = data.GitSSHPrivateKeyPath.ValueString() - } - - if !data.GitUsername.IsNull() { - overrides["ENVBUILDER_GIT_USERNAME"] = struct{}{} - opts.GitUsername = data.GitUsername.ValueString() - } - - if !data.GitPassword.IsNull() { - overrides["ENVBUILDER_GIT_PASSWORD"] = struct{}{} - opts.GitPassword = data.GitPassword.ValueString() - } - - if !data.IgnorePaths.IsNull() { - overrides["ENVBUILDER_IGNORE_PATHS"] = struct{}{} - opts.IgnorePaths = tfListToStringSlice(data.IgnorePaths) - } - - if !data.Insecure.IsNull() { - overrides["ENVBUILDER_INSECURE"] = struct{}{} - opts.Insecure = data.Insecure.ValueBool() - } - - if data.RemoteRepoBuildMode.IsNull() { - opts.RemoteRepoBuildMode = true - } else { - overrides["ENVBUILDER_REMOTE_REPO_BUILD_MODE"] = struct{}{} - opts.RemoteRepoBuildMode = data.RemoteRepoBuildMode.ValueBool() - } - - if !data.SSLCertBase64.IsNull() { - overrides["ENVBUILDER_SSL_CERT_BASE64"] = struct{}{} - opts.SSLCertBase64 = data.SSLCertBase64.ValueString() - } - - if !data.Verbose.IsNull() { - overrides["ENVBUILDER_VERBOSE"] = struct{}{} - opts.Verbose = data.Verbose.ValueBool() - } - - if !data.WorkspaceFolder.IsNull() { - overrides["ENVBUILDER_WORKSPACE_FOLDER"] = struct{}{} - opts.WorkspaceFolder = data.WorkspaceFolder.ValueString() - } - - // convert extraEnv to a map for ease of use. - extraEnv := make(map[string]string) - for k, v := range data.ExtraEnv.Elements() { - extraEnv[k] = tfValueToString(v) - } - diags = append(diags, overrideOptionsFromExtraEnv(&opts, extraEnv, overrides)...) - - return opts, diags -} - -func overrideOptionsFromExtraEnv(opts *eboptions.Options, extraEnv map[string]string, overrides map[string]struct{}) diag.Diagnostics { - var diags diag.Diagnostics - // Make a map of the options for easy lookup. - optsMap := make(map[string]pflag.Value) - for _, opt := range opts.CLI() { - optsMap[opt.Env] = opt.Value - } - for key, val := range extraEnv { - switch key { - - // These options may not be overridden. - case "ENVBUILDER_CACHE_REPO", "ENVBUILDER_GIT_URL": - diags.AddAttributeWarning(path.Root("extra_env"), - "Cannot override required environment variable", - fmt.Sprintf("The key %q in extra_env cannot be overridden.", key), - ) - continue - - default: - // Check if the option was set on the provider data model and generate a warning if so. - if _, overridden := overrides[key]; overridden { - diags.AddAttributeWarning(path.Root("extra_env"), - "Overriding provider environment variable", - fmt.Sprintf("The key %q in extra_env overrides an option set on the provider.", key), - ) - } - - // XXX: workaround for serpent behaviour where calling Set() on a - // string slice will append instead of replace: set to empty first. - if key == "ENVBUILDER_IGNORE_PATHS" { - _ = optsMap[key].Set("") - } - - opt, found := optsMap[key] - if !found { - // ignore unknown keys - continue - } - - if err := opt.Set(val); err != nil { - diags.AddAttributeError(path.Root("extra_env"), - "Invalid value for environment variable", - fmt.Sprintf("The key %q in extra_env has an invalid value: %s", key, err), - ) - } - } - } - return diags -} - -func computeEnvFromOptions(opts eboptions.Options, extraEnv map[string]string) map[string]string { - allEnvKeys := make(map[string]struct{}) - for _, opt := range opts.CLI() { - if opt.Env == "" { - continue - } - allEnvKeys[opt.Env] = struct{}{} - } - - // Only set the environment variables from opts that are not legacy options. - // Legacy options are those that are not prefixed with ENVBUILDER_. - // While we can detect when a legacy option is set, overriding it becomes - // problematic. Erring on the side of caution, we will not override legacy options. - isEnvbuilderOption := func(key string) bool { - switch key { - case "CODER_AGENT_URL", "CODER_AGENT_TOKEN", "CODER_AGENT_SUBSYSTEM": - return true // kinda - default: - return strings.HasPrefix(key, "ENVBUILDER_") - } - } - - computed := make(map[string]string) - for _, opt := range opts.CLI() { - if opt.Env == "" { - continue - } - // TODO: remove this check once support for legacy options is removed. - if !isEnvbuilderOption(opt.Env) { - continue - } - var val string - if sa, ok := opt.Value.(*serpent.StringArray); ok { - val = strings.Join(sa.GetSlice(), ",") - } else { - val = opt.Value.String() - } - - switch val { - case "", "false", "0": - // Skip zero values. - continue - } - computed[opt.Env] = val - } - - // Merge in extraEnv, which may override values from opts. - // Skip any keys that are envbuilder options. - for key, val := range extraEnv { - if isEnvbuilderOption(key) { - continue - } - computed[key] = val - } - return computed -} - // setComputedEnv sets data.Env and data.EnvMap based on the values of the // other fields in the model. func (data *CachedImageResourceModel) setComputedEnv(ctx context.Context, env map[string]string) diag.Diagnostics { var diag, ds diag.Diagnostics data.EnvMap, ds = basetypes.NewMapValueFrom(ctx, types.StringType, env) diag = append(diag, ds...) - data.Env, ds = basetypes.NewListValueFrom(ctx, types.StringType, sortedKeyValues(env)) + data.Env, ds = basetypes.NewListValueFrom(ctx, types.StringType, tfutil.DockerEnv(env)) diag = append(diag, ds...) return diag } @@ -558,7 +313,7 @@ func (r *CachedImageResource) Read(ctx context.Context, req resource.ReadRequest return } // Set the expected environment variables. - computedEnv := computeEnvFromOptions(opts, tfMapToStringMap(data.ExtraEnv)) + computedEnv := computeEnvFromOptions(opts, tfutil.TFMapToStringMap(data.ExtraEnv)) resp.Diagnostics.Append(data.setComputedEnv(ctx, computedEnv)...) // If the previous state is that Image == BuilderImage, then we previously did @@ -574,7 +329,7 @@ func (r *CachedImageResource) Read(ctx context.Context, req resource.ReadRequest } // Check the remote registry for the image we previously found. - img, err := getRemoteImage(data.Image.ValueString()) + img, err := imgutil.GetRemoteImage(data.Image.ValueString()) if err != nil { if !strings.Contains(err.Error(), "MANIFEST_UNKNOWN") { // Explicitly not making this an error diag. @@ -629,7 +384,7 @@ func (r *CachedImageResource) Create(ctx context.Context, req resource.CreateReq } // Set the expected environment variables. - computedEnv := computeEnvFromOptions(opts, tfMapToStringMap(data.ExtraEnv)) + computedEnv := computeEnvFromOptions(opts, tfutil.TFMapToStringMap(data.ExtraEnv)) resp.Diagnostics.Append(data.setComputedEnv(ctx, computedEnv)...) cachedImg, err := runCacheProbe(ctx, data.BuilderImage.ValueString(), opts) @@ -716,7 +471,7 @@ func runCacheProbe(ctx context.Context, builderImage string, opts eboptions.Opti // In order to correctly reproduce the final layer of the cached image, we // need the envbuilder binary used to originally build the image! envbuilderPath := filepath.Join(tmpDir, "envbuilder") - if err := extractEnvbuilderFromImage(ctx, builderImage, envbuilderPath); err != nil { + if err := imgutil.ExtractEnvbuilderFromImage(ctx, builderImage, envbuilderPath); err != nil { tflog.Error(ctx, "failed to fetch envbuilder binary from builder image", map[string]any{"err": err}) return nil, fmt.Errorf("failed to fetch the envbuilder binary from the builder image: %s", err.Error()) } @@ -729,7 +484,7 @@ func runCacheProbe(ctx context.Context, builderImage string, opts eboptions.Opti // We always want to get the cached image. opts.GetCachedImage = true // Log to the Terraform logger. - opts.Logger = tfLogFunc(ctx) + opts.Logger = tfutil.TFLogFunc(ctx) // We don't require users to set a workspace folder, but maybe there's a // reason someone may need to. @@ -766,165 +521,3 @@ func runCacheProbe(ctx context.Context, builderImage string, opts eboptions.Opti return envbuilder.RunCacheProbe(ctx, opts) } - -// getRemoteImage fetches the image manifest of the image. -func getRemoteImage(imgRef string) (v1.Image, error) { - ref, err := name.ParseReference(imgRef) - if err != nil { - return nil, fmt.Errorf("parse reference: %w", err) - } - - img, err := remote.Image(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) - if err != nil { - return nil, fmt.Errorf("check remote image: %w", err) - } - - return img, nil -} - -// extractEnvbuilderFromImage reads the image located at imgRef and extracts -// MagicBinaryLocation to destPath. -func extractEnvbuilderFromImage(ctx context.Context, imgRef, destPath string) error { - needle := filepath.Clean(constants.MagicBinaryLocation)[1:] // skip leading '/' - img, err := getRemoteImage(imgRef) - if err != nil { - return fmt.Errorf("check remote image: %w", err) - } - - layers, err := img.Layers() - if err != nil { - return fmt.Errorf("get image layers: %w", err) - } - - // Check the layers in reverse order. The last layers are more likely to - // include the binary. - for i := len(layers) - 1; i >= 0; i-- { - ul, err := layers[i].Uncompressed() - if err != nil { - return fmt.Errorf("get uncompressed layer: %w", err) - } - - tr := tar.NewReader(ul) - for { - th, err := tr.Next() - if err == io.EOF { - break - } - - if err != nil { - return fmt.Errorf("read tar header: %w", err) - } - - name := filepath.Clean(th.Name) - if th.Typeflag != tar.TypeReg { - tflog.Debug(ctx, "skip non-regular file", map[string]any{"name": name, "layer_idx": i + 1}) - continue - } - - if name != needle { - tflog.Debug(ctx, "skip file", map[string]any{"name": name, "layer_idx": i + 1}) - continue - } - - tflog.Debug(ctx, "found file", map[string]any{"name": name, "layer_idx": i + 1}) - if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { - return fmt.Errorf("create parent directories: %w", err) - } - destF, err := os.Create(destPath) - if err != nil { - return fmt.Errorf("create dest file for writing: %w", err) - } - defer destF.Close() - _, err = io.Copy(destF, tr) - if err != nil { - return fmt.Errorf("copy dest file from image: %w", err) - } - if err := destF.Close(); err != nil { - return fmt.Errorf("close dest file: %w", err) - } - - if err := os.Chmod(destPath, 0o755); err != nil { - return fmt.Errorf("chmod file: %w", err) - } - return nil - } - } - - return fmt.Errorf("extract envbuilder binary from image %q: %w", imgRef, os.ErrNotExist) -} - -// tfValueToString converts an attr.Value to its string representation -// based on its Terraform type. This is needed because the String() -// method on an attr.Value creates a 'human-readable' version of the type, which -// leads to quotes, escaped characters, and other assorted sadness. -func tfValueToString(val attr.Value) string { - if val.IsUnknown() || val.IsNull() { - return "" - } - if vs, ok := val.(interface{ ValueString() string }); ok { - return vs.ValueString() - } - if vb, ok := val.(interface{ ValueBool() bool }); ok { - return fmt.Sprintf("%t", vb.ValueBool()) - } - if vi, ok := val.(interface{ ValueInt64() int64 }); ok { - return fmt.Sprintf("%d", vi.ValueInt64()) - } - panic(fmt.Errorf("tfValueToString: value %T is not a supported type", val)) -} - -// tfListToStringSlice converts a types.List to a []string by calling -// tfValueToString on each element. -func tfListToStringSlice(l types.List) []string { - var ss []string - for _, el := range l.Elements() { - ss = append(ss, tfValueToString(el)) - } - return ss -} - -// tfMapToStringMap converts a types.Map to a map[string]string by calling -// tfValueToString on each element. -func tfMapToStringMap(m types.Map) map[string]string { - res := make(map[string]string) - for k, v := range m.Elements() { - res[k] = tfValueToString(v) - } - return res -} - -// tfLogFunc is an adapter to envbuilder/log.Func. -func tfLogFunc(ctx context.Context) eblog.Func { - return func(level eblog.Level, format string, args ...any) { - var logFn func(context.Context, string, ...map[string]interface{}) - switch level { - case eblog.LevelTrace: - logFn = tflog.Trace - case eblog.LevelDebug: - logFn = tflog.Debug - case eblog.LevelWarn: - logFn = tflog.Warn - case eblog.LevelError: - logFn = tflog.Error - default: - logFn = tflog.Info - } - logFn(ctx, fmt.Sprintf(format, args...)) - } -} - -// sortedKeyValues returns the keys and values of the map in the form "key=value" -// sorted by key in lexicographical order. -func sortedKeyValues(m map[string]string) []string { - pairs := make([]string, 0, len(m)) - var sb strings.Builder - for k := range m { - _, _ = sb.WriteString(k) - _, _ = sb.WriteRune('=') - _, _ = sb.WriteString(m[k]) - pairs = append(pairs, sb.String()) - sb.Reset() - } - sort.Strings(pairs) - return pairs -} diff --git a/internal/provider/cached_image_resource_test.go b/internal/provider/cached_image_resource_test.go index b5fcb1d..29e043a 100644 --- a/internal/provider/cached_image_resource_test.go +++ b/internal/provider/cached_image_resource_test.go @@ -34,13 +34,17 @@ func TestAccCachedImageResource(t *testing.T) { ".devcontainer/devcontainer.json": `{"image": "localhost:5000/test-ubuntu:latest"}`, }, extraEnv: map[string]string{ - "FOO": testEnvValue, + "CODER_AGENT_TOKEN": "some-token", + "CODER_AGENT_URL": "https://coder.example.com", "ENVBUILDER_GIT_URL": "https://not.the.real.git/url", "ENVBUILDER_CACHE_REPO": "not-the-real-cache-repo", + "FOO": testEnvValue, }, assertEnv: func(t *testing.T, deps testDependencies) resource.TestCheckFunc { return resource.ComposeAggregateTestCheckFunc( assertEnv(t, + "CODER_AGENT_TOKEN", "some-token", + "CODER_AGENT_URL", "https://coder.example.com", "ENVBUILDER_CACHE_REPO", deps.CacheRepo, "ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH", deps.Repo.Key, "ENVBUILDER_GIT_URL", deps.Repo.URL, @@ -62,6 +66,8 @@ func TestAccCachedImageResource(t *testing.T) { RUN date > /date.txt`, }, extraEnv: map[string]string{ + "CODER_AGENT_TOKEN": "some-token", + "CODER_AGENT_URL": "https://coder.example.com", "FOO": testEnvValue, "ENVBUILDER_GIT_URL": "https://not.the.real.git/url", "ENVBUILDER_CACHE_REPO": "not-the-real-cache-repo", @@ -69,6 +75,8 @@ RUN date > /date.txt`, assertEnv: func(t *testing.T, deps testDependencies) resource.TestCheckFunc { return resource.ComposeAggregateTestCheckFunc( assertEnv(t, + "CODER_AGENT_TOKEN", "some-token", + "CODER_AGENT_URL", "https://coder.example.com", "ENVBUILDER_CACHE_REPO", deps.CacheRepo, "ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH", deps.Repo.Key, "ENVBUILDER_GIT_URL", deps.Repo.URL, @@ -89,6 +97,8 @@ RUN date > /date.txt`, RUN date > /date.txt`, }, extraEnv: map[string]string{ + "CODER_AGENT_TOKEN": "some-token", + "CODER_AGENT_URL": "https://coder.example.com", "FOO": testEnvValue, "ENVBUILDER_GIT_URL": "https://not.the.real.git/url", "ENVBUILDER_CACHE_REPO": "not-the-real-cache-repo", @@ -99,6 +109,8 @@ RUN date > /date.txt`, assertEnv: func(t *testing.T, deps testDependencies) resource.TestCheckFunc { return resource.ComposeAggregateTestCheckFunc( assertEnv(t, + "CODER_AGENT_TOKEN", "some-token", + "CODER_AGENT_URL", "https://coder.example.com", "ENVBUILDER_CACHE_REPO", deps.CacheRepo, "ENVBUILDER_DEVCONTAINER_DIR", "path/to/.devcontainer", "ENVBUILDER_DEVCONTAINER_JSON_PATH", "path/to/.devcontainer/devcontainer.json", diff --git a/internal/provider/helpers.go b/internal/provider/helpers.go new file mode 100644 index 0000000..21137f1 --- /dev/null +++ b/internal/provider/helpers.go @@ -0,0 +1,255 @@ +package provider + +import ( + "fmt" + "strings" + + eboptions "github.com/coder/envbuilder/options" + "github.com/coder/serpent" + "github.com/coder/terraform-provider-envbuilder/internal/tfutil" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/spf13/pflag" +) + +const ( + envbuilderOptionPrefix = "ENVBUILDER_" +) + +// nonOverrideOptions are options that cannot be overridden by extra_env. +var nonOverrideOptions = map[string]bool{ + "ENVBUILDER_CACHE_REPO": true, + "ENVBUILDER_GIT_URL": true, +} + +// optionsFromDataModel converts a CachedImageResourceModel into a corresponding set of +// Envbuilder options. It returns the options and any diagnostics encountered. +func optionsFromDataModel(data CachedImageResourceModel) (eboptions.Options, diag.Diagnostics) { + var diags diag.Diagnostics + var opts eboptions.Options + + // Required options. Cannot be overridden by extra_env. + opts.CacheRepo = data.CacheRepo.ValueString() + opts.GitURL = data.GitURL.ValueString() + + // Other options can be overridden by extra_env, with a warning. + // Keep track of which options are set from the data model so we + // can check if they are being overridden. + providerOpts := make(map[string]bool) + + if !data.BaseImageCacheDir.IsNull() { + providerOpts["ENVBUILDER_BASE_IMAGE_CACHE_DIR"] = true + opts.BaseImageCacheDir = data.BaseImageCacheDir.ValueString() + } + + if !data.BuildContextPath.IsNull() { + providerOpts["ENVBUILDER_BUILD_CONTEXT_PATH"] = true + opts.BuildContextPath = data.BuildContextPath.ValueString() + } + + if !data.CacheTTLDays.IsNull() { + providerOpts["ENVBUILDER_CACHE_TTL_DAYS"] = true + opts.CacheTTLDays = data.CacheTTLDays.ValueInt64() + } + + if !data.DevcontainerDir.IsNull() { + providerOpts["ENVBUILDER_DEVCONTAINER_DIR"] = true + opts.DevcontainerDir = data.DevcontainerDir.ValueString() + } + + if !data.DevcontainerJSONPath.IsNull() { + providerOpts["ENVBUILDER_DEVCONTAINER_JSON_PATH"] = true + opts.DevcontainerJSONPath = data.DevcontainerJSONPath.ValueString() + } + + if !data.DockerfilePath.IsNull() { + providerOpts["ENVBUILDER_DOCKERFILE_PATH"] = true + opts.DockerfilePath = data.DockerfilePath.ValueString() + } + + if !data.DockerConfigBase64.IsNull() { + providerOpts["ENVBUILDER_DOCKER_CONFIG_BASE64"] = true + opts.DockerConfigBase64 = data.DockerConfigBase64.ValueString() + } + + if !data.ExitOnBuildFailure.IsNull() { + providerOpts["ENVBUILDER_EXIT_ON_BUILD_FAILURE"] = true + opts.ExitOnBuildFailure = data.ExitOnBuildFailure.ValueBool() + } + + if !data.FallbackImage.IsNull() { + providerOpts["ENVBUILDER_FALLBACK_IMAGE"] = true + opts.FallbackImage = data.FallbackImage.ValueString() + } + + if !data.GitCloneDepth.IsNull() { + providerOpts["ENVBUILDER_GIT_CLONE_DEPTH"] = true + opts.GitCloneDepth = data.GitCloneDepth.ValueInt64() + } + + if !data.GitCloneSingleBranch.IsNull() { + providerOpts["ENVBUILDER_GIT_CLONE_SINGLE_BRANCH"] = true + opts.GitCloneSingleBranch = data.GitCloneSingleBranch.ValueBool() + } + + if !data.GitHTTPProxyURL.IsNull() { + providerOpts["ENVBUILDER_GIT_HTTP_PROXY_URL"] = true + opts.GitHTTPProxyURL = data.GitHTTPProxyURL.ValueString() + } + + if !data.GitSSHPrivateKeyPath.IsNull() { + providerOpts["ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH"] = true + opts.GitSSHPrivateKeyPath = data.GitSSHPrivateKeyPath.ValueString() + } + + if !data.GitUsername.IsNull() { + providerOpts["ENVBUILDER_GIT_USERNAME"] = true + opts.GitUsername = data.GitUsername.ValueString() + } + + if !data.GitPassword.IsNull() { + providerOpts["ENVBUILDER_GIT_PASSWORD"] = true + opts.GitPassword = data.GitPassword.ValueString() + } + + if !data.IgnorePaths.IsNull() { + providerOpts["ENVBUILDER_IGNORE_PATHS"] = true + opts.IgnorePaths = tfutil.TFListToStringSlice(data.IgnorePaths) + } + + if !data.Insecure.IsNull() { + providerOpts["ENVBUILDER_INSECURE"] = true + opts.Insecure = data.Insecure.ValueBool() + } + + if data.RemoteRepoBuildMode.IsNull() { + opts.RemoteRepoBuildMode = true + } else { + providerOpts["ENVBUILDER_REMOTE_REPO_BUILD_MODE"] = true + opts.RemoteRepoBuildMode = data.RemoteRepoBuildMode.ValueBool() + } + + if !data.SSLCertBase64.IsNull() { + providerOpts["ENVBUILDER_SSL_CERT_BASE64"] = true + opts.SSLCertBase64 = data.SSLCertBase64.ValueString() + } + + if !data.Verbose.IsNull() { + providerOpts["ENVBUILDER_VERBOSE"] = true + opts.Verbose = data.Verbose.ValueBool() + } + + if !data.WorkspaceFolder.IsNull() { + providerOpts["ENVBUILDER_WORKSPACE_FOLDER"] = true + opts.WorkspaceFolder = data.WorkspaceFolder.ValueString() + } + + // convert extraEnv to a map for ease of use. + extraEnv := make(map[string]string) + for k, v := range data.ExtraEnv.Elements() { + extraEnv[k] = tfutil.TFValueToString(v) + } + diags = append(diags, overrideOptionsFromExtraEnv(&opts, extraEnv, providerOpts)...) + + return opts, diags +} + +// overrideOptionsFromExtraEnv overrides the options in opts with values from extraEnv. +// It returns any diagnostics encountered. +// It will not override certain options, such as ENVBUILDER_CACHE_REPO and ENVBUILDER_GIT_URL. +func overrideOptionsFromExtraEnv(opts *eboptions.Options, extraEnv map[string]string, providerOpts map[string]bool) diag.Diagnostics { + var diags diag.Diagnostics + // Make a map of the options for easy lookup. + optsMap := make(map[string]pflag.Value) + for _, opt := range opts.CLI() { + optsMap[opt.Env] = opt.Value + } + for key, val := range extraEnv { + opt, found := optsMap[key] + if !found { + // ignore unknown keys + continue + } + + if nonOverrideOptions[key] { + diags.AddAttributeWarning(path.Root("extra_env"), + "Cannot override required environment variable", + fmt.Sprintf("The key %q in extra_env cannot be overridden.", key), + ) + continue + } + + // Check if the option was set on the provider data model and generate a warning if so. + if providerOpts[key] { + diags.AddAttributeWarning(path.Root("extra_env"), + "Overriding provider environment variable", + fmt.Sprintf("The key %q in extra_env overrides an option set on the provider.", key), + ) + } + + // XXX: workaround for serpent behaviour where calling Set() on a + // string slice will append instead of replace: set to empty first. + if key == "ENVBUILDER_IGNORE_PATHS" { + _ = optsMap[key].Set("") + } + + if err := opt.Set(val); err != nil { + diags.AddAttributeError(path.Root("extra_env"), + "Invalid value for environment variable", + fmt.Sprintf("The key %q in extra_env has an invalid value: %s", key, err), + ) + } + } + return diags +} + +// computeEnvFromOptions computes the environment variables to set based on the +// options in opts and the extra environment variables in extraEnv. +// It returns the computed environment variables as a map. +// It will not set certain options, such as ENVBUILDER_CACHE_REPO and ENVBUILDER_GIT_URL. +// It will also not handle legacy Envbuilder options (i.e. those not prefixed with ENVBUILDER_). +func computeEnvFromOptions(opts eboptions.Options, extraEnv map[string]string) map[string]string { + for _, opt := range opts.CLI() { + if opt.Env == "" { + continue + } + } + + computed := make(map[string]string) + for _, opt := range opts.CLI() { + if opt.Env == "" { + continue + } + // TODO: remove this check once support for legacy options is removed. + // Only set the environment variables from opts that are not legacy options. + // Legacy options are those that are not prefixed with ENVBUILDER_. + // While we can detect when a legacy option is set, overriding it becomes + // problematic. Erring on the side of caution, we will not override legacy options. + if !strings.HasPrefix(opt.Env, envbuilderOptionPrefix) { + continue + } + var val string + if sa, ok := opt.Value.(*serpent.StringArray); ok { + val = strings.Join(sa.GetSlice(), ",") + } else { + val = opt.Value.String() + } + + switch val { + case "", "false", "0": + // Skip zero values. + continue + } + computed[opt.Env] = val + } + + // Merge in extraEnv, which may override values from opts. + // Skip any keys that are envbuilder options. + for key, val := range extraEnv { + if strings.HasPrefix(key, envbuilderOptionPrefix) { + continue + } + computed[key] = val + } + return computed +} diff --git a/internal/provider/provider_internal_test.go b/internal/provider/provider_internal_test.go index 5601832..a9be0ae 100644 --- a/internal/provider/provider_internal_test.go +++ b/internal/provider/provider_internal_test.go @@ -284,9 +284,6 @@ func Test_computeEnvFromOptions(t *testing.T) { "FOO": "bar", // should be included }, expectEnv: map[string]string{ - "CODER_AGENT_SUBSYSTEM": "one,two", - "CODER_AGENT_TOKEN": "string", - "CODER_AGENT_URL": "string", "ENVBUILDER_BASE_IMAGE_CACHE_DIR": "string", "ENVBUILDER_BINARY_PATH": "string", "ENVBUILDER_BUILD_CONTEXT_PATH": "string", diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index de9ad8c..26dc7d4 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -118,7 +118,7 @@ func seedCache(ctx context.Context, t testing.TB, deps testDependencies) { } for k, v := range deps.ExtraEnv { - if !strings.HasPrefix(k, "ENVBUILDER_") { + if !strings.HasPrefix(k, envbuilderOptionPrefix) { continue } if _, ok := seedEnv[k]; ok { diff --git a/internal/tfutil/tfutil.go b/internal/tfutil/tfutil.go new file mode 100644 index 0000000..3366b6f --- /dev/null +++ b/internal/tfutil/tfutil.go @@ -0,0 +1,92 @@ +package tfutil + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/coder/envbuilder/log" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// TFValueToString converts an attr.Value to its string representation +// based on its Terraform type. This is needed because the String() +// method on an attr.Value creates a 'human-readable' version of the type, which +// leads to quotes, escaped characters, and other assorted sadness. +func TFValueToString(val attr.Value) string { + if val.IsUnknown() || val.IsNull() { + return "" + } + if vs, ok := val.(interface{ ValueString() string }); ok { + return vs.ValueString() + } + if vb, ok := val.(interface{ ValueBool() bool }); ok { + return fmt.Sprintf("%t", vb.ValueBool()) + } + if vi, ok := val.(interface{ ValueInt64() int64 }); ok { + return fmt.Sprintf("%d", vi.ValueInt64()) + } + panic(fmt.Errorf("tfValueToString: value %T is not a supported type", val)) +} + +// TFListToStringSlice converts a types.List to a []string by calling +// tfValueToString on each element. +func TFListToStringSlice(l types.List) []string { + els := l.Elements() + ss := make([]string, len(els)) + for idx, el := range els { + ss[idx] = TFValueToString(el) + } + return ss +} + +// TFMapToStringMap converts a types.Map to a map[string]string by calling +// tfValueToString on each element. +func TFMapToStringMap(m types.Map) map[string]string { + els := m.Elements() + res := make(map[string]string, len(els)) + for k, v := range els { + res[k] = TFValueToString(v) + } + return res +} + +// TFLogFunc is an adapter to envbuilder/log.Func. +func TFLogFunc(ctx context.Context) log.Func { + return func(level log.Level, format string, args ...any) { + var logFn func(context.Context, string, ...map[string]interface{}) + switch level { + case log.LevelTrace: + logFn = tflog.Trace + case log.LevelDebug: + logFn = tflog.Debug + case log.LevelWarn: + logFn = tflog.Warn + case log.LevelError: + logFn = tflog.Error + default: + logFn = tflog.Info + } + logFn(ctx, fmt.Sprintf(format, args...)) + } +} + +// DockerEnv returns the keys and values of the map in the form "key=value" +// sorted by key in lexicographical order. This is the format expected by +// Docker and some other tools that consume environment variables. +func DockerEnv(m map[string]string) []string { + pairs := make([]string, 0, len(m)) + var sb strings.Builder + for k := range m { + _, _ = sb.WriteString(k) + _, _ = sb.WriteRune('=') + _, _ = sb.WriteString(m[k]) + pairs = append(pairs, sb.String()) + sb.Reset() + } + sort.Strings(pairs) + return pairs +} From 6137223cea8c4ccc5c7b650c60423e848e515004 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 5 Sep 2024 17:41:42 +0100 Subject: [PATCH 3/3] update kaniko to latest main to keep in sync with envbuilder (#47) --- go.mod | 8 ++--- go.sum | 8 ++--- .../provider/cached_image_resource_test.go | 34 +++++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 923e81f..d05a28a 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/coder/terraform-provider-envbuilder go 1.22.4 // We use our own Kaniko fork. -replace github.com/GoogleContainerTools/kaniko => github.com/coder/kaniko v0.0.0-20240807142221-ffc5e60fca41 +replace github.com/GoogleContainerTools/kaniko => github.com/coder/kaniko v0.0.0-20240830141327-f307586e3dca // Required to import codersdk due to gvisor dependency. replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20240702054557-aa558fbe5374 @@ -11,6 +11,7 @@ replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20240702054557-aa55 require ( github.com/GoogleContainerTools/kaniko v1.9.2 github.com/coder/envbuilder v1.0.0-rc.0.0.20240830145058-fb7e689f39ed + github.com/coder/serpent v0.7.0 github.com/docker/docker v26.1.5+incompatible github.com/gliderlabs/ssh v0.3.7 github.com/go-git/go-billy/v5 v5.5.0 @@ -22,6 +23,7 @@ require ( github.com/hashicorp/terraform-plugin-go v0.23.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.10.0 + github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.9.0 ) @@ -96,7 +98,6 @@ require ( github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 // indirect github.com/coder/quartz v0.1.0 // indirect github.com/coder/retry v1.5.1 // indirect - github.com/coder/serpent v0.7.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 @@ -185,7 +186,6 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect github.com/jsimonetti/rtnetlink v1.3.5 // indirect - github.com/karrick/godirwalk v1.16.1 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.17.9 // indirect @@ -255,7 +255,6 @@ require ( github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d // indirect github.com/tailscale/golang-x-crypto v0.0.0-20230713185742-f0b76a10a08e // indirect github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect @@ -264,6 +263,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/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 github.com/vbatts/tar-split v0.11.5 // indirect diff --git a/go.sum b/go.sum index fd61f13..3678f90 100644 --- a/go.sum +++ b/go.sum @@ -188,8 +188,8 @@ github.com/coder/coder/v2 v2.10.1-0.20240704130443-c2d44d16a352 h1:L/EjCuZxs5tOc github.com/coder/coder/v2 v2.10.1-0.20240704130443-c2d44d16a352/go.mod h1:P1KoQSgnKEAG6Mnd3YlGzAophty+yKA9VV48LpfNRvo= github.com/coder/envbuilder v1.0.0-rc.0.0.20240830145058-fb7e689f39ed h1:sDEjs9qB2uJ7O85vGmzMja99IZuLvesxElOUFyy22UY= github.com/coder/envbuilder v1.0.0-rc.0.0.20240830145058-fb7e689f39ed/go.mod h1:LWImvtIWaX3eiAI3zyU46WE/PrE099nCut1zJultSk0= -github.com/coder/kaniko v0.0.0-20240807142221-ffc5e60fca41 h1:1Ye7AcLnuT5IDv6il7Fxo+aqpzlWfedkpraCCwx8Lyo= -github.com/coder/kaniko v0.0.0-20240807142221-ffc5e60fca41/go.mod h1:YMK7BlxerzLlMwihGxNWUaFoN9LXCij4P+w/8/fNlcM= +github.com/coder/kaniko v0.0.0-20240830141327-f307586e3dca h1:PrcSWrllqipTrtet50a3VyAJEQmjziIZyhpy0bsC6o0= +github.com/coder/kaniko v0.0.0-20240830141327-f307586e3dca/go.mod h1:XoTDIhNF0Ll4tLmRYdOn31udU9w5zFrY2PME/crSRCA= 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= @@ -524,8 +524,6 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw= -github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= @@ -774,6 +772,8 @@ 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/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= +github.com/twpayne/go-vfs/v5 v5.0.4/go.mod h1:zTPFJUbgsEMFNSWnWQlLq9wh4AN83edZzx3VXbxrS1w= github.com/u-root/uio v0.0.0-20240209044354-b3d14b93376a h1:BH1SOPEvehD2kVrndDnGJiUF0TrBpNs+iyYocu6h0og= github.com/u-root/uio v0.0.0-20240209044354-b3d14b93376a/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= diff --git a/internal/provider/cached_image_resource_test.go b/internal/provider/cached_image_resource_test.go index 29e043a..00b50ff 100644 --- a/internal/provider/cached_image_resource_test.go +++ b/internal/provider/cached_image_resource_test.go @@ -124,6 +124,40 @@ RUN date > /date.txt`, ) }, }, + { + // This tests that a multi-stage build works correctly. + name: "multistage_run_copy", + files: map[string]string{ + "Dockerfile": ` + FROM localhost:5000/test-ubuntu:latest AS a + RUN date > /date.txt + FROM localhost:5000/test-ubuntu:latest + COPY --from=a /date.txt /date.txt`, + }, + extraEnv: map[string]string{ + "CODER_AGENT_TOKEN": "some-token", + "CODER_AGENT_URL": "https://coder.example.com", + "FOO": testEnvValue, + "ENVBUILDER_GIT_URL": "https://not.the.real.git/url", + "ENVBUILDER_CACHE_REPO": "not-the-real-cache-repo", + "ENVBUILDER_DOCKERFILE_PATH": "Dockerfile", + }, + assertEnv: func(t *testing.T, deps testDependencies) resource.TestCheckFunc { + return resource.ComposeAggregateTestCheckFunc( + assertEnv(t, + "CODER_AGENT_TOKEN", "some-token", + "CODER_AGENT_URL", "https://coder.example.com", + "ENVBUILDER_CACHE_REPO", deps.CacheRepo, + "ENVBUILDER_DOCKERFILE_PATH", "Dockerfile", + "ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH", deps.Repo.Key, + "ENVBUILDER_GIT_URL", deps.Repo.URL, + "ENVBUILDER_REMOTE_REPO_BUILD_MODE", "true", + "ENVBUILDER_VERBOSE", "true", + "FOO", "bar\nbaz", + ), + ) + }, + }, } { t.Run(tc.name, func(t *testing.T) { //nolint: paralleltest