feat(internal/provider): add env_map to cached_image_resource ()

This PR adds `env_map` to `cached_image_resource.` This consists of the computed env in map format, which can be useful for other providers that do not expect `KEY=VALUE` format.
This commit is contained in:
Cian Johnston 2024-08-16 09:42:27 +01:00 committed by GitHub
commit 6cf3d93444
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 105 additions and 46 deletions

View file

@ -48,7 +48,8 @@ The cached image resource can be used to retrieve a cached image produced by env
### Read-Only ### Read-Only
- `env` (List of String, Sensitive) Computed envbuilder configuration to be set for the container. May contain secrets. - `env` (List of String, Sensitive) Computed envbuilder configuration to be set for the container in the form of a list of strings of `key=value`. May contain secrets.
- `env_map` (Map of String, Sensitive) Computed envbuilder configuration to be set for the container in the form of a key-value map. May contain secrets.
- `exists` (Boolean) Whether the cached image was exists or not for the given config. - `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. - `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. - `image` (String) Outputs the cached image repo@digest if it exists, and builder image otherwise.

View file

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
kconfig "github.com/GoogleContainerTools/kaniko/pkg/config" kconfig "github.com/GoogleContainerTools/kaniko/pkg/config"
@ -23,6 +24,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema" "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/boolplanmodifier"
@ -31,6 +33,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-log/tflog"
) )
@ -77,6 +80,7 @@ type CachedImageResourceModel struct {
WorkspaceFolder types.String `tfsdk:"workspace_folder"` WorkspaceFolder types.String `tfsdk:"workspace_folder"`
// Computed "outputs". // Computed "outputs".
Env types.List `tfsdk:"env"` Env types.List `tfsdk:"env"`
EnvMap types.Map `tfsdk:"env_map"`
Exists types.Bool `tfsdk:"exists"` Exists types.Bool `tfsdk:"exists"`
ID types.String `tfsdk:"id"` ID types.String `tfsdk:"id"`
Image types.String `tfsdk:"image"` Image types.String `tfsdk:"image"`
@ -226,9 +230,8 @@ func (r *CachedImageResource) Schema(ctx context.Context, req resource.SchemaReq
}, },
// Computed "outputs". // Computed "outputs".
// TODO(mafredri): Map vs List? Support both?
"env": schema.ListAttribute{ "env": schema.ListAttribute{
MarkdownDescription: "Computed envbuilder configuration to be set for the container. May contain secrets.", MarkdownDescription: "Computed envbuilder configuration to be set for the container in the form of a list of strings of `key=value`. May contain secrets.",
ElementType: types.StringType, ElementType: types.StringType,
Computed: true, Computed: true,
Sensitive: true, Sensitive: true,
@ -236,6 +239,15 @@ func (r *CachedImageResource) Schema(ctx context.Context, req resource.SchemaReq
listplanmodifier.RequiresReplace(), listplanmodifier.RequiresReplace(),
}, },
}, },
"env_map": schema.MapAttribute{
MarkdownDescription: "Computed envbuilder configuration to be set for the container in the form of a key-value map. May contain secrets.",
ElementType: types.StringType,
Computed: true,
Sensitive: true,
PlanModifiers: []planmodifier.Map{
mapplanmodifier.RequiresReplace(),
},
},
"exists": schema.BoolAttribute{ "exists": schema.BoolAttribute{
MarkdownDescription: "Whether the cached image was exists or not for the given config.", MarkdownDescription: "Whether the cached image was exists or not for the given config.",
Computed: true, Computed: true,
@ -338,28 +350,36 @@ func (r *CachedImageResource) Read(ctx context.Context, req resource.ReadRequest
data.Exists = types.BoolValue(true) data.Exists = types.BoolValue(true)
// Set the expected environment variables. // Set the expected environment variables.
env := make(map[string]string)
for key, elem := range data.ExtraEnv.Elements() { for key, elem := range data.ExtraEnv.Elements() {
data.Env = appendKnownEnvToList(data.Env, key, elem) env[key] = tfValueToString(elem)
} }
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_REPO", data.CacheRepo) env["ENVBUILDER_CACHE_REPO"] = tfValueToString(data.CacheRepo)
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_URL", data.GitURL) env["ENVBUILDER_GIT_URL"] = tfValueToString(data.GitURL)
if !data.CacheTTLDays.IsNull() { if !data.CacheTTLDays.IsNull() {
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_TTL_DAYS", data.CacheTTLDays) env["ENVBUILDER_CACHE_TTL_DAYS"] = tfValueToString(data.CacheTTLDays)
} }
if !data.GitUsername.IsNull() { if !data.GitUsername.IsNull() {
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_USERNAME", data.GitUsername) env["ENVBUILDER_GIT_USERNAME"] = tfValueToString(data.GitUsername)
} }
if !data.GitPassword.IsNull() { if !data.GitPassword.IsNull() {
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_PASSWORD", data.GitPassword) env["ENVBUILDER_GIT_PASSWORD"] = tfValueToString(data.GitPassword)
} }
// Default to remote build mode. // Default to remote build mode.
if data.RemoteRepoBuildMode.IsNull() { if data.RemoteRepoBuildMode.IsNull() {
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_REMOTE_REPO_BUILD_MODE", types.BoolValue(true)) env["ENVBUILDER_REMOTE_REPO_BUILD_MODE"] = "true"
} else { } else {
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_REMOTE_REPO_BUILD_MODE", data.RemoteRepoBuildMode) env["ENVBUILDER_REMOTE_REPO_BUILD_MODE"] = tfValueToString(data.RemoteRepoBuildMode)
} }
var diag diag.Diagnostics
data.EnvMap, diag = basetypes.NewMapValueFrom(ctx, types.StringType, env)
resp.Diagnostics.Append(diag...)
data.Env, diag = basetypes.NewListValueFrom(ctx, types.StringType, sortedKeyValues(env))
resp.Diagnostics.Append(diag...)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
} }
@ -396,29 +416,36 @@ func (r *CachedImageResource) Create(ctx context.Context, req resource.CreateReq
data.ID = types.StringValue(digest.String()) data.ID = types.StringValue(digest.String())
} }
// Compute the env attribute from the config map. // Compute the env attribute from the config map.
// TODO(mafredri): Convert any other relevant attributes given via schema. env := make(map[string]string)
for key, elem := range data.ExtraEnv.Elements() { for key, elem := range data.ExtraEnv.Elements() {
data.Env = appendKnownEnvToList(data.Env, key, elem) env[key] = tfValueToString(elem)
} }
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_REPO", data.CacheRepo) env["ENVBUILDER_CACHE_REPO"] = tfValueToString(data.CacheRepo)
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_URL", data.GitURL) env["ENVBUILDER_GIT_URL"] = tfValueToString(data.GitURL)
if !data.CacheTTLDays.IsNull() { if !data.CacheTTLDays.IsNull() {
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_TTL_DAYS", data.CacheTTLDays) env["ENVBUILDER_CACHE_TTL_DAYS"] = tfValueToString(data.CacheTTLDays)
} }
if !data.GitUsername.IsNull() { if !data.GitUsername.IsNull() {
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_USERNAME", data.GitUsername) env["ENVBUILDER_GIT_USERNAME"] = tfValueToString(data.GitUsername)
} }
if !data.GitPassword.IsNull() { if !data.GitPassword.IsNull() {
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_PASSWORD", data.GitPassword) env["ENVBUILDER_GIT_PASSWORD"] = tfValueToString(data.GitPassword)
} }
// Default to remote build mode. // Default to remote build mode.
if data.RemoteRepoBuildMode.IsNull() { if data.RemoteRepoBuildMode.IsNull() {
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_REMOTE_REPO_BUILD_MODE", types.BoolValue(true)) env["ENVBUILDER_REMOTE_REPO_BUILD_MODE"] = "true"
} else { } else {
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_REMOTE_REPO_BUILD_MODE", data.RemoteRepoBuildMode) env["ENVBUILDER_REMOTE_REPO_BUILD_MODE"] = tfValueToString(data.RemoteRepoBuildMode)
} }
var diag diag.Diagnostics
data.EnvMap, diag = basetypes.NewMapValueFrom(ctx, types.StringType, env)
resp.Diagnostics.Append(diag...)
data.Env, diag = basetypes.NewListValueFrom(ctx, types.StringType, sortedKeyValues(env))
resp.Diagnostics.Append(diag...)
// Save data into Terraform state // Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
} }
@ -652,19 +679,8 @@ func tfValueToString(val attr.Value) string {
panic(fmt.Errorf("tfValueToString: value %T is not a supported type", val)) panic(fmt.Errorf("tfValueToString: value %T is not a supported type", val))
} }
func appendKnownEnvToList(list types.List, key string, value attr.Value) types.List { // tfListToStringSlice converts a types.List to a []string by calling
if value.IsUnknown() || value.IsNull() { // tfValueToString on each element.
return list
}
var sb strings.Builder
_, _ = sb.WriteString(key)
_, _ = sb.WriteRune('=')
_, _ = sb.WriteString(tfValueToString(value))
elem := types.StringValue(sb.String())
list, _ = types.ListValue(types.StringType, append(list.Elements(), elem))
return list
}
func tfListToStringSlice(l types.List) []string { func tfListToStringSlice(l types.List) []string {
var ss []string var ss []string
for _, el := range l.Elements() { for _, el := range l.Elements() {
@ -692,3 +708,19 @@ func tfLogFunc(ctx context.Context) eblog.Func {
logFn(ctx, fmt.Sprintf(format, args...)) 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
}

View file

@ -24,12 +24,18 @@ func TestAccCachedImageResource(t *testing.T) {
files map[string]string files map[string]string
}{ }{
{ {
// This test case is the simplest possible case: a devcontainer.json.
// However, it also makes sure we are able to generate a Dockerfile
// from the devcontainer.json.
name: "devcontainer only", name: "devcontainer only",
files: map[string]string{ files: map[string]string{
".devcontainer/devcontainer.json": `{"image": "localhost:5000/test-ubuntu:latest"}`, ".devcontainer/devcontainer.json": `{"image": "localhost:5000/test-ubuntu:latest"}`,
}, },
}, },
{ {
// This test case includes a Dockerfile in addition to the devcontainer.json.
// The Dockerfile writes the current date to a file. This is currently not checked but
// illustrates that a RUN instruction is cached.
name: "devcontainer and Dockerfile", name: "devcontainer and Dockerfile",
files: map[string]string{ files: map[string]string{
".devcontainer/devcontainer.json": `{"build": { "dockerfile": "Dockerfile" }}`, ".devcontainer/devcontainer.json": `{"build": { "dockerfile": "Dockerfile" }}`,
@ -46,20 +52,19 @@ RUN date > /date.txt`,
resource.Test(t, resource.TestCase{ resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{ Steps: []resource.TestStep{
// Initial state: cache has not been seeded. // 1) Initial state: cache has not been seeded.
{ {
Config: deps.Config(t), Config: deps.Config(t),
PlanOnly: true, PlanOnly: true,
ExpectNonEmptyPlan: true, ExpectNonEmptyPlan: true,
}, },
// Should detect that no cached image is present and plan to create the resource. // 2) Should detect that no cached image is present and plan to create the resource.
{ {
Config: deps.Config(t), Config: deps.Config(t),
Check: resource.ComposeAggregateTestCheckFunc( Check: resource.ComposeAggregateTestCheckFunc(
// Computed values MUST be present. // Computed values MUST be present.
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "id", uuid.Nil.String()), resource.TestCheckResourceAttr("envbuilder_cached_image.test", "id", uuid.Nil.String()),
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "exists", "false"), 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. // Cached image should be set to the builder image.
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "image", deps.BuilderImage), resource.TestCheckResourceAttr("envbuilder_cached_image.test", "image", deps.BuilderImage),
// Inputs should still be present. // Inputs should still be present.
@ -70,17 +75,18 @@ RUN date > /date.txt`,
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_username"), resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_username"),
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_password"), resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_password"),
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "cache_ttl_days"), resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "cache_ttl_days"),
// Environment variables
assertEnv(t, deps),
), ),
ExpectNonEmptyPlan: true, // TODO: check the plan. ExpectNonEmptyPlan: true, // TODO: check the plan.
}, },
// Re-running plan should have the same effect. // 3) Re-running plan should have the same effect.
{ {
Config: deps.Config(t), Config: deps.Config(t),
Check: resource.ComposeAggregateTestCheckFunc( Check: resource.ComposeAggregateTestCheckFunc(
// Computed values MUST be present. // Computed values MUST be present.
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "id", uuid.Nil.String()), resource.TestCheckResourceAttr("envbuilder_cached_image.test", "id", uuid.Nil.String()),
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "exists", "false"), 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. // Cached image should be set to the builder image.
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "image", deps.BuilderImage), resource.TestCheckResourceAttr("envbuilder_cached_image.test", "image", deps.BuilderImage),
// Inputs should still be present. // Inputs should still be present.
@ -91,10 +97,12 @@ RUN date > /date.txt`,
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_username"), resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_username"),
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_password"), resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_password"),
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "cache_ttl_days"), resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "cache_ttl_days"),
// Environment variables
assertEnv(t, deps),
), ),
ExpectNonEmptyPlan: true, // TODO: check the plan. ExpectNonEmptyPlan: true, // TODO: check the plan.
}, },
// Now, seed the cache and re-run. We should now successfully create the cached image resource. // 4) Now, seed the cache and re-run. We should now successfully create the cached image resource.
{ {
PreConfig: func() { PreConfig: func() {
seedCache(ctx, t, deps) seedCache(ctx, t, deps)
@ -114,19 +122,16 @@ RUN date > /date.txt`,
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "exists", "true"), resource.TestCheckResourceAttr("envbuilder_cached_image.test", "exists", "true"),
resource.TestCheckResourceAttrSet("envbuilder_cached_image.test", "image"), resource.TestCheckResourceAttrSet("envbuilder_cached_image.test", "image"),
resource.TestCheckResourceAttrWith("envbuilder_cached_image.test", "image", quotedPrefix(deps.CacheRepo)), resource.TestCheckResourceAttrWith("envbuilder_cached_image.test", "image", quotedPrefix(deps.CacheRepo)),
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.0", "FOO=bar\nbaz"), // Environment variables
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.1", fmt.Sprintf("ENVBUILDER_CACHE_REPO=%s", deps.CacheRepo)), assertEnv(t, deps),
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.TestCheckNoResourceAttr("envbuilder_cached_image.test", "env.4"),
), ),
}, },
// Should produce an empty plan after apply // 5) Should produce an empty plan after apply
{ {
Config: deps.Config(t), Config: deps.Config(t),
PlanOnly: true, PlanOnly: true,
}, },
// Ensure idempotence in this state! // 6) Ensure idempotence in this state!
{ {
Config: deps.Config(t), Config: deps.Config(t),
PlanOnly: true, PlanOnly: true,
@ -136,3 +141,24 @@ 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 {
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_URL=%s", deps.Repo.URL)),
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.2", "ENVBUILDER_REMOTE_REPO_BUILD_MODE=true"),
// Check that the extra environment variables are set correctly.
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.3", "FOO=bar\nbaz"),
// We should not have any other environment variables set.
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "env.4"),
// Check that the same values are set in env_map.
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env_map.FOO", "bar\nbaz"),
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env_map.ENVBUILDER_CACHE_REPO", deps.CacheRepo),
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"),
)
}