Skip to content

Commit 5a746de

Browse files
committed
feat: extract envbuilder binary from builder image
1 parent baebf86 commit 5a746de

File tree

3 files changed

+114
-15
lines changed

3 files changed

+114
-15
lines changed

internal/provider/cached_image_data_source.go

Lines changed: 105 additions & 3 deletions
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,77 @@ 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+
if th.Typeflag != tar.TypeReg {
472+
tflog.Debug(ctx, "skip non-regular file", map[string]any{"name": filepath.Clean(th.Name), "layer_idx": i + 1})
473+
continue
474+
}
475+
476+
if filepath.Clean(th.Name) != needle {
477+
tflog.Debug(ctx, "skip file", map[string]any{"name": filepath.Clean(th.Name), "layer_idx": i + 1})
478+
continue
479+
}
480+
481+
tflog.Debug(ctx, "found file", map[string]any{"name": filepath.Clean(th.Name), "layer_idx": i + 1})
482+
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
483+
return fmt.Errorf("create parent directories: %w", err)
484+
}
485+
destF, err := os.Create(destPath)
486+
if err != nil {
487+
return fmt.Errorf("create dest file for writing: %w", err)
488+
}
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+
_ = destF.Close()
496+
497+
if err := os.Chmod(destPath, 0o755); err != nil {
498+
return fmt.Errorf("chmod file: %w", err)
499+
}
500+
return nil
501+
}
502+
}
503+
504+
return fmt.Errorf("extract envbuilder binary from image %q: %w", imgRef, os.ErrNotExist)
505+
}

internal/provider/cached_image_data_source_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,15 @@ 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"
@@ -78,15 +78,15 @@ func TestAccCachedImageDataSource(t *testing.T) {
7878

7979
t.Run("NotFound", func(t *testing.T) {
8080
files := map[string]string{
81-
"devcontainer.json": `{"build": { "dockerfile": "Dockerfile" }}`,
82-
"Dockerfile": `FROM localhost:5000/test-ubuntu:latest
81+
".devcontainer/devcontainer.json": `{"build": { "dockerfile": "Dockerfile" }}`,
82+
".devcontainer/Dockerfile": `FROM localhost:5000/test-ubuntu:latest
8383
RUN apt-get update && apt-get install -y cowsay`,
8484
}
8585
deps := setup(t, files)
8686
// We do not seed the cache.
8787
tfCfg := fmt.Sprintf(`data "envbuilder_cached_image" "test" {
8888
builder_image = %q
89-
devcontainer_dir = %q
89+
workspace_folder = %q
9090
git_url = %q
9191
extra_env = {
9292
"FOO" : "bar"

internal/provider/provider_test.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,16 @@ 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",
9087
}}, &container.HostConfig{
9188
NetworkMode: container.NetworkMode("host"),
92-
Binds: []string{deps.RepoDir + ":" + deps.RepoDir},
89+
Binds: []string{deps.RepoDir + ":" + "/workspaces/empty"},
9390
}, nil, nil, "")
9491
require.NoError(t, err, "failed to run envbuilder to seed cache")
9592
t.Cleanup(func() {

0 commit comments

Comments
 (0)