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 +}