-
Notifications
You must be signed in to change notification settings - Fork 29
Cache function runtime base image layers between builds #304
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
Merged
adamwg
merged 4 commits into
crossplane:main
from
stevendborrelli:cache-function-base-images
Sep 1, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
0b1ef36
Cache function runtime base image layers between builds
stevendborrelli 550c8df
Document the base image cache and its growth
stevendborrelli b4f0a4a
Do not let a failing base image cache fail the build
stevendborrelli 17f173c
Address review feedback on the base image cache
stevendborrelli File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| /* | ||
| Copyright 2026 The Crossplane Authors. | ||
|
|
||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||
| you may not use this file except in compliance with the License. | ||
| You may obtain a copy of the License at | ||
|
|
||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| Unless required by applicable law or agreed to in writing, software | ||
| distributed under the License is distributed on an "AS IS" BASIS, | ||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| See the License for the specific language governing permissions and | ||
| limitations under the License. | ||
| */ | ||
|
|
||
| package functions | ||
|
|
||
| import ( | ||
| "io" | ||
| "os" | ||
| "path/filepath" | ||
|
|
||
| v1 "github.com/google/go-containerregistry/pkg/v1" | ||
| "github.com/google/go-containerregistry/pkg/v1/cache" | ||
|
|
||
| "github.com/crossplane/crossplane-runtime/v2/pkg/errors" | ||
| ) | ||
|
|
||
| // DefaultBaseImageCacheDir returns the default per-user cache directory for | ||
| // function runtime base image layers, or "" to disable caching when there is no | ||
| // per-user directory to put it in. It sits beside the xpkg cache rather than | ||
| // inside it, since the two hold different kinds of artifact and would be pruned | ||
| // on different terms. | ||
| // | ||
| // Falling back to os.TempDir() would put the cache at a predictable path that | ||
| // another user on a shared machine could have created first, with permissions | ||
| // of their choosing. The layers are public registry content rather than | ||
| // anything sensitive, but a cache is not worth a world-readable directory | ||
| // someone else controls — and callers already treat "" as "do not cache", so | ||
| // declining is cheap. | ||
| // | ||
| // Nothing prunes this directory. Layers are keyed by content digest, so entries | ||
| // are never stale, but they are also never replaced: every base image version a | ||
| // user builds against accumulates. Users can delete the directory safely — the | ||
| // next build refetches what it needs — but the CLI should grow a retention | ||
| // policy or a prune command before this becomes the kind of thing people | ||
| // discover by running out of disk. | ||
| func DefaultBaseImageCacheDir() string { | ||
| base, err := os.UserCacheDir() | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| return filepath.Join(base, "crossplane", "base-images") | ||
| } | ||
|
|
||
| // tolerantCache degrades to the registry instead of failing the build when the | ||
| // cache itself cannot be used. | ||
| // | ||
| // go-containerregistry's cache is not forgiving on its own. A read error that | ||
| // is not ErrNotFound propagates out of Compressed, and the filesystem cache | ||
| // creates its backing file lazily, so an unwritable or full cache directory | ||
| // surfaces as an error from the layer rather than a cache miss. Neither should | ||
| // break a build that could have fetched the layer remotely — especially since | ||
| // nothing prunes this cache, which makes a full disk a plausible way to reach | ||
| // it. See DefaultBaseImageCacheDir. | ||
| // | ||
| // One case is not recoverable here: if the directory is writable but fills up | ||
| // partway through a layer, the write error surfaces mid-stream, after the | ||
| // reader has been handed to the caller. Restarting from the remote layer at | ||
| // that point would mean re-reading bytes the caller already consumed. | ||
| type tolerantCache struct { | ||
| cache.Cache | ||
| } | ||
|
|
||
| // Get reports any failure other than a genuine miss as a miss, so that an | ||
| // unreadable or corrupt entry sends the caller to the registry. | ||
| func (c tolerantCache) Get(h v1.Hash) (v1.Layer, error) { | ||
| l, err := c.Cache.Get(h) | ||
| if err != nil && !errors.Is(err, cache.ErrNotFound) { | ||
| return nil, cache.ErrNotFound | ||
| } | ||
| return l, err | ||
| } | ||
|
|
||
| // Put keeps the original layer alongside the caching one so that a layer which | ||
| // cannot be written still reads. | ||
| func (c tolerantCache) Put(l v1.Layer) (v1.Layer, error) { | ||
| cached, err := c.Cache.Put(l) | ||
| if err != nil { | ||
| // Deliberate: a cache that cannot store the layer is not a reason to | ||
| // fail. Hand back the uncached layer and carry on. | ||
| return l, nil //nolint:nilerr // Cache failures degrade to no caching. | ||
| } | ||
| return tolerantLayer{Layer: cached, uncached: l}, nil | ||
| } | ||
|
|
||
| // tolerantLayer reads through to an uncached layer when the caching layer | ||
| // cannot open its backing file. | ||
| type tolerantLayer struct { | ||
| v1.Layer | ||
|
|
||
| uncached v1.Layer | ||
| } | ||
|
|
||
| func (l tolerantLayer) Compressed() (io.ReadCloser, error) { | ||
| rc, err := l.Layer.Compressed() | ||
| if err != nil { | ||
| return l.uncached.Compressed() | ||
| } | ||
| return rc, nil | ||
| } | ||
|
|
||
| func (l tolerantLayer) Uncompressed() (io.ReadCloser, error) { | ||
| rc, err := l.Layer.Uncompressed() | ||
| if err != nil { | ||
| return l.uncached.Uncompressed() | ||
| } | ||
| return rc, nil | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| /* | ||
| Copyright 2026 The Crossplane Authors. | ||
|
|
||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||
| you may not use this file except in compliance with the License. | ||
| You may obtain a copy of the License at | ||
|
|
||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| Unless required by applicable law or agreed to in writing, software | ||
| distributed under the License is distributed on an "AS IS" BASIS, | ||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| See the License for the specific language governing permissions and | ||
| limitations under the License. | ||
| */ | ||
|
|
||
| package functions | ||
|
|
||
| import ( | ||
| "io" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/google/go-cmp/cmp" | ||
| v1 "github.com/google/go-containerregistry/pkg/v1" | ||
| "github.com/google/go-containerregistry/pkg/v1/cache" | ||
| "github.com/google/go-containerregistry/pkg/v1/static" | ||
| "github.com/google/go-containerregistry/pkg/v1/types" | ||
|
|
||
| "github.com/crossplane/crossplane-runtime/v2/pkg/errors" | ||
| ) | ||
|
|
||
| func testLayer() v1.Layer { | ||
| return static.NewLayer([]byte("hello from a layer"), types.DockerLayer) | ||
| } | ||
|
|
||
| func readAll(t *testing.T, l v1.Layer) string { | ||
| t.Helper() | ||
| rc, err := l.Compressed() | ||
| if err != nil { | ||
| t.Fatalf("Compressed(): unexpected error: %v", err) | ||
| } | ||
| defer func() { _ = rc.Close() }() | ||
| bs, err := io.ReadAll(rc) | ||
| if err != nil { | ||
| t.Fatalf("reading layer: unexpected error: %v", err) | ||
| } | ||
| return string(bs) | ||
| } | ||
|
|
||
| // erroringCache fails every operation the way a corrupt or unreadable cache | ||
| // directory would, rather than reporting a miss. | ||
| type erroringCache struct{} | ||
|
|
||
| func (erroringCache) Get(v1.Hash) (v1.Layer, error) { return nil, errors.New("boom") } | ||
| func (erroringCache) Put(v1.Layer) (v1.Layer, error) { return nil, errors.New("boom") } | ||
| func (erroringCache) Delete(v1.Hash) error { return errors.New("boom") } | ||
|
|
||
| func TestTolerantCacheGetReportsFailuresAsMisses(t *testing.T) { | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| // A read failure that is not a genuine miss must still look like a miss, | ||
| // so the caller falls through to the registry instead of erroring out. | ||
| c := tolerantCache{erroringCache{}} | ||
|
|
||
| _, err := c.Get(v1.Hash{Algorithm: "sha256", Hex: "cafe"}) | ||
| if !errors.Is(err, cache.ErrNotFound) { | ||
| t.Errorf("Get(): want cache.ErrNotFound, got %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestTolerantCachePutFailureReturnsUsableLayer(t *testing.T) { | ||
| // Put failing outright must hand back a layer that still reads. | ||
| c := tolerantCache{erroringCache{}} | ||
|
|
||
| l, err := c.Put(testLayer()) | ||
| if err != nil { | ||
| t.Fatalf("Put(): unexpected error: %v", err) | ||
| } | ||
| if diff := cmp.Diff("hello from a layer", readAll(t, l)); diff != "" { | ||
| t.Errorf("layer contents (-want +got):\n%s", diff) | ||
| } | ||
| } | ||
|
|
||
| func TestTolerantCacheUnwritableDirStillReads(t *testing.T) { | ||
| // The regression this guards: an unwritable cache directory used to fail | ||
| // the build, because the filesystem cache creates its backing file lazily | ||
| // inside Compressed(). | ||
| if os.Geteuid() == 0 { | ||
| t.Skip("running as root, which can write to a read-only directory") | ||
| } | ||
|
|
||
| dir := filepath.Join(t.TempDir(), "cache") | ||
| if err := os.Mkdir(dir, 0o500); err != nil { | ||
| t.Fatalf("creating read-only cache dir: %v", err) | ||
| } | ||
|
|
||
| plain := cache.NewFilesystemCache(dir) | ||
| l, err := plain.Put(testLayer()) | ||
| if err != nil { | ||
| t.Fatalf("Put(): unexpected error: %v", err) | ||
| } | ||
| if _, err := l.Compressed(); err == nil { | ||
| t.Skip("cache directory turned out to be writable; nothing to assert") | ||
| } | ||
|
|
||
| // Same directory, wrapped: the layer must read regardless. | ||
| tolerant := tolerantCache{cache.NewFilesystemCache(dir)} | ||
| wrapped, err := tolerant.Put(testLayer()) | ||
| if err != nil { | ||
| t.Fatalf("Put(): unexpected error: %v", err) | ||
| } | ||
| if diff := cmp.Diff("hello from a layer", readAll(t, wrapped)); diff != "" { | ||
| t.Errorf("layer contents (-want +got):\n%s", diff) | ||
| } | ||
| } | ||
|
|
||
| func TestTolerantCachePassesThroughOnSuccess(t *testing.T) { | ||
| // A working cache must behave exactly as it would unwrapped: a miss is | ||
| // still a miss, and a stored layer still reads back. | ||
| c := tolerantCache{cache.NewFilesystemCache(t.TempDir())} | ||
|
|
||
| if _, err := c.Get(v1.Hash{Algorithm: "sha256", Hex: "cafe"}); !errors.Is(err, cache.ErrNotFound) { | ||
| t.Errorf("Get() on empty cache: want cache.ErrNotFound, got %v", err) | ||
| } | ||
|
|
||
| l, err := c.Put(testLayer()) | ||
| if err != nil { | ||
| t.Fatalf("Put(): unexpected error: %v", err) | ||
| } | ||
| if diff := cmp.Diff("hello from a layer", readAll(t, l)); diff != "" { | ||
| t.Errorf("layer contents (-want +got):\n%s", diff) | ||
| } | ||
|
|
||
| // Reading the layer populates the cache, so the digest is now a hit. | ||
| d, err := l.Digest() | ||
| if err != nil { | ||
| t.Fatalf("Digest(): unexpected error: %v", err) | ||
| } | ||
| if _, err := c.Get(d); err != nil { | ||
| t.Errorf("Get() after populating cache: unexpected error: %v", err) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.