From 23f2cf5f48b4cc9ecc379bccacb197c0f6f0f8f5 Mon Sep 17 00:00:00 2001 From: Cian Johnston <cian@coder.com> Date: Wed, 4 Sep 2024 11:19:25 +0100 Subject: [PATCH] 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 | 359 ++++++++++++------ .../provider/cached_image_resource_test.go | 134 +++++-- internal/provider/provider_internal_test.go | 359 ++++++++++++++++++ internal/provider/provider_test.go | 48 ++- 4 files changed, 737 insertions(+), 163 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()) } + opts.BinaryPath = envbuilderPath - 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}) + // 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}) } - 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, + // 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)) + } - // 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"), - ) + 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) + } + + 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", },