diff --git a/README.md b/README.md index ef1f0fe25..2cfcccc97 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,9 @@ With this configuration, use `https://api.example.com` as the API base URL. With # Pull an image hypeman pull nginx:alpine +# Create a local tag without pulling the image again +hypeman tag nginx:alpine my-registry.example.com/myapp:latest + # Boot a new VM (auto-pulls image if needed) hypeman run --name my-app nginx:alpine diff --git a/cmd/api/api/images.go b/cmd/api/api/images.go index dc003370d..3575ec0cc 100644 --- a/cmd/api/api/images.go +++ b/cmd/api/api/images.go @@ -94,6 +94,45 @@ func (s *ApiService) CreateImage(ctx context.Context, request oapi.CreateImageRe return oapi.CreateImage202JSONResponse(imageToOAPI(*img)), nil } +func (s *ApiService) TagImage(ctx context.Context, request oapi.TagImageRequestObject) (oapi.TagImageResponseObject, error) { + if request.Body == nil { + return oapi.TagImage400JSONResponse{ + Code: "invalid_request", + Message: "request body is required", + }, nil + } + + img, err := s.ImageManager.TagImage(ctx, request.Name, request.Body.Target) + if err != nil { + log := logger.FromContext(ctx) + switch { + case errors.Is(err, images.ErrInvalidName): + return oapi.TagImage400JSONResponse{ + Code: "invalid_name", + Message: err.Error(), + }, nil + case errors.Is(err, images.ErrNotFound): + return oapi.TagImage404JSONResponse{ + Code: "not_found", + Message: "source image not found", + }, nil + case errors.Is(err, images.ErrImageNotReady): + return oapi.TagImage409JSONResponse{ + Code: "image_not_ready", + Message: err.Error(), + }, nil + default: + log.ErrorContext(ctx, "failed to tag image", "error", err, "source", request.Name, "target", request.Body.Target) + return oapi.TagImage500JSONResponse{ + Code: "internal_error", + Message: "failed to tag image", + }, nil + } + } + + return oapi.TagImage200JSONResponse(imageToOAPI(*img)), nil +} + // GetImage gets image details by name // Note: Resolution is handled by ResolveResource middleware func (s *ApiService) GetImage(ctx context.Context, request oapi.GetImageRequestObject) (oapi.GetImageResponseObject, error) { @@ -119,7 +158,7 @@ func (s *ApiService) DeleteImage(ctx context.Context, request oapi.DeleteImageRe } log := logger.FromContext(ctx) - err := s.ImageManager.DeleteImage(ctx, img.Name) + err := s.ImageManager.DeleteImage(ctx, request.Name) if err != nil { log.ErrorContext(ctx, "failed to delete image", "error", err) return oapi.DeleteImage500JSONResponse{ diff --git a/cmd/api/api/images_test.go b/cmd/api/api/images_test.go index bb13a9c02..5bcace067 100644 --- a/cmd/api/api/images_test.go +++ b/cmd/api/api/images_test.go @@ -37,6 +37,15 @@ func (m *captureCreateImageManager) CreateImage(_ context.Context, req images.Cr return &images.Image{Name: req.Name, Digest: "sha256:test", Status: images.StatusPending, CreatedAt: time.Now()}, nil } +type tagImageErrManager struct { + images.Manager + err error +} + +func (m tagImageErrManager) TagImage(context.Context, string, string) (*images.Image, error) { + return nil, m.err +} + func TestCreateImage_MapsBorrowedCredentials(t *testing.T) { t.Parallel() @@ -147,6 +156,64 @@ func errorCodeOf(resp oapi.CreateImageResponseObject) string { } } +func TestTagImage_ErrorStatusMapping(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + err error + wantType any + wantCode string + }{ + { + name: "invalid name -> 400", + err: fmt.Errorf("tag: %w", images.ErrInvalidName), + wantType: oapi.TagImage400JSONResponse{}, + wantCode: "invalid_name", + }, + { + name: "not found -> 404", + err: fmt.Errorf("tag: %w", images.ErrNotFound), + wantType: oapi.TagImage404JSONResponse{}, + wantCode: "not_found", + }, + { + name: "not ready -> 409", + err: fmt.Errorf("tag: %w", images.ErrImageNotReady), + wantType: oapi.TagImage409JSONResponse{}, + wantCode: "image_not_ready", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := &ApiService{ImageManager: tagImageErrManager{err: tc.err}} + resp, err := svc.TagImage(ctx(), oapi.TagImageRequestObject{ + Name: "docker.io/library/alpine:latest", + Body: &oapi.TagImageRequest{Target: "docker.io/library/alpine:stable"}, + }) + require.NoError(t, err) + require.IsType(t, tc.wantType, resp) + require.Equal(t, tc.wantCode, tagImageErrorCode(resp)) + }) + } +} + +func tagImageErrorCode(resp oapi.TagImageResponseObject) string { + switch r := resp.(type) { + case oapi.TagImage400JSONResponse: + return r.Code + case oapi.TagImage404JSONResponse: + return r.Code + case oapi.TagImage409JSONResponse: + return r.Code + case oapi.TagImage500JSONResponse: + return r.Code + default: + return "" + } +} + func TestListImages_Empty(t *testing.T) { t.Parallel() svc := newTestService(t) @@ -217,6 +284,28 @@ func TestDeleteImage_DigestOnlyImageDoesNotInternalError(t *testing.T) { require.True(t, ok, "expected deleting an existing digest-only image not to return internal_error") } +func TestDeleteImage_DigestUsesRequestedRepository(t *testing.T) { + t.Parallel() + svc := newTestService(t) + + const ( + sourceRepo = "docker.io/library/alpine" + targetRepo = "registry.example.com/app" + digest = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + ) + sourceRef := sourceRepo + "@sha256:" + digest + targetRef := targetRepo + "@sha256:" + digest + seedReadyDigestOnlyImage(t, svc, sourceRef, nil) + _, err := svc.ImageManager.TagImage(ctx(), sourceRef, targetRepo+":latest") + require.NoError(t, err) + + resp, err := svc.DeleteImage(ctxWithImage(svc, targetRef), oapi.DeleteImageRequestObject{Name: targetRef}) + require.NoError(t, err) + require.IsType(t, oapi.DeleteImage204Response{}, resp) + _, err = svc.ImageManager.GetImage(ctx(), targetRepo+":latest") + require.ErrorIs(t, err, images.ErrNotFound) +} + func TestCreateImage_Async(t *testing.T) { t.Parallel() svc := newTestService(t) diff --git a/lib/builds/manager_test.go b/lib/builds/manager_test.go index 7be58b0fc..396b507c2 100644 --- a/lib/builds/manager_test.go +++ b/lib/builds/manager_test.go @@ -350,6 +350,17 @@ func (m *mockImageManager) ImportLocalImage(ctx context.Context, repo, reference return img, nil } +func (m *mockImageManager) TagImage(ctx context.Context, source, target string) (*images.Image, error) { + sourceImage, err := m.GetImage(ctx, source) + if err != nil { + return nil, err + } + targetImage := *sourceImage + targetImage.Name = target + m.images[target] = &targetImage + return &targetImage, nil +} + func (m *mockImageManager) GetImage(ctx context.Context, name string) (*images.Image, error) { m.mu.RLock() defer m.mu.RUnlock() diff --git a/lib/images/credentials_test.go b/lib/images/credentials_test.go index 46212ba77..fd210c772 100644 --- a/lib/images/credentials_test.go +++ b/lib/images/credentials_test.go @@ -5,7 +5,6 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "strings" "testing" "time" @@ -203,7 +202,7 @@ func TestRecoverInterruptedCredentialedPullFailsForFreshRetry(t *testing.T) { assert.Equal(t, ErrBorrowedCredentialsExpired.Error(), *stored.Error) assert.Zero(t, m.queue.QueueLength()) - data, err := os.ReadFile(filepath.Join(p.ImageDigestDir(repository, strings.TrimPrefix(digest, "sha256:")), "metadata.json")) + data, err := os.ReadFile(metadataPath(p, repository, strings.TrimPrefix(digest, "sha256:"))) require.NoError(t, err) assert.NotContains(t, string(data), "password") } diff --git a/lib/images/disk_usage.go b/lib/images/disk_usage.go index 47914c27b..b65dba1ac 100644 --- a/lib/images/disk_usage.go +++ b/lib/images/disk_usage.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "syscall" ) // totalReadyImageBytesFromMetadata sums ready image sizes directly from metadata.json files. @@ -14,6 +15,7 @@ import ( // files found in the digest directory so we do not undercount host disk usage. func totalReadyImageBytesFromMetadata(imagesDir string) (int64, error) { var total int64 + seenRootfs := make(map[rootfsIdentity]struct{}) err := filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -28,7 +30,7 @@ func totalReadyImageBytesFromMetadata(imagesDir string) (int64, error) { data, err := os.ReadFile(path) if err != nil { - rootfsBytes, fallbackErr := totalRootfsBytesInDigestDir(filepath.Dir(path)) + rootfsBytes, fallbackErr := totalUniqueRootfsBytesInDigestDir(filepath.Dir(path), seenRootfs) if fallbackErr == nil { total += rootfsBytes return nil @@ -38,18 +40,33 @@ func totalReadyImageBytesFromMetadata(imagesDir string) (int64, error) { var meta imageMetadata if err := json.Unmarshal(data, &meta); err != nil { - rootfsBytes, fallbackErr := totalRootfsBytesInDigestDir(filepath.Dir(path)) + rootfsBytes, fallbackErr := totalUniqueRootfsBytesInDigestDir(filepath.Dir(path), seenRootfs) if fallbackErr == nil { total += rootfsBytes return nil } return fmt.Errorf("unmarshal image metadata %s: %w", path, err) } - if meta.Status == StatusReady && meta.SizeBytes > 0 { - total += meta.SizeBytes - return nil - } if meta.Status == StatusReady { + rootfsPaths, globErr := filepath.Glob(filepath.Join(filepath.Dir(path), "rootfs.*")) + if globErr != nil { + return fmt.Errorf("find ready image rootfs for %s: %w", path, globErr) + } + for _, rootfsPath := range rootfsPaths { + rootfsInfo, statErr := os.Stat(rootfsPath) + if statErr != nil { + continue + } + if !markUniqueRootfs(rootfsInfo, seenRootfs) { + return nil + } + break + } + + if meta.SizeBytes > 0 { + total += meta.SizeBytes + return nil + } rootfsBytes, err := totalRootfsBytesInDigestDir(filepath.Dir(path)) if err != nil { return fmt.Errorf("stat ready image rootfs for %s: %w", path, err) @@ -172,3 +189,55 @@ func totalRootfsBytesInDigestDir(digestDir string) (int64, error) { } return total, nil } + +type rootfsIdentity struct { + dev uint64 + ino uint64 +} + +func markUniqueRootfs(info os.FileInfo, seen map[rootfsIdentity]struct{}) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return true + } + identity := rootfsIdentity{dev: uint64(stat.Dev), ino: uint64(stat.Ino)} + if _, exists := seen[identity]; exists { + return false + } + seen[identity] = struct{}{} + return true +} + +func totalUniqueRootfsBytesInDigestDir(digestDir string, seen map[rootfsIdentity]struct{}) (int64, error) { + rootfsPaths, err := filepath.Glob(filepath.Join(digestDir, "rootfs.*")) + if err != nil { + return 0, err + } + if len(rootfsPaths) == 0 { + return 0, os.ErrNotExist + } + + var total int64 + found := false + for _, rootfsPath := range rootfsPaths { + info, err := os.Stat(rootfsPath) + if err != nil { + if os.IsNotExist(err) { + continue + } + return 0, err + } + if info.IsDir() { + continue + } + found = true + if !markUniqueRootfs(info, seen) { + continue + } + total += info.Size() + } + if !found { + return 0, os.ErrNotExist + } + return total, nil +} diff --git a/lib/images/disk_usage_test.go b/lib/images/disk_usage_test.go index 36e5bc0df..bd6056fef 100644 --- a/lib/images/disk_usage_test.go +++ b/lib/images/disk_usage_test.go @@ -22,6 +22,48 @@ func TestTotalReadyImageBytesFromMetadata_UsesRootfsFallbackForMalformedMetadata require.Equal(t, int64(len("rootfs-data")), total) } +func TestTotalReadyImageBytesFromMetadata_DeduplicatesHardLinkedAliases(t *testing.T) { + t.Parallel() + + imagesDir := t.TempDir() + sourceDir := filepath.Join(imagesDir, "source", "digest") + targetDir := filepath.Join(imagesDir, "target", "digest") + require.NoError(t, os.MkdirAll(sourceDir, 0o755)) + require.NoError(t, os.MkdirAll(targetDir, 0o755)) + + sourceRootfs := filepath.Join(sourceDir, "rootfs.erofs") + targetRootfs := filepath.Join(targetDir, "rootfs.erofs") + require.NoError(t, os.WriteFile(sourceRootfs, []byte("shared-rootfs"), 0o644)) + require.NoError(t, os.Link(sourceRootfs, targetRootfs)) + metadata := []byte(`{"status":"ready","size_bytes":13}`) + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "metadata.json"), metadata, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "metadata.json"), metadata, 0o644)) + + total, err := totalReadyImageBytesFromMetadata(imagesDir) + require.NoError(t, err) + require.Equal(t, int64(len("shared-rootfs")), total) +} + +func TestTotalReadyImageBytesFromMetadata_DeduplicatesMalformedAliases(t *testing.T) { + t.Parallel() + + imagesDir := t.TempDir() + malformedDir := filepath.Join(imagesDir, "a-malformed", "digest") + validDir := filepath.Join(imagesDir, "b-valid", "digest") + require.NoError(t, os.MkdirAll(malformedDir, 0o755)) + require.NoError(t, os.MkdirAll(validDir, 0o755)) + + rootfs := filepath.Join(malformedDir, "rootfs.erofs") + require.NoError(t, os.WriteFile(rootfs, []byte("shared-rootfs"), 0o644)) + require.NoError(t, os.Link(rootfs, filepath.Join(validDir, "rootfs.erofs"))) + require.NoError(t, os.WriteFile(filepath.Join(malformedDir, "metadata.json"), []byte("{not-json"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(validDir, "metadata.json"), []byte(`{"status":"ready","size_bytes":13}`), 0o644)) + + total, err := totalReadyImageBytesFromMetadata(imagesDir) + require.NoError(t, err) + require.Equal(t, int64(len("shared-rootfs")), total) +} + func TestTotalReadyImageBytesFromMetadata_UsesRootfsFallbackForReadyImageWithoutSize(t *testing.T) { t.Parallel() diff --git a/lib/images/errors.go b/lib/images/errors.go index 6a63741f0..47be5032c 100644 --- a/lib/images/errors.go +++ b/lib/images/errors.go @@ -17,6 +17,7 @@ var ( ErrNotFound = errors.New("image not found") ErrInvalidName = errors.New("invalid image name") ErrInvalidPlatform = errors.New("invalid platform") + ErrImageNotReady = errors.New("image is not ready") // ErrPlatformNotAvailable means the requested platform is well-formed but the // image's manifest index does not publish a matching variant. Unlike // ErrInvalidPlatform (bad user syntax), the platform itself is valid; the diff --git a/lib/images/manager.go b/lib/images/manager.go index c1a0da0ac..d991e83b6 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -46,6 +46,10 @@ type Manager interface { // Unlike CreateImage, it does not resolve from a remote registry. ImportLocalImage(ctx context.Context, repo, reference, digest string) (*Image, error) GetImage(ctx context.Context, name string) (*Image, error) + // TagImage creates or updates a local tag for an existing image. + TagImage(ctx context.Context, source, target string) (*Image, error) + // DeleteImage removes local tags in the reference's repository. Shared + // content remains available by digest while other local references exist. DeleteImage(ctx context.Context, name string) error RecoverInterruptedBuilds() // TotalImageBytes returns the total size of all ready images on disk. @@ -210,8 +214,7 @@ func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Ima // Don't cache failed builds - allow retry if meta.Status == StatusFailed { // Clean up the failed build directory so we can retry - digestDir := filepath.Join(m.paths.ImagesDir(), ref.Repository(), ref.DigestHex()) - os.RemoveAll(digestDir) + os.RemoveAll(digestDir(m.paths, ref.Repository(), ref.DigestHex())) // Fall through to re-queue the build } else { // We have this digest already (ready, pending, pulling, or converting). @@ -267,8 +270,7 @@ func (m *manager) ImportLocalImage(ctx context.Context, repo, reference, digest if meta, err := readMetadata(m.paths, ref.Repository(), ref.DigestHex()); err == nil { // Don't cache failed builds - allow retry if meta.Status == StatusFailed { - digestDir := filepath.Join(m.paths.ImagesDir(), ref.Repository(), ref.DigestHex()) - os.RemoveAll(digestDir) + os.RemoveAll(digestDir(m.paths, ref.Repository(), ref.DigestHex())) // Fall through to re-queue the build } else { // We have this digest already; last-pull-wins repoints the tag. @@ -465,10 +467,11 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef, credentials m.updateStatusByDigest(ref, StatusConverting, nil, buildID) - diskPath := digestPath(m.paths, ref.Repository(), ref.DigestHex()) + diskPath := resolveImageLayout(m.paths, ref.Repository(), ref.DigestHex()).disk + diskTempPath := filepath.Join(buildDir, ".rootfs-"+buildID+filepath.Ext(diskPath)) // Use default image format (erofs on Linux, ext4 on Darwin) convertStart := time.Now() - diskSize, err := ExportRootfs(tempDir, diskPath, DefaultImageFormat) + diskSize, err := ExportRootfs(tempDir, diskTempPath, DefaultImageFormat) m.recordImageBuildPhase(ctx, ref.Digest(), "filesystem_export", time.Since(convertStart), phaseStatus(err), "not_applicable") if err != nil { m.updateStatusByDigest(ref, StatusFailed, fmt.Errorf("convert to %s: %w", DefaultImageFormat, err), buildID) @@ -476,7 +479,7 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef, credentials } finalizeStart := time.Now() - err = m.finalizeImage(ref, result, diskSize, buildID) + err = m.finalizeImage(ref, result, diskSize, buildID, diskTempPath) m.recordImageBuildPhase(ctx, ref.Digest(), "finalize", time.Since(finalizeStart), phaseStatus(err), "not_applicable") if err != nil { if errors.Is(err, errStaleBuild) { @@ -489,7 +492,11 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef, credentials buildStatus = "success" } -func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize int64, buildID string) error { +func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize int64, buildID, diskTempPath string) error { + if diskTempPath != "" { + defer os.Remove(diskTempPath) + } + m.createMu.Lock() defer m.createMu.Unlock() @@ -499,6 +506,15 @@ func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize i return errStaleBuild } + if diskTempPath != "" { + finalDiskPath := resolveImageLayout(m.paths, ref.Repository(), ref.DigestHex()).disk + if err := installAtomically(finalDiskPath, func(path string) error { + return os.Rename(diskTempPath, path) + }); err != nil { + return fmt.Errorf("install image disk: %w", err) + } + } + // The pulled image config is the source of truth for the platform. var requestedPlatform string if meta.Request != nil { @@ -660,6 +676,7 @@ func (m *manager) GetImage(ctx context.Context, name string) (*Image, error) { } img := meta.toImage() + img.Name = ref.String() if meta.Status == StatusPending { img.QueuePosition = m.queue.GetPosition(meta.Digest) @@ -668,6 +685,52 @@ func (m *manager) GetImage(ctx context.Context, name string) (*Image, error) { return img, nil } +// TagImage creates or updates target as a local tag for source without pulling +// the image again. Source may be a tag or digest reference; target must be a tag. +func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, error) { + sourceRef, err := ParseNormalizedRef(source) + if err != nil { + return nil, fmt.Errorf("%w: invalid source reference: %s", ErrInvalidName, err) + } + targetRef, err := ParseNormalizedRef(target) + if err != nil { + return nil, fmt.Errorf("%w: invalid target reference: %s", ErrInvalidName, err) + } + if targetRef.IsDigest() { + return nil, fmt.Errorf("%w: target must include a tag", ErrInvalidName) + } + m.createMu.Lock() + defer m.createMu.Unlock() + + digestHex := sourceRef.DigestHex() + if !sourceRef.IsDigest() { + digestHex, err = resolveTag(m.paths, sourceRef.Repository(), sourceRef.Tag()) + if err != nil { + return nil, err + } + } + + meta, err := readMetadata(m.paths, sourceRef.Repository(), digestHex) + if err != nil { + return nil, err + } + if meta.Status != StatusReady { + return nil, fmt.Errorf("%w: %s", ErrImageNotReady, meta.Status) + } + if sourceRef.Repository() != targetRef.Repository() { + if err := promoteImageToContent(m.paths, sourceRef.Repository(), digestHex, meta); err != nil { + return nil, fmt.Errorf("create image alias: %w", err) + } + } + if err := createTagSymlink(m.paths, targetRef.Repository(), targetRef.Tag(), digestHex); err != nil { + return nil, fmt.Errorf("create image tag: %w", err) + } + + img := *meta.toImage() + img.Name = targetRef.String() + return &img, nil +} + func (m *manager) DeleteImage(ctx context.Context, name string) error { // Parse and normalize the reference ref, err := ParseNormalizedRef(name) @@ -690,7 +753,7 @@ func (m *manager) DeleteImage(ctx context.Context, name string) error { if err := deleteTagsForDigest(m.paths, repository, digestHex); err != nil { return err } - if err := deleteDigest(m.paths, repository, digestHex); err != nil { + if err := removeDigestIfUnreferenced(m.paths, repository, digestHex, false); err != nil { return err } m.refreshDiskUsageTotals() @@ -718,8 +781,7 @@ func (m *manager) DeleteImage(ctx context.Context, name string) error { } if count == 0 { - // Digest is orphaned, delete it - if err := deleteDigest(m.paths, repository, digestHex); err != nil { + if err := removeDigestIfUnreferenced(m.paths, repository, digestHex, true); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to delete orphaned digest %s: %v\n", digestHex, err) return nil } diff --git a/lib/images/manager_test.go b/lib/images/manager_test.go index 89d3fb4fe..cbe74b9c8 100644 --- a/lib/images/manager_test.go +++ b/lib/images/manager_test.go @@ -98,10 +98,12 @@ func TestCreateImage(t *testing.T) { require.NoError(t, err) require.NotEqual(t, 0, linkStat.Mode()&os.ModeSymlink, "should be a symlink") - // Verify symlink points to digest directory + // Verify symlink resolves to the expected digest in either layout. linkTarget, err := os.Readlink(linkPath) require.NoError(t, err) - require.Equal(t, digestHex, linkTarget, "symlink should point to digest") + resolvedDigest, err := resolveTag(paths.New(dataDir), ref.Repository(), ref.Tag()) + require.NoError(t, err) + require.Equal(t, digestHex, resolvedDigest, "symlink should resolve to the image digest") t.Logf("Tag symlink: %s -> %s", linkPath, linkTarget) } @@ -188,6 +190,138 @@ func TestTagFollowsLastPull(t *testing.T) { t.Run("emulated then native", func(t *testing.T) { check(t, emul, native) }) } +func TestTagImage(t *testing.T) { + p := paths.New(t.TempDir()) + mgr, err := NewManager(p, 1, nil) + require.NoError(t, err) + + const ( + repo = "docker.io/library/alpine" + digestRef = repo + "@sha256:029a752048e32e843bd6defe3841186fb8d19a28dae8ec287f433bb9d6d1ad85" + ) + seedReadyDigestOnlyImageMetadata(t, p, digestRef, nil) + + ctx := context.Background() + got, err := mgr.TagImage(ctx, digestRef, repo+":stable") + require.NoError(t, err) + require.Equal(t, repo+":stable", got.Name) + require.Equal(t, "sha256:029a752048e32e843bd6defe3841186fb8d19a28dae8ec287f433bb9d6d1ad85", got.Digest) + resolved, err := mgr.GetImage(ctx, repo+":stable") + require.NoError(t, err) + require.Equal(t, repo+":stable", resolved.Name) + require.Equal(t, StatusReady, resolved.Status) + require.Equal(t, got.Digest, resolved.Digest) + + aliased, err := mgr.TagImage(ctx, digestRef, "docker.io/library/busybox:stable") + require.NoError(t, err) + require.Equal(t, "docker.io/library/busybox:stable", aliased.Name) + digestHex := strings.TrimPrefix(got.Digest, "sha256:") + require.FileExists(t, p.ImageContentPath(digestHex)) + require.FileExists(t, p.ImageContentMetadata(digestHex)) + _, err = os.Lstat(p.ImageRepositoryTagSymlink("docker.io/library/busybox", "stable")) + require.NoError(t, err) + _, err = os.Stat(p.ImageDigestDir(repo, digestHex)) + require.ErrorIs(t, err, os.ErrNotExist) + resolvedAlias, err := mgr.GetImage(ctx, "docker.io/library/busybox:stable") + require.NoError(t, err) + require.Equal(t, "docker.io/library/busybox:stable", resolvedAlias.Name) + require.Equal(t, got.Digest, resolvedAlias.Digest) + + require.NoError(t, mgr.DeleteImage(ctx, digestRef)) + resolvedAlias, err = mgr.GetImage(ctx, "docker.io/library/busybox:stable") + require.NoError(t, err) + require.Equal(t, "docker.io/library/busybox:stable", resolvedAlias.Name) + require.Equal(t, got.Digest, resolvedAlias.Digest) + resolvedDigest, err := mgr.GetImage(ctx, digestRef) + require.NoError(t, err) + require.Equal(t, got.Digest, resolvedDigest.Digest) + + require.NoError(t, mgr.DeleteImage(ctx, "docker.io/library/busybox:stable")) + _, err = os.Stat(p.ImageContentDir(digestHex)) + require.NoError(t, err) + require.NoError(t, mgr.DeleteImage(ctx, digestRef)) + _, err = os.Stat(p.ImageContentDir(digestHex)) + require.ErrorIs(t, err, os.ErrNotExist) + + _, err = mgr.TagImage(ctx, digestRef, repo+"@sha256:029a752048e32e843bd6defe3841186fb8d19a28dae8ec287f433bb9d6d1ad85") + require.ErrorIs(t, err, ErrInvalidName) +} + +func TestTagImagePromotesOverIncompleteContent(t *testing.T) { + p := paths.New(t.TempDir()) + mgr, err := NewManager(p, 1, nil) + require.NoError(t, err) + + repository := "docker.io/library/alpine" + digest := "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + digestRef := repository + "@sha256:" + digest + seedReadyDigestOnlyImageMetadata(t, p, digestRef, nil) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: "registry.example.com/app:latest", + Digest: "sha256:" + digest, + Status: StatusFailed, + })) + + _, err = mgr.TagImage(context.Background(), digestRef, "registry.example.com/app:v1") + require.NoError(t, err) + resolved, err := mgr.GetImage(context.Background(), "registry.example.com/app:v1") + require.NoError(t, err) + require.Equal(t, StatusReady, resolved.Status) + require.Equal(t, "sha256:"+digest, resolved.Digest) + require.FileExists(t, p.ImageContentPath(digest)) + _, err = mgr.GetImage(context.Background(), digestRef) + require.NoError(t, err) +} + +func TestGetImageDigestUsesRequestedReference(t *testing.T) { + p := paths.New(t.TempDir()) + mgr, err := NewManager(p, 1, nil) + require.NoError(t, err) + + const ( + sourceRepo = "docker.io/library/alpine" + targetRepo = "registry.example.com/app" + digest = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ) + sourceRef := sourceRepo + "@sha256:" + digest + targetRef := targetRepo + "@sha256:" + digest + seedReadyDigestOnlyImageMetadata(t, p, sourceRef, nil) + + _, err = mgr.TagImage(context.Background(), sourceRef, targetRepo+":latest") + require.NoError(t, err) + + got, err := mgr.GetImage(context.Background(), targetRef) + require.NoError(t, err) + require.Equal(t, targetRef, got.Name) +} + +func TestDigestOnlyContentSurvivesAliasDeletion(t *testing.T) { + p := paths.New(t.TempDir()) + mgr, err := NewManager(p, 1, nil) + require.NoError(t, err) + + repository := "docker.io/library/alpine" + digest := "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: repository + "@sha256:" + digest, + Digest: "sha256:" + digest, + Status: StatusReady, + SizeBytes: 5, + CreatedAt: time.Now().UTC(), + })) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("rootfs"), 0o644)) + _, err = mgr.TagImage(context.Background(), repository+"@sha256:"+digest, "registry.example.com/myapp:v1") + require.NoError(t, err) + + require.NoError(t, mgr.DeleteImage(context.Background(), "registry.example.com/myapp:v1")) + _, err = mgr.GetImage(context.Background(), repository+"@sha256:"+digest) + require.NoError(t, err) + + require.NoError(t, mgr.DeleteImage(context.Background(), repository+"@sha256:"+digest)) + _, err = os.Stat(p.ImageContentDir(digest)) + require.ErrorIs(t, err, os.ErrNotExist) +} + func TestListImages(t *testing.T) { dataDir := t.TempDir() mgr, err := NewManager(paths.New(dataDir), 1, nil) @@ -395,6 +529,78 @@ func TestDeleteImagePreservesSharedDigest(t *testing.T) { require.True(t, os.IsNotExist(err), "digest directory should be deleted when last tag is removed") } +func TestDeleteDigestRemovesLegacyTreeWhenContentIsReferenced(t *testing.T) { + p := paths.New(t.TempDir()) + mgr, err := NewManager(p, 1, nil) + require.NoError(t, err) + + const ( + legacyRepo = "docker.io/library/alpine" + contentRepo = "registry.example.com/app" + digest = "1111111111111111111111111111111111111111111111111111111111111111" + ) + digestRef := legacyRepo + "@sha256:" + digest + seedReadyDigestOnlyImageMetadata(t, p, digestRef, nil) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: contentRepo + ":latest", + Digest: "sha256:" + digest, + Status: StatusReady, + })) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("content rootfs"), 0o644)) + require.NoError(t, createTagSymlink(p, contentRepo, "latest", digest)) + + require.NoError(t, mgr.DeleteImage(context.Background(), digestRef)) + _, err = os.Stat(p.ImageDigestDir(legacyRepo, digest)) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(p.ImageContentDir(digest)) + require.NoError(t, err) +} + +func TestDeleteDigestPreservesInFlightContentPull(t *testing.T) { + p := paths.New(t.TempDir()) + mgr, err := NewManager(p, 1, nil) + require.NoError(t, err) + + const ( + repository = "docker.io/library/alpine" + digest = "2222222222222222222222222222222222222222222222222222222222222222" + ) + digestRef := repository + "@sha256:" + digest + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusPulling, + BuildID: "in-flight-build", + })) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("partial rootfs"), 0o644)) + + require.NoError(t, mgr.DeleteImage(context.Background(), digestRef)) + require.FileExists(t, p.ImageContentMetadata(digest)) + require.FileExists(t, p.ImageContentPath(digest)) +} + +func TestDeleteRemovesFailedContentPull(t *testing.T) { + p := paths.New(t.TempDir()) + mgr, err := NewManager(p, 1, nil) + require.NoError(t, err) + + const ( + repository = "docker.io/library/alpine" + digest = "3333333333333333333333333333333333333333333333333333333333333333" + ) + digestRef := repository + "@sha256:" + digest + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: digestRef, + Digest: "sha256:" + digest, + Status: StatusFailed, + })) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("failed rootfs"), 0o644)) + + require.NoError(t, mgr.DeleteImage(context.Background(), digestRef)) + _, err = os.Stat(p.ImageContentDir(digest)) + require.ErrorIs(t, err, os.ErrNotExist) +} + func TestNormalizedRefParsing(t *testing.T) { tests := []struct { input string @@ -602,7 +808,7 @@ func TestImportLocalImageFromOCICache(t *testing.T) { t.Logf("Disk path verified: %s (%d bytes)", diskPath, diskStat.Size()) } -func TestDeleteAndRecreateDuringBuildTail(t *testing.T) { +func TestDeletePreservesInFlightBuild(t *testing.T) { origFormat := DefaultImageFormat DefaultImageFormat = FormatCpio defer func() { DefaultImageFormat = origFormat }() @@ -678,7 +884,7 @@ func TestDeleteAndRecreateDuringBuildTail(t *testing.T) { require.Equal(t, 1, *recreatedAgain.QueuePosition) latestMeta, err := readMetadata(p, repo, digestHex) require.NoError(t, err) - require.NotEqual(t, currentMeta.BuildID, latestMeta.BuildID) + require.Equal(t, currentMeta.BuildID, latestMeta.BuildID) currentMeta = latestMeta waitCtx, cancelWait := context.WithCancel(ctx) @@ -698,7 +904,7 @@ func TestDeleteAndRecreateDuringBuildTail(t *testing.T) { m.updateStatusByDigest(staleRef, StatusFailed, errors.New("stale build"), firstMeta.BuildID) staleResult, _, _, err := m.ociClient.extractOCIImageDetails(digestHex) require.NoError(t, err) - require.ErrorIs(t, m.finalizeImage(staleRef, &pullResult{Metadata: staleResult}, 1, firstMeta.BuildID), errStaleBuild) + require.ErrorIs(t, m.finalizeImage(staleRef, &pullResult{Metadata: staleResult}, 1, firstMeta.BuildID, ""), errStaleBuild) currentMeta, err = readMetadata(p, repo, digestHex) require.NoError(t, err) require.Equal(t, StatusPending, currentMeta.Status) diff --git a/lib/images/storage.go b/lib/images/storage.go index 8764400c1..e9912bfcd 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -77,16 +77,73 @@ func (m *imageMetadata) toImage() *Image { return img } -// digestDir returns the directory for a specific digest -// e.g., /var/lib/hypeman/images/docker.io/library/alpine/abc123def456... +// imageLayout contains the paths for one image layout. +type imageLayout struct { + dir string + metadata string + disk string + content bool +} + +// resolveImageLayout selects one layout for all operations on a digest. A +// complete legacy image remains authoritative while content is incomplete; +// once content metadata is ready, content becomes canonical even if its disk +// is missing so callers report the corruption instead of mixing layouts. +func resolveImageLayout(p *paths.Paths, repository, digestHex string) imageLayout { + legacy := imageLayout{ + dir: p.ImageDigestDir(repository, digestHex), + metadata: p.ImageMetadata(repository, digestHex), + disk: p.ImageDigestPath(repository, digestHex), + } + content := imageLayout{ + dir: p.ImageContentDir(digestHex), + metadata: p.ImageContentMetadata(digestHex), + disk: p.ImageContentPath(digestHex), + content: true, + } + + if legacyImageExists(p, repository, digestHex) { + contentStatus, contentOK := metadataStatus(content.metadata) + if !contentOK || contentStatus != StatusReady { + return legacy + } + } + if pathExists(content.metadata) || pathExists(content.disk) { + return content + } + if pathExists(legacy.metadata) { + return legacy + } + return content +} + +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// digestDir returns the directory for a specific digest, using the same layout +// selection as metadata and disk lookup. func digestDir(p *paths.Paths, repository, digestHex string) string { - return p.ImageDigestDir(repository, digestHex) + return resolveImageLayout(p, repository, digestHex).dir } -// digestPath returns the path to the rootfs disk file for a digest -// Uses .erofs on Linux (compressed) or .ext4 on Darwin (VZ kernel lacks erofs support) -func digestPath(p *paths.Paths, repository, digestHex string) string { - return p.ImageDigestPath(repository, digestHex) +func legacyImageExists(p *paths.Paths, repository, digestHex string) bool { + _, metadataErr := os.Stat(p.ImageMetadata(repository, digestHex)) + _, diskErr := os.Stat(p.ImageDigestPath(repository, digestHex)) + return metadataErr == nil && diskErr == nil +} + +func metadataStatus(path string) (string, bool) { + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + var meta imageMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return "", false + } + return meta.Status, true } // GetDiskPath returns the filesystem path to an image's rootfs disk file (public for instances manager) @@ -100,25 +157,34 @@ func GetDiskPath(p *paths.Paths, imageName string, digest string) (string, error // Extract digest hex (remove "sha256:" prefix) digestHex := strings.TrimPrefix(digest, "sha256:") - return digestPath(p, ref.Repository(), digestHex), nil + return resolveImageLayout(p, ref.Repository(), digestHex).disk, nil +} + +func digestPath(p *paths.Paths, repository, digestHex string) string { + return resolveImageLayout(p, repository, digestHex).disk } -// metadataPath returns the path to metadata.json for a digest func metadataPath(p *paths.Paths, repository, digestHex string) string { - return p.ImageMetadata(repository, digestHex) + return resolveImageLayout(p, repository, digestHex).metadata } -// tagSymlinkPath returns the path to a tag symlink -// e.g., /var/lib/hypeman/images/docker.io/library/alpine/latest +// tagSymlinkPath returns the path to a tag symlink in the active layout. func tagSymlinkPath(p *paths.Paths, repository, tag string) string { + newPath := p.ImageRepositoryTagSymlink(repository, tag) + if _, err := os.Lstat(newPath); err == nil { + return newPath + } return p.ImageTagSymlink(repository, tag) } -// writeMetadata writes metadata for a digest +// writeMetadata writes metadata for a digest. func writeMetadata(p *paths.Paths, repository, digestHex string, meta *imageMetadata) error { - dir := digestDir(p, repository, digestHex) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("create digest directory: %w", err) + return writeMetadataFile(resolveImageLayout(p, repository, digestHex).metadata, meta) +} + +func writeMetadataFile(path string, meta *imageMetadata) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create metadata directory: %w", err) } data, err := json.MarshalIndent(meta, "", " ") @@ -126,23 +192,32 @@ func writeMetadata(p *paths.Paths, repository, digestHex string, meta *imageMeta return fmt.Errorf("marshal metadata: %w", err) } - tempPath := metadataPath(p, repository, digestHex) + ".tmp" + tempPath := path + ".tmp" if err := os.WriteFile(tempPath, data, 0644); err != nil { return fmt.Errorf("write temp metadata: %w", err) } - - finalPath := metadataPath(p, repository, digestHex) - if err := os.Rename(tempPath, finalPath); err != nil { - os.Remove(tempPath) + if err := os.Rename(tempPath, path); err != nil { + _ = os.Remove(tempPath) return fmt.Errorf("rename metadata: %w", err) } - return nil } -// readMetadata reads metadata for a digest func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata, error) { - path := metadataPath(p, repository, digestHex) + return readMetadataAt(resolveImageLayout(p, repository, digestHex)) +} + +func readContentMetadata(p *paths.Paths, digestHex string) (*imageMetadata, error) { + return readMetadataAt(imageLayout{ + dir: p.ImageContentDir(digestHex), + metadata: p.ImageContentMetadata(digestHex), + disk: p.ImageContentPath(digestHex), + content: true, + }) +} + +func readMetadataAt(layout imageLayout) (*imageMetadata, error) { + path := layout.metadata data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -157,10 +232,9 @@ func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata, } if meta.Status == StatusReady { - diskPath := digestPath(p, repository, digestHex) - if _, err := os.Stat(diskPath); err != nil { + if _, err := os.Stat(layout.disk); err != nil { if os.IsNotExist(err) { - return nil, fmt.Errorf("disk image missing: %s", diskPath) + return nil, fmt.Errorf("disk image missing: %s", layout.disk) } return nil, fmt.Errorf("stat disk image: %w", err) } @@ -169,6 +243,148 @@ func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata, return &meta, nil } +func promoteImageToContent(p *paths.Paths, sourceRepository, digestHex string, sourceMeta *imageMetadata) error { + contentReady := false + if contentMeta, err := readContentMetadata(p, digestHex); err == nil { + contentReady = contentMeta.Status == StatusReady + } + if !contentReady { + sourceLayout := resolveImageLayout(p, sourceRepository, digestHex) + sourceDiskPath := sourceLayout.disk + if _, err := os.Stat(sourceDiskPath); err != nil { + return fmt.Errorf("stat source disk: %w", err) + } + if err := os.MkdirAll(p.ImageContentDir(digestHex), 0755); err != nil { + return fmt.Errorf("create content directory: %w", err) + } + contentMeta := *sourceMeta + contentMeta.Status = StatusConverting + if err := writeMetadataFile(p.ImageContentMetadata(digestHex), &contentMeta); err != nil { + return fmt.Errorf("write content metadata: %w", err) + } + if err := installAtomically(p.ImageContentPath(digestHex), func(path string) error { + return os.Link(sourceDiskPath, path) + }); err != nil { + return fmt.Errorf("link source disk: %w", err) + } + if err := writeMetadataFile(p.ImageContentMetadata(digestHex), sourceMeta); err != nil { + return fmt.Errorf("finalize content metadata: %w", err) + } + } + + // A legacy source may still have tags pointing at its repository-local + // digest directory. Move those references to the shared content before + // removing the duplicate legacy tree. + legacyDir := p.ImageDigestDir(sourceRepository, digestHex) + if _, err := os.Stat(legacyDir); err == nil { + tags, err := listTags(p, sourceRepository) + if err != nil { + return err + } + staged := make([]stagedTagSymlink, 0, len(tags)) + cleanupStaged := func() { + for _, ref := range staged { + _ = os.RemoveAll(ref.tempDir) + } + } + defer cleanupStaged() + for _, tag := range tags { + target, err := resolveTag(p, sourceRepository, tag) + if err != nil || target != digestHex { + continue + } + ref, err := stageTagSymlink(p, sourceRepository, tag, digestHex) + if err != nil { + return fmt.Errorf("stage legacy tag %s: %w", tag, err) + } + staged = append(staged, ref) + } + for _, ref := range staged { + if err := os.Rename(ref.tempPath, ref.linkPath); err != nil { + return fmt.Errorf("promote legacy tag: %w", err) + } + } + for _, ref := range staged { + if err := removeStaleTagSymlink(p, &ref); err != nil { + return err + } + } + if err := os.RemoveAll(legacyDir); err != nil { + return fmt.Errorf("remove legacy digest directory: %w", err) + } + } + + return nil +} + +func installAtomically(path string, install func(string) error) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + tempDir, err := os.MkdirTemp(filepath.Dir(path), ".install-*") + if err != nil { + return err + } + defer os.RemoveAll(tempDir) + tempPath := filepath.Join(tempDir, filepath.Base(path)) + if err := install(tempPath); err != nil { + return err + } + return os.Rename(tempPath, path) +} + +type stagedTagSymlink struct { + repository string + tag string + linkPath string + tempDir string + tempPath string +} + +func stageTagSymlink(p *paths.Paths, repository, tag, digestHex string) (stagedTagSymlink, error) { + layout := resolveImageLayout(p, repository, digestHex) + linkPath := p.ImageTagSymlink(repository, tag) + targetPath := digestHex // Relative path (just the digest hex) + if layout.content { + linkPath = p.ImageRepositoryTagSymlink(repository, tag) + var err error + targetPath, err = filepath.Rel(filepath.Dir(linkPath), layout.dir) + if err != nil { + return stagedTagSymlink{}, fmt.Errorf("calculate content symlink target: %w", err) + } + } + if err := os.MkdirAll(filepath.Dir(linkPath), 0755); err != nil { + return stagedTagSymlink{}, fmt.Errorf("create parent directory: %w", err) + } + tempDir, err := os.MkdirTemp(filepath.Dir(linkPath), ".tag-stage-*") + if err != nil { + return stagedTagSymlink{}, fmt.Errorf("create temporary tag directory: %w", err) + } + tempPath := filepath.Join(tempDir, filepath.Base(linkPath)) + if err := os.Symlink(targetPath, tempPath); err != nil { + _ = os.RemoveAll(tempDir) + return stagedTagSymlink{}, fmt.Errorf("create temporary tag symlink: %w", err) + } + return stagedTagSymlink{ + repository: repository, + tag: tag, + linkPath: linkPath, + tempDir: tempDir, + tempPath: tempPath, + }, nil +} + +func removeStaleTagSymlink(p *paths.Paths, ref *stagedTagSymlink) error { + stalePath := p.ImageTagSymlink(ref.repository, ref.tag) + if stalePath == ref.linkPath { + stalePath = p.ImageRepositoryTagSymlink(ref.repository, ref.tag) + } + if err := os.Remove(stalePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove stale tag symlink: %w", err) + } + return nil +} + // createTagSymlink creates or updates a tag symlink to point to a digest (only // if the digest dir exists and the build is ready). // @@ -179,22 +395,19 @@ func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata, // amd64) and was non-recoverable. Always repointing is symmetric and matches // Docker; callers repoint unconditionally on a ready digest. func createTagSymlink(p *paths.Paths, repository, tag, digestHex string) error { - linkPath := tagSymlinkPath(p, repository, tag) - targetPath := digestHex // Relative path (just the digest hex) - - // Ensure parent directory exists - if err := os.MkdirAll(filepath.Dir(linkPath), 0755); err != nil { - return fmt.Errorf("create parent directory: %w", err) + ref, err := stageTagSymlink(p, repository, tag, digestHex) + if err != nil { + return fmt.Errorf("stage tag symlink: %w", err) } - - // Remove existing symlink if present - os.Remove(linkPath) - - // Create new symlink - if err := os.Symlink(targetPath, linkPath); err != nil { - return fmt.Errorf("create symlink: %w", err) + if err := os.Rename(ref.tempPath, ref.linkPath); err != nil { + _ = os.RemoveAll(ref.tempDir) + return fmt.Errorf("install tag symlink: %w", err) } - + if err := removeStaleTagSymlink(p, &ref); err != nil { + _ = os.RemoveAll(ref.tempDir) + return err + } + _ = os.RemoveAll(ref.tempDir) return nil } @@ -211,54 +424,87 @@ func resolveTag(p *paths.Paths, repository, tag string) (string, error) { return "", fmt.Errorf("read symlink: %w", err) } - // Validate it's just a digest hex (not an absolute path) - if filepath.IsAbs(target) || strings.Contains(target, "/") { + // Legacy links contain only the digest. New links point relatively into the + // shared content directory; validate that they resolve to that digest only. + if filepath.IsAbs(target) { + return "", fmt.Errorf("invalid symlink target: %s", target) + } + digestHex := filepath.Base(target) + if digestHex == "." || digestHex == string(filepath.Separator) { return "", fmt.Errorf("invalid symlink target: %s", target) } + if target != digestHex { + resolved := filepath.Clean(filepath.Join(filepath.Dir(linkPath), target)) + if resolved != filepath.Clean(p.ImageContentDir(digestHex)) { + return "", fmt.Errorf("invalid symlink target: %s", target) + } + } - return target, nil + return digestHex, nil } // listTags returns all tags for a repository func listTags(p *paths.Paths, repository string) ([]string, error) { - repoDir := p.ImageRepositoryDir(repository) - - entries, err := os.ReadDir(repoDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("read repository directory: %w", err) - } - - var tags []string - for _, entry := range entries { - // Check if it's a symlink - info, err := os.Lstat(filepath.Join(repoDir, entry.Name())) + dirs := []string{filepath.Join(p.ImageRepositoriesDir(), repository), p.ImageRepositoryDir(repository)} + seen := make(map[string]struct{}) + tags := make([]string, 0) + for _, repoDir := range dirs { + entries, err := os.ReadDir(repoDir) if err != nil { - continue + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("read repository directory: %w", err) } - - if info.Mode()&os.ModeSymlink != 0 { + for _, entry := range entries { + path := filepath.Join(repoDir, entry.Name()) + info, err := os.Lstat(path) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + continue + } + if _, ok := seen[entry.Name()]; ok { + continue + } + seen[entry.Name()] = struct{}{} tags = append(tags, entry.Name()) } } - return tags, nil } -// listAllMetadata returns one metadata record per digest across all repositories. +// listAllMetadata returns one metadata record per tag across all repositories. // Tagged images are discovered through tag symlinks, and digest-only images are // discovered directly from their metadata.json files. func listAllMetadata(p *paths.Paths) ([]*imageMetadata, error) { imagesDir := p.ImagesDir() seen := make(map[string]struct{}) + contentDigests := make(map[string]struct{}) + taggedDigests := make(map[string]struct{}) + taggedContentDigests := make(map[string]struct{}) + metadataRefs := make([]metadataReference, 0) + seenMetadataRefs := make(map[string]struct{}) metas := make([]*imageMetadata, 0) err := filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // Skip errors } + rel, err := filepath.Rel(imagesDir, path) + if err != nil { + return nil + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) > 0 && parts[0] == "content" { + if info.IsDir() { + return nil + } + if info.Name() != "metadata.json" { + return nil + } + digestHex := filepath.Base(filepath.Dir(path)) + contentDigests[digestHex] = struct{}{} + return nil + } switch { case info.Mode()&os.ModeSymlink != 0: @@ -266,21 +512,29 @@ func listAllMetadata(p *paths.Paths) ([]*imageMetadata, error) { if err != nil { return nil // Skip invalid symlinks } - - repository, err := filepath.Rel(imagesDir, filepath.Dir(path)) - if err != nil { - return nil + digestHex = filepath.Base(digestHex) + + var repository, tag string + if len(parts) > 1 && parts[0] == "repositories" { + repository = filepath.Join(parts[1 : len(parts)-1]...) + tag = parts[len(parts)-1] + } else { + repository = filepath.Dir(rel) + tag = filepath.Base(path) } - - return appendMetadataIfNew(p, repository, digestHex, seen, &metas) + return appendMetadataForTag(p, repository, tag, digestHex, seen, taggedDigests, taggedContentDigests, &metas) case !info.IsDir() && info.Name() == "metadata.json": digestHex := filepath.Base(filepath.Dir(path)) repository, err := filepath.Rel(imagesDir, filepath.Dir(filepath.Dir(path))) if err != nil { return nil } - - return appendMetadataIfNew(p, repository, digestHex, seen, &metas) + key := repository + "@" + digestHex + if _, ok := seenMetadataRefs[key]; !ok { + seenMetadataRefs[key] = struct{}{} + metadataRefs = append(metadataRefs, metadataReference{repository: repository, digestHex: digestHex}) + } + return nil default: return nil } @@ -289,10 +543,36 @@ func listAllMetadata(p *paths.Paths) ([]*imageMetadata, error) { if err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("walk images directory: %w", err) } + seenDigests := make(map[string]struct{}, len(metas)) + for _, ref := range metadataRefs { + if _, tagged := taggedDigests[ref.repository+"@"+ref.digestHex]; tagged { + continue + } + if err := appendMetadataIfNew(p, ref.repository, ref.digestHex, seen, &metas); err != nil { + return nil, err + } + seenDigests[ref.digestHex] = struct{}{} + } + for digestHex := range contentDigests { + if _, found := taggedContentDigests[digestHex]; found { + continue + } + if _, found := seenDigests[digestHex]; found { + continue + } + if err := appendContentMetadataIfNew(p, digestHex, seen, &metas); err != nil { + return nil, err + } + } return metas, nil } +type metadataReference struct { + repository string + digestHex string +} + func appendMetadataIfNew(p *paths.Paths, repository, digestHex string, seen map[string]struct{}, metas *[]*imageMetadata) error { key := repository + "@" + digestHex if _, ok := seen[key]; ok { @@ -309,6 +589,37 @@ func appendMetadataIfNew(p *paths.Paths, repository, digestHex string, seen map[ return nil } +func appendContentMetadataIfNew(p *paths.Paths, digestHex string, seen map[string]struct{}, metas *[]*imageMetadata) error { + key := "@" + digestHex + if _, ok := seen[key]; ok { + return nil + } + meta, err := readContentMetadata(p, digestHex) + if err != nil { + return nil + } + seen[key] = struct{}{} + *metas = append(*metas, meta) + return nil +} + +func appendMetadataForTag(p *paths.Paths, repository, tag, digestHex string, seen, taggedDigests, taggedContentDigests map[string]struct{}, metas *[]*imageMetadata) error { + tagKey := repository + ":" + tag + if _, ok := seen[tagKey]; ok { + return nil + } + meta, err := readMetadata(p, repository, digestHex) + if err != nil { + return nil + } + meta.Name = repository + ":" + tag + seen[tagKey] = struct{}{} + taggedDigests[repository+"@"+digestHex] = struct{}{} + taggedContentDigests[digestHex] = struct{}{} + *metas = append(*metas, meta) + return nil +} + // digestExists checks if a digest directory exists func digestExists(p *paths.Paths, repository, digestHex string) bool { dir := digestDir(p, repository, digestHex) @@ -316,23 +627,29 @@ func digestExists(p *paths.Paths, repository, digestHex string) bool { return err == nil } -// deleteTag removes a tag symlink (does not delete the digest directory) +// deleteTag removes a tag symlink in either supported layout (does not delete +// the digest directory). func deleteTag(p *paths.Paths, repository, tag string) error { - linkPath := tagSymlinkPath(p, repository, tag) - - // Check if symlink exists - if _, err := os.Lstat(linkPath); err != nil { - if os.IsNotExist(err) { - return ErrNotFound + pathsToRemove := []string{ + p.ImageRepositoryTagSymlink(repository, tag), + p.ImageTagSymlink(repository, tag), + } + found := false + for _, linkPath := range pathsToRemove { + if _, err := os.Lstat(linkPath); err != nil { + if os.IsNotExist(err) { + continue + } + return fmt.Errorf("stat symlink: %w", err) + } + found = true + if err := os.Remove(linkPath); err != nil { + return fmt.Errorf("remove symlink: %w", err) } - return fmt.Errorf("stat symlink: %w", err) } - - // Remove symlink - if err := os.Remove(linkPath); err != nil { - return fmt.Errorf("remove symlink: %w", err) + if !found { + return ErrNotFound } - return nil } @@ -378,11 +695,94 @@ func deleteTagsForDigest(p *paths.Paths, repository, digestHex string) error { return 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) +func contentTagsForDigest(p *paths.Paths, digestHex string) ([]string, error) { + root := p.ImageRepositoriesDir() + refs := make([]string, 0) + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || info.Mode()&os.ModeSymlink == 0 { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return nil + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) < 2 { + return nil + } + repository := filepath.Join(parts[:len(parts)-1]...) + tag := parts[len(parts)-1] + target, err := resolveTag(p, repository, tag) + if err == nil && target == digestHex { + refs = append(refs, path) + } + return nil + }) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("walk content tags: %w", err) + } + return refs, nil +} + +func contentMetadataStatus(p *paths.Paths, digestHex string) (string, error) { + data, err := os.ReadFile(p.ImageContentMetadata(digestHex)) + if err != nil { + return "", err + } + var meta imageMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return "", fmt.Errorf("unmarshal content metadata: %w", err) + } + return meta.Status, nil +} + +func contentPullInProgress(p *paths.Paths, digestHex string) bool { + status, err := contentMetadataStatus(p, digestHex) + if err != nil { + return false + } + switch status { + case StatusPending, StatusPulling, StatusConverting: + return true + default: + return false + } +} + +func contentIsDigestOnly(p *paths.Paths, digestHex string) bool { + meta, err := readContentMetadata(p, digestHex) + if err != nil { + return false + } + ref, err := ParseNormalizedRef(meta.Name) + return err == nil && ref.IsDigest() +} + +// removeDigestIfUnreferenced removes the repository-local legacy tree and +// removes shared content only when no tag or active pull still references it. +// Digest-only content is retained when removing a tag, but an explicit digest +// deletion removes it. +func removeDigestIfUnreferenced(p *paths.Paths, repository, digestHex string, preserveDigestOnly bool) error { + if err := os.RemoveAll(p.ImageDigestDir(repository, digestHex)); err != nil { + return fmt.Errorf("remove legacy digest directory: %w", err) + } + + contentDir := p.ImageContentDir(digestHex) + if _, err := os.Stat(contentDir); os.IsNotExist(err) { + return nil + } else if err != nil { + return fmt.Errorf("stat content digest directory: %w", err) + } + + refs, err := contentTagsForDigest(p, digestHex) + if err != nil { + return err + } + if len(refs) > 0 || contentPullInProgress(p, digestHex) || (preserveDigestOnly && contentIsDigestOnly(p, digestHex)) { + return nil + } + if err := os.RemoveAll(contentDir); err != nil { + return fmt.Errorf("remove content digest directory: %w", err) } return nil } diff --git a/lib/images/storage_test.go b/lib/images/storage_test.go index f8ec44dcd..c4a8ccf17 100644 --- a/lib/images/storage_test.go +++ b/lib/images/storage_test.go @@ -1,12 +1,180 @@ package images import ( + "os" + "path/filepath" "testing" "time" + "github.com/kernel/hypeman/lib/paths" "github.com/stretchr/testify/require" ) +func TestDeleteTagRemovesBothLayoutReferences(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + tag := "latest" + digest := "abababababababababababababababababababababababababababababababab" + legacyPath := p.ImageTagSymlink(repository, tag) + contentPath := p.ImageRepositoryTagSymlink(repository, tag) + require.NoError(t, os.MkdirAll(filepath.Dir(legacyPath), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Dir(contentPath), 0o755)) + require.NoError(t, os.Symlink(digest, legacyPath)) + target, err := filepath.Rel(filepath.Dir(contentPath), p.ImageContentDir(digest)) + require.NoError(t, err) + require.NoError(t, os.Symlink(target, contentPath)) + + require.NoError(t, deleteTag(p, repository, tag)) + _, err = os.Lstat(legacyPath) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Lstat(contentPath) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestLegacyImageIsNotShadowedByContentMetadata(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + legacyMeta := &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusReady, + } + legacyDir := p.ImageDigestDir(repository, digest) + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digest), legacyMeta)) + require.NoError(t, os.WriteFile(p.ImageDigestPath(repository, digest), []byte("legacy rootfs"), 0o644)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: "docker.io/library/busybox:latest", + Digest: "sha256:" + digest, + Status: StatusFailed, + })) + + meta, err := readMetadata(p, repository, digest) + require.NoError(t, err) + require.Equal(t, StatusReady, meta.Status) + require.Equal(t, p.ImageDigestPath(repository, digest), digestPath(p, repository, digest)) +} + +func TestListAllMetadataDeduplicatesDualLayouts(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "9999999999999999999999999999999999999999999999999999999999999999" + meta := &imageMetadata{ + Name: repository + "@sha256:" + digest, + Digest: "sha256:" + digest, + Status: StatusReady, + } + + require.NoError(t, os.MkdirAll(p.ImageDigestDir(repository, digest), 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digest), meta)) + require.NoError(t, os.WriteFile(p.ImageDigestPath(repository, digest), []byte("legacy rootfs"), 0o644)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), meta)) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("content rootfs"), 0o644)) + + metas, err := listAllMetadata(p) + require.NoError(t, err) + require.Len(t, metas, 1) + require.Equal(t, meta.Digest, metas[0].Digest) +} + +func TestFailedLegacyImageUsesReadyContent(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + legacyDir := p.ImageDigestDir(repository, digest) + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digest), &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusFailed, + })) + require.NoError(t, os.WriteFile(p.ImageDigestPath(repository, digest), []byte("legacy rootfs"), 0o644)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: "registry.example.com/app:latest", + Digest: "sha256:" + digest, + Status: StatusReady, + SizeBytes: 13, + })) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("content rootfs"), 0o644)) + + meta, err := readMetadata(p, repository, digest) + require.NoError(t, err) + require.Equal(t, StatusReady, meta.Status) + require.Equal(t, p.ImageContentDir(digest), digestDir(p, repository, digest)) + require.Equal(t, p.ImageContentPath(digest), digestPath(p, repository, digest)) +} + +func TestReadyContentDoesNotFallBackToLegacyDisk(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + legacyDir := p.ImageDigestDir(repository, digest) + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digest), &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusReady, + })) + require.NoError(t, os.WriteFile(p.ImageDigestPath(repository, digest), []byte("legacy rootfs"), 0o644)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusReady, + })) + + require.Equal(t, p.ImageContentMetadata(digest), metadataPath(p, repository, digest)) + require.Equal(t, p.ImageContentPath(digest), digestPath(p, repository, digest)) + _, err := readMetadata(p, repository, digest) + require.ErrorContains(t, err, "disk image missing") +} + +func TestWriteMetadataUsesContentWhenLegacyDirectoryIsEmpty(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + require.NoError(t, os.MkdirAll(p.ImageDigestDir(repository, digest), 0o755)) + require.NoError(t, writeMetadata(p, repository, digest, &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusPending, + })) + require.FileExists(t, p.ImageContentMetadata(digest)) + _, err := os.Stat(p.ImageMetadata(repository, digest)) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestListAllMetadataContentLayout(t *testing.T) { + p := paths.New(t.TempDir()) + digest := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + meta := &imageMetadata{ + Name: "docker.io/library/alpine:latest", + Digest: "sha256:" + digest, + Status: StatusReady, + SizeBytes: 5, + CreatedAt: time.Now().UTC(), + } + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), meta)) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("rootfs"), 0o644)) + linkPath := p.ImageRepositoryTagSymlink("docker.io/library/alpine", "latest") + require.NoError(t, os.MkdirAll(filepath.Dir(linkPath), 0o755)) + target, err := filepath.Rel(filepath.Dir(linkPath), p.ImageContentDir(digest)) + require.NoError(t, err) + require.NoError(t, os.Symlink(target, linkPath)) + require.NoError(t, createTagSymlink(p, "docker.io/library/alpine", "stable", digest)) + _, err = os.Lstat(p.ImageRepositoryTagSymlink("docker.io/library/alpine", "stable")) + require.NoError(t, err) + + metas, err := listAllMetadata(p) + require.NoError(t, err) + require.Len(t, metas, 2) + names := []string{metas[0].Name, metas[1].Name} + require.ElementsMatch(t, []string{ + "docker.io/library/alpine:latest", + "docker.io/library/alpine:stable", + }, names) +} + func TestImageMetadataToImage_ClonesMetadata(t *testing.T) { createdAt := time.Now().UTC().Truncate(time.Second) source := &imageMetadata{ diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index 52ab3b2c0..3ddf3cb84 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -1832,6 +1832,12 @@ type StandbyInstanceRequest struct { CompressionDelay *string `json:"compression_delay,omitempty"` } +// TagImageRequest defines model for TagImageRequest. +type TagImageRequest struct { + // Target Target OCI image reference with a tag. The local tag points to the source image without pulling it again. + Target string `json:"target"` +} + // Tags User-defined key-value tags. type Tags map[string]string @@ -2116,6 +2122,9 @@ type CreateDeviceJSONRequestBody = CreateDeviceRequest // CreateImageJSONRequestBody defines body for CreateImage for application/json ContentType. type CreateImageJSONRequestBody = CreateImageRequest +// TagImageJSONRequestBody defines body for TagImage for application/json ContentType. +type TagImageJSONRequestBody = TagImageRequest + // CreateIngressJSONRequestBody defines body for CreateIngress for application/json ContentType. type CreateIngressJSONRequestBody = CreateIngressRequest @@ -2300,6 +2309,11 @@ type ClientInterface interface { // GetImage request GetImage(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*http.Response, error) + // TagImageWithBody request with any body + TagImageWithBody(ctx context.Context, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + TagImage(ctx context.Context, name string, body TagImageJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListIngresses request ListIngresses(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2740,6 +2754,30 @@ func (c *Client) GetImage(ctx context.Context, name string, reqEditors ...Reques return c.Client.Do(req) } +func (c *Client) TagImageWithBody(ctx context.Context, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTagImageRequestWithBody(c.Server, name, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) TagImage(ctx context.Context, name string, body TagImageJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTagImageRequest(c.Server, name, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ListIngresses(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewListIngressesRequest(c.Server, params) if err != nil { @@ -4188,6 +4226,53 @@ func NewGetImageRequest(server string, name string) (*http.Request, error) { return req, nil } +// NewTagImageRequest calls the generic TagImage builder with application/json body +func NewTagImageRequest(server string, name string, body TagImageJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewTagImageRequestWithBody(server, name, "application/json", bodyReader) +} + +// NewTagImageRequestWithBody generates requests for TagImage with any type of body +func NewTagImageRequestWithBody(server string, name string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/images/%s/tag", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewListIngressesRequest generates requests for ListIngresses func NewListIngressesRequest(server string, params *ListIngressesParams) (*http.Request, error) { var err error @@ -6151,6 +6236,11 @@ type ClientWithResponsesInterface interface { // GetImageWithResponse request GetImageWithResponse(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*GetImageResponse, error) + // TagImageWithBodyWithResponse request with any body + TagImageWithBodyWithResponse(ctx context.Context, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TagImageResponse, error) + + TagImageWithResponse(ctx context.Context, name string, body TagImageJSONRequestBody, reqEditors ...RequestEditorFn) (*TagImageResponse, error) + // ListIngressesWithResponse request ListIngressesWithResponse(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*ListIngressesResponse, error) @@ -6818,6 +6908,32 @@ func (r GetImageResponse) StatusCode() int { return 0 } +type TagImageResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Image + JSON400 *Error + JSON404 *Error + JSON409 *Error + JSON500 *Error +} + +// Status returns HTTPResponse.Status +func (r TagImageResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r TagImageResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type ListIngressesResponse struct { Body []byte HTTPResponse *http.Response @@ -8049,6 +8165,23 @@ func (c *ClientWithResponses) GetImageWithResponse(ctx context.Context, name str return ParseGetImageResponse(rsp) } +// TagImageWithBodyWithResponse request with arbitrary body returning *TagImageResponse +func (c *ClientWithResponses) TagImageWithBodyWithResponse(ctx context.Context, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TagImageResponse, error) { + rsp, err := c.TagImageWithBody(ctx, name, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseTagImageResponse(rsp) +} + +func (c *ClientWithResponses) TagImageWithResponse(ctx context.Context, name string, body TagImageJSONRequestBody, reqEditors ...RequestEditorFn) (*TagImageResponse, error) { + rsp, err := c.TagImage(ctx, name, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseTagImageResponse(rsp) +} + // ListIngressesWithResponse request returning *ListIngressesResponse func (c *ClientWithResponses) ListIngressesWithResponse(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*ListIngressesResponse, error) { rsp, err := c.ListIngresses(ctx, params, reqEditors...) @@ -9447,6 +9580,60 @@ func ParseGetImageResponse(rsp *http.Response) (*GetImageResponse, error) { return response, nil } +// ParseTagImageResponse parses an HTTP response from a TagImageWithResponse call +func ParseTagImageResponse(rsp *http.Response) (*TagImageResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &TagImageResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Image + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseListIngressesResponse parses an HTTP response from a ListIngressesWithResponse call func ParseListIngressesResponse(rsp *http.Response) (*ListIngressesResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -11390,6 +11577,9 @@ type ServerInterface interface { // Get image details // (GET /images/{name}) GetImage(w http.ResponseWriter, r *http.Request, name string) + // Create or update a local image tag + // (POST /images/{name}/tag) + TagImage(w http.ResponseWriter, r *http.Request, name string) // List ingresses // (GET /ingresses) ListIngresses(w http.ResponseWriter, r *http.Request, params ListIngressesParams) @@ -11645,6 +11835,12 @@ func (_ Unimplemented) GetImage(w http.ResponseWriter, r *http.Request, name str w.WriteHeader(http.StatusNotImplemented) } +// Create or update a local image tag +// (POST /images/{name}/tag) +func (_ Unimplemented) TagImage(w http.ResponseWriter, r *http.Request, name string) { + w.WriteHeader(http.StatusNotImplemented) +} + // List ingresses // (GET /ingresses) func (_ Unimplemented) ListIngresses(w http.ResponseWriter, r *http.Request, params ListIngressesParams) { @@ -12487,6 +12683,37 @@ func (siw *ServerInterfaceWrapper) GetImage(w http.ResponseWriter, r *http.Reque handler.ServeHTTP(w, r) } +// TagImage operation middleware +func (siw *ServerInterfaceWrapper) TagImage(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "name" ------------- + var name string + + err = runtime.BindStyledParameterWithOptions("simple", "name", chi.URLParam(r, "name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "name", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.TagImage(w, r, name) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListIngresses operation middleware func (siw *ServerInterfaceWrapper) ListIngresses(w http.ResponseWriter, r *http.Request) { @@ -14043,6 +14270,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/images/{name}", wrapper.GetImage) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/images/{name}/tag", wrapper.TagImage) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/ingresses", wrapper.ListIngresses) }) @@ -15053,6 +15283,60 @@ func (response GetImage500JSONResponse) VisitGetImageResponse(w http.ResponseWri return json.NewEncoder(w).Encode(response) } +type TagImageRequestObject struct { + Name string `json:"name"` + Body *TagImageJSONRequestBody +} + +type TagImageResponseObject interface { + VisitTagImageResponse(w http.ResponseWriter) error +} + +type TagImage200JSONResponse Image + +func (response TagImage200JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type TagImage400JSONResponse Error + +func (response TagImage400JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type TagImage404JSONResponse Error + +func (response TagImage404JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type TagImage409JSONResponse Error + +func (response TagImage409JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + +type TagImage500JSONResponse Error + +func (response TagImage500JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type ListIngressesRequestObject struct { Params ListIngressesParams } @@ -16942,6 +17226,9 @@ type StrictServerInterface interface { // Get image details // (GET /images/{name}) GetImage(ctx context.Context, request GetImageRequestObject) (GetImageResponseObject, error) + // Create or update a local image tag + // (POST /images/{name}/tag) + TagImage(ctx context.Context, request TagImageRequestObject) (TagImageResponseObject, error) // List ingresses // (GET /ingresses) ListIngresses(ctx context.Context, request ListIngressesRequestObject) (ListIngressesResponseObject, error) @@ -17657,6 +17944,39 @@ func (sh *strictHandler) GetImage(w http.ResponseWriter, r *http.Request, name s } } +// TagImage operation middleware +func (sh *strictHandler) TagImage(w http.ResponseWriter, r *http.Request, name string) { + var request TagImageRequestObject + + request.Name = name + + var body TagImageJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.TagImage(ctx, request.(TagImageRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "TagImage") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(TagImageResponseObject); ok { + if err := validResponse.VisitTagImageResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // ListIngresses operation middleware func (sh *strictHandler) ListIngresses(w http.ResponseWriter, r *http.Request, params ListIngressesParams) { var request ListIngressesRequestObject @@ -19064,135 +19384,137 @@ var swaggerSpec = []string{ "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", - "eSdugOoBAA==", + "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", + "AQAA//+8ndH7sO8BAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/paths/paths.go b/lib/paths/paths.go index add23ff52..814dc1432 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -142,6 +142,35 @@ func (p *Paths) UFFDSessionsDir(versionKey string) string { // Image path methods +// ImageContentDir returns the directory for content-addressed image data. +func (p *Paths) ImageContentDir(digestHex string) string { + return filepath.Join(p.dataDir, "images", "content", digestHex) +} + +// ImageContentPath returns the path to a content-addressed rootfs disk file. +func (p *Paths) ImageContentPath(digestHex string) string { + ext := "erofs" + if runtime.GOOS == "darwin" { + ext = "ext4" + } + return filepath.Join(p.ImageContentDir(digestHex), "rootfs."+ext) +} + +// ImageContentMetadata returns the path to metadata for content-addressed image data. +func (p *Paths) ImageContentMetadata(digestHex string) string { + return filepath.Join(p.ImageContentDir(digestHex), "metadata.json") +} + +// ImageRepositoriesDir returns the root directory for repository tag references. +func (p *Paths) ImageRepositoriesDir() string { + return filepath.Join(p.dataDir, "images", "repositories") +} + +// ImageRepositoryTagSymlink returns the path to a tag reference in the new layout. +func (p *Paths) ImageRepositoryTagSymlink(repository, tag string) string { + return filepath.Join(p.ImageRepositoriesDir(), repository, tag) +} + // ImageDigestDir returns the directory for a specific image digest. func (p *Paths) ImageDigestDir(repository, digestHex string) string { return filepath.Join(p.dataDir, "images", repository, digestHex) diff --git a/lib/scopes/scopes.go b/lib/scopes/scopes.go index 4773437a0..9d078c1fb 100644 --- a/lib/scopes/scopes.go +++ b/lib/scopes/scopes.go @@ -229,10 +229,11 @@ var RouteScopes = map[string]Scope{ "POST /resources/memory/reclaim": ResourceWrite, // Images - "GET /images": ImageRead, - "POST /images": ImageWrite, - "DELETE /images/{name}": ImageDelete, - "GET /images/{name}": ImageRead, + "GET /images": ImageRead, + "POST /images": ImageWrite, + "POST /images/{name}/tag": ImageWrite, + "DELETE /images/{name}": ImageDelete, + "GET /images/{name}": ImageRead, // Pushes (outbound image pushes to remote registries) "POST /pushes": ImageWrite, diff --git a/lib/snapshot/testsupport/images.go b/lib/snapshot/testsupport/images.go index d033cd0ff..0608368c3 100644 --- a/lib/snapshot/testsupport/images.go +++ b/lib/snapshot/testsupport/images.go @@ -73,15 +73,19 @@ func EnsureImageReady(t *testing.T, ctx context.Context, p *paths.Paths, imageMa digestHex := strings.TrimPrefix(cached.Digest, "sha256:") require.NotEmpty(t, digestHex) - srcDigestDir := cachePaths.ImageDigestDir(ref.Repository(), digestHex) - dstDigestDir := p.ImageDigestDir(ref.Repository(), digestHex) + srcDiskPath, err := images.GetDiskPath(cachePaths, waitName, cached.Digest) + require.NoError(t, err) + srcDigestDir := filepath.Dir(srcDiskPath) + dstDigestDir := p.ImageContentDir(digestHex) require.NoError(t, copyDirWithHardlinks(srcDigestDir, dstDigestDir)) if ref.Tag() != "" { - linkPath := p.ImageTagSymlink(ref.Repository(), ref.Tag()) + linkPath := p.ImageRepositoryTagSymlink(ref.Repository(), ref.Tag()) + target, err := filepath.Rel(filepath.Dir(linkPath), dstDigestDir) + require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(linkPath), 0755)) _ = os.Remove(linkPath) - require.NoError(t, os.Symlink(digestHex, linkPath)) + require.NoError(t, os.Symlink(target, linkPath)) } reference := ref.Tag() diff --git a/openapi.yaml b/openapi.yaml index b117d2035..11ec8b0ae 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1266,6 +1266,15 @@ components: tags: $ref: "#/components/schemas/Tags" + TagImageRequest: + type: object + required: [target] + properties: + target: + type: string + description: Target OCI image reference with a tag. The local tag points to the source image without pulling it again. + example: docker.io/library/nginx:stable + Image: type: object required: [name, digest, status, created_at] @@ -2616,6 +2625,57 @@ paths: schema: $ref: "#/components/schemas/Error" + /images/{name}/tag: + post: + summary: Create or update a local image tag + operationId: tagImage + security: + - bearerAuth: [] + parameters: + - name: name + in: path + required: true + schema: + type: string + description: URL-encoded source image name or digest + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TagImageRequest" + responses: + 200: + description: Image tag created + content: + application/json: + schema: + $ref: "#/components/schemas/Image" + 400: + description: Invalid source or target image reference + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 404: + description: Source image not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 409: + description: Source image is not ready + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 500: + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /images/{name}: get: summary: Get image details diff --git a/stainless.yaml b/stainless.yaml index 0d95928b2..dbd40fbc9 100644 --- a/stainless.yaml +++ b/stainless.yaml @@ -97,11 +97,13 @@ resources: # [reference]: https://www.stainless.com/docs/reference/config#model models: image: "#/components/schemas/Image" + tag_image_request: "#/components/schemas/TagImageRequest" methods: list: get /images create: post /images get: get /images/{name} delete: delete /images/{name} + tag: post /images/{name}/tag instances: models: