diff --git a/Makefile b/Makefile index db88e71b..baea68fc 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ AIR ?= $(BIN_DIR)/air WIRE ?= $(BIN_DIR)/wire XCADDY ?= $(BIN_DIR)/xcaddy -# Install oapi-codegen +# Install oapi-codegen (pinned to match committed generated code) $(OAPI_CODEGEN): | $(BIN_DIR) GOBIN=$(BIN_DIR) go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) diff --git a/go.mod b/go.mod index bf2cbdd1..aea5119c 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( github.com/miekg/dns v1.1.68 github.com/nrednav/cuid2 v1.1.0 github.com/oapi-codegen/nethttp-middleware v1.1.2 - github.com/oapi-codegen/runtime v1.1.2 + github.com/oapi-codegen/runtime v1.2.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/runtime-spec v1.2.1 diff --git a/go.sum b/go.sum index 7e44f095..d2e1ff06 100644 --- a/go.sum +++ b/go.sum @@ -191,6 +191,8 @@ github.com/oapi-codegen/nethttp-middleware v1.1.2 h1:TQwEU3WM6ifc7ObBEtiJgbRPaCe github.com/oapi-codegen/nethttp-middleware v1.1.2/go.mod h1:5qzjxMSiI8HjLljiOEjvs4RdrWyMPKnExeFS2kr8om4= github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4= +github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= @@ -250,6 +252,7 @@ github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/vbatts/go-mtree v0.6.1-0.20250911112631-8307d76bc1b9 h1:R6l9BtUe83abUGu1YKGkfa17wMMFLt6mhHVQ8MxpfRE= github.com/vbatts/go-mtree v0.6.1-0.20250911112631-8307d76bc1b9/go.mod h1:W7bcG9PCn6lFY+ljGlZxx9DONkxL3v8a7HyN+PrSrjA= github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= diff --git a/lib/images/README.md b/lib/images/README.md index ad698294..eb2d422b 100644 --- a/lib/images/README.md +++ b/lib/images/README.md @@ -90,7 +90,7 @@ Content-addressable storage with tag symlinks (similar to Docker/Unikraft): - Pulling same tag twice updates the symlink if digest changed - OCI cache uses digest hex as layout tag for true content-addressable caching - Shared blob storage enables automatic layer deduplication across all images -- Old digests remain until explicitly garbage collected +- Orphaned digests are automatically deleted when the last tag referencing them is removed - Symlinks only created after successful build (status: ready) ## Reference Handling (reference.go) diff --git a/lib/images/manager.go b/lib/images/manager.go index f5e5468d..ac170c0e 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -421,7 +421,39 @@ func (m *manager) DeleteImage(ctx context.Context, name string) error { repository := ref.Repository() tag := ref.Tag() - return deleteTag(m.paths, repository, tag) + // Resolve the tag to get the digest before deleting + digestHex, err := resolveTag(m.paths, repository, tag) + if err != nil { + return err + } + + // Delete the tag symlink + if err := deleteTag(m.paths, repository, tag); err != nil { + return err + } + + // Hold createMu during orphan check and delete to prevent race with CreateImage. + // Without this lock, a concurrent CreateImage could create a new tag pointing to + // the same digest between our count check and delete, leaving a dangling symlink. + m.createMu.Lock() + defer m.createMu.Unlock() + + // Check if the digest is now orphaned (no other tags reference it) + count, err := countTagsForDigest(m.paths, repository, digestHex) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to count tags for digest %s: %v\n", digestHex, err) + return nil + } + + if count == 0 { + // Digest is orphaned, delete it + if err := deleteDigest(m.paths, repository, digestHex); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to delete orphaned digest %s: %v\n", digestHex, err) + return nil + } + } + + return nil } // TotalImageBytes returns the total size of all ready images on disk. diff --git a/lib/images/manager_test.go b/lib/images/manager_test.go index b3b74efd..90d3c560 100644 --- a/lib/images/manager_test.go +++ b/lib/images/manager_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -228,10 +229,10 @@ func TestDeleteImage(t *testing.T) { _, err = mgr.GetImage(ctx, created.Name) require.ErrorIs(t, err, ErrNotFound) - // But digest directory should still exist + // Digest directory should also be deleted (no orphaned digests) digestDir := digestPath(paths.New(dataDir), ref.Repository(), digestHex) _, err = os.Stat(digestDir) - require.NoError(t, err) + require.True(t, os.IsNotExist(err), "digest directory should be deleted when orphaned") } func TestDeleteImageNotFound(t *testing.T) { @@ -245,6 +246,60 @@ func TestDeleteImageNotFound(t *testing.T) { require.ErrorIs(t, err, ErrNotFound) } +func TestDeleteImagePreservesSharedDigest(t *testing.T) { + dataDir := t.TempDir() + p := paths.New(dataDir) + mgr, err := NewManager(p, 1, nil) + require.NoError(t, err) + + ctx := context.Background() + req := CreateImageRequest{ + Name: "docker.io/library/alpine:latest", + } + + created, err := mgr.CreateImage(ctx, req) + require.NoError(t, err) + + waitForReady(t, mgr, ctx, created.Name) + + // Get the digest + img, err := mgr.GetImage(ctx, created.Name) + require.NoError(t, err) + ref, err := ParseNormalizedRef(img.Name) + require.NoError(t, err) + digestHex := strings.SplitN(img.Digest, ":", 2)[1] + + // Create a second tag pointing to the same digest + err = createTagSymlink(p, ref.Repository(), "v1.0", digestHex) + require.NoError(t, err) + + // Delete the first tag + err = mgr.DeleteImage(ctx, created.Name) + require.NoError(t, err) + + // First tag should be gone + _, err = mgr.GetImage(ctx, created.Name) + require.ErrorIs(t, err, ErrNotFound) + + // Digest directory should still exist (shared by v1.0 tag) + digestDir := digestPath(p, ref.Repository(), digestHex) + _, err = os.Stat(digestDir) + require.NoError(t, err, "digest directory should be preserved when other tags reference it") + + // Second tag should still work + img2, err := mgr.GetImage(ctx, "docker.io/library/alpine:v1.0") + require.NoError(t, err) + assert.Equal(t, img.Digest, img2.Digest) + + // Now delete the second tag + err = mgr.DeleteImage(ctx, "docker.io/library/alpine:v1.0") + require.NoError(t, err) + + // Now the digest directory should be deleted + _, err = os.Stat(digestDir) + require.True(t, os.IsNotExist(err), "digest directory should be deleted when last tag is removed") +} + func TestNormalizedRefParsing(t *testing.T) { tests := []struct { input string diff --git a/lib/images/storage.go b/lib/images/storage.go index 63d730ae..26d73271 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -288,3 +288,32 @@ func deleteTag(p *paths.Paths, repository, tag string) error { return nil } + +// countTagsForDigest counts how many tags in a repository point to a given digest +func countTagsForDigest(p *paths.Paths, repository, digestHex string) (int, error) { + tags, err := listTags(p, repository) + if err != nil { + return 0, err + } + + count := 0 + for _, tag := range tags { + target, err := resolveTag(p, repository, tag) + if err != nil { + continue + } + if target == digestHex { + count++ + } + } + return count, nil +} + +// deleteDigest removes a digest directory and all its contents +func deleteDigest(p *paths.Paths, repository, digestHex string) error { + dir := digestDir(p, repository, digestHex) + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("remove digest directory: %w", err) + } + return nil +}