Skip to content

Commit f7858b6

Browse files
committed
Enforce lease when building SOCI index
Previously, in our CLI create command, we would create zTOCs, push them to the content store, then label the index to refer to the zTOCs. This created a problem where a user using the containerd content store might have their zTOCs deleted by the containerd garbage collector before it could be labeled. While a lease was used, it was only done during the latter step, so it did not prevent zTOCs from being deleted before being labeled. This commit fixes this by using a lease on the entire process via a new command, BuildAndWriteIndex, which is called by "soci create". Signed-off-by: David Son <[email protected]>
1 parent 98f2fb4 commit f7858b6

File tree

6 files changed

+113
-33
lines changed

6 files changed

+113
-33
lines changed

cmd/soci/commands/create.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,7 @@ var CreateCommand = cli.Command{
122122
return err
123123
}
124124

125-
sociIndexWithMetadata, err := builder.Build(ctx, srcImg)
126-
if err != nil {
127-
return err
128-
}
129-
130-
err = soci.WriteSociIndex(ctx, sociIndexWithMetadata, blobStore, builder.ArtifactsDb)
125+
_, err = builder.Build(ctx, srcImg)
131126
if err != nil {
132127
return err
133128
}

integration/create_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package integration
1919
import (
2020
"bytes"
2121
"encoding/json"
22+
"fmt"
2223
"path/filepath"
2324
"testing"
2425

@@ -202,3 +203,32 @@ func TestSociCreate(t *testing.T) {
202203
})
203204
}
204205
}
206+
207+
// TestSociCreateGarbageCollection ensures that SOCI index artifacts are not garbage collected after creation.
208+
func TestSociCreateGarbageCollection(t *testing.T) {
209+
image := rabbitmqImage
210+
smallImage := alpineImage
211+
sh, done := newSnapshotterBaseShell(t)
212+
defer done()
213+
214+
extraContainerdConfig := `
215+
[plugins."io.containerd.gc.v1.scheduler"]
216+
deletion_threshold = 1`
217+
218+
rebootContainerd(t, sh, getContainerdConfigToml(t, false, extraContainerdConfig), getSnapshotterConfigToml(t, false, tcpMetricsConfig, GetContentStoreConfigToml(store.WithType(config.ContainerdContentStoreType))))
219+
220+
imgInfo := dockerhub(image)
221+
sh.X("nerdctl", "pull", "-q", imgInfo.ref)
222+
indexDigest := buildIndex(sh, imgInfo, withMinLayerSize(0), withContentStoreType(config.ContainerdContentStoreType))
223+
224+
// Pull and remove a small image to automatically trigger GC.
225+
smallImageInfo := dockerhub(smallImage)
226+
sh.X("nerdctl", "pull", "-q", smallImageInfo.ref)
227+
sh.X("nerdctl", "rmi", smallImageInfo.ref)
228+
229+
sh.X(append(imagePullCmd, "--soci-index-digest", indexDigest, imgInfo.ref)...)
230+
curlOutput := string(sh.O("curl", tcpMetricsAddress+metricsPath))
231+
if err := checkOverlayFallbackCount(curlOutput, 0); err != nil {
232+
t.Fatal(fmt.Errorf("resources unexpectedly garbage collected: %v", err))
233+
}
234+
}

soci/soci_index.go

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ import (
3939
"github.com/opencontainers/go-digest"
4040
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
4141

42-
orascontent "oras.land/oras-go/v2/content"
4342
"oras.land/oras-go/v2/errdef"
4443
)
4544

