diff --git a/docs/data-sources/cached_image.md b/docs/resources/cached_image.md similarity index 78% rename from docs/data-sources/cached_image.md rename to docs/resources/cached_image.md index df1b4a4..b83aaed 100644 --- a/docs/data-sources/cached_image.md +++ b/docs/resources/cached_image.md @@ -1,32 +1,16 @@ --- # generated by https://github.com/hashicorp/terraform-plugin-docs -page_title: "envbuilder_cached_image Data Source - envbuilder" +page_title: "envbuilder_cached_image Resource - envbuilder" subcategory: "" description: |- - The cached image data source can be used to retrieve a cached image produced by envbuilder. Reading from this data source will clone the specified Git repository, read a Devcontainer specification or Dockerfile, and check for its presence in the provided cache repo. + The cached image resource can be used to retrieve a cached image produced by envbuilder. Creating this resource will clone the specified Git repository, read a Devcontainer specification or Dockerfile, and check for its presence in the provided cache repo. If any of the layers of the cached image are missing in the provided cache repo, the image will be considered as missing. A cached image in this state will be recreated until found. --- -# envbuilder_cached_image (Data Source) +# envbuilder_cached_image (Resource) -The cached image data source can be used to retrieve a cached image produced by envbuilder. Reading from this data source will clone the specified Git repository, read a Devcontainer specification or Dockerfile, and check for its presence in the provided cache repo. +The cached image resource can be used to retrieve a cached image produced by envbuilder. Creating this resource will clone the specified Git repository, read a Devcontainer specification or Dockerfile, and check for its presence in the provided cache repo. If any of the layers of the cached image are missing in the provided cache repo, the image will be considered as missing. A cached image in this state will be recreated until found. -## Example Usage -```terraform -data "envbuilder_cached_image" "example" { - builder_image = "ghcr.io/coder/envbuilder:latest" - git_url = "https://github.com/coder/envbuilder-starter-devcontainer" - cache_repo = "localhost:5000/local/test-cache" - extra_env = { - "ENVBUILDER_VERBOSE" : "true" - } -} - -resource "docker_container" "container" { - image = envbuilder_cached_image.example.image - env = data.envbuilder_image.cached.env -} -``` ## Schema @@ -63,7 +47,7 @@ resource "docker_container" "container" { ### Read-Only -- `env` (List of String) Computed envbuilder configuration to be set for the container. +- `env` (List of String, Sensitive) Computed envbuilder configuration to be set for the container. May contain secrets. - `exists` (Boolean) Whether the cached image was exists or not for the given config. - `id` (String) Cached image identifier. This will generally be the image's SHA256 digest. - `image` (String) Outputs the cached image repo@digest if it exists, and builder image otherwise. diff --git a/examples/data-sources/envbuilder_cached_image/data-source.tf b/examples/data-sources/envbuilder_cached_image/data-source.tf deleted file mode 100644 index 5169ce1..0000000 --- a/examples/data-sources/envbuilder_cached_image/data-source.tf +++ /dev/null @@ -1,13 +0,0 @@ -data "envbuilder_cached_image" "example" { - builder_image = "ghcr.io/coder/envbuilder:latest" - git_url = "https://github.com/coder/envbuilder-starter-devcontainer" - cache_repo = "localhost:5000/local/test-cache" - extra_env = { - "ENVBUILDER_VERBOSE" : "true" - } -} - -resource "docker_container" "container" { - image = envbuilder_cached_image.example.image - env = data.envbuilder_image.cached.env -} \ No newline at end of file diff --git a/examples/resources/envbuilder_cached_image/envbuilder_cached_image_resource.tf b/examples/resources/envbuilder_cached_image/envbuilder_cached_image_resource.tf new file mode 100644 index 0000000..f652329 --- /dev/null +++ b/examples/resources/envbuilder_cached_image/envbuilder_cached_image_resource.tf @@ -0,0 +1,93 @@ +// The below example illustrates the behavior of the envbuilder_cached_image +// resource. +// 1) Run a local registry: +// +// ```shell +// docker run -d -p 5000:5000 --name test-registry registry:2 +// ``` +// +// 2) Running a `terraform plan` should result in the following outputs: +// +// ``` +// + builder_image = "ghcr.io/coder/envbuilder-preview:latest" +// + exists = (known after apply) +// + id = (known after apply) +// + image = (known after apply) +// ``` +// +// 3) Running `terraform apply` should result in outputs similar to the below: +// +// ``` +// builder_image = "ghcr.io/coder/envbuilder-preview:latest" +// exists = false +// id = "00000000-0000-0000-0000-000000000000" +// image = "ghcr.io/coder/envbuilder-preview:latest" +// ``` +// +// 4) Populate the cache by running Envbuilder and pushing the built image to +// the local registry: +// +// ```shell +// docker run -it --rm \ +// -e ENVBUILDER_CACHE_REPO=localhost:5000/test \ +// -e ENVBUILDER_GIT_URL=https://github.com/coder/envbuilder-starter-devcontainer \ +// -e ENVBUILDER_PUSH_IMAGE=true \ +// -e ENVBUILDER_INIT_SCRIPT=exit \ +// --net=host \ +// ghcr.io/coder/envbuilder-preview:latest +// ``` +// +// 5) Run `terraform plan` once more. Now, the cached image will be detected: +// +// ``` +// Note: Objects have changed outside of Terraform +// +// Terraform detected the following changes made outside of Terraform since the last "terraform apply" which may have affected this plan: +// envbuilder_cached_image.example has been deleted +// - resource "envbuilder_cached_image" "example" { +// - exists = false -> null +// - id = "00000000-0000-0000-0000-000000000000" -> null +// - image = "ghcr.io/coder/envbuilder-preview:latest" -> null +// # (5 unchanged attributes hidden) +// ``` +// +// 6) Run `terraform apply` and the newly pushed image will be saved in the Terraform state: +// ```shell +// builder_image = "ghcr.io/coder/envbuilder-preview:latest" +// exists = true +// id = "sha256:xxx..." +// image = "localhost:5000/test@sha256:xxx..." +// ``` + +terraform { + required_providers { + envbuilder = { + source = "coder/envbuilder" + } + } +} + +resource "envbuilder_cached_image" "example" { + builder_image = "ghcr.io/coder/envbuilder-preview:latest" + git_url = "https://github.com/coder/envbuilder-starter-devcontainer" + cache_repo = "localhost:5000/test" + extra_env = { + "ENVBUILDER_VERBOSE" : "true" + } +} + +output "builder_image" { + value = envbuilder_cached_image.example.builder_image +} + +output "exists" { + value = envbuilder_cached_image.example.exists +} + +output "id" { + value = envbuilder_cached_image.example.id +} + +output "image" { + value = envbuilder_cached_image.example.image +} diff --git a/internal/provider/cached_image_data_source_test.go b/internal/provider/cached_image_data_source_test.go deleted file mode 100644 index 8dccdbb..0000000 --- a/internal/provider/cached_image_data_source_test.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package provider - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "github.com/hashicorp/terraform-plugin-testing/helper/resource" -) - -// TODO: change this to only test for a non-existent image. -// Move the heavy lifting to integration. -func TestAccCachedImageDataSource(t *testing.T) { - t.Run("Found", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - files := map[string]string{ - ".devcontainer/devcontainer.json": `{"build": { "dockerfile": "Dockerfile" }}`, - ".devcontainer/Dockerfile": `FROM localhost:5000/test-ubuntu:latest - RUN apt-get update && apt-get install -y cowsay`, - } - - deps := setup(ctx, t, files) - seedCache(ctx, t, deps) - tfCfg := fmt.Sprintf(`data "envbuilder_cached_image" "test" { - builder_image = %q - workspace_folder = %q - git_url = %q - git_ssh_private_key_path = %q - extra_env = { - "FOO" : "bar" - } - cache_repo = %q - verbose = true -}`, deps.BuilderImage, "/workspace", deps.Repo.URL, deps.Repo.Key, deps.CacheRepo) - resource.Test(t, resource.TestCase{ - PreCheck: func() { testAccPreCheck(t) }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: tfCfg, - Check: resource.ComposeAggregateTestCheckFunc( - // Inputs should still be present. - resource.TestCheckResourceAttr("data.envbuilder_cached_image.test", "cache_repo", deps.CacheRepo), - resource.TestCheckResourceAttr("data.envbuilder_cached_image.test", "extra_env.FOO", "bar"), - resource.TestCheckResourceAttr("data.envbuilder_cached_image.test", "git_url", deps.Repo.URL), - // Should be empty - resource.TestCheckNoResourceAttr("data.envbuilder_cached_image.test", "git_username"), - resource.TestCheckNoResourceAttr("data.envbuilder_cached_image.test", "git_password"), - resource.TestCheckNoResourceAttr("data.envbuilder_cached_image.test", "cache_ttl_days"), - // Computed - resource.TestCheckResourceAttrWith("data.envbuilder_cached_image.test", "id", func(value string) error { - // value is enclosed in quotes - value = strings.Trim(value, `"`) - if !strings.HasPrefix(value, "sha256:") { - return fmt.Errorf("expected image %q to have prefix %q", value, deps.CacheRepo) - } - return nil - }), - resource.TestCheckResourceAttr("data.envbuilder_cached_image.test", "exists", "true"), - resource.TestCheckResourceAttrSet("data.envbuilder_cached_image.test", "image"), - resource.TestCheckResourceAttrWith("data.envbuilder_cached_image.test", "image", func(value string) error { - // value is enclosed in quotes - value = strings.Trim(value, `"`) - if !strings.HasPrefix(value, deps.CacheRepo) { - return fmt.Errorf("expected image %q to have prefix %q", value, deps.CacheRepo) - } - return nil - }), - resource.TestCheckResourceAttr("data.envbuilder_cached_image.test", "env.0", "FOO=\"bar\""), - ), - }, - }, - }) - }) - - t.Run("NotFound", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - files := map[string]string{ - ".devcontainer/devcontainer.json": `{"build": { "dockerfile": "Dockerfile" }}`, - ".devcontainer/Dockerfile": `FROM localhost:5000/test-ubuntu:latest - RUN apt-get update && apt-get install -y cowsay`, - } - deps := setup(ctx, t, files) - // We do not seed the cache. - tfCfg := fmt.Sprintf(`data "envbuilder_cached_image" "test" { - builder_image = %q - workspace_folder = %q - git_url = %q - git_ssh_private_key_path = %q - extra_env = { - "FOO" : "bar" - } - cache_repo = %q - verbose = true -}`, deps.BuilderImage, "/workspace", deps.Repo.URL, deps.Repo.Key, deps.CacheRepo) - resource.Test(t, resource.TestCase{ - PreCheck: func() { testAccPreCheck(t) }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: tfCfg, - Check: resource.ComposeAggregateTestCheckFunc( - // Inputs should still be present. - resource.TestCheckResourceAttr("data.envbuilder_cached_image.test", "cache_repo", deps.CacheRepo), - resource.TestCheckResourceAttr("data.envbuilder_cached_image.test", "extra_env.FOO", "bar"), - resource.TestCheckResourceAttr("data.envbuilder_cached_image.test", "git_url", deps.Repo.URL), - resource.TestCheckResourceAttr("data.envbuilder_cached_image.test", "exists", "false"), - resource.TestCheckResourceAttr("data.envbuilder_cached_image.test", "image", deps.BuilderImage), - // Should be empty - resource.TestCheckNoResourceAttr("data.envbuilder_cached_image.test", "git_username"), - resource.TestCheckNoResourceAttr("data.envbuilder_cached_image.test", "git_password"), - resource.TestCheckNoResourceAttr("data.envbuilder_cached_image.test", "cache_ttl_days"), - // Computed values should be empty. - resource.TestCheckNoResourceAttr("data.envbuilder_cached_image.test", "id"), - resource.TestCheckResourceAttrSet("data.envbuilder_cached_image.test", "env.0"), - ), - }, - }, - }) - }) -} diff --git a/internal/provider/cached_image_data_source.go b/internal/provider/cached_image_resource.go similarity index 67% rename from internal/provider/cached_image_data_source.go rename to internal/provider/cached_image_resource.go index 2e876d2..7f9d5a8 100644 --- a/internal/provider/cached_image_data_source.go +++ b/internal/provider/cached_image_resource.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "path/filepath" + "strings" kconfig "github.com/GoogleContainerTools/kaniko/pkg/config" "github.com/coder/envbuilder" @@ -20,28 +21,35 @@ import ( "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/hashicorp/terraform-plugin-framework/datasource" - "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/google/uuid" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" ) // Ensure provider defined types fully satisfy framework interfaces. -var _ datasource.DataSource = &CachedImageDataSource{} +var _ resource.Resource = &CachedImageResource{} -func NewCachedImageDataSource() datasource.DataSource { - return &CachedImageDataSource{} +func NewCachedImageResource() resource.Resource { + return &CachedImageResource{} } -// CachedImageDataSource defines the data source implementation. -type CachedImageDataSource struct { +// CachedImageResource defines the resource implementation. +type CachedImageResource struct { client *http.Client } -// CachedImageDataSourceModel describes the data source data model. -type CachedImageDataSourceModel struct { +// CachedImageResourceModel describes an envbuilder cached image resource. +type CachedImageResourceModel struct { // Required "inputs". BuilderImage types.String `tfsdk:"builder_image"` CacheRepo types.String `tfsdk:"cache_repo"` @@ -75,28 +83,37 @@ type CachedImageDataSourceModel struct { Image types.String `tfsdk:"image"` } -func (d *CachedImageDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { +func (r *CachedImageResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_cached_image" } -func (d *CachedImageDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { +func (r *CachedImageResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ // This description is used by the documentation generator and the language server. - MarkdownDescription: "The cached image data source can be used to retrieve a cached image produced by envbuilder. Reading from this data source will clone the specified Git repository, read a Devcontainer specification or Dockerfile, and check for its presence in the provided cache repo.", + MarkdownDescription: "The cached image resource can be used to retrieve a cached image produced by envbuilder. Creating this resource will clone the specified Git repository, read a Devcontainer specification or Dockerfile, and check for its presence in the provided cache repo. If any of the layers of the cached image are missing in the provided cache repo, the image will be considered as missing. A cached image in this state will be recreated until found.", Attributes: map[string]schema.Attribute{ // Required "inputs". "builder_image": schema.StringAttribute{ MarkdownDescription: "The envbuilder image to use if the cached version is not found.", Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, }, "cache_repo": schema.StringAttribute{ MarkdownDescription: "(Envbuilder option) The name of the container registry to fetch the cache image from.", Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, }, "git_url": schema.StringAttribute{ MarkdownDescription: "(Envbuilder option) The URL of a Git repository containing a Devcontainer or Docker image to clone.", Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, }, // Optional "inputs". "base_image_cache_dir": schema.StringAttribute{ @@ -114,14 +131,23 @@ func (d *CachedImageDataSource) Schema(ctx context.Context, req datasource.Schem "devcontainer_dir": schema.StringAttribute{ MarkdownDescription: "(Envbuilder option) The path to the folder containing the devcontainer.json file that will be used to build the workspace and can either be an absolute path or a path relative to the workspace folder. If not provided, defaults to `.devcontainer`.", Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, }, "devcontainer_json_path": schema.StringAttribute{ MarkdownDescription: "(Envbuilder option) The path to a devcontainer.json file that is either an absolute path or a path relative to DevcontainerDir. This can be used in cases where one wants to substitute an edited devcontainer.json file for the one that exists in the repo.", Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, }, "dockerfile_path": schema.StringAttribute{ MarkdownDescription: "(Envbuilder option) The relative path to the Dockerfile that will be used to build the workspace. This is an alternative to using a devcontainer that some might find simpler.", Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, }, "docker_config_base64": schema.StringAttribute{ MarkdownDescription: "(Envbuilder option) The base64 encoded Docker config file that will be used to pull images from private container registries.", @@ -136,6 +162,9 @@ func (d *CachedImageDataSource) Schema(ctx context.Context, req datasource.Schem MarkdownDescription: "Extra environment variables to set for the container. This may include envbuilder options.", ElementType: types.StringType, Optional: true, + PlanModifiers: []planmodifier.Map{ + mapplanmodifier.RequiresReplace(), + }, }, "fallback_image": schema.StringAttribute{ MarkdownDescription: "(Envbuilder option) Specifies an alternative image to use when neither an image is declared in the devcontainer.json file nor a Dockerfile is present. If there's a build failure (from a faulty Dockerfile) or a misconfiguration, this image will be the substitute. Set ExitOnBuildFailure to true to halt the container if the build faces an issue.", @@ -193,27 +222,40 @@ func (d *CachedImageDataSource) Schema(ctx context.Context, req datasource.Schem // Computed "outputs". // TODO(mafredri): Map vs List? Support both? "env": schema.ListAttribute{ - MarkdownDescription: "Computed envbuilder configuration to be set for the container.", + MarkdownDescription: "Computed envbuilder configuration to be set for the container. May contain secrets.", ElementType: types.StringType, Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.List{ + listplanmodifier.RequiresReplace(), + }, }, "exists": schema.BoolAttribute{ MarkdownDescription: "Whether the cached image was exists or not for the given config.", Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, }, "id": schema.StringAttribute{ MarkdownDescription: "Cached image identifier. This will generally be the image's SHA256 digest.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, }, "image": schema.StringAttribute{ MarkdownDescription: "Outputs the cached image repo@digest if it exists, and builder image otherwise.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, }, }, } } -func (d *CachedImageDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { +func (r *CachedImageResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { // Prevent panic if the provider has not been configured. if req.ProviderData == nil { return @@ -223,38 +265,161 @@ func (d *CachedImageDataSource) Configure(ctx context.Context, req datasource.Co if !ok { resp.Diagnostics.AddError( - "Unexpected Data Source Configure Type", + "Unexpected Resource Configure Type", fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), ) return } - d.client = client + r.client = client +} + +func (r *CachedImageResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data CachedImageResourceModel + + // Read prior state into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // 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) { + tflog.Debug(ctx, "Image previously not found. Recreating.", map[string]any{"ref": data.Image.ValueString()}) + resp.State.RemoveResource(ctx) + return + } + + // Check the remote registry for the image we previously found. + img, err := getRemoteImage(data.Image.ValueString()) + if err != nil { + if !strings.Contains(err.Error(), "MANIFEST_UNKNOWN") { + resp.Diagnostics.AddError("Error checking remote image", err.Error()) + return + } + // Image does not exist any longer! Remove the resource so we can re-create + // it next time. + tflog.Debug(ctx, "Remote image does not exist any longer. Recreating.", map[string]any{"ref": data.Image.ValueString()}) + resp.State.RemoveResource(ctx) + return + } + + // Found image! Get the digest. + digest, err := img.Digest() + if err != nil { + resp.Diagnostics.AddError("Error fetching image digest", err.Error()) + return + } + + data.ID = types.StringValue(digest.String()) + data.Image = types.StringValue(fmt.Sprintf("%s@%s", data.CacheRepo.ValueString(), digest)) + data.Exists = types.BoolValue(true) + + // Set the expected environment variables. + for key, elem := range data.ExtraEnv.Elements() { + data.Env = appendKnownEnvToList(data.Env, key, elem) + } + + data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_REPO", data.CacheRepo) + data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_URL", data.GitURL) + if !data.CacheTTLDays.IsNull() { + data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_TTL_DAYS", data.CacheTTLDays) + } + if !data.GitUsername.IsNull() { + data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_USERNAME", data.GitUsername) + } + if !data.GitPassword.IsNull() { + data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_PASSWORD", data.GitPassword) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *CachedImageResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data CachedImageResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + cachedImg, err := r.runCacheProbe(ctx, data) + data.ID = types.StringValue(uuid.Nil.String()) + data.Exists = types.BoolValue(err == nil) + if err != nil { + // FIXME: there are legit errors that can crop up here. + // We should add a sentinel error in Kaniko for uncached layers, and check + // it here. + tflog.Info(ctx, "cached image not found", map[string]any{"err": err.Error()}) + data.Image = data.BuilderImage + } else if digest, err := cachedImg.Digest(); err != nil { + // There's something seriously up with this image! + resp.Diagnostics.AddError("Failed to get cached image digest", err.Error()) + return + } else { + tflog.Info(ctx, fmt.Sprintf("found image: %s@%s", data.CacheRepo.ValueString(), digest)) + data.Image = types.StringValue(fmt.Sprintf("%s@%s", data.CacheRepo.ValueString(), digest)) + data.ID = types.StringValue(digest.String()) + } + // Compute the env attribute from the config map. + // TODO(mafredri): Convert any other relevant attributes given via schema. + for key, elem := range data.ExtraEnv.Elements() { + data.Env = appendKnownEnvToList(data.Env, key, elem) + } + + data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_REPO", data.CacheRepo) + data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_URL", data.GitURL) + if !data.CacheTTLDays.IsNull() { + data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_TTL_DAYS", data.CacheTTLDays) + } + if !data.GitUsername.IsNull() { + data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_USERNAME", data.GitUsername) + } + if !data.GitPassword.IsNull() { + data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_PASSWORD", data.GitPassword) + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *CachedImageResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Updates are a no-op. + var data CachedImageResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } -func (d *CachedImageDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var data CachedImageDataSourceModel +func (r *CachedImageResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Deletes are a no-op. + var data CachedImageResourceModel - // Read Terraform configuration data into the model - resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } - // If applicable, this is a great opportunity to initialize any necessary - // provider client data and make a call using it. - // httpResp, err := d.client.Do(httpReq) - // if err != nil { - // resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read cached image, got error: %s", err)) - // return - // } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} +// 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) { tmpDir, err := os.MkdirTemp(os.TempDir(), "envbuilder-provider-cached-image-data-source") if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create temp directory: %s", err.Error())) - return + return nil, fmt.Errorf("unable to create temp directory: %s", err.Error()) } defer func() { if err := os.RemoveAll(tmpDir); err != nil { @@ -271,9 +436,9 @@ func (d *CachedImageDataSource) Read(ctx context.Context, req datasource.ReadReq kconfig.KanikoDir = oldKanikoDir tflog.Info(ctx, "restored kaniko dir to "+oldKanikoDir) }() + if err := os.MkdirAll(tmpKanikoDir, 0o755); err != nil { - tflog.Error(ctx, "failed to create kaniko dir: "+err.Error()) - return + return nil, fmt.Errorf("failed to create kaniko dir: %w", err) } // In order to correctly reproduce the final layer of the cached image, we @@ -281,8 +446,7 @@ func (d *CachedImageDataSource) Read(ctx context.Context, req datasource.ReadReq envbuilderPath := filepath.Join(tmpDir, "envbuilder") if err := extractEnvbuilderFromImage(ctx, data.BuilderImage.ValueString(), envbuilderPath); err != nil { tflog.Error(ctx, "failed to fetch envbuilder binary from builder image", map[string]any{"err": err}) - resp.Diagnostics.AddError("Internal Error", fmt.Sprintf("Failed to fetch the envbuilder binary from the builder image: %s", err.Error())) - return + return nil, fmt.Errorf("failed to fetch the envbuilder binary from the builder image: %s", err.Error()) } workspaceFolder := data.WorkspaceFolder.ValueString() @@ -291,19 +455,15 @@ func (d *CachedImageDataSource) Read(ctx context.Context, req datasource.ReadReq tflog.Debug(ctx, "workspace_folder not specified, using temp dir", map[string]any{"workspace_folder": workspaceFolder}) } - // TODO: check if this is a "plan" or "apply", and only run envbuilder on "apply". - // This may require changing this to be a resource instead of a data source. 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, - RemoteRepoBuildMode: true, - RemoteRepoDir: filepath.Join(tmpDir, "repo"), // Hidden option used by this provider. + 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(), @@ -345,103 +505,29 @@ func (d *CachedImageDataSource) Read(ctx context.Context, req datasource.ReadReq SkipRebuild: false, } - image, err := envbuilder.RunCacheProbe(ctx, opts) - data.Exists = types.BoolValue(err == nil) - if err != nil { - resp.Diagnostics.AddWarning("Cached image not found", err.Error()) - // TODO: Get the repo digest of the envbuilder image and use that as the ID - data.Image = data.BuilderImage - } else { - digest, err := image.Digest() - if err != nil { - resp.Diagnostics.AddError("Failed to get cached image digest", err.Error()) - return - } - tflog.Info(ctx, fmt.Sprintf("found image: %s@%s", opts.CacheRepo, digest)) - data.ID = types.StringValue(digest.String()) - data.Image = types.StringValue(fmt.Sprintf("%s@%s", opts.CacheRepo, digest)) - } - - // Compute the env attribute from the config map. - // TODO(mafredri): Convert any other relevant attributes given via schema. - for key, elem := range data.ExtraEnv.Elements() { - data.Env = appendKnownEnvToList(data.Env, key, elem) - } - - data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_REPO", data.CacheRepo) - data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_TTL_DAYS", data.CacheTTLDays) - data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_URL", data.GitURL) - data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_USERNAME", data.GitUsername) - data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_PASSWORD", data.GitPassword) - - // Write logs using the tflog package - // Documentation: https://terraform.io/plugin/log - tflog.Trace(ctx, "read a data source") - - // Save data into Terraform state - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return envbuilder.RunCacheProbe(ctx, opts) } -// 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...)) +// 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) } -} -// NOTE: the String() method of Terraform values will evalue to `` if unknown. -// Check IsUnknown() first before calling String(). -type stringable interface { - IsUnknown() bool - String() string -} - -func appendKnownEnvToList(list types.List, key string, value stringable) types.List { - if value.IsUnknown() { - return list + img, err := remote.Image(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return nil, fmt.Errorf("check remote image: %w", err) } - elem := types.StringValue(fmt.Sprintf("%s=%s", key, value.String())) - list, _ = types.ListValue(types.StringType, append(list.Elements(), elem)) - return list -} -func tfListToStringSlice(l types.List) []string { - var ss []string - for _, el := range l.Elements() { - if sv, ok := el.(stringable); !ok { - panic(fmt.Sprintf("developer error: element %+v must be stringable", el)) - } else if sv.IsUnknown() { - ss = append(ss, "") - } else { - ss = append(ss, sv.String()) - } - } - return ss + 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 '/' - ref, err := name.ParseReference(imgRef) - if err != nil { - return fmt.Errorf("parse reference: %w", err) - } - - img, err := remote.Image(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + img, err := getRemoteImage(imgRef) if err != nil { return fmt.Errorf("check remote image: %w", err) } @@ -507,3 +593,53 @@ func extractEnvbuilderFromImage(ctx context.Context, imgRef, destPath string) er return fmt.Errorf("extract envbuilder binary from image %q: %w", imgRef, os.ErrNotExist) } + +// NOTE: the String() method of Terraform values will evalue to `` if unknown. +// Check IsUnknown() first before calling String(). +type stringable interface { + IsUnknown() bool + String() string +} + +func appendKnownEnvToList(list types.List, key string, value stringable) types.List { + if value.IsUnknown() { + return list + } + elem := types.StringValue(fmt.Sprintf("%s=%s", key, value.String())) + list, _ = types.ListValue(types.StringType, append(list.Elements(), elem)) + return list +} + +func tfListToStringSlice(l types.List) []string { + var ss []string + for _, el := range l.Elements() { + if sv, ok := el.(stringable); !ok { + panic(fmt.Sprintf("developer error: element %+v must be stringable", el)) + } else if sv.IsUnknown() { + ss = append(ss, "") + } else { + ss = append(ss, sv.String()) + } + } + return ss +} + +// 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...)) + } +} diff --git a/internal/provider/cached_image_resource_test.go b/internal/provider/cached_image_resource_test.go new file mode 100644 index 0000000..8b50e26 --- /dev/null +++ b/internal/provider/cached_image_resource_test.go @@ -0,0 +1,115 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccCachedImageDataSource(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + files := map[string]string{ + ".devcontainer/devcontainer.json": `{"build": { "dockerfile": "Dockerfile" }}`, + ".devcontainer/Dockerfile": `FROM localhost:5000/test-ubuntu:latest + RUN date > /date.txt`, + } + + deps := setup(ctx, t, files) + deps.ExtraEnv["FOO"] = "bar" + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Initial state: cache has not been seeded. + { + Config: deps.Config(t), + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + // Should detect that no cached image is present and plan to create the resource. + { + Config: deps.Config(t), + Check: resource.ComposeAggregateTestCheckFunc( + // Computed values MUST be present. + resource.TestCheckResourceAttr("envbuilder_cached_image.test", "id", uuid.Nil.String()), + resource.TestCheckResourceAttr("envbuilder_cached_image.test", "exists", "false"), + resource.TestCheckResourceAttrSet("envbuilder_cached_image.test", "env.0"), + // Cached image should be set to the builder image. + 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"), + 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"), + ), + ExpectNonEmptyPlan: true, // TODO: check the plan. + }, + // Re-running plan should have the same effect. + { + Config: deps.Config(t), + Check: resource.ComposeAggregateTestCheckFunc( + // Computed values MUST be present. + resource.TestCheckResourceAttr("envbuilder_cached_image.test", "id", uuid.Nil.String()), + resource.TestCheckResourceAttr("envbuilder_cached_image.test", "exists", "false"), + resource.TestCheckResourceAttrSet("envbuilder_cached_image.test", "env.0"), + // Cached image should be set to the builder image. + 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"), + 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"), + ), + ExpectNonEmptyPlan: true, // TODO: check the plan. + }, + // Now, seed the cache and re-run. We should now successfully create the cached image resource. + { + PreConfig: func() { + seedCache(ctx, t, deps) + }, + Config: deps.Config(t), + 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"), + 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"), + // Computed + resource.TestCheckResourceAttrWith("envbuilder_cached_image.test", "id", quotedPrefix("sha256:")), + resource.TestCheckResourceAttr("envbuilder_cached_image.test", "exists", "true"), + resource.TestCheckResourceAttrSet("envbuilder_cached_image.test", "image"), + resource.TestCheckResourceAttrWith("envbuilder_cached_image.test", "image", quotedPrefix(deps.CacheRepo)), + resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.0", "FOO=\"bar\""), + resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.1", fmt.Sprintf("ENVBUILDER_CACHE_REPO=%q", deps.CacheRepo)), + resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.2", fmt.Sprintf("ENVBUILDER_GIT_URL=%q", deps.Repo.URL)), + ), + }, + // Should produce an empty plan after apply + { + Config: deps.Config(t), + PlanOnly: true, + }, + // Ensure idempotence in this state! + { + Config: deps.Config(t), + PlanOnly: true, + }, + }, + }) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index f96858e..e271812 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -61,13 +61,11 @@ func (p *EnvbuilderProvider) Configure(ctx context.Context, req provider.Configu } func (p *EnvbuilderProvider) Resources(ctx context.Context) []func() resource.Resource { - return []func() resource.Resource{} + return []func() resource.Resource{NewCachedImageResource} } func (p *EnvbuilderProvider) DataSources(ctx context.Context) []func() datasource.DataSource { - return []func() datasource.DataSource{ - NewCachedImageDataSource, - } + return []func() datasource.DataSource{} } func (p *EnvbuilderProvider) Functions(ctx context.Context) []func() function.Function { diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 0a7edc3..0b4fa51 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -6,11 +6,13 @@ package provider import ( "bufio" "context" + "fmt" "io" "os" "slices" "strings" "testing" + "text/template" "github.com/coder/terraform-provider-envbuilder/testutil/registrytest" "github.com/hashicorp/terraform-plugin-framework/providerserver" @@ -34,18 +36,45 @@ var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServe "envbuilder": providerserver.NewProtocol6WithError(New("test")()), } -func testAccPreCheck(t *testing.T) { - // You can add code here to run prior to any test case execution, for example assertions - // about the appropriate environment variables being set are common to see in a pre-check - // function. -} - +// testDependencies contain information about stuff the test depends on. type testDependencies struct { BuilderImage string CacheRepo string + ExtraEnv map[string]string Repo testGitRepoSSH } +// Config generates a valid Terraform config file from the dependencies. +func (d *testDependencies) Config(t testing.TB) string { + t.Helper() + + tpl := `provider envbuilder {} +resource "envbuilder_cached_image" "test" { + builder_image = {{ quote .BuilderImage }} + cache_repo = {{ quote .CacheRepo }} + extra_env = { + {{ range $k, $v := .ExtraEnv }} + {{ quote $k }}: {{ quote $v }} + {{ end }} + } + git_url = {{ quote .Repo.URL }} + git_ssh_private_key_path = {{ quote .Repo.Key }} + verbose = true + workspace_folder = {{ quote .Repo.Dir }} +}` + + fm := template.FuncMap{"quote": quote} + var sb strings.Builder + tmpl, err := template.New("envbuilder_cached_image").Funcs(fm).Parse(tpl) + require.NoError(t, err) + require.NoError(t, tmpl.Execute(&sb, d)) + return sb.String() +} + +func quote(s string) string { + return fmt.Sprintf("%q", s) +} + func setup(ctx context.Context, t testing.TB, files map[string]string) testDependencies { t.Helper() @@ -64,6 +93,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), Repo: gitRepo, } } @@ -167,3 +197,14 @@ func ensureImage(ctx context.Context, t testing.TB, cli *client.Client, ref stri _, err = io.ReadAll(resp) require.NoError(t, err) } + +// quotedPrefix is a helper for asserting quoted strings. +func quotedPrefix(prefix string) func(string) error { + return func(val string) error { + trimmed := strings.Trim(val, `"`) + if !strings.HasPrefix(trimmed, prefix) { + return fmt.Errorf("expected value %q to have prefix %q", trimmed, prefix) + } + return nil + } +}