Skip to content

Commit 88ad94f

Browse files
authored
feat: extract envbuilder binary from builder image (#13)
- Adds workspace_folder parameter. - Adds functionality to extract the envbuilder binary from the builder image. - Drive-by: updates provider address.
1 parent baebf86 commit 88ad94f

File tree

8 files changed

+134
-25
lines changed

8 files changed

+134
-25
lines changed

GNUmakefile

+6-1
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ test: testacc
77
testacc:
88
TF_ACC=1 go test ./... -v $(TESTARGS) -timeout 120m
99

10-
fmt: examples/*/*.tf
10+
fmt: fmt/tf fmt/go
11+
12+
fmt/tf: $(shell find . -type f -name '*.tf')
1113
terraform fmt -recursive
1214

15+
fmt/go: $(shell find . -type f -name '*.go')
16+
go run mvdan.cc/[email protected] -l -w .
17+
1318
gen:
1419
go generate
1520

docs/data-sources/cached_image.md

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ resource "docker_container" "container" {
5959
- `insecure` (Boolean) (Envbuilder option) Bypass TLS verification when cloning and pulling from container registries.
6060
- `ssl_cert_base64` (String) (Envbuilder option) The content of an SSL cert file. This is useful for self-signed certificates.
6161
- `verbose` (Boolean) (Envbuilder option) Enable verbose output.
62+
- `workspace_folder` (String) (Envbuilder option) path to the workspace folder that will be built. This is optional.
6263

6364
### Read-Only
6465

go.mod

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ module github.com/coder/terraform-provider-envbuilder
33
go 1.22.4
44

55
// We use our own Kaniko fork.
6-
replace github.com/GoogleContainerTools/kaniko => github.com/coder/kaniko v0.0.0-20240717115058-0ba2908ca4d3
6+
replace github.com/GoogleContainerTools/kaniko => github.com/coder/kaniko v0.0.0-20240803153527-10d1800455b9
77

88
// Required to import codersdk due to gvisor dependency.
99
replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20240702054557-aa558fbe5374
1010

1111
require (
1212
github.com/GoogleContainerTools/kaniko v1.9.2
13-
github.com/coder/envbuilder v1.0.0-rc.0.0.20240731115920-cacbcb8fef6c
13+
github.com/coder/envbuilder v1.0.0-rc.0.0.20240803183847-6afe89e6950e
1414
github.com/docker/docker v26.1.4+incompatible
1515
github.com/go-git/go-billy/v5 v5.5.0
1616
github.com/google/go-containerregistry v0.19.1

go.sum

+4-4
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,10 @@ github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoC
186186
github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI=
187187
github.com/coder/coder/v2 v2.10.1-0.20240704130443-c2d44d16a352 h1:L/EjCuZxs5tOcqqCaASj/nu65TRYEFcTt8qRQfHZXX0=
188188
github.com/coder/coder/v2 v2.10.1-0.20240704130443-c2d44d16a352/go.mod h1:P1KoQSgnKEAG6Mnd3YlGzAophty+yKA9VV48LpfNRvo=
189-
github.com/coder/envbuilder v1.0.0-rc.0.0.20240731115920-cacbcb8fef6c h1:wb+i7vP0pl4R4r66dDRK7no86hFfPY+G/tCq8R9M+Cw=
190-
github.com/coder/envbuilder v1.0.0-rc.0.0.20240731115920-cacbcb8fef6c/go.mod h1:APdfhjDHEF5gkAyhn+9MoCem+qKS84iRkNQ5mBZsajQ=
191-
github.com/coder/kaniko v0.0.0-20240717115058-0ba2908ca4d3 h1:Q7L6cjKfw3DIyhKIcgCJEmgxnUTBajmMDrHxXvxgBZs=
192-
github.com/coder/kaniko v0.0.0-20240717115058-0ba2908ca4d3/go.mod h1:YMK7BlxerzLlMwihGxNWUaFoN9LXCij4P+w/8/fNlcM=
189+
github.com/coder/envbuilder v1.0.0-rc.0.0.20240803183847-6afe89e6950e h1:gchZb6E2C5giRJwS2wPjbwHfxle4rJX7NqHCpN1XaT0=
190+
github.com/coder/envbuilder v1.0.0-rc.0.0.20240803183847-6afe89e6950e/go.mod h1:SCpGkbd04qsTIHUYRWEJMgt4R+uK+q4lGnOhEyTorjU=
191+
github.com/coder/kaniko v0.0.0-20240803153527-10d1800455b9 h1:d01T5YbPN1yc1mXjIXG59YcQQoT/9idvqFErjWHfsZ4=
192+
github.com/coder/kaniko v0.0.0-20240803153527-10d1800455b9/go.mod h1:YMK7BlxerzLlMwihGxNWUaFoN9LXCij4P+w/8/fNlcM=
193193
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs=
194194
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc=
195195
github.com/coder/quartz v0.1.0 h1:cLL+0g5l7xTf6ordRnUMMiZtRE8Sq5LxpghS63vEXrQ=

internal/provider/cached_image_data_source.go

+107-3
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
package provider
55

66
import (
7+
"archive/tar"
78
"context"
89
"fmt"
10+
"io"
911
"net/http"
1012
"os"
1113
"path/filepath"
@@ -16,6 +18,9 @@ import (
1618
eblog "github.com/coder/envbuilder/log"
1719
eboptions "github.com/coder/envbuilder/options"
1820
"github.com/go-git/go-billy/v5/osfs"
21+
"github.com/google/go-containerregistry/pkg/authn"
22+
"github.com/google/go-containerregistry/pkg/name"
23+
"github.com/google/go-containerregistry/pkg/v1/remote"
1924

2025
"github.com/hashicorp/terraform-plugin-framework/datasource"
2126
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
@@ -62,6 +67,7 @@ type CachedImageDataSourceModel struct {
6267
Insecure types.Bool `tfsdk:"insecure"`
6368
SSLCertBase64 types.String `tfsdk:"ssl_cert_base64"`
6469
Verbose types.Bool `tfsdk:"verbose"`
70+
WorkspaceFolder types.String `tfsdk:"workspace_folder"`
6571
// Computed "outputs".
6672
Env types.List `tfsdk:"env"`
6773
Exists types.Bool `tfsdk:"exists"`
@@ -179,6 +185,10 @@ func (d *CachedImageDataSource) Schema(ctx context.Context, req datasource.Schem
179185
MarkdownDescription: "(Envbuilder option) Enable verbose output.",
180186
Optional: true,
181187
},
188+
"workspace_folder": schema.StringAttribute{
189+
MarkdownDescription: "(Envbuilder option) path to the workspace folder that will be built. This is optional.",
190+
Optional: true,
191+
},
182192

183193
// Computed "outputs".
184194
// TODO(mafredri): Map vs List? Support both?
@@ -248,9 +258,10 @@ func (d *CachedImageDataSource) Read(ctx context.Context, req datasource.ReadReq
248258
}
249259
defer func() {
250260
if err := os.RemoveAll(tmpDir); err != nil {
251-
tflog.Error(ctx, "failed to clean up tmpDir", map[string]any{"tmpDir": tmpDir, "err": err.Error()})
261+
tflog.Error(ctx, "failed to clean up tmpDir", map[string]any{"tmpDir": tmpDir, "err": err})
252262
}
253263
}()
264+
254265
oldKanikoDir := kconfig.KanikoDir
255266
tmpKanikoDir := filepath.Join(tmpDir, constants.MagicDir)
256267
// Normally you would set the KANIKO_DIR environment variable, but we are importing kaniko directly.
@@ -262,6 +273,22 @@ func (d *CachedImageDataSource) Read(ctx context.Context, req datasource.ReadReq
262273
}()
263274
if err := os.MkdirAll(tmpKanikoDir, 0o755); err != nil {
264275
tflog.Error(ctx, "failed to create kaniko dir: "+err.Error())
276+
return
277+
}
278+
279+
// In order to correctly reproduce the final layer of the cached image, we
280+
// need the envbuilder binary used to originally build the image!
281+
envbuilderPath := filepath.Join(tmpDir, "envbuilder")
282+
if err := extractEnvbuilderFromImage(ctx, data.BuilderImage.ValueString(), envbuilderPath); err != nil {
283+
tflog.Error(ctx, "failed to fetch envbuilder binary from builder image", map[string]any{"err": err})
284+
resp.Diagnostics.AddError("Internal Error", fmt.Sprintf("Failed to fetch the envbuilder binary from the builder image: %s", err.Error()))
285+
return
286+
}
287+
288+
workspaceFolder := data.WorkspaceFolder.ValueString()
289+
if workspaceFolder == "" {
290+
workspaceFolder = filepath.Join(tmpDir, "workspace")
291+
tflog.Debug(ctx, "workspace_folder not specified, using temp dir", map[string]any{"workspace_folder": workspaceFolder})
265292
}
266293

267294
// TODO: check if this is a "plan" or "apply", and only run envbuilder on "apply".
@@ -274,7 +301,7 @@ func (d *CachedImageDataSource) Read(ctx context.Context, req datasource.ReadReq
274301
GetCachedImage: true, // always!
275302
Logger: tfLogFunc(ctx),
276303
Verbose: data.Verbose.ValueBool(),
277-
WorkspaceFolder: tmpDir,
304+
WorkspaceFolder: workspaceFolder,
278305

279306
// Options related to compiling the devcontainer
280307
BuildContextPath: data.BuildContextPath.ValueString(),
@@ -297,6 +324,7 @@ func (d *CachedImageDataSource) Read(ctx context.Context, req datasource.ReadReq
297324

298325
// Other options
299326
BaseImageCacheDir: data.BaseImageCacheDir.ValueString(),
327+
BinaryPath: envbuilderPath, // needed to reproduce the final layer.
300328
ExitOnBuildFailure: data.ExitOnBuildFailure.ValueBool(), // may wish to do this instead of fallback image?
301329
Insecure: data.Insecure.ValueBool(), // might have internal CAs?
302330
IgnorePaths: tfListToStringSlice(data.IgnorePaths), // may need to be specified?
@@ -310,7 +338,7 @@ func (d *CachedImageDataSource) Read(ctx context.Context, req datasource.ReadReq
310338
InitScript: "",
311339
LayerCacheDir: "",
312340
PostStartScriptPath: "",
313-
PushImage: false,
341+
PushImage: false, // This is only relevant when building.
314342
SetupScript: "",
315343
SkipRebuild: false,
316344
}
@@ -401,3 +429,79 @@ func tfListToStringSlice(l types.List) []string {
401429
}
402430
return ss
403431
}
432+
433+
// extractEnvbuilderFromImage reads the image located at imgRef and extracts
434+
// MagicBinaryLocation to destPath.
435+
func extractEnvbuilderFromImage(ctx context.Context, imgRef, destPath string) error {
436+
needle := filepath.Clean(constants.MagicBinaryLocation)[1:] // skip leading '/'
437+
ref, err := name.ParseReference(imgRef)
438+
if err != nil {
439+
return fmt.Errorf("parse reference: %w", err)
440+
}
441+
442+
img, err := remote.Image(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain))
443+
if err != nil {
444+
return fmt.Errorf("check remote image: %w", err)
445+
}
446+
447+
layers, err := img.Layers()
448+
if err != nil {
449+
return fmt.Errorf("get image layers: %w", err)
450+
}
451+
452+
// Check the layers in reverse order. The last layers are more likely to
453+
// include the binary.
454+
for i := len(layers) - 1; i >= 0; i-- {
455+
ul, err := layers[i].Uncompressed()
456+
if err != nil {
457+
return fmt.Errorf("get uncompressed layer: %w", err)
458+
}
459+
460+
tr := tar.NewReader(ul)
461+
for {
462+
th, err := tr.Next()
463+
if err == io.EOF {
464+
break
465+
}
466+
467+
if err != nil {
468+
return fmt.Errorf("read tar header: %w", err)
469+
}
470+
471+
name := filepath.Clean(th.Name)
472+
if th.Typeflag != tar.TypeReg {
473+
tflog.Debug(ctx, "skip non-regular file", map[string]any{"name": name, "layer_idx": i + 1})
474+
continue
475+
}
476+
477+
if name != needle {
478+
tflog.Debug(ctx, "skip file", map[string]any{"name": name, "layer_idx": i + 1})
479+
continue
480+
}
481+
482+
tflog.Debug(ctx, "found file", map[string]any{"name": name, "layer_idx": i + 1})
483+
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
484+
return fmt.Errorf("create parent directories: %w", err)
485+
}
486+
destF, err := os.Create(destPath)
487+
if err != nil {
488+
return fmt.Errorf("create dest file for writing: %w", err)
489+
}
490+
defer destF.Close()
491+
_, err = io.Copy(destF, tr)
492+
if err != nil {
493+
return fmt.Errorf("copy dest file from image: %w", err)
494+
}
495+
if err := destF.Close(); err != nil {
496+
return fmt.Errorf("close dest file: %w", err)
497+
}
498+
499+
if err := os.Chmod(destPath, 0o755); err != nil {
500+
return fmt.Errorf("chmod file: %w", err)
501+
}
502+
return nil
503+
}
504+
}
505+
506+
return fmt.Errorf("extract envbuilder binary from image %q: %w", imgRef, os.ErrNotExist)
507+
}

internal/provider/cached_image_data_source_test.go

+8-6
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,21 @@ func TestAccCachedImageDataSource(t *testing.T) {
2020
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
2121
t.Cleanup(cancel)
2222
files := map[string]string{
23-
"devcontainer.json": `{"build": { "dockerfile": "Dockerfile" }}`,
24-
"Dockerfile": `FROM localhost:5000/test-ubuntu:latest
23+
".devcontainer/devcontainer.json": `{"build": { "dockerfile": "Dockerfile" }}`,
24+
".devcontainer/Dockerfile": `FROM localhost:5000/test-ubuntu:latest
2525
RUN apt-get update && apt-get install -y cowsay`,
2626
}
2727
deps := setup(t, files)
2828
seedCache(ctx, t, deps)
2929
tfCfg := fmt.Sprintf(`data "envbuilder_cached_image" "test" {
3030
builder_image = %q
31-
devcontainer_dir = %q
31+
workspace_folder = %q
3232
git_url = %q
3333
extra_env = {
3434
"FOO" : "bar"
3535
}
3636
cache_repo = %q
37+
verbose = true
3738
}`, deps.BuilderImage, deps.RepoDir, deps.RepoDir, deps.CacheRepo)
3839
resource.Test(t, resource.TestCase{
3940
PreCheck: func() { testAccPreCheck(t) },
@@ -78,20 +79,21 @@ func TestAccCachedImageDataSource(t *testing.T) {
7879

7980
t.Run("NotFound", func(t *testing.T) {
8081
files := map[string]string{
81-
"devcontainer.json": `{"build": { "dockerfile": "Dockerfile" }}`,
82-
"Dockerfile": `FROM localhost:5000/test-ubuntu:latest
82+
".devcontainer/devcontainer.json": `{"build": { "dockerfile": "Dockerfile" }}`,
83+
".devcontainer/Dockerfile": `FROM localhost:5000/test-ubuntu:latest
8384
RUN apt-get update && apt-get install -y cowsay`,
8485
}
8586
deps := setup(t, files)
8687
// We do not seed the cache.
8788
tfCfg := fmt.Sprintf(`data "envbuilder_cached_image" "test" {
8889
builder_image = %q
89-
devcontainer_dir = %q
90+
workspace_folder = %q
9091
git_url = %q
9192
extra_env = {
9293
"FOO" : "bar"
9394
}
9495
cache_repo = %q
96+
verbose = true
9597
}`, deps.BuilderImage, deps.RepoDir, deps.RepoDir, deps.CacheRepo)
9698
resource.Test(t, resource.TestCase{
9799
PreCheck: func() { testAccPreCheck(t) },

internal/provider/provider_test.go

+5-8
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,17 @@ func seedCache(ctx context.Context, t testing.TB, deps testDependencies) {
7777
Image: deps.BuilderImage,
7878
Env: []string{
7979
"ENVBUILDER_CACHE_REPO=" + deps.CacheRepo,
80-
"ENVBUILDER_DEVCONTAINER_DIR=" + deps.RepoDir,
8180
"ENVBUILDER_EXIT_ON_BUILD_FAILURE=true",
8281
"ENVBUILDER_INIT_SCRIPT=exit",
83-
// FIXME: Enabling this options causes envbuilder to add its binary to the image under the path
84-
// /.envbuilder/bin/envbuilder. This file will have ownership root:root and permissions 0o755.
85-
// Because of this, t.Cleanup() will be unable to delete the temp dir, causing the test to fail.
86-
// "ENVBUILDER_PUSH_IMAGE=true",
82+
"ENVBUILDER_PUSH_IMAGE=true",
83+
"ENVBUILDER_VERBOSE=true",
8784
},
8885
Labels: map[string]string{
8986
testContainerLabel: "true",
90-
}}, &container.HostConfig{
87+
},
88+
}, &container.HostConfig{
9189
NetworkMode: container.NetworkMode("host"),
92-
Binds: []string{deps.RepoDir + ":" + deps.RepoDir},
90+
Binds: []string{deps.RepoDir + ":" + "/workspaces/empty"},
9391
}, nil, nil, "")
9492
require.NoError(t, err, "failed to run envbuilder to seed cache")
9593
t.Cleanup(func() {
@@ -126,7 +124,6 @@ SCANLOGS:
126124
}
127125
}
128126
}
129-
130127
}
131128

132129
func getEnvOrDefault(env, defVal string) string {

main.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func main() {
3939
// TODO: Update this string with the published name of your provider.
4040
// Also update the tfplugindocs generate command to either remove the
4141
// -provider-name flag or set its value to the updated provider name.
42-
Address: "registry.terraform.io/hashicorp/envbuilder",
42+
Address: "registry.terraform.io/coder/envbuilder",
4343
Debug: debug,
4444
}
4545

0 commit comments

Comments
 (0)