Skip to content

Commit d06efaa

Browse files
committed
client: add reset option for local cache exporter
Add `reset=true` attribute to the local cache exporter that removes unreferenced blobs from the cache directory after export, preventing unbounded growth. Signed-off-by: Jiří Moravčík <jiri.moravcik@gmail.com>
1 parent c3281f6 commit d06efaa

File tree

4 files changed

+327
-0
lines changed

4 files changed

+327
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ The directory layout conforms to OCI Image Spec v1.0.
497497
* `compression-level=<value>`: compression level for gzip, estargz (0-9) and zstd (0-22)
498498
* `force-compression=true`: forcibly apply `compression` option to all layers
499499
* `ignore-error=<false|true>`: specify if error is ignored in case cache export fails (default: `false`)
500+
* `reset=<true|false>`: remove any blobs in the cache directory that are not referenced by the current manifests in `index.json` (default: `false`). This is useful for keeping the local cache directory from growing indefinitely.
500501

501502
`--import-cache` options:
502503
* `type=local`

client/client_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){
199199
testZstdLocalCacheExport,
200200
testCacheExportIgnoreError,
201201
testCacheExportCacheDeletedContent,
202+
testLocalCacheExportReset,
202203
testZstdRegistryCacheImportExport,
203204
testZstdLocalCacheImportExport,
204205
testUncompressedLocalCacheImportExport,
@@ -516,6 +517,93 @@ func testCacheExportCacheDeletedContent(t *testing.T, sb integration.Sandbox) {
516517
}
517518
}
518519

520+
func testLocalCacheExportReset(t *testing.T, sb integration.Sandbox) {
521+
integration.SkipOnPlatform(t, "windows")
522+
workers.CheckFeatureCompat(t, sb, workers.FeatureCacheExport, workers.FeatureCacheBackendLocal)
523+
c, err := New(sb.Context(), sb.Address())
524+
require.NoError(t, err)
525+
defer c.Close()
526+
527+
tmpdir := integration.Tmpdir(t)
528+
cacheDir := filepath.Join(tmpdir.Name, "cache")
529+
530+
// Build 1: export cache for a simple build
531+
st1 := llb.Image("alpine:latest").Run(llb.Shlex(`sh -c "echo build1 > /out"`)).Root()
532+
def1, err := llb.Scratch().File(llb.Copy(st1, "/out", "/out")).Marshal(sb.Context())
533+
require.NoError(t, err)
534+
535+
_, err = c.Solve(sb.Context(), def1, SolveOpt{
536+
CacheExports: []CacheOptionsEntry{
537+
{
538+
Type: "local",
539+
Attrs: map[string]string{
540+
"dest": cacheDir,
541+
},
542+
},
543+
},
544+
}, nil)
545+
require.NoError(t, err)
546+
547+
// Verify blobs were written
548+
blobDir := filepath.Join(cacheDir, "blobs", "sha256")
549+
entries1, err := os.ReadDir(blobDir)
550+
require.NoError(t, err)
551+
require.Greater(t, len(entries1), 0)
552+
553+
// Build 2: different build, export with reset=true
554+
st2 := llb.Image("alpine:latest").Run(llb.Shlex(`sh -c "echo build2-different > /out2"`)).Root()
555+
def2, err := llb.Scratch().File(llb.Copy(st2, "/out2", "/out2")).Marshal(sb.Context())
556+
require.NoError(t, err)
557+
558+
_, err = c.Solve(sb.Context(), def2, SolveOpt{
559+
CacheExports: []CacheOptionsEntry{
560+
{
561+
Type: "local",
562+
Attrs: map[string]string{
563+
"dest": cacheDir,
564+
"reset": "true",
565+
},
566+
},
567+
},
568+
}, nil)
569+
require.NoError(t, err)
570+
571+
// Read the manifest to determine which blobs should be referenced
572+
dt, err := os.ReadFile(filepath.Join(cacheDir, "index.json"))
573+
require.NoError(t, err)
574+
575+
var index ocispecs.Index
576+
err = json.Unmarshal(dt, &index)
577+
require.NoError(t, err)
578+
require.Len(t, index.Manifests, 1)
579+
580+
dt, err = os.ReadFile(filepath.Join(blobDir, index.Manifests[0].Digest.Hex()))
581+
require.NoError(t, err)
582+
583+
var mfst ocispecs.Manifest
584+
err = json.Unmarshal(dt, &mfst)
585+
require.NoError(t, err)
586+
587+
// Build the set of expected blobs: manifest + config + all layers
588+
expectedBlobs := map[string]struct{}{
589+
index.Manifests[0].Digest.Hex(): {},
590+
mfst.Config.Digest.Hex(): {},
591+
}
592+
for _, l := range mfst.Layers {
593+
expectedBlobs[l.Digest.Hex()] = struct{}{}
594+
}
595+
596+
// Verify only referenced blobs remain
597+
entries2, err := os.ReadDir(blobDir)
598+
require.NoError(t, err)
599+
600+
actualBlobs := map[string]struct{}{}
601+
for _, e := range entries2 {
602+
actualBlobs[e.Name()] = struct{}{}
603+
}
604+
require.Equal(t, expectedBlobs, actualBlobs, "after reset, only blobs referenced by the current manifest should remain")
605+
}
606+
519607
func testBridgeNetworking(t *testing.T, sb integration.Sandbox) {
520608
if os.Getenv("BUILDKIT_RUN_NETWORK_INTEGRATION_TESTS") == "" {
521609
t.SkipNow()

client/solve.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"maps"
99
"os"
1010
"slices"
11+
"strconv"
1112
"strings"
1213
"time"
1314

@@ -420,9 +421,61 @@ func (c *Client) solve(ctx context.Context, def *llb.Definition, runGateway runG
420421
}
421422
}
422423
}
424+
// Reset cache stores that have reset=true — delete unreferenced blobs
425+
for _, ref := range cacheOpt.storesToReset {
426+
if err := resetCacheStore(ctx, ref.store, ref.path); err != nil {
427+
bklog.G(ctx).WithError(err).Warn("failed to reset cache store")
428+
}
429+
}
423430
return res, nil
424431
}
425432

433+
// resetCacheStore deletes all blobs not referenced by any manifest in
434+
// index.json. Referenced blobs are always preserved.
435+
func resetCacheStore(ctx context.Context, cs content.Store, storePath string) error {
436+
idx := ociindex.NewStoreIndex(storePath)
437+
index, err := idx.Read()
438+
if err != nil {
439+
return errors.Wrap(err, "reset: failed to read index.json")
440+
}
441+
442+
referenced := make(map[digest.Digest]struct{})
443+
444+
for _, manifestDesc := range index.Manifests {
445+
referenced[manifestDesc.Digest] = struct{}{}
446+
447+
dt, err := content.ReadBlob(ctx, cs, manifestDesc)
448+
if err != nil {
449+
return errors.Wrapf(err, "reset: failed to read manifest %s", manifestDesc.Digest)
450+
}
451+
452+
var manifest ocispecs.Manifest
453+
if err := json.Unmarshal(dt, &manifest); err == nil && manifest.Config.Digest != "" {
454+
referenced[manifest.Config.Digest] = struct{}{}
455+
for _, l := range manifest.Layers {
456+
referenced[l.Digest] = struct{}{}
457+
}
458+
}
459+
}
460+
461+
var toDelete []digest.Digest
462+
if err := cs.Walk(ctx, func(info content.Info) error {
463+
if _, ok := referenced[info.Digest]; !ok {
464+
toDelete = append(toDelete, info.Digest)
465+
}
466+
return nil
467+
}); err != nil {
468+
return errors.Wrap(err, "reset: failed to walk content store")
469+
}
470+
471+
for _, dgst := range toDelete {
472+
if err := cs.Delete(ctx, dgst); err != nil {
473+
bklog.G(ctx).WithError(err).Warnf("reset: failed to delete blob %s", dgst)
474+
}
475+
}
476+
return nil
477+
}
478+
426479
func prepareSyncedFiles(def *llb.Definition, localMounts map[string]fsutil.FS) (filesync.StaticDirSource, error) {
427480
resetUIDAndGID := func(p string, st *fstypes.Stat) fsutil.MapResult {
428481
st.Uid = 0
@@ -467,10 +520,16 @@ func prepareSyncedFiles(def *llb.Definition, localMounts map[string]fsutil.FS) (
467520
return result, nil
468521
}
469522

523+
type cacheStoreRef struct {
524+
path string
525+
store content.Store
526+
}
527+
470528
type cacheOptions struct {
471529
options controlapi.CacheOptions
472530
contentStores map[string]content.Store // key: ID of content store ("local:" + csDir)
473531
storesToUpdate map[string]string // key: path to content store, value: tag
532+
storesToReset []cacheStoreRef // cache stores with reset=true
474533
frontendAttrs map[string]string
475534
}
476535

@@ -479,6 +538,7 @@ func parseCacheOptions(ctx context.Context, isGateway bool, opt SolveOpt) (*cach
479538
cacheExports []*controlapi.CacheOptionsEntry
480539
cacheImports []*controlapi.CacheOptionsEntry
481540
)
541+
var storesToReset []cacheStoreRef
482542
contentStores := make(map[string]content.Store)
483543
storesToUpdate := make(map[string]string)
484544
frontendAttrs := make(map[string]string)
@@ -503,6 +563,16 @@ func parseCacheOptions(ctx context.Context, isGateway bool, opt SolveOpt) (*cach
503563
}
504564
// TODO(AkihiroSuda): support custom index JSON path and tag
505565
storesToUpdate[csDir] = tag
566+
567+
if v, ok := ex.Attrs["reset"]; ok {
568+
b, err := strconv.ParseBool(v)
569+
if err != nil {
570+
return nil, errors.Wrapf(err, "failed to parse reset attribute")
571+
}
572+
if b {
573+
storesToReset = append(storesToReset, cacheStoreRef{path: csDir, store: cs})
574+
}
575+
}
506576
}
507577
if ex.Type == "registry" {
508578
regRef := ex.Attrs["ref"]
@@ -582,6 +652,7 @@ func parseCacheOptions(ctx context.Context, isGateway bool, opt SolveOpt) (*cach
582652
},
583653
contentStores: contentStores,
584654
storesToUpdate: storesToUpdate,
655+
storesToReset: storesToReset,
585656
frontendAttrs: frontendAttrs,
586657
}
587658
return &res, nil

client/solve_resetcache_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package client
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"testing"
8+
9+
"github.com/containerd/containerd/v2/core/content"
10+
contentlocal "github.com/containerd/containerd/v2/plugins/content/local"
11+
"github.com/moby/buildkit/client/ociindex"
12+
digest "github.com/opencontainers/go-digest"
13+
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func writeBlob(ctx context.Context, t *testing.T, cs content.Store, data []byte) digest.Digest {
18+
t.Helper()
19+
dgst := digest.FromBytes(data)
20+
desc := ocispecs.Descriptor{Digest: dgst, Size: int64(len(data))}
21+
err := content.WriteBlob(ctx, cs, dgst.String(), bytes.NewReader(data), desc)
22+
require.NoError(t, err)
23+
return dgst
24+
}
25+
26+
func listDigests(ctx context.Context, t *testing.T, cs content.Store) map[digest.Digest]struct{} {
27+
t.Helper()
28+
result := map[digest.Digest]struct{}{}
29+
err := cs.Walk(ctx, func(info content.Info) error {
30+
result[info.Digest] = struct{}{}
31+
return nil
32+
})
33+
require.NoError(t, err)
34+
return result
35+
}
36+
37+
// setupCacheStore creates a content store with an index.json and a manifest
38+
// referencing the given config and layers. Returns the store path, content
39+
// store, and manifest digest.
40+
func setupCacheStore(t *testing.T, ctx context.Context, configData []byte, layersData [][]byte, tag string) (string, content.Store, digest.Digest) {
41+
t.Helper()
42+
dir := t.TempDir()
43+
cs, err := contentlocal.NewStore(dir)
44+
require.NoError(t, err)
45+
46+
configDgst := writeBlob(ctx, t, cs, configData)
47+
48+
layers := make([]ocispecs.Descriptor, len(layersData))
49+
for i, ld := range layersData {
50+
dgst := writeBlob(ctx, t, cs, ld)
51+
layers[i] = ocispecs.Descriptor{Digest: dgst, Size: int64(len(ld))}
52+
}
53+
54+
manifest := ocispecs.Manifest{
55+
MediaType: ocispecs.MediaTypeImageManifest,
56+
Config: ocispecs.Descriptor{
57+
Digest: configDgst,
58+
Size: int64(len(configData)),
59+
MediaType: "application/vnd.buildkit.cacheconfig.v0",
60+
},
61+
Layers: layers,
62+
}
63+
manifestData, err := json.Marshal(manifest)
64+
require.NoError(t, err)
65+
manifestDgst := writeBlob(ctx, t, cs, manifestData)
66+
67+
idx := ociindex.NewStoreIndex(dir)
68+
err = idx.Put(ocispecs.Descriptor{
69+
Digest: manifestDgst,
70+
Size: int64(len(manifestData)),
71+
MediaType: ocispecs.MediaTypeImageManifest,
72+
}, ociindex.Tag(tag))
73+
require.NoError(t, err)
74+
75+
return dir, cs, manifestDgst
76+
}
77+
78+
func TestResetCacheStoreImageManifest(t *testing.T) {
79+
ctx := context.Background()
80+
dir, cs, manifestDgst := setupCacheStore(t, ctx,
81+
[]byte(`{"test":"config"}`),
82+
[][]byte{[]byte("layer1-data")},
83+
"latest",
84+
)
85+
86+
// Write orphan blob
87+
orphanDgst := writeBlob(ctx, t, cs, []byte("orphan-old-layer"))
88+
89+
// Verify 4 blobs exist
90+
require.Len(t, listDigests(ctx, t, cs), 4)
91+
92+
err := resetCacheStore(ctx, cs, dir)
93+
require.NoError(t, err)
94+
95+
remaining := listDigests(ctx, t, cs)
96+
require.Len(t, remaining, 3) // manifest + config + layer
97+
require.Contains(t, remaining, manifestDgst)
98+
require.NotContains(t, remaining, orphanDgst)
99+
}
100+
101+
func TestResetCacheStoreMultipleTags(t *testing.T) {
102+
ctx := context.Background()
103+
dir := t.TempDir()
104+
cs, err := contentlocal.NewStore(dir)
105+
require.NoError(t, err)
106+
107+
// Manifest 1 (tag=v1)
108+
config1Dgst := writeBlob(ctx, t, cs, []byte(`{"tag":"v1"}`))
109+
layer1Dgst := writeBlob(ctx, t, cs, []byte("layer1-only-in-v1"))
110+
m1 := ocispecs.Manifest{
111+
MediaType: ocispecs.MediaTypeImageManifest,
112+
Config: ocispecs.Descriptor{Digest: config1Dgst, Size: 12, MediaType: "application/vnd.buildkit.cacheconfig.v0"},
113+
Layers: []ocispecs.Descriptor{{Digest: layer1Dgst, Size: 17}},
114+
}
115+
m1Data, err := json.Marshal(m1)
116+
require.NoError(t, err)
117+
m1Dgst := writeBlob(ctx, t, cs, m1Data)
118+
119+
// Manifest 2 (tag=v2)
120+
config2Dgst := writeBlob(ctx, t, cs, []byte(`{"tag":"v2"}`))
121+
layer2Dgst := writeBlob(ctx, t, cs, []byte("layer2-only-in-v2"))
122+
m2 := ocispecs.Manifest{
123+
MediaType: ocispecs.MediaTypeImageManifest,
124+
Config: ocispecs.Descriptor{Digest: config2Dgst, Size: 12, MediaType: "application/vnd.buildkit.cacheconfig.v0"},
125+
Layers: []ocispecs.Descriptor{{Digest: layer2Dgst, Size: 17}},
126+
}
127+
m2Data, err := json.Marshal(m2)
128+
require.NoError(t, err)
129+
m2Dgst := writeBlob(ctx, t, cs, m2Data)
130+
131+
// Orphan blob
132+
orphanDgst := writeBlob(ctx, t, cs, []byte("orphan-blob"))
133+
134+
// Write index.json with both tags
135+
idx := ociindex.NewStoreIndex(dir)
136+
require.NoError(t, idx.Put(ocispecs.Descriptor{Digest: m1Dgst, Size: int64(len(m1Data)), MediaType: ocispecs.MediaTypeImageManifest}, ociindex.Tag("v1")))
137+
require.NoError(t, idx.Put(ocispecs.Descriptor{Digest: m2Dgst, Size: int64(len(m2Data)), MediaType: ocispecs.MediaTypeImageManifest}, ociindex.Tag("v2")))
138+
139+
require.Len(t, listDigests(ctx, t, cs), 7)
140+
141+
err = resetCacheStore(ctx, cs, dir)
142+
require.NoError(t, err)
143+
144+
remaining := listDigests(ctx, t, cs)
145+
require.Len(t, remaining, 6)
146+
require.Contains(t, remaining, m1Dgst)
147+
require.Contains(t, remaining, config1Dgst)
148+
require.Contains(t, remaining, layer1Dgst)
149+
require.Contains(t, remaining, m2Dgst)
150+
require.Contains(t, remaining, config2Dgst)
151+
require.Contains(t, remaining, layer2Dgst)
152+
require.NotContains(t, remaining, orphanDgst)
153+
}
154+
155+
func TestResetCacheStoreNoOrphans(t *testing.T) {
156+
ctx := context.Background()
157+
dir, cs, _ := setupCacheStore(t, ctx,
158+
[]byte(`{"test":"config"}`),
159+
nil,
160+
"latest",
161+
)
162+
163+
err := resetCacheStore(ctx, cs, dir)
164+
require.NoError(t, err)
165+
166+
require.Len(t, listDigests(ctx, t, cs), 2) // manifest + config
167+
}

0 commit comments

Comments
 (0)