Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/imageretention/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ When auto-delete is enabled:
- Shared OCI cache data under `data_dir/system/oci-cache` is not modified by this feature; see `lib/ocicachegc` for a separate mark-and-sweep collector that reclaims orphaned blobs from that directory.
- An image repository must also match at least one `allowed` pattern before any retention state is recorded or deletion is attempted.

An image is considered in use if any persisted instance metadata or snapshot record still references it. As long as at least one such reference exists, the image is protected from deletion.
An image is considered in use if any persisted instance metadata or snapshot record still references it. As long as at least one such reference exists, the image is protected from deletion. Protection applies to the digest, so every repository whose image resolves to a referenced digest is kept, including a build's `builds/<id>` image after the same content was pushed elsewhere and an instance was created from the pushed name.

The retention timer starts only after the last persisted reference disappears and the image repository is allowed by policy. At that point the server records `unused_since` for the image digest. Once `unused_since + unused_for` has elapsed, the cached image digest is deleted.

Expand Down
25 changes: 24 additions & 1 deletion lib/imageretention/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,14 @@ func (c *Controller) sweep(ctx context.Context) (sweepStats, error) {
stats.protectedImages = len(protected)
stats.staleReferences = staleRefs

// Converted content is shared by digest across repositories, so every
// image that resolves to a referenced digest must survive, not only the
// repository the reference names.
protectedDigests := make(map[string]struct{}, len(protected))
for _, ref := range protected {
protectedDigests[ref.DigestHex] = struct{}{}
}

for _, ref := range protected {
removed, err := c.deleteStateIfExists(ref)
if err != nil {
Expand Down Expand Up @@ -232,7 +240,16 @@ func (c *Controller) sweep(ctx context.Context) (sweepStats, error) {

now := c.now().UTC()
for key, ref := range ready {
if _, ok := protected[key]; ok {
if _, ok := protectedDigests[ref.DigestHex]; ok {
if _, direct := protected[key]; !direct {
removed, err := c.deleteStateIfExists(ref)
if err != nil {
return stats, err
}
if removed {
stats.clearedStates++
}
}
continue
}
if !c.isAllowed(ref.Repository) {
Expand Down Expand Up @@ -361,6 +378,9 @@ func (c *Controller) collectReferencedImageNames(ctx context.Context) (map[strin
if stored.Image != "" {
refs[stored.Image] = struct{}{}
}
if stored.ResolvedImage != "" {
refs[stored.ResolvedImage] = struct{}{}
}
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("read guests directory: %w", err)
Expand All @@ -383,6 +403,9 @@ func (c *Controller) collectReferencedImageNames(ctx context.Context) (map[strin
if stored.Image != "" {
refs[stored.Image] = struct{}{}
}
if stored.ResolvedImage != "" {
refs[stored.ResolvedImage] = struct{}{}
}
}

return refs, nil
Expand Down
51 changes: 51 additions & 0 deletions lib/imageretention/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,57 @@ func TestSweepDeletesExpiredImageDigestAndAllTags(t *testing.T) {
require.True(t, os.IsNotExist(err))
}

func TestSweepProtectsAliasOfReferencedDigest(t *testing.T) {
controller, p, _ := newTestController(t, 24*time.Hour, []string{"*"})
const digest = "sha256:7777777777777777777777777777777777777777777777777777777777777777"
published := "123456789012.dkr.ecr.us-east-1.amazonaws.com/quickbox/apps@" + digest
seedReadyImage(t, p, "docker.io/builds/abc:latest", digest)
seedReadyImage(t, p, published, digest)
writeStateAt(t, p, "docker.io/builds/abc", digest, time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC))
writeInstanceMetadata(t, p, "inst-1", instances.StoredMetadata{
Id: "inst-1",
Name: "instance-1",
Image: published,
})

controller.now = func() time.Time {
return time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC)
}

require.NoError(t, controller.Sweep(context.Background()))

_, err := os.Stat(p.ImageDigestPath("docker.io/builds/abc", stringsTrimDigest(digest)))
require.NoError(t, err)
_, err = os.Stat(p.ImageRetentionState("docker.io/builds/abc", stringsTrimDigest(digest)))
require.True(t, os.IsNotExist(err))
}

func TestSweepProtectsResolvedDigestAfterTagMoves(t *testing.T) {
controller, p, _ := newTestController(t, 24*time.Hour, []string{"*"})
const oldDigest = "sha256:8888888888888888888888888888888888888888888888888888888888888888"
const newDigest = "sha256:9999999999999999999999999999999999999999999999999999999999999999"
seedReadyImage(t, p, "docker.io/library/alpine@"+oldDigest, oldDigest)
seedReadyImage(t, p, "docker.io/library/alpine:latest", newDigest)
writeStateAt(t, p, "docker.io/library/alpine", oldDigest, time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC))
writeInstanceMetadata(t, p, "inst-1", instances.StoredMetadata{
Id: "inst-1",
Name: "instance-1",
Image: "docker.io/library/alpine:latest",
ResolvedImage: "docker.io/library/alpine@" + oldDigest,
})

controller.now = func() time.Time {
return time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC)
}

require.NoError(t, controller.Sweep(context.Background()))

_, err := os.Stat(p.ImageDigestPath("docker.io/library/alpine", stringsTrimDigest(oldDigest)))
require.NoError(t, err)
_, err = os.Stat(p.ImageRetentionState("docker.io/library/alpine", stringsTrimDigest(oldDigest)))
require.True(t, os.IsNotExist(err))
}

func TestSweepProtectedImageClearsPriorState(t *testing.T) {
controller, p, _ := newTestController(t, 24*time.Hour, []string{"*"})
const digest = "sha256:5555555555555555555555555555555555555555555555555555555555555555"
Expand Down
Loading