@@ -273,14 +272,14 @@ func WithArtifactsDb(db *ArtifactsDb) BuildOption {
273272
// IndexBuilder creates soci indices.
274273
type IndexBuilder struct {
275274
contentStore content.Store
276-
blobStore orascontent.Storage
275+
blobStore store.Store
277276
ArtifactsDb *ArtifactsDb
278277
config *buildConfig
279278
ztocBuilder *ztoc.Builder
280279
}
281280

282281
// NewIndexBuilder returns an `IndexBuilder` that is used to create soci indices.
283-
func NewIndexBuilder(contentStore content.Store, blobStore orascontent.Storage, artifactsDb *ArtifactsDb, opts ...BuildOption) (*IndexBuilder, error) {
282+
func NewIndexBuilder(contentStore content.Store, blobStore store.Store, artifactsDb *ArtifactsDb, opts ...BuildOption) (*IndexBuilder, error) {
284283
defaultPlatform := platforms.DefaultSpec()
285284
config := &buildConfig{
286285
spanSize: defaultSpanSize,
@@ -305,8 +304,35 @@ func NewIndexBuilder(contentStore content.Store, blobStore orascontent.Storage,
305304
}, nil
306305
}
307306

308-
// Build builds a soci index for `img` and return the index with metadata.
307+
// Build builds a soci index for `img` and pushes it with its corresponding zTOCs to the blob store.
308+
// Returns the SOCI index and its metadata.
309309
func (b *IndexBuilder) Build(ctx context.Context, img images.Image) (*IndexWithMetadata, error) {
310+
// batch will prevent content from being garbage collected in the middle of the following operations
311+
ctx, done, err := b.blobStore.BatchOpen(ctx)
312+
if err != nil {
313+
return nil, err
314+
}
315+
defer done(ctx)
316+
317+
// Create and push zTOCs to blob store
318+
index, err := b.build(ctx, img)
319+
if err != nil {
320+
return nil, err
321+
}
322+
323+
// Label zTOCs and push SOCI index
324+
err = b.writeSociIndex(ctx, index)
325+
if err != nil {
326+
return nil, err
327+
}
328+
329+
return index, nil
330+
}
331+
332+
// build attempts to create a zTOC in each layer and pushes the zTOC to the blob store.
333+
// It then creates the SOCI index and returns it with some metadata.
334+
// This should be done within a Batch and followed by writeSociIndex() to prevent garbage collection.
335+
func (b *IndexBuilder) build(ctx context.Context, img images.Image) (*IndexWithMetadata, error) {
310336
// we get manifest descriptor before calling images.Manifest, since after calling
311337
// images.Manifest, images.Children will error out when reading the manifest blob (this happens on containerd side)
312338
imgManifestDesc, err := GetImageManifestDescriptor(ctx, b.contentStore, img.Target, platforms.OnlyStrict(b.config.platform))
@@ -357,7 +383,6 @@ func (b *IndexBuilder) Build(ctx context.Context, img images.Image) (*IndexWithM
357383
for _, err := range errs {
358384
errWrap = fmt.Errorf("%w; %v", errWrap, err)
359385
}
360-
361386
return nil, errWrap
362387
}
363388

@@ -393,6 +418,7 @@ func (b *IndexBuilder) Build(ctx context.Context, img images.Image) (*IndexWithM
393418

394419
// buildSociLayer builds a ztoc for an image layer (`desc`) and returns ztoc descriptor.
395420
// It may skip building ztoc (e.g., if layer size < `minLayerSize`) and return nil.
421+
// This should be done within a Batch and followed by Label calls to prevent garbage collection.
396422
func (b *IndexBuilder) buildSociLayer(ctx context.Context, desc ocispec.Descriptor) (*ocispec.Descriptor, error) {
397423
if !images.IsLayerType(desc.MediaType) {
398424
return nil, errNotLayerType
@@ -541,15 +567,9 @@ func GetImageManifestDescriptor(ctx context.Context, cs content.Store, imageTarg
541567
return nil, nil
542568
}
543569

544-
// WriteSociIndex writes the SociIndex manifest to oras `store`.
545-
func WriteSociIndex(ctx context.Context, indexWithMetadata *IndexWithMetadata, contentStore store.Store, artifactsDb *ArtifactsDb) error {
546-
// batch will prevent content from being garbage collected in the middle of the following operations
547-
ctx, batchDone, err := contentStore.BatchOpen(ctx)
548-
if err != nil {
549-
return err
550-
}
551-
defer batchDone(ctx)
552-
570+
// writeSociIndex writes the SociIndex manifest to the blob store.
571+
// This should be done within a Batch to prevent garbage collection.
572+
func (b *IndexBuilder) writeSociIndex(ctx context.Context, indexWithMetadata *IndexWithMetadata) error {
553573
manifest, err := MarshalIndex(indexWithMetadata.Index)
554574
if err != nil {
555575
return err
@@ -559,7 +579,7 @@ func WriteSociIndex(ctx context.Context, indexWithMetadata *IndexWithMetadata, c
559579
// empty config objct in the store as well. We will need to push this to the
560580
// registry later.
561581
if indexWithMetadata.Index.MediaType == ocispec.MediaTypeImageManifest {
562-
err = contentStore.Push(ctx, defaultConfigDescriptor, bytes.NewReader(defaultConfigContent))
582+
err = b.blobStore.Push(ctx, defaultConfigDescriptor, bytes.NewReader(defaultConfigContent))
563583
if err != nil && !errors.Is(err, errdef.ErrAlreadyExists) {
564584
return fmt.Errorf("error creating OCI 1.0 empty config: %w", err)
565585
}
@@ -572,25 +592,25 @@ func WriteSociIndex(ctx context.Context, indexWithMetadata *IndexWithMetadata, c
572592
Size: size,
573593
}
574594

575-
err = contentStore.Push(ctx, desc, bytes.NewReader(manifest))
595+
err = b.blobStore.Push(ctx, desc, bytes.NewReader(manifest))
576596
if err != nil && !errors.Is(err, errdef.ErrAlreadyExists) {
577597
return fmt.Errorf("cannot write SOCI index to local store: %w", err)
578598
}
579599

580600
log.G(ctx).WithField("digest", dgst.String()).Debugf("soci index has been written")
581601

582-
err = store.LabelGCRoot(ctx, contentStore, desc)
602+
err = store.LabelGCRoot(ctx, b.blobStore, desc)
583603
if err != nil {
584604
return fmt.Errorf("cannot apply garbage collection label to index %s: %w", desc.Digest.String(), err)
585605
}
586-
err = store.LabelGCRefContent(ctx, contentStore, desc, "config", defaultConfigDescriptor.Digest.String())
606+
err = store.LabelGCRefContent(ctx, b.blobStore, desc, "config", defaultConfigDescriptor.Digest.String())
587607
if err != nil {
588608
return fmt.Errorf("cannot apply garbage collection label to index %s referencing default config: %w", desc.Digest.String(), err)
589609
}
590610

591611
var allErr error
592612
for i, blob := range indexWithMetadata.Index.Blobs {
593-
err = store.LabelGCRefContent(ctx, contentStore, desc, "ztoc."+strconv.Itoa(i), blob.Digest.String())
613+
err = store.LabelGCRefContent(ctx, b.blobStore, desc, "ztoc."+strconv.Itoa(i), blob.Digest.String())
594614
if err != nil {
595615
errors.Join(allErr, err)
596616
}
@@ -617,7 +637,7 @@ func WriteSociIndex(ctx context.Context, indexWithMetadata *IndexWithMetadata, c
617637
MediaType: indexWithMetadata.Index.MediaType,
618638
CreatedAt: indexWithMetadata.CreatedAt,
619639
}
620-
return artifactsDb.WriteArtifactEntry(entry)
640+
return b.ArtifactsDb.WriteArtifactEntry(entry)
621641
}
622642

623643
func (b *IndexBuilder) maybeAddDisableXattrAnnotation(ztocDesc *ocispec.Descriptor, ztoc *ztoc.Ztoc) {

soci/soci_index_test.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import (
2828
"github.com/google/go-cmp/cmp"
2929
"github.com/opencontainers/go-digest"
3030
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
31-
"oras.land/oras-go/v2/content/memory"
3231
)
3332

3433
func TestSkipBuildingZtoc(t *testing.T) {
@@ -136,7 +135,7 @@ func TestBuildSociIndexNotLayer(t *testing.T) {
136135
spanSize := int64(65535)
137136
ctx := context.Background()
138137
cs := newFakeContentStore()
139-
blobStore := memory.New()
138+
blobStore := NewOrasMemoryStore()
140139

141140
artifactsDb, err := newTestableDb()
142141
if err != nil {
@@ -209,7 +208,7 @@ func TestBuildSociIndexWithLimits(t *testing.T) {
209208
Size: tc.layerSize,
210209
}
211210
spanSize := int64(65535)
212-
blobStore := memory.New()
211+
blobStore := NewOrasMemoryStore()
213212
artifactsDb, err := newTestableDb()
214213
if err != nil {
215214
t.Fatalf("can't create a test db")
@@ -305,7 +304,7 @@ func TestDisableXattrs(t *testing.T) {
305304
}
306305

307306
cs := newFakeContentStore()
308-
blobStore := memory.New()
307+
blobStore := NewOrasMemoryStore()
309308
artifactsDb, err := newTestableDb()
310309
if err != nil {
311310
t.Fatalf("can't create a test db")

soci/store/store.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ func GetContentStorePath(contentStoreType ContentStoreType) (string, error) {
141141

142142
type CleanupFunc func(context.Context) error
143143

144-
func nopCleanup(context.Context) error { return nil }
144+
func NopCleanup(context.Context) error { return nil }
145145

146146
func NewContentStore(ctx context.Context, opts ...Option) (context.Context, Store, error) {
147147
storeConfig := NewStoreConfig(opts...)
@@ -185,7 +185,7 @@ func (s *SociStore) Delete(_ context.Context, _ digest.Digest) error {
185185

186186
// BatchOpen is a no-op for sociStore; it does not support batching operations.
187187
func (s *SociStore) BatchOpen(ctx context.Context) (context.Context, CleanupFunc, error) {
188-
return ctx, nopCleanup, nil
188+
return ctx, NopCleanup, nil
189189
}
190190

191191
type ContainerdStore struct {
@@ -336,7 +336,7 @@ func (s *ContainerdStore) Delete(ctx context.Context, dgst digest.Digest) error
336336
func (s *ContainerdStore) BatchOpen(ctx context.Context) (context.Context, CleanupFunc, error) {
337337
ctx, leaseDone, err := s.client.WithLease(ctx)
338338
if err != nil {
339-
return ctx, nopCleanup, fmt.Errorf("unable to open batch: %w", err)
339+
return ctx, NopCleanup, fmt.Errorf("unable to open batch: %w", err)
340340
}
341341
return ctx, leaseDone, nil
342342
}

soci/util_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,52 @@ import (
2020
"context"
2121
"io"
2222

23+
"github.com/awslabs/soci-snapshotter/soci/store"
2324
"github.com/containerd/containerd/content"
2425
"github.com/opencontainers/go-digest"
2526
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
27+
"oras.land/oras-go/v2/content/memory"
2628
)
2729

2830
func parseDigest(digestString string) digest.Digest {
2931
dgst, _ := digest.Parse(digestString)
3032
return dgst
3133
}
3234

35+
type OrasMemoryStore struct {
36+
s *memory.Store
37+
}
38+
39+
func (*OrasMemoryStore) BatchOpen(ctx context.Context) (context.Context, store.CleanupFunc, error) {
40+
return ctx, store.NopCleanup, nil
41+
}
42+
43+
func (m *OrasMemoryStore) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) {
44+
return m.s.Exists(ctx, target)
45+
}
46+
47+
func (m *OrasMemoryStore) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) {
48+
return m.s.Fetch(ctx, target)
49+
}
50+
51+
func (m *OrasMemoryStore) Push(ctx context.Context, expected ocispec.Descriptor, reader io.Reader) error {
52+
return m.s.Push(ctx, expected, reader)
53+
}
54+
55+
func (m *OrasMemoryStore) Label(ctx context.Context, target ocispec.Descriptor, label string, value string) error {
56+
return nil
57+
}
58+
59+
func (m *OrasMemoryStore) Delete(ctx context.Context, dgst digest.Digest) error {
60+
return nil
61+
}
62+
63+
func NewOrasMemoryStore() *OrasMemoryStore {
64+
return &OrasMemoryStore{
65+
s: memory.New(),
66+
}
67+
}
68+
3369
type fakeContentStore struct {
3470
}
3571

0 commit comments

Comments
 (0)