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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 40 additions & 1 deletion cmd/api/api/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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{
Expand Down
89 changes: 89 additions & 0 deletions cmd/api/api/images_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions lib/builds/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 1 addition & 2 deletions lib/images/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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")
}
81 changes: 75 additions & 6 deletions lib/images/disk_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"syscall"
)

// totalReadyImageBytesFromMetadata sums ready image sizes directly from metadata.json files.
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Comment thread
cursor[bot] marked this conversation as resolved.
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)
Expand Down Expand Up @@ -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
}
42 changes: 42 additions & 0 deletions lib/images/disk_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions lib/images/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading