-
Notifications
You must be signed in to change notification settings - Fork 45
feat: implement reproducible build and get cached image #213
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 12 commits
d0b4d6a
5a50f4a
6cb1217
7764535
d54ca81
ea91cd5
1979ffd
a962a26
d804fc9
3b18839
b7502ee
aacdbfc
149bdb3
354d885
15904df
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -98,6 +98,9 @@ func Run(ctx context.Context, options Options) error { | |
if options.InitCommand == "" { | ||
options.InitCommand = "/bin/sh" | ||
} | ||
if options.CacheRepo == "" && options.PushImage { | ||
return fmt.Errorf("--cache-repo must be set when using --push-image") | ||
} | ||
// Default to the shell! | ||
initArgs := []string{"-c", options.InitScript} | ||
if options.InitArgs != "" { | ||
|
@@ -118,11 +121,11 @@ func Run(ctx context.Context, options Options) error { | |
options.WorkspaceFolder = f | ||
} | ||
|
||
stageNumber := 1 | ||
stageNumber := 0 | ||
startStage := func(format string, args ...any) func(format string, args ...any) { | ||
now := time.Now() | ||
stageNum := stageNumber | ||
stageNumber++ | ||
stageNum := stageNumber | ||
options.Logger(notcodersdk.LogLevelInfo, "#%d: %s", stageNum, fmt.Sprintf(format, args...)) | ||
|
||
return func(format string, args ...any) { | ||
|
@@ -341,7 +344,7 @@ func Run(ctx context.Context, options Options) error { | |
|
||
HijackLogrus(func(entry *logrus.Entry) { | ||
for _, line := range strings.Split(entry.Message, "\r") { | ||
options.Logger(notcodersdk.LogLevelInfo, "#2: %s", color.HiBlackString(line)) | ||
options.Logger(notcodersdk.LogLevelInfo, "#%d: %s", stageNumber, color.HiBlackString(line)) | ||
} | ||
}) | ||
|
||
|
@@ -471,20 +474,24 @@ func Run(ctx context.Context, options Options) error { | |
cacheTTL = time.Hour * 24 * time.Duration(options.CacheTTLDays) | ||
} | ||
|
||
endStage := startStage("ποΈ Building image...") | ||
// At this point we have all the context, we can now build! | ||
registryMirror := []string{} | ||
if val, ok := os.LookupEnv("KANIKO_REGISTRY_MIRROR"); ok { | ||
registryMirror = strings.Split(val, ";") | ||
} | ||
image, err := executor.DoBuild(&config.KanikoOptions{ | ||
var destinations []string | ||
if options.CacheRepo != "" { | ||
destinations = append(destinations, options.CacheRepo) | ||
} | ||
opts := &config.KanikoOptions{ | ||
// Boilerplate! | ||
CustomPlatform: platforms.Format(platforms.Normalize(platforms.DefaultSpec())), | ||
SnapshotMode: "redo", | ||
RunV2: true, | ||
RunStdout: stdoutWriter, | ||
RunStderr: stderrWriter, | ||
Destinations: []string{"local"}, | ||
Destinations: destinations, | ||
NoPush: !options.PushImage || len(destinations) == 0, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm assuming the reason for the logical disjuction here is that Kaniko will push all intermediate cache layers to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep ππ» |
||
CacheRunLayers: true, | ||
CacheCopyLayers: true, | ||
CompressedCaching: true, | ||
|
@@ -515,11 +522,40 @@ func Run(ctx context.Context, options Options) error { | |
RegistryMirrors: registryMirror, | ||
}, | ||
SrcContext: buildParams.BuildContext, | ||
}) | ||
|
||
// For cached image utilization, produce reproducible builds. | ||
Reproducible: options.PushImage, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Where is this getting used? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See coder/kaniko#12 |
||
} | ||
|
||
if options.GetCachedImage { | ||
endStage := startStage("ποΈ Checking for cached image...") | ||
image, err := executor.DoCacheProbe(opts) | ||
if err != nil { | ||
return nil, xerrors.Errorf("get cached image: %w", err) | ||
} | ||
digest, err := image.Digest() | ||
if err != nil { | ||
return nil, xerrors.Errorf("get cached image digest: %w", err) | ||
} | ||
endStage("ποΈ Found cached image!") | ||
_, _ = fmt.Fprintf(os.Stdout, "%s@%s\n", options.CacheRepo, digest.String()) | ||
os.Exit(0) | ||
} | ||
|
||
endStage := startStage("ποΈ Building image...") | ||
image, err := executor.DoBuild(opts) | ||
if err != nil { | ||
return nil, err | ||
return nil, xerrors.Errorf("do build: %w", err) | ||
} | ||
endStage("ποΈ Built image!") | ||
if options.PushImage { | ||
endStage = startStage("ποΈ Pushing image...") | ||
if err := executor.DoPush(image, opts); err != nil { | ||
return nil, xerrors.Errorf("do push: %w", err) | ||
} | ||
endStage("ποΈ Pushed image!") | ||
} | ||
|
||
return image, err | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -34,6 +34,8 @@ import ( | |
"github.com/go-git/go-billy/v5/memfs" | ||
"github.com/google/go-containerregistry/pkg/authn" | ||
"github.com/google/go-containerregistry/pkg/name" | ||
"github.com/google/go-containerregistry/pkg/registry" | ||
"github.com/google/go-containerregistry/pkg/v1/remote" | ||
"github.com/google/go-containerregistry/pkg/v1/remote/transport" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
|
@@ -987,6 +989,252 @@ COPY %s .`, testImageAlpine, inclFile) | |
} | ||
} | ||
|
||
func TestPushImage(t *testing.T) { | ||
t.Parallel() | ||
|
||
t.Run("CacheWithoutPush", func(t *testing.T) { | ||
t.Parallel() | ||
|
||
srv := createGitServer(t, gitServerOptions{ | ||
files: map[string]string{ | ||
".devcontainer/Dockerfile": fmt.Sprintf("FROM %s\nRUN date --utc > /root/date.txt", testImageAlpine), | ||
".devcontainer/devcontainer.json": `{ | ||
"name": "Test", | ||
"build": { | ||
"dockerfile": "Dockerfile" | ||
}, | ||
}`, | ||
}, | ||
}) | ||
|
||
// Given: an empty registry | ||
testReg := setupInMemoryRegistry(t) | ||
testRepo := testReg + "/test" | ||
ref, err := name.ParseReference(testRepo + ":latest") | ||
require.NoError(t, err) | ||
_, err = remote.Image(ref) | ||
require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push") | ||
|
||
// When: we run envbuilder with GET_CACHED_IMAGE | ||
_, err = runEnvbuilder(t, options{env: []string{ | ||
envbuilderEnv("GIT_URL", srv.URL), | ||
envbuilderEnv("CACHE_REPO", testRepo), | ||
envbuilderEnv("GET_CACHED_IMAGE", "1"), | ||
}}) | ||
require.ErrorContains(t, err, "not supported in fake build") | ||
johnstcn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// Then: it should fail to build the image and nothing should be pushed | ||
_, err = remote.Image(ref) | ||
require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push") | ||
|
||
// When: we run envbuilder with PUSH_IMAGE set | ||
_, err = runEnvbuilder(t, options{env: []string{ | ||
envbuilderEnv("GIT_URL", srv.URL), | ||
envbuilderEnv("CACHE_REPO", testRepo), | ||
}}) | ||
require.NoError(t, err) | ||
|
||
// Then: the image tag should not be present, only the layers | ||
_, err = remote.Image(ref) | ||
require.ErrorContains(t, err, "MANIFEST_UNKNOWN", "expected image to not be present before build + push") | ||
|
||
// Then: re-running envbuilder with GET_CACHED_IMAGE should succeed | ||
_, err = runEnvbuilder(t, options{env: []string{ | ||
envbuilderEnv("GIT_URL", srv.URL), | ||
envbuilderEnv("CACHE_REPO", testRepo), | ||
envbuilderEnv("GET_CACHED_IMAGE", "1"), | ||
}}) | ||
require.NoError(t, err) | ||
}) | ||
|
||
t.Run("CacheAndPush", func(t *testing.T) { | ||
t.Parallel() | ||
|
||
srv := createGitServer(t, gitServerOptions{ | ||
files: map[string]string{ | ||
".devcontainer/Dockerfile": fmt.Sprintf("FROM %s\nRUN date --utc > /root/date.txt", testImageAlpine), | ||
".devcontainer/devcontainer.json": `{ | ||
"name": "Test", | ||
"build": { | ||
"dockerfile": "Dockerfile" | ||
}, | ||
}`, | ||
}, | ||
}) | ||
|
||
// Given: an empty registry | ||
testReg := setupInMemoryRegistry(t) | ||
testRepo := testReg + "/test" | ||
ref, err := name.ParseReference(testRepo + ":latest") | ||
require.NoError(t, err) | ||
_, err = remote.Image(ref) | ||
require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push") | ||
|
||
// When: we run envbuilder with GET_CACHED_IMAGE | ||
_, err = runEnvbuilder(t, options{env: []string{ | ||
envbuilderEnv("GIT_URL", srv.URL), | ||
envbuilderEnv("CACHE_REPO", testRepo), | ||
envbuilderEnv("GET_CACHED_IMAGE", "1"), | ||
}}) | ||
require.ErrorContains(t, err, "not supported in fake build") | ||
// Then: it should fail to build the image and nothing should be pushed | ||
_, err = remote.Image(ref) | ||
require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push") | ||
|
||
// When: we run envbuilder with PUSH_IMAGE set | ||
_, err = runEnvbuilder(t, options{env: []string{ | ||
envbuilderEnv("GIT_URL", srv.URL), | ||
envbuilderEnv("CACHE_REPO", testRepo), | ||
envbuilderEnv("PUSH_IMAGE", "1"), | ||
}}) | ||
require.NoError(t, err) | ||
|
||
// Then: the image should be pushed | ||
_, err = remote.Image(ref) | ||
require.NoError(t, err, "expected image to be present after build + push") | ||
|
||
// Then: re-running envbuilder with GET_CACHED_IMAGE should succeed | ||
_, err = runEnvbuilder(t, options{env: []string{ | ||
envbuilderEnv("GIT_URL", srv.URL), | ||
envbuilderEnv("CACHE_REPO", testRepo), | ||
envbuilderEnv("GET_CACHED_IMAGE", "1"), | ||
}}) | ||
require.NoError(t, err) | ||
}) | ||
|
||
t.Run("CacheAndPushMultistage", func(t *testing.T) { | ||
// Currently fails with: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a way we can make this fail more gracefully? This output is rather difficult to reason about. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There probably is, but I'd say we should handle it as part of #230 |
||
// /home/coder/src/coder/envbuilder/integration/integration_test.go:1417: "error: unable to get cached image: error fake building stage: failed to optimize instructions: failed to get files used from context: failed to get fileinfo for /.envbuilder/0/root/date.txt: lstat /.envbuilder/0/root/date.txt: no such file or directory" | ||
// /home/coder/src/coder/envbuilder/integration/integration_test.go:1156: | ||
// Error Trace: /home/coder/src/coder/envbuilder/integration/integration_test.go:1156 | ||
// Error: Received unexpected error: | ||
// error: unable to get cached image: error fake building stage: failed to optimize instructions: failed to get files used from context: failed to get fileinfo for /.envbuilder/0/root/date.txt: lstat /.envbuilder/0/root/date.txt: no such file or directory | ||
// Test: TestPushImage/CacheAndPushMultistage | ||
t.Skip("TODO: https://github.com/coder/envbuilder/issues/230") | ||
t.Parallel() | ||
|
||
srv := createGitServer(t, gitServerOptions{ | ||
files: map[string]string{ | ||
"Dockerfile": fmt.Sprintf(`FROM %s AS a | ||
RUN date --utc > /root/date.txt | ||
FROM %s as b | ||
COPY --from=a /root/date.txt /date.txt`, testImageAlpine, testImageAlpine), | ||
}, | ||
}) | ||
|
||
// Given: an empty registry | ||
testReg := setupInMemoryRegistry(t) | ||
testRepo := testReg + "/test" | ||
ref, err := name.ParseReference(testRepo + ":latest") | ||
require.NoError(t, err) | ||
_, err = remote.Image(ref) | ||
require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push") | ||
|
||
// When: we run envbuilder with GET_CACHED_IMAGE | ||
_, err = runEnvbuilder(t, options{env: []string{ | ||
envbuilderEnv("GIT_URL", srv.URL), | ||
envbuilderEnv("CACHE_REPO", testRepo), | ||
envbuilderEnv("GET_CACHED_IMAGE", "1"), | ||
envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"), | ||
}}) | ||
require.ErrorContains(t, err, "not supported in fake build") | ||
// Then: it should fail to build the image and nothing should be pushed | ||
_, err = remote.Image(ref) | ||
require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push") | ||
|
||
// When: we run envbuilder with PUSH_IMAGE set | ||
ctrID, err := runEnvbuilder(t, options{env: []string{ | ||
envbuilderEnv("GIT_URL", srv.URL), | ||
envbuilderEnv("CACHE_REPO", testRepo), | ||
envbuilderEnv("PUSH_IMAGE", "1"), | ||
envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"), | ||
}}) | ||
require.NoError(t, err) | ||
// Then: The file copied from stage a should be present | ||
out := execContainer(t, ctrID, "cat /date.txt") | ||
require.NotEmpty(t, out) | ||
|
||
// Then: the image should be pushed | ||
_, err = remote.Image(ref) | ||
require.NoError(t, err, "expected image to be present after build + push") | ||
|
||
// Then: re-running envbuilder with GET_CACHED_IMAGE should succeed | ||
_, err = runEnvbuilder(t, options{env: []string{ | ||
envbuilderEnv("GIT_URL", srv.URL), | ||
envbuilderEnv("CACHE_REPO", testRepo), | ||
envbuilderEnv("GET_CACHED_IMAGE", "1"), | ||
envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"), | ||
}}) | ||
require.NoError(t, err) | ||
}) | ||
|
||
t.Run("PushImageRequiresCache", func(t *testing.T) { | ||
t.Parallel() | ||
|
||
srv := createGitServer(t, gitServerOptions{ | ||
files: map[string]string{ | ||
".devcontainer/Dockerfile": fmt.Sprintf("FROM %s\nRUN date --utc > /root/date.txt", testImageAlpine), | ||
".devcontainer/devcontainer.json": `{ | ||
"name": "Test", | ||
"build": { | ||
"dockerfile": "Dockerfile" | ||
}, | ||
}`, | ||
}, | ||
}) | ||
|
||
// When: we run envbuilder with PUSH_IMAGE set but no cache repo set | ||
_, err := runEnvbuilder(t, options{env: []string{ | ||
envbuilderEnv("GIT_URL", srv.URL), | ||
envbuilderEnv("PUSH_IMAGE", "1"), | ||
}}) | ||
|
||
// Then: Envbuilder should fail explicitly, as it does not make sense to | ||
// specify PUSH_IMAGE | ||
require.ErrorContains(t, err, "--cache-repo must be set when using --push-image") | ||
}) | ||
|
||
t.Run("PushErr", func(t *testing.T) { | ||
t.Parallel() | ||
|
||
srv := createGitServer(t, gitServerOptions{ | ||
files: map[string]string{ | ||
".devcontainer/Dockerfile": fmt.Sprintf("FROM %s\nRUN date --utc > /root/date.txt", testImageAlpine), | ||
".devcontainer/devcontainer.json": `{ | ||
"name": "Test", | ||
"build": { | ||
"dockerfile": "Dockerfile" | ||
}, | ||
}`, | ||
}, | ||
}) | ||
|
||
// Given: registry is not set up (in this case, not a registry) | ||
notRegSrv := httptest.NewServer(http.NotFoundHandler()) | ||
notRegURL := strings.TrimPrefix(notRegSrv.URL, "http://") + "/test" | ||
|
||
// When: we run envbuilder with PUSH_IMAGE set | ||
_, err := runEnvbuilder(t, options{env: []string{ | ||
envbuilderEnv("GIT_URL", srv.URL), | ||
envbuilderEnv("CACHE_REPO", notRegURL), | ||
envbuilderEnv("PUSH_IMAGE", "1"), | ||
}}) | ||
|
||
// Then: envbuilder should fail with a descriptive error | ||
require.ErrorContains(t, err, "failed to push to destination") | ||
}) | ||
} | ||
|
||
func setupInMemoryRegistry(t *testing.T) string { | ||
t.Helper() | ||
tempDir := t.TempDir() | ||
testReg := registry.New(registry.WithBlobHandler(registry.NewDiskBlobHandler(tempDir))) | ||
regSrv := httptest.NewServer(testReg) | ||
t.Cleanup(func() { regSrv.Close() }) | ||
regSrvURL, err := url.Parse(regSrv.URL) | ||
require.NoError(t, err) | ||
return fmt.Sprintf("localhost:%s", regSrvURL.Port()) | ||
} | ||
|
||
// TestMain runs before all tests to build the envbuilder image. | ||
func TestMain(m *testing.M) { | ||
checkTestRegistry() | ||
|
Uh oh!
There was an error while loading. Please reload this page.