Skip to content

Commit 6cf3d93

Browse files
authored
feat(internal/provider): add env_map to cached_image_resource (#37)
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.
1 parent b55c378 commit 6cf3d93

File tree

3 files changed

+105
-46
lines changed

3 files changed

+105
-46
lines changed

docs/resources/cached_image.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ The cached image resource can be used to retrieve a cached image produced by env
4848

4949
### Read-Only
5050

51-
- `env` (List of String, Sensitive) Computed envbuilder configuration to be set for the container. May contain secrets.
51+
- `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.
52+
- `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.
5253
- `exists` (Boolean) Whether the cached image was exists or not for the given config.
5354
- `id` (String) Cached image identifier. This will generally be the image's SHA256 digest.
5455
- `image` (String) Outputs the cached image repo@digest if it exists, and builder image otherwise.

internal/provider/cached_image_resource.go

+64-32
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http"
99
"os"
1010
"path/filepath"
11+
"sort"
1112
"strings"
1213

1314
kconfig "github.com/GoogleContainerTools/kaniko/pkg/config"
@@ -23,6 +24,7 @@ import (
2324
"github.com/google/uuid"
2425

2526
"github.com/hashicorp/terraform-plugin-framework/attr"
27+
"github.com/hashicorp/terraform-plugin-framework/diag"
2628
"github.com/hashicorp/terraform-plugin-framework/resource"
2729
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
2830
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
@@ -31,6 +33,7 @@ import (
3133
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
3234
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
3335
"github.com/hashicorp/terraform-plugin-framework/types"
36+
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
3437
"github.com/hashicorp/terraform-plugin-log/tflog"
3538
)
3639

@@ -77,6 +80,7 @@ type CachedImageResourceModel struct {
7780
WorkspaceFolder types.String `tfsdk:"workspace_folder"`
7881
// Computed "outputs".
7982
Env types.List `tfsdk:"env"`
83+
EnvMap types.Map `tfsdk:"env_map"`
8084
Exists types.Bool `tfsdk:"exists"`
8185
ID types.String `tfsdk:"id"`
8286
Image types.String `tfsdk:"image"`
@@ -226,16 +230,24 @@ func (r *CachedImageResource) Schema(ctx context.Context, req resource.SchemaReq
226230
},
227231

228232
// Computed "outputs".
229-
// TODO(mafredri): Map vs List? Support both?
230233
"env": schema.ListAttribute{
231-
MarkdownDescription: "Computed envbuilder configuration to be set for the container. May contain secrets.",
234+
MarkdownDescription: "Computed envbuilder configuration to be set for the container in the form of a list of strings of `key=value`. May contain secrets.",
232235
ElementType: types.StringType,
233236
Computed: true,
234237
Sensitive: true,
235238
PlanModifiers: []planmodifier.List{
236239
listplanmodifier.RequiresReplace(),
237240
},
238241
},
242+
"env_map": schema.MapAttribute{
243+
MarkdownDescription: "Computed envbuilder configuration to be set for the container in the form of a key-value map. May contain secrets.",
244+
ElementType: types.StringType,
245+
Computed: true,
246+
Sensitive: true,
247+
PlanModifiers: []planmodifier.Map{
248+
mapplanmodifier.RequiresReplace(),
249+
},
250+
},
239251
"exists": schema.BoolAttribute{
240252
MarkdownDescription: "Whether the cached image was exists or not for the given config.",
241253
Computed: true,
@@ -338,28 +350,36 @@ func (r *CachedImageResource) Read(ctx context.Context, req resource.ReadRequest
338350
data.Exists = types.BoolValue(true)
339351

340352
// Set the expected environment variables.
353+
env := make(map[string]string)
341354
for key, elem := range data.ExtraEnv.Elements() {
342-
data.Env = appendKnownEnvToList(data.Env, key, elem)
355+
env[key] = tfValueToString(elem)
343356
}
344357

345-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_REPO", data.CacheRepo)
346-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_URL", data.GitURL)
358+
env["ENVBUILDER_CACHE_REPO"] = tfValueToString(data.CacheRepo)
359+
env["ENVBUILDER_GIT_URL"] = tfValueToString(data.GitURL)
360+
347361
if !data.CacheTTLDays.IsNull() {
348-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_TTL_DAYS", data.CacheTTLDays)
362+
env["ENVBUILDER_CACHE_TTL_DAYS"] = tfValueToString(data.CacheTTLDays)
349363
}
350364
if !data.GitUsername.IsNull() {
351-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_USERNAME", data.GitUsername)
365+
env["ENVBUILDER_GIT_USERNAME"] = tfValueToString(data.GitUsername)
352366
}
353367
if !data.GitPassword.IsNull() {
354-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_PASSWORD", data.GitPassword)
368+
env["ENVBUILDER_GIT_PASSWORD"] = tfValueToString(data.GitPassword)
355369
}
356370
// Default to remote build mode.
357371
if data.RemoteRepoBuildMode.IsNull() {
358-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_REMOTE_REPO_BUILD_MODE", types.BoolValue(true))
372+
env["ENVBUILDER_REMOTE_REPO_BUILD_MODE"] = "true"
359373
} else {
360-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_REMOTE_REPO_BUILD_MODE", data.RemoteRepoBuildMode)
374+
env["ENVBUILDER_REMOTE_REPO_BUILD_MODE"] = tfValueToString(data.RemoteRepoBuildMode)
361375
}
362376

377+
var diag diag.Diagnostics
378+
data.EnvMap, diag = basetypes.NewMapValueFrom(ctx, types.StringType, env)
379+
resp.Diagnostics.Append(diag...)
380+
data.Env, diag = basetypes.NewListValueFrom(ctx, types.StringType, sortedKeyValues(env))
381+
resp.Diagnostics.Append(diag...)
382+
363383
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
364384
}
365385

@@ -396,29 +416,36 @@ func (r *CachedImageResource) Create(ctx context.Context, req resource.CreateReq
396416
data.ID = types.StringValue(digest.String())
397417
}
398418
// Compute the env attribute from the config map.
399-
// TODO(mafredri): Convert any other relevant attributes given via schema.
419+
env := make(map[string]string)
400420
for key, elem := range data.ExtraEnv.Elements() {
401-
data.Env = appendKnownEnvToList(data.Env, key, elem)
421+
env[key] = tfValueToString(elem)
402422
}
403423

404-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_REPO", data.CacheRepo)
405-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_URL", data.GitURL)
424+
env["ENVBUILDER_CACHE_REPO"] = tfValueToString(data.CacheRepo)
425+
env["ENVBUILDER_GIT_URL"] = tfValueToString(data.GitURL)
426+
406427
if !data.CacheTTLDays.IsNull() {
407-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_CACHE_TTL_DAYS", data.CacheTTLDays)
428+
env["ENVBUILDER_CACHE_TTL_DAYS"] = tfValueToString(data.CacheTTLDays)
408429
}
409430
if !data.GitUsername.IsNull() {
410-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_USERNAME", data.GitUsername)
431+
env["ENVBUILDER_GIT_USERNAME"] = tfValueToString(data.GitUsername)
411432
}
412433
if !data.GitPassword.IsNull() {
413-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_GIT_PASSWORD", data.GitPassword)
434+
env["ENVBUILDER_GIT_PASSWORD"] = tfValueToString(data.GitPassword)
414435
}
415436
// Default to remote build mode.
416437
if data.RemoteRepoBuildMode.IsNull() {
417-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_REMOTE_REPO_BUILD_MODE", types.BoolValue(true))
438+
env["ENVBUILDER_REMOTE_REPO_BUILD_MODE"] = "true"
418439
} else {
419-
data.Env = appendKnownEnvToList(data.Env, "ENVBUILDER_REMOTE_REPO_BUILD_MODE", data.RemoteRepoBuildMode)
440+
env["ENVBUILDER_REMOTE_REPO_BUILD_MODE"] = tfValueToString(data.RemoteRepoBuildMode)
420441
}
421442

443+
var diag diag.Diagnostics
444+
data.EnvMap, diag = basetypes.NewMapValueFrom(ctx, types.StringType, env)
445+
resp.Diagnostics.Append(diag...)
446+
data.Env, diag = basetypes.NewListValueFrom(ctx, types.StringType, sortedKeyValues(env))
447+
resp.Diagnostics.Append(diag...)
448+
422449
// Save data into Terraform state
423450
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
424451
}
@@ -652,19 +679,8 @@ func tfValueToString(val attr.Value) string {
652679
panic(fmt.Errorf("tfValueToString: value %T is not a supported type", val))
653680
}
654681

655-
func appendKnownEnvToList(list types.List, key string, value attr.Value) types.List {
656-
if value.IsUnknown() || value.IsNull() {
657-
return list
658-
}
659-
var sb strings.Builder
660-
_, _ = sb.WriteString(key)
661-
_, _ = sb.WriteRune('=')
662-
_, _ = sb.WriteString(tfValueToString(value))
663-
elem := types.StringValue(sb.String())
664-
list, _ = types.ListValue(types.StringType, append(list.Elements(), elem))
665-
return list
666-
}
667-
682+
// tfListToStringSlice converts a types.List to a []string by calling
683+
// tfValueToString on each element.
668684
func tfListToStringSlice(l types.List) []string {
669685
var ss []string
670686
for _, el := range l.Elements() {
@@ -692,3 +708,19 @@ func tfLogFunc(ctx context.Context) eblog.Func {
692708
logFn(ctx, fmt.Sprintf(format, args...))
693709
}
694710
}
711+
712+
// sortedKeyValues returns the keys and values of the map in the form "key=value"
713+
// sorted by key in lexicographical order.
714+
func sortedKeyValues(m map[string]string) []string {
715+
pairs := make([]string, 0, len(m))
716+
var sb strings.Builder
717+
for k := range m {
718+
_, _ = sb.WriteString(k)
719+
_, _ = sb.WriteRune('=')
720+
_, _ = sb.WriteString(m[k])
721+
pairs = append(pairs, sb.String())
722+
sb.Reset()
723+
}
724+
sort.Strings(pairs)
725+
return pairs
726+
}

internal/provider/cached_image_resource_test.go

+39-13
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,18 @@ func TestAccCachedImageResource(t *testing.T) {
2424
files map[string]string
2525
}{
2626
{
27+
// This test case is the simplest possible case: a devcontainer.json.
28+
// However, it also makes sure we are able to generate a Dockerfile
29+
// from the devcontainer.json.
2730
name: "devcontainer only",
2831
files: map[string]string{
2932
".devcontainer/devcontainer.json": `{"image": "localhost:5000/test-ubuntu:latest"}`,
3033
},
3134
},
3235
{
36+
// This test case includes a Dockerfile in addition to the devcontainer.json.
37+
// The Dockerfile writes the current date to a file. This is currently not checked but
38+
// illustrates that a RUN instruction is cached.
3339
name: "devcontainer and Dockerfile",
3440
files: map[string]string{
3541
".devcontainer/devcontainer.json": `{"build": { "dockerfile": "Dockerfile" }}`,
@@ -46,20 +52,19 @@ RUN date > /date.txt`,
4652
resource.Test(t, resource.TestCase{
4753
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
4854
Steps: []resource.TestStep{
49-
// Initial state: cache has not been seeded.
55+
// 1) Initial state: cache has not been seeded.
5056
{
5157
Config: deps.Config(t),
5258
PlanOnly: true,
5359
ExpectNonEmptyPlan: true,
5460
},
55-
// Should detect that no cached image is present and plan to create the resource.
61+
// 2) Should detect that no cached image is present and plan to create the resource.
5662
{
5763
Config: deps.Config(t),
5864
Check: resource.ComposeAggregateTestCheckFunc(
5965
// Computed values MUST be present.
6066
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "id", uuid.Nil.String()),
6167
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "exists", "false"),
62-
resource.TestCheckResourceAttrSet("envbuilder_cached_image.test", "env.0"),
6368
// Cached image should be set to the builder image.
6469
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "image", deps.BuilderImage),
6570
// Inputs should still be present.
@@ -70,17 +75,18 @@ RUN date > /date.txt`,
7075
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_username"),
7176
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_password"),
7277
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "cache_ttl_days"),
78+
// Environment variables
79+
assertEnv(t, deps),
7380
),
7481
ExpectNonEmptyPlan: true, // TODO: check the plan.
7582
},
76-
// Re-running plan should have the same effect.
83+
// 3) Re-running plan should have the same effect.
7784
{
7885
Config: deps.Config(t),
7986
Check: resource.ComposeAggregateTestCheckFunc(
8087
// Computed values MUST be present.
8188
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "id", uuid.Nil.String()),
8289
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "exists", "false"),
83-
resource.TestCheckResourceAttrSet("envbuilder_cached_image.test", "env.0"),
8490
// Cached image should be set to the builder image.
8591
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "image", deps.BuilderImage),
8692
// Inputs should still be present.
@@ -91,10 +97,12 @@ RUN date > /date.txt`,
9197
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_username"),
9298
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "git_password"),
9399
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "cache_ttl_days"),
100+
// Environment variables
101+
assertEnv(t, deps),
94102
),
95103
ExpectNonEmptyPlan: true, // TODO: check the plan.
96104
},
97-
// Now, seed the cache and re-run. We should now successfully create the cached image resource.
105+
// 4) Now, seed the cache and re-run. We should now successfully create the cached image resource.
98106
{
99107
PreConfig: func() {
100108
seedCache(ctx, t, deps)
@@ -114,19 +122,16 @@ RUN date > /date.txt`,
114122
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "exists", "true"),
115123
resource.TestCheckResourceAttrSet("envbuilder_cached_image.test", "image"),
116124
resource.TestCheckResourceAttrWith("envbuilder_cached_image.test", "image", quotedPrefix(deps.CacheRepo)),
117-
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.0", "FOO=bar\nbaz"),
118-
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.1", fmt.Sprintf("ENVBUILDER_CACHE_REPO=%s", deps.CacheRepo)),
119-
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.2", fmt.Sprintf("ENVBUILDER_GIT_URL=%s", deps.Repo.URL)),
120-
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.3", "ENVBUILDER_REMOTE_REPO_BUILD_MODE=true"),
121-
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "env.4"),
125+
// Environment variables
126+
assertEnv(t, deps),
122127
),
123128
},
124-
// Should produce an empty plan after apply
129+
// 5) Should produce an empty plan after apply
125130
{
126131
Config: deps.Config(t),
127132
PlanOnly: true,
128133
},
129-
// Ensure idempotence in this state!
134+
// 6) Ensure idempotence in this state!
130135
{
131136
Config: deps.Config(t),
132137
PlanOnly: true,
@@ -136,3 +141,24 @@ RUN date > /date.txt`,
136141
})
137142
}
138143
}
144+
145+
// assertEnv is a test helper that checks the environment variables set on the
146+
// cached image resource based on the provided test dependencies.
147+
func assertEnv(t *testing.T, deps testDependencies) resource.TestCheckFunc {
148+
t.Helper()
149+
return resource.ComposeAggregateTestCheckFunc(
150+
// Check that the environment variables are set correctly.
151+
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.0", fmt.Sprintf("ENVBUILDER_CACHE_REPO=%s", deps.CacheRepo)),
152+
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.1", fmt.Sprintf("ENVBUILDER_GIT_URL=%s", deps.Repo.URL)),
153+
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.2", "ENVBUILDER_REMOTE_REPO_BUILD_MODE=true"),
154+
// Check that the extra environment variables are set correctly.
155+
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env.3", "FOO=bar\nbaz"),
156+
// We should not have any other environment variables set.
157+
resource.TestCheckNoResourceAttr("envbuilder_cached_image.test", "env.4"),
158+
// Check that the same values are set in env_map.
159+
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env_map.FOO", "bar\nbaz"),
160+
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env_map.ENVBUILDER_CACHE_REPO", deps.CacheRepo),
161+
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env_map.ENVBUILDER_GIT_URL", deps.Repo.URL),
162+
resource.TestCheckResourceAttr("envbuilder_cached_image.test", "env_map.ENVBUILDER_REMOTE_REPO_BUILD_MODE", "true"),
163+
)
164+
}

0 commit comments

Comments
 (0)