diff --git a/cmd/api/api/images.go b/cmd/api/api/images.go index dc003370d..e9f6defc8 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) { diff --git a/cmd/api/api/images_test.go b/cmd/api/api/images_test.go index bb13a9c02..2a5d02367 100644 --- a/cmd/api/api/images_test.go +++ b/cmd/api/api/images_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "testing" "time" @@ -37,6 +38,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() @@ -530,3 +540,130 @@ func seedReadyDigestOnlyImage(t *testing.T, svc *ApiService, imageRef string, im require.NoError(t, err) require.NoError(t, os.WriteFile(p.ImageMetadata(ref.Repository(), ref.DigestHex()), data, 0o644)) } + +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 TestTagImage_MissingBody(t *testing.T) { + t.Parallel() + + svc := &ApiService{ImageManager: tagImageErrManager{}} + resp, err := svc.TagImage(ctx(), oapi.TagImageRequestObject{ + Name: "docker.io/library/alpine:latest", + }) + require.NoError(t, err) + require.IsType(t, oapi.TagImage400JSONResponse{}, 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 "" + } +} + +// seedReadyContentImage writes a ready image into the shared content layout +// plus a repository tag reference, without pulling from a registry. +func seedReadyContentImage(t *testing.T, svc *ApiService, repository, tag, digestHex string) { + t.Helper() + + p := paths.New(svc.Config.DataDir) + contentDir := p.ImageContentDir(digestHex) + require.NoError(t, os.MkdirAll(contentDir, 0o755)) + require.NoError(t, os.WriteFile(p.ImageContentPath(digestHex), []byte("rootfs"), 0o644)) + + meta := struct { + Name string `json:"name"` + Digest string `json:"digest"` + Status string `json:"status"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt time.Time `json:"created_at"` + }{ + Name: repository + ":" + tag, + Digest: "sha256:" + digestHex, + Status: "ready", + SizeBytes: int64(len("rootfs")), + CreatedAt: time.Now().UTC(), + } + data, err := json.Marshal(meta) + require.NoError(t, err) + require.NoError(t, os.WriteFile(p.ImageContentMetadata(digestHex), data, 0o644)) + + linkPath := p.ImageRepositoryTagSymlink(repository, tag) + target, err := filepath.Rel(filepath.Dir(linkPath), contentDir) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(linkPath), 0o755)) + require.NoError(t, os.Symlink(target, linkPath)) +} + +func TestTagImage_Success(t *testing.T) { + t.Parallel() + svc := newTestService(t) + + const repository = "docker.io/library/alpine" + const digestHex = "ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34" + seedReadyContentImage(t, svc, repository, "latest", digestHex) + + resp, err := svc.TagImage(ctx(), oapi.TagImageRequestObject{ + Name: repository + ":latest", + Body: &oapi.TagImageRequest{Target: "registry.example/apps/alpine:v1"}, + }) + require.NoError(t, err) + okResp, ok := resp.(oapi.TagImage200JSONResponse) + require.True(t, ok, "expected 200 response, got %T", resp) + require.Equal(t, "registry.example/apps/alpine:v1", okResp.Name) + require.Equal(t, "sha256:"+digestHex, okResp.Digest) + + // The new tag resolves through GetImage. + img, err := svc.ImageManager.GetImage(ctx(), "registry.example/apps/alpine:v1") + require.NoError(t, err) + require.Equal(t, images.StatusReady, img.Status) +} diff --git a/cmd/api/main_test.go b/cmd/api/main_test.go index 34dbba428..ef88b586b 100644 --- a/cmd/api/main_test.go +++ b/cmd/api/main_test.go @@ -252,6 +252,50 @@ func (h *testImageHandler) GetImage(w http.ResponseWriter, r *http.Request, name } } +func TestTagImageRoute_URLDecodedName(t *testing.T) { + // Verifies the generated POST /images/{name}/tag route exists and that the + // URL-encoded source name is decoded before reaching the handler. + r := chi.NewRouter() + + var receivedName string + handler := &testTagImageHandler{ + tagImage: func(w http.ResponseWriter, r *http.Request, name string) { + receivedName = name + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"name":"` + name + `","digest":"sha256:test","status":"ready","created_at":"2026-01-01T00:00:00Z"}`)) + }, + } + + oapi.HandlerFromMux(handler, r) + + token, err := generateValidJWT("user-123") + require.NoError(t, err) + + body := bytes.NewReader([]byte(`{"target":"docker.io/library/alpine:stable"}`)) + req := httptest.NewRequest(http.MethodPost, "/images/docker.io%2Flibrary%2Falpine%3Alatest/tag", body) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + assert.Equal(t, "docker.io/library/alpine:latest", receivedName) +} + +// testTagImageHandler implements oapi.ServerInterface with just TagImage for testing +type testTagImageHandler struct { + oapi.Unimplemented + tagImage func(w http.ResponseWriter, r *http.Request, name string) +} + +func (h *testTagImageHandler) TagImage(w http.ResponseWriter, r *http.Request, name string) { + if h.tagImage != nil { + h.tagImage(w, r, name) + } +} + func TestImageNameWithSlashes_URLEncoding(t *testing.T) { // This test verifies how chi router handles image names with slashes. // Image names like "docker.io/onkernel/chromium-headful:latest" contain slashes diff --git a/lib/builds/manager_test.go b/lib/builds/manager_test.go index 7be58b0fc..a947090a2 100644 --- a/lib/builds/manager_test.go +++ b/lib/builds/manager_test.go @@ -369,6 +369,19 @@ func (m *mockImageManager) DeleteImage(ctx context.Context, name string) error { return nil } +func (m *mockImageManager) TagImage(ctx context.Context, source, target string) (*images.Image, error) { + m.mu.Lock() + defer m.mu.Unlock() + img, ok := m.images[source] + if !ok { + return nil, images.ErrNotFound + } + tagged := *img + tagged.Name = target + m.images[target] = &tagged + return &tagged, nil +} + func (m *mockImageManager) RecoverInterruptedBuilds() {} func (m *mockImageManager) TotalImageBytes(ctx context.Context) (int64, error) { diff --git a/lib/hypervisor/firecracker/binaries.go b/lib/hypervisor/firecracker/binaries.go index 52fedd90a..32389d13a 100644 --- a/lib/hypervisor/firecracker/binaries.go +++ b/lib/hypervisor/firecracker/binaries.go @@ -84,19 +84,23 @@ func resolveBinaryPath(p *paths.Paths, version string) (string, error) { return "", fmt.Errorf("paths are required when using embedded firecracker binaries") } - return extractBinary(p, parseVersion(version)) + parsedVersion, err := parseVersion(version) + if err != nil { + return "", err + } + return extractBinary(p, parsedVersion) } -func parseVersion(version string) Version { +func parseVersion(version string) (Version, error) { if version == "" { - return defaultVersion + return defaultVersion, nil } for _, supported := range supportedVersions { if version == string(supported) { - return supported + return supported, nil } } - return defaultVersion + return "", fmt.Errorf("unsupported firecracker version %q", version) } func extractBinary(p *paths.Paths, version Version) (string, error) { diff --git a/lib/hypervisor/firecracker/binaries_test.go b/lib/hypervisor/firecracker/binaries_test.go index 6683fed31..2ebfe936e 100644 --- a/lib/hypervisor/firecracker/binaries_test.go +++ b/lib/hypervisor/firecracker/binaries_test.go @@ -35,12 +35,21 @@ func TestResolveBinaryPathInvalidCustomPath(t *testing.T) { assert.Contains(t, err.Error(), "invalid firecracker custom binary path") } -func TestParseVersionFallback(t *testing.T) { - assert.Equal(t, V1_16_1, defaultVersion) - assert.Equal(t, defaultVersion, parseVersion("")) - assert.Equal(t, defaultVersion, parseVersion("unknown")) - assert.Equal(t, V1_14_2, parseVersion("v1.14.2")) - assert.Equal(t, V1_16_1, parseVersion("v1.16.1")) +func TestParseVersion(t *testing.T) { + version, err := parseVersion("") + require.NoError(t, err) + assert.Equal(t, defaultVersion, version) + + _, err = parseVersion("unknown") + require.ErrorContains(t, err, "unsupported firecracker version") + + version, err = parseVersion("v1.14.2") + require.NoError(t, err) + assert.Equal(t, V1_14_2, version) + + version, err = parseVersion("v1.16.1") + require.NoError(t, err) + assert.Equal(t, V1_16_1, version) } func TestResolveEmbeddedBinaryVersions(t *testing.T) { diff --git a/lib/images/credentials_test.go b/lib/images/credentials_test.go index 46212ba77..7f680b980 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(p.ImageContentMetadata(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..a9b78c8b4 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 pointing at an existing ready + // image, without pulling or reconverting. Source and target may be in + // different repositories. + TagImage(ctx context.Context, source, target string) (*Image, error) DeleteImage(ctx context.Context, name string) error RecoverInterruptedBuilds() // TotalImageBytes returns the total size of all ready images on disk. @@ -72,6 +76,7 @@ type manager struct { queue *queue.Queue createMu sync.Mutex diskUsageMu sync.RWMutex + tagGenerations map[string]uint64 diskUsageLoaded bool readyImageBytes int64 ociCacheBytes int64 @@ -99,6 +104,7 @@ func NewManager(p *paths.Paths, maxConcurrentBuilds int, meter metric.Meter) (Ma inflightPulls: make(map[string]*inflightImagePull), borrowedCredentialsTimeout: DefaultBorrowedCredentialsTimeout, readySubscribers: make(map[string][]chan StatusEvent), + tagGenerations: make(map[string]uint64), } // Initialize metrics if meter is provided @@ -111,9 +117,19 @@ func NewManager(p *paths.Paths, maxConcurrentBuilds int, meter metric.Meter) (Ma } m.RecoverInterruptedBuilds() + m.promoteLegacyImages() return m, nil } +// promoteLegacyImages migrates ready legacy per-repository images into the +// shared content layout so every repository referencing the same digest +// shares one rootfs copy. Promotion hardlinks the disk, repoints tags, and +// removes the legacy tree; failures only warn so a partial migration never +// blocks startup. +func (m *manager) promoteLegacyImages() { + promoteLegacyImages(m.paths) +} + func credentialsPresent(credentials *authn.AuthConfig) bool { return credentials != nil && (credentials.Username != "" || credentials.Password != "" || credentials.Auth != "" || credentials.IdentityToken != "" || credentials.RegistryToken != "") } @@ -205,35 +221,9 @@ func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Ima m.createMu.Lock() defer m.createMu.Unlock() - // Check if we already have this digest (deduplication) - if meta, err := readMetadata(m.paths, ref.Repository(), ref.DigestHex()); err == nil { - // 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) - // Fall through to re-queue the build - } else { - // We have this digest already (ready, pending, pulling, or converting). - // last-pull-wins: repoint the tag to it (see createTagSymlink). - if meta.Status == StatusReady { - if ref.Tag() != "" { - createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) - } - return meta.toImage(), nil - } - if !m.inflightCredentialsMatch(ref.Digest(), req.Credentials) { - return nil, fmt.Errorf("%w: retry after the current pull completes", ErrCredentialConflict) - } - img := meta.toImage() - if meta.Status == StatusPending { - img.QueuePosition = m.queue.GetPosition(meta.Digest) - } - return img, nil - } + if img, found, err := m.reuseExistingImage(ref, req.Credentials); found || err != nil { + return img, err } - - // Don't have this digest yet, queue the build return m.createAndQueueImage(ref, req, platform) } @@ -267,15 +257,25 @@ 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) + if err := removeDigestIfUnreferenced(m.paths, ref.Repository(), ref.DigestHex(), false); err != nil { + return nil, fmt.Errorf("remove failed image: %w", err) + } // Fall through to re-queue the build } else { - // We have this digest already; last-pull-wins repoints the tag. - if meta.Status == StatusReady && ref.Tag() != "" { - createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + if ref.Tag() != "" { + var tagErr error + if meta.Status == StatusReady { + m.nextTagGeneration(ref.Repository(), ref.Tag()) + tagErr = createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + } else { + tagErr = ensurePendingTag(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + } + if tagErr != nil { + return nil, fmt.Errorf("create image tag: %w", tagErr) + } } img := meta.toImage() + img.Name = ref.String() if meta.Status == StatusPending { img.QueuePosition = m.queue.GetPosition(meta.Digest) } @@ -287,6 +287,70 @@ func (m *manager) ImportLocalImage(ctx context.Context, repo, reference, digest return m.createAndQueueImage(ref, CreateImageRequest{Name: imageRef}, hostPlatform()) } +func (m *manager) reuseExistingImage(ref *ResolvedRef, credentials *authn.AuthConfig) (*Image, bool, error) { + meta, err := readMetadata(m.paths, ref.Repository(), ref.DigestHex()) + if err != nil { + return nil, false, nil + } + if meta.Status == StatusFailed { + if err := removeDigestIfUnreferenced(m.paths, ref.Repository(), ref.DigestHex(), false); err != nil { + return nil, true, fmt.Errorf("remove failed image: %w", err) + } + return nil, false, nil + } + if ref.Tag() != "" { + if meta.Status == StatusReady { + m.nextTagGeneration(ref.Repository(), ref.Tag()) + err = createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + } else { + err = ensurePendingTag(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + } + if err != nil { + return nil, true, fmt.Errorf("create image tag: %w", err) + } + } + img := meta.toImage() + img.Name = ref.String() + if meta.Status == StatusReady { + return img, true, nil + } + if !m.inflightCredentialsMatch(ref.Digest(), credentials) { + return nil, true, fmt.Errorf("%w: retry after the current pull completes", ErrCredentialConflict) + } + if meta.Status == StatusPending { + img.QueuePosition = m.queue.GetPosition(meta.Digest) + } + return img, true, nil +} + +func tagGenerationKey(repository, tag string) string { + return repository + ":" + tag +} + +func (m *manager) nextTagGeneration(repository, tag string) uint64 { + key := tagGenerationKey(repository, tag) + m.tagGenerations[key]++ + return m.tagGenerations[key] +} + +func (m *manager) restoreTagGenerations(metas []*imageMetadata) { + m.createMu.Lock() + defer m.createMu.Unlock() + for _, meta := range metas { + if meta.RequestedTag == "" { + continue + } + ref, err := ParseNormalizedRef(meta.Name) + if err != nil { + continue + } + key := tagGenerationKey(ref.Repository(), meta.RequestedTag) + if meta.TagGeneration > m.tagGenerations[key] { + m.tagGenerations[key] = meta.TagGeneration + } + } +} + func (m *manager) inflightCredentialsMatch(digest string, credentials *authn.AuthConfig) bool { inflight := m.inflightPulls[digest] var existingFingerprint [32]byte @@ -367,22 +431,42 @@ func (m *manager) createAndQueueImage(ref *ResolvedRef, req CreateImageRequest, // Record the requested platform optimistically while the build is pending; // buildImage overwrites it with the authoritative manifest platform once the // image config is pulled. + previousTagDigest := "" + var err error + if ref.Tag() != "" { + previousTagDigest, err = resolveTag(m.paths, ref.Repository(), ref.Tag()) + if err != nil && !errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("resolve existing image tag: %w", err) + } + } + tagGeneration := uint64(0) + if ref.Tag() != "" { + tagGeneration = m.nextTagGeneration(ref.Repository(), ref.Tag()) + } meta := &imageMetadata{ - Name: ref.String(), - Digest: ref.Digest(), - Platform: requestedPlatform.String(), - Status: StatusPending, - Request: &storedReq, - BorrowedAuth: req.Credentials != nil, - BuildID: uuid.New().String(), - Tags: tags.Clone(req.Tags), - CreatedAt: time.Now(), + Name: ref.String(), + Digest: ref.Digest(), + Platform: requestedPlatform.String(), + Status: StatusPending, + Request: &storedReq, + BorrowedAuth: req.Credentials != nil, + BuildID: uuid.New().String(), + Tags: tags.Clone(req.Tags), + RequestedTag: ref.Tag(), + PreviousTagDigest: previousTagDigest, + TagGeneration: tagGeneration, + CreatedAt: time.Now(), } // Write initial metadata if err := writeMetadata(m.paths, ref.Repository(), ref.DigestHex(), meta); err != nil { return nil, fmt.Errorf("write initial metadata: %w", err) } + if ref.Tag() != "" && previousTagDigest == "" { + if err := createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()); err != nil { + return nil, fmt.Errorf("create pending image tag: %w", err) + } + } // Keep borrowed credentials outside the queued closure so their lifetime is // bounded even when this job waits behind another pull. @@ -456,7 +540,13 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef, credentials if meta.Status == StatusReady { // Another build completed first; last-pull-wins repoints the tag. if ref.Tag() != "" { - createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + m.createMu.Lock() + m.nextTagGeneration(ref.Repository(), ref.Tag()) + err := createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + m.createMu.Unlock() + if err != nil { + slog.Warn("failed to claim ready image tag", "repository", ref.Repository(), "tag", ref.Tag(), "error", err) + } } buildStatus = "success" return @@ -465,10 +555,14 @@ 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 + // Keep the temporary filesystem beside its final path so finalization stays + // atomic even when system/builds and images are on different filesystems. + diskTempPath := diskPath + ".tmp-" + buildID + defer os.Remove(diskTempPath) // 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 +570,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 +583,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 +597,13 @@ func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize i return errStaleBuild } + 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 { @@ -509,6 +614,17 @@ func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize i return err } + // Persist the manifest content model beside the shared content so later + // stages can recompose the image from per-layer artifacts and GC can tell + // which OCI blobs are still referenced. + if result.Manifest != nil { + model := *result.Manifest + model.Platform = actualPlatform.String() + if err := writeManifestModel(m.paths, ref.DigestHex(), &model); err != nil { + return fmt.Errorf("write manifest model: %w", err) + } + } + meta.Status = StatusReady meta.Error = nil meta.Platform = actualPlatform.String() @@ -524,9 +640,13 @@ func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize i } m.notifyReady(ref.DigestHex(), StatusReady, nil) - if ref.Tag() != "" { - if err := createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to create tag symlink: %v\n", err) + if meta.RequestedTag != "" { + current, resolveErr := resolveTag(m.paths, ref.Repository(), meta.RequestedTag) + generationMatches := m.tagGenerations[tagGenerationKey(ref.Repository(), meta.RequestedTag)] == meta.TagGeneration + if resolveErr == nil && generationMatches && (current == ref.DigestHex() || current == meta.PreviousTagDigest) { + if err := createTagSymlink(m.paths, ref.Repository(), meta.RequestedTag, ref.DigestHex()); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to create tag symlink: %v\n", err) + } } } m.refreshDiskUsageTotals() @@ -602,12 +722,14 @@ func (m *manager) RecoverInterruptedBuilds() { if err != nil { return // Best effort } + m.restoreTagGenerations(metas) // Sort by created_at to maintain FIFO order sort.Slice(metas, func(i, j int) bool { return metas[i].CreatedAt.Before(metas[j].CreatedAt) }) + seenDigests := make(map[string]struct{}) for _, meta := range metas { if meta.Status != StatusPending && meta.Status != StatusPulling && meta.Status != StatusConverting { continue @@ -615,6 +737,10 @@ func (m *manager) RecoverInterruptedBuilds() { if meta.Request == nil || meta.Digest == "" { continue } + if _, seen := seenDigests[meta.Digest]; seen { + continue + } + seenDigests[meta.Digest] = struct{}{} normalized, err := ParseNormalizedRef(meta.Name) if err != nil { continue @@ -660,6 +786,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 +795,69 @@ func (m *manager) GetImage(ctx context.Context, name string) (*Image, error) { return img, nil } +// TagImage creates or updates a local tag pointing at an existing ready +// image. The source may be a tag or a digest; the target must carry a tag. +// No pull or conversion happens: the target becomes another reference to the +// same shared content. Source and target may live in different repositories; +// a cross-repository target first promotes the digest into shared content so +// both references resolve to one rootfs copy. +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() + m.nextTagGeneration(targetRef.Repository(), targetRef.Tag()) + + digestHex, err := m.resolveTagSource(sourceRef) + if err != nil { + return nil, err + } + meta, err := m.readyImageMetadata(sourceRef.Repository(), digestHex) + if err != nil { + return nil, err + } + 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) resolveTagSource(ref *NormalizedRef) (string, error) { + if ref.IsDigest() { + return ref.DigestHex(), nil + } + return resolveTag(m.paths, ref.Repository(), ref.Tag()) +} + +func (m *manager) readyImageMetadata(repository, digestHex string) (*imageMetadata, error) { + meta, err := readMetadata(m.paths, repository, digestHex) + if err != nil { + return nil, err + } + if meta.Status != StatusReady { + return nil, fmt.Errorf("%w: %s", ErrImageNotReady, meta.Status) + } + return meta, nil +} + func (m *manager) DeleteImage(ctx context.Context, name string) error { // Parse and normalize the reference ref, err := ParseNormalizedRef(name) @@ -690,7 +880,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() @@ -698,6 +888,7 @@ func (m *manager) DeleteImage(ctx context.Context, name string) error { } tag := ref.Tag() + m.nextTagGeneration(repository, tag) // Resolve the tag to get the digest before deleting digestHex, err := resolveTag(m.paths, repository, tag) @@ -713,15 +904,12 @@ func (m *manager) DeleteImage(ctx context.Context, name string) error { // Check if the digest is now orphaned (no other tags reference it) count, err := countTagsForDigest(m.paths, repository, digestHex) if err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to count tags for digest %s: %v\n", digestHex, err) - return nil + return fmt.Errorf("count tags for digest %s: %w", digestHex, err) } if count == 0 { - // Digest is orphaned, delete it - if err := deleteDigest(m.paths, repository, digestHex); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to delete orphaned digest %s: %v\n", digestHex, err) - return nil + if err := removeDigestIfUnreferenced(m.paths, repository, digestHex, true); err != nil { + return fmt.Errorf("delete orphaned digest %s: %w", digestHex, err) } m.refreshDiskUsageTotals() } @@ -747,43 +935,41 @@ func (m *manager) TotalOCICacheBytes(ctx context.Context) (int64, error) { return ociCacheBytes, nil } -// WaitForReady blocks until the image reaches a terminal state (ready or failed) -// or the context is cancelled. -// -// The image may not exist yet when this is called (e.g., the registry's -// triggerConversion goroutine hasn't called ImportLocalImage yet), so we -// poll briefly for the image to appear before subscribing for notifications. -func (m *manager) WaitForReady(ctx context.Context, name string) error { - // Wait for the image to appear in the store. In the build flow, the - // registry triggers ImportLocalImage asynchronously after a push, so the - // image may not exist when the build manager calls WaitForReady. - const maxWaitForExist = 30 * time.Second - const pollInterval = 100 * time.Millisecond - - var img *Image - deadline := time.Now().Add(maxWaitForExist) - for { - got, err := m.GetImage(ctx, name) - if err == nil { - img = got - break - } - if time.Now().After(deadline) { - return fmt.Errorf("get image: %w", err) +func (m *manager) findRequestedTagImage(ref *NormalizedRef) *Image { + metas, err := listAllMetadata(m.paths) + if err != nil { + return nil + } + var newest *imageMetadata + for _, meta := range metas { + if meta.RequestedTag != ref.Tag() || !strings.HasPrefix(meta.Name, ref.Repository()+":") { + continue } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(pollInterval): + if newest == nil || newest.CreatedAt.Before(meta.CreatedAt) { + newest = meta } } - - // Check if already in terminal state - switch img.Status { - case StatusReady: + if newest == nil { return nil - case StatusFailed: - return conversionFailedErr(img.Error, nil) + } + image := newest.toImage() + image.Name = ref.String() + return image +} + +// WaitForReady blocks until the image reaches a terminal state (ready or failed) +// or the context is cancelled. +func (m *manager) WaitForReady(ctx context.Context, name string) error { + ref, err := ParseNormalizedRef(name) + if err != nil { + return fmt.Errorf("parse image name: %w", err) + } + img, err := m.waitForImage(ctx, name, ref) + if err != nil { + return err + } + if terminal, err := terminalImageError(img); terminal { + return err } digestHex := strings.TrimPrefix(img.Digest, "sha256:") @@ -794,13 +980,10 @@ func (m *manager) WaitForReady(ctx context.Context, name string) error { defer m.unsubscribeFromReady(digestHex, ch) // Re-check after subscribing to close the race window - img, err := m.GetImage(ctx, name) + img, err = m.GetImage(ctx, ref.Repository()+"@"+img.Digest) if err == nil { - switch img.Status { - case StatusReady: - return nil - case StatusFailed: - return conversionFailedErr(img.Error, nil) + if terminal, terminalErr := terminalImageError(img); terminal { + return terminalErr } } @@ -816,6 +999,44 @@ func (m *manager) WaitForReady(ctx context.Context, name string) error { } } +func (m *manager) waitForImage(ctx context.Context, name string, ref *NormalizedRef) (*Image, error) { + const maxWaitForExist = 30 * time.Second + const pollInterval = 100 * time.Millisecond + var lastErr error + deadline := time.Now().Add(maxWaitForExist) + for { + var img *Image + if !ref.IsDigest() { + img = m.findRequestedTagImage(ref) + } + if img == nil { + img, lastErr = m.GetImage(ctx, name) + } + if img != nil { + return img, nil + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("get image: %w", lastErr) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(pollInterval): + } + } +} + +func terminalImageError(img *Image) (bool, error) { + switch img.Status { + case StatusReady: + return true, nil + case StatusFailed: + return true, conversionFailedErr(img.Error, nil) + default: + return false, nil + } +} + func conversionFailedErr(message *string, cause error) error { if cause != nil { return fmt.Errorf("image conversion failed: %w", cause) diff --git a/lib/images/manager_test.go b/lib/images/manager_test.go index 89d3fb4fe..8abd90a65 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 digest 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 digest") t.Logf("Tag symlink: %s -> %s", linkPath, linkTarget) } @@ -395,6 +397,39 @@ func TestDeleteImagePreservesSharedDigest(t *testing.T) { require.True(t, os.IsNotExist(err), "digest directory should be deleted when last tag is removed") } +func TestDeleteImagePreservesCrossRepositoryContent(t *testing.T) { + p := paths.New(t.TempDir()) + mgr, err := NewManager(p, 1, nil) + require.NoError(t, err) + + const digest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + meta := &imageMetadata{ + Name: "docker.io/library/alpine@sha256:" + digest, + Digest: "sha256:" + digest, + Status: StatusReady, + SizeBytes: 6, + CreatedAt: time.Now().UTC(), + } + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), meta)) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("rootfs"), 0o644)) + require.NoError(t, createTagSymlink(p, "docker.io/library/alpine", "latest", digest)) + require.NoError(t, createTagSymlink(p, "registry.example.com/app", "v1", digest)) + + require.NoError(t, mgr.DeleteImage(context.Background(), "docker.io/library/alpine:latest")) + _, err = mgr.GetImage(context.Background(), "registry.example.com/app:v1") + require.NoError(t, err) + _, err = os.Stat(p.ImageContentPath(digest)) + require.NoError(t, err) + + require.NoError(t, mgr.DeleteImage(context.Background(), "registry.example.com/app:v1")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.NoError(t, err) + + require.NoError(t, mgr.DeleteImage(context.Background(), "docker.io/library/alpine@sha256:"+digest)) + _, err = os.Stat(p.ImageContentDir(digest)) + require.ErrorIs(t, err, os.ErrNotExist) +} + func TestNormalizedRefParsing(t *testing.T) { tests := []struct { input string @@ -671,6 +706,9 @@ func TestDeleteAndRecreateDuringBuildTail(t *testing.T) { require.NoError(t, err) require.NotEqual(t, firstMeta.BuildID, currentMeta.BuildID) require.NoError(t, m.DeleteImage(ctx, repo+"@"+digestStr)) + // Deleting a digest whose build is still in flight keeps the shared content + // so the running build can finish; re-import joins that build instead of + // starting a second one for the same digest. recreatedAgain, err := m.ImportLocalImage(ctx, repo, tag, digestStr) require.NoError(t, err) require.Equal(t, StatusPending, recreatedAgain.Status) @@ -678,7 +716,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 +736,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/manifest_model.go b/lib/images/manifest_model.go new file mode 100644 index 000000000..0a8331fa9 --- /dev/null +++ b/lib/images/manifest_model.go @@ -0,0 +1,185 @@ +package images + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kernel/hypeman/lib/paths" + digest "github.com/opencontainers/go-digest" + v1 "github.com/opencontainers/image-spec/specs-go/v1" +) + +// imageManifestModel is the persisted content model for one OCI manifest. It +// records everything needed to recompose the image from shared layer +// artifacts and to decide which OCI blobs are still referenced: the immutable +// manifest digest, the resolved platform, the image config with its diff ids, +// and the ordered layer descriptors. +type imageManifestModel struct { + SchemaVersion int `json:"schema_version"` + Digest string `json:"digest"` // manifest digest, sha256:... + MediaType string `json:"media_type,omitempty"` + Platform string `json:"platform"` // os/arch[/variant] + Config manifestConfigRef `json:"config"` + RootFSType string `json:"rootfs_type,omitempty"` + Layers []layerDescriptor `json:"layers"` // manifest order, base layer first +} + +const manifestModelSchemaVersion = 1 + +type manifestConfigRef struct { + Digest string `json:"digest"` // config blob digest, sha256:... + MediaType string `json:"media_type,omitempty"` + DiffIDs []string `json:"diff_ids,omitempty"` // uncompressed layer ids, matching Layers order +} + +// layerDescriptor describes one compressed layer blob exactly as it appears in +// the manifest, so the blob can be located in the shared OCI cache by digest. +type layerDescriptor struct { + Digest string `json:"digest"` // compressed blob digest, sha256:... + Size int64 `json:"size"` // compressed bytes + MediaType string `json:"media_type,omitempty"` + DiffID string `json:"diff_id,omitempty"` // uncompressed diff id from the image config +} + +// digestFromHex returns the full sha256 digest string for a bare hex value. +func digestFromHex(hex string) string { + if strings.HasPrefix(hex, "sha256:") { + return hex + } + return "sha256:" + hex +} + +// blobReferences returns every OCI blob digest the manifest depends on: the +// config blob plus all layer blobs. GC must keep these while the manifest is +// referenced. +func (m *imageManifestModel) blobReferences() []string { + refs := make([]string, 0, len(m.Layers)+1) + if m.Config.Digest != "" { + refs = append(refs, m.Config.Digest) + } + for _, layer := range m.Layers { + refs = append(refs, layer.Digest) + } + return refs +} + +// writeManifestModel persists the manifest model for a digest atomically. +func validateManifestModel(digestHex string, model *imageManifestModel) error { + if model == nil { + return fmt.Errorf("manifest model is nil") + } + if model.SchemaVersion != manifestModelSchemaVersion { + return fmt.Errorf("unsupported manifest model schema version: %d", model.SchemaVersion) + } + if model.Digest != digestFromHex(digestHex) { + return fmt.Errorf("manifest model digest %q does not match %q", model.Digest, digestFromHex(digestHex)) + } + if model.RootFSType != "" && model.RootFSType != "layers" { + return fmt.Errorf("unsupported manifest rootfs type: %q", model.RootFSType) + } + if err := validateManifestConfig(model); err != nil { + return err + } + return validateManifestLayers(model) +} + +func validateManifestConfig(model *imageManifestModel) error { + if model.Config.Digest == "" { + return fmt.Errorf("manifest model config digest is empty") + } + if _, err := parseSHA256Digest(model.Config.Digest); err != nil { + return fmt.Errorf("invalid manifest model config digest: %q", model.Config.Digest) + } + if model.Config.MediaType != "" && convertToOCIMediaType(model.Config.MediaType) != v1.MediaTypeImageConfig { + return fmt.Errorf("invalid manifest model config media type: %q", model.Config.MediaType) + } + if len(model.Config.DiffIDs) != len(model.Layers) { + return fmt.Errorf("manifest model has %d diff ids for %d layers", len(model.Config.DiffIDs), len(model.Layers)) + } + return nil +} + +func validateManifestLayers(model *imageManifestModel) error { + for i, layer := range model.Layers { + if _, err := parseSHA256Digest(layer.Digest); err != nil { + return fmt.Errorf("invalid manifest model layer %d digest: %q", i, layer.Digest) + } + diffID, err := parseSHA256Digest(model.Config.DiffIDs[i]) + if err != nil || layer.DiffID != diffID.String() { + return fmt.Errorf("invalid manifest model layer %d diff id", i) + } + if layer.Size < 0 { + return fmt.Errorf("invalid manifest model layer %d size: %d", i, layer.Size) + } + } + return nil +} + +func parseSHA256Digest(value string) (digest.Digest, error) { + parsed, err := digest.Parse(value) + if err != nil || parsed.Algorithm() != digest.SHA256 { + return "", fmt.Errorf("not a sha256 digest") + } + return parsed, nil +} + +func writeManifestModel(p *paths.Paths, digestHex string, model *imageManifestModel) error { + data, err := json.MarshalIndent(model, "", " ") + if err != nil { + return fmt.Errorf("marshal manifest model: %w", err) + } + return writeJSONAtomic(p.ImageContentManifestModel(digestHex), data) +} + +// readManifestModel loads the manifest model for a digest, if present. +// Missing models return (nil, nil): images converted before the manifest model +// existed only have a flattened rootfs. +func readManifestModel(p *paths.Paths, digestHex string) (*imageManifestModel, error) { + data, err := os.ReadFile(p.ImageContentManifestModel(digestHex)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read manifest model: %w", err) + } + var model imageManifestModel + if err := json.Unmarshal(data, &model); err != nil { + return nil, fmt.Errorf("unmarshal manifest model: %w", err) + } + if err := validateManifestModel(digestHex, &model); err != nil { + return nil, err + } + return &model, nil +} + +// writeJSONAtomic writes data to path via a temp file in the same directory +// followed by a rename, so readers never observe a partial document. +func writeJSONAtomic(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + tempFile, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tempPath := tempFile.Name() + defer os.Remove(tempPath) + if err := tempFile.Chmod(0644); err != nil { + _ = tempFile.Close() + return fmt.Errorf("chmod temp file: %w", err) + } + if _, err := tempFile.Write(data); err != nil { + _ = tempFile.Close() + return fmt.Errorf("write temp file: %w", err) + } + if err := tempFile.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Rename(tempPath, path); err != nil { + return fmt.Errorf("rename into place: %w", err) + } + return nil +} diff --git a/lib/images/manifest_model_test.go b/lib/images/manifest_model_test.go new file mode 100644 index 000000000..8e9e63777 --- /dev/null +++ b/lib/images/manifest_model_test.go @@ -0,0 +1,240 @@ +package images + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "io" + "os" + "strings" + "testing" + "time" + + gcr "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +// syntheticLayer builds a gzipped tar layer containing one file. +func syntheticLayer(t *testing.T, name, content string) gcr.Layer { + t.Helper() + + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: name, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + Mode: 0644, + })) + _, err := tw.Write([]byte(content)) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gzw.Close()) + + data := buf.Bytes() + layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + }) + require.NoError(t, err) + return layer +} + +// writeSyntheticLayout writes img into a fresh OCI layout cache tagged with the +// image's own digest, mirroring pullToOCILayout. +func writeSyntheticLayout(t *testing.T, img gcr.Image) (*ociClient, string) { + t.Helper() + + client, err := newOCIClient(t.TempDir()) + require.NoError(t, err) + + digest, err := img.Digest() + require.NoError(t, err) + layoutPath, err := layout.Write(client.cacheDir, empty.Index) + require.NoError(t, err) + require.NoError(t, layoutPath.AppendImage(img, layout.WithAnnotations(map[string]string{ + "org.opencontainers.image.ref.name": digestToLayoutTag(digest.String()), + }))) + return client, digestToLayoutTag(digest.String()) +} + +func TestExtractManifestModel(t *testing.T) { + base := syntheticLayer(t, "base.txt", "base layer content") + top := syntheticLayer(t, "top.txt", "top layer content") + img, err := mutate.AppendLayers(empty.Image, base, top) + require.NoError(t, err) + img = mutate.MediaType(img, types.OCIManifestSchema1) + + client, layoutTag := writeSyntheticLayout(t, img) + + model, err := client.extractManifestModel(layoutTag) + require.NoError(t, err) + + manifest, err := img.Manifest() + require.NoError(t, err) + configFile, err := img.ConfigFile() + require.NoError(t, err) + configDigest, err := img.ConfigName() + require.NoError(t, err) + + require.Equal(t, "sha256:"+layoutTag, model.Digest) + require.Equal(t, manifest.MediaType, types.MediaType(model.MediaType)) + require.Equal(t, configDigest.String(), model.Config.Digest) + require.Len(t, model.Layers, 2) + require.Len(t, model.Config.DiffIDs, 2) + + // Layer order and pairing must match the manifest and config diff ids. + for i, desc := range model.Layers { + require.Equal(t, manifest.Layers[i].Digest.String(), desc.Digest) + require.Equal(t, manifest.Layers[i].Size, desc.Size) + require.Equal(t, configFile.RootFS.DiffIDs[i].String(), desc.DiffID) + require.Equal(t, model.Config.DiffIDs[i], desc.DiffID) + } + + // Digests are content addresses, so identical layer content dedupes. + require.NotEqual(t, model.Layers[0].Digest, model.Layers[1].Digest) +} + +func TestExtractManifestModelPlatform(t *testing.T) { + layer := syntheticLayer(t, "file.txt", "content") + img, err := mutate.AppendLayers(empty.Image, layer) + require.NoError(t, err) + cfgFile, err := img.ConfigFile() + require.NoError(t, err) + cfgFile = cfgFile.DeepCopy() + cfgFile.OS = "linux" + cfgFile.Architecture = "amd64" + img, err = mutate.ConfigFile(img, cfgFile) + require.NoError(t, err) + + client, layoutTag := writeSyntheticLayout(t, img) + + model, err := client.extractManifestModel(layoutTag) + require.NoError(t, err) + require.Equal(t, "linux/amd64", model.Platform) +} + +func TestManifestModelWriteReadRoundtrip(t *testing.T) { + p := paths.New(t.TempDir()) + digestHex := "ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01" + + model := &imageManifestModel{ + SchemaVersion: manifestModelSchemaVersion, + Digest: "sha256:" + digestHex, + Platform: "linux/amd64", + Config: manifestConfigRef{ + Digest: "sha256:" + strings.Repeat("c", 64), + DiffIDs: []string{"sha256:" + strings.Repeat("d", 64), "sha256:" + strings.Repeat("e", 64)}, + }, + Layers: []layerDescriptor{ + {Digest: "sha256:" + strings.Repeat("f", 64), Size: 10, DiffID: "sha256:" + strings.Repeat("d", 64)}, + {Digest: "sha256:" + strings.Repeat("0", 64), Size: 20, DiffID: "sha256:" + strings.Repeat("e", 64)}, + }, + } + require.NoError(t, writeManifestModel(p, digestHex, model)) + + read, err := readManifestModel(p, digestHex) + require.NoError(t, err) + require.Equal(t, model, read) + + // No leftover temp files from the atomic write. + entries, err := os.ReadDir(p.ImageContentDir(digestHex)) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, "manifest.json", entries[0].Name()) +} + +func TestReadManifestModelRejectsInvalidSchema(t *testing.T) { + p := paths.New(t.TempDir()) + digestHex := strings.Repeat("a", 64) + require.NoError(t, os.MkdirAll(p.ImageContentDir(digestHex), 0755)) + require.NoError(t, os.WriteFile(p.ImageContentManifestModel(digestHex), []byte(`{"schema_version":1,"digest":"sha256:`+digestHex+`"}`), 0644)) + _, err := readManifestModel(p, digestHex) + require.ErrorContains(t, err, "config digest is empty") +} + +func TestReadManifestModelMissing(t *testing.T) { + p := paths.New(t.TempDir()) + model, err := readManifestModel(p, "deadbeef") + require.NoError(t, err) + require.Nil(t, model, "missing model must read as nil for pre-model images") +} + +func TestManifestModelBlobReferences(t *testing.T) { + model := &imageManifestModel{ + Config: manifestConfigRef{Digest: "sha256:cfg"}, + Layers: []layerDescriptor{ + {Digest: "sha256:layer0"}, + {Digest: "sha256:layer1"}, + }, + } + require.Equal(t, []string{"sha256:cfg", "sha256:layer0", "sha256:layer1"}, model.blobReferences()) +} + +func TestImportLocalImagePersistsManifestModel(t *testing.T) { + dataDir := t.TempDir() + p := paths.New(dataDir) + mgr, err := NewManager(p, 1, nil) + require.NoError(t, err) + m := mgr.(*manager) + + ctx := context.Background() + const repo = "kernel.local/test/manifest-model" + const tag = "v1" + + testImg := createTestDockerImage(t) + imgDigest, err := testImg.Digest() + require.NoError(t, err) + digestStr := imgDigest.String() + + cacheDir := p.SystemOCICache() + layoutPath, err := layout.Write(cacheDir, empty.Index) + require.NoError(t, err) + require.NoError(t, layoutPath.AppendImage(testImg, layout.WithAnnotations(map[string]string{ + "org.opencontainers.image.ref.name": digestToLayoutTag(digestStr), + }))) + + digestHex := digestToLayoutTag(digestStr) + events := make(chan StatusEvent, 2) + m.subscribeToReady(digestHex, events) + defer m.unsubscribeFromReady(digestHex, events) + + _, err = m.ImportLocalImage(ctx, repo, tag, digestStr) + require.NoError(t, err) + select { + case event := <-events: + require.Equal(t, StatusReady, event.Status) + case <-time.After(30 * time.Second): + t.Fatal("build did not become ready") + } + + model, err := readManifestModel(p, digestHex) + require.NoError(t, err) + require.NotNil(t, model, "ready image must persist a manifest model") + require.Equal(t, "sha256:"+digestHex, model.Digest) + + configFile, err := testImg.ConfigFile() + require.NoError(t, err) + manifest, err := testImg.Manifest() + require.NoError(t, err) + require.Len(t, model.Layers, len(manifest.Layers)) + for i, desc := range model.Layers { + require.Equal(t, manifest.Layers[i].Digest.String(), desc.Digest) + require.Equal(t, configFile.RootFS.DiffIDs[i].String(), desc.DiffID) + } + require.Equal(t, StatusReady, mustReadContentStatus(t, p, digestHex)) +} + +func mustReadContentStatus(t *testing.T, p *paths.Paths, digestHex string) string { + t.Helper() + meta, err := readContentMetadata(p, digestHex) + require.NoError(t, err) + return meta.Status +} diff --git a/lib/images/oci.go b/lib/images/oci.go index 33206d134..407d77604 100644 --- a/lib/images/oci.go +++ b/lib/images/oci.go @@ -194,6 +194,7 @@ func (c *ociClient) inspectDigestPlatformAuth(ctx context.Context, imageRef stri // pullResult contains the metadata and digest from pulling an image type pullResult struct { Metadata *containerMetadata + Manifest *imageManifestModel Digest string // sha256:abc123... CacheHit bool LayerCount int @@ -263,15 +264,17 @@ func (c *ociClient) pullAndExportWithPlatformAuth(ctx context.Context, imageRef, var meta *containerMetadata var layerCount int var compressedBytes int64 + var model *imageManifestModel err := result.measure("metadata_extract", func() error { var err error - meta, layerCount, compressedBytes, err = c.extractOCIImageDetails(layoutTag) + meta, model, layerCount, compressedBytes, err = c.extractOCIImageBundle(layoutTag) return err }) if err != nil { return result, fmt.Errorf("extract metadata: %w", err) } result.Metadata = meta + result.Manifest = model result.LayerCount = layerCount result.CompressedBytes = compressedBytes @@ -403,36 +406,37 @@ func (c *ociClient) extractOCIMetadata(layoutTag string) (*containerMetadata, er } func (c *ociClient) extractOCIImageDetails(layoutTag string) (*containerMetadata, int, int64, error) { - // Open OCI layout using go-containerregistry (handles Docker v2 and OCI v1) + meta, _, layerCount, compressedBytes, err := c.extractOCIImageBundle(layoutTag) + return meta, layerCount, compressedBytes, err +} + +func (c *ociClient) extractManifestModel(layoutTag string) (*imageManifestModel, error) { + _, model, _, _, err := c.extractOCIImageBundle(layoutTag) + return model, err +} + +func (c *ociClient) extractOCIImageBundle(layoutTag string) (*containerMetadata, *imageManifestModel, int, int64, error) { path, err := layout.FromPath(c.cacheDir) if err != nil { - return nil, 0, 0, fmt.Errorf("open oci layout: %w", err) + return nil, nil, 0, 0, fmt.Errorf("open oci layout: %w", err) } - - // Get the image by annotation tag from the layout img, err := imageByAnnotation(path, layoutTag) if err != nil { - return nil, 0, 0, fmt.Errorf("find image by tag %s: %w", layoutTag, err) + return nil, nil, 0, 0, fmt.Errorf("find image by tag %s: %w", layoutTag, err) } - - // Get config file (go-containerregistry handles manifest format automatically) configFile, err := img.ConfigFile() if err != nil { - return nil, 0, 0, fmt.Errorf("get config file: %w", err) + return nil, nil, 0, 0, fmt.Errorf("get config file: %w", err) } - manifest, err := img.Manifest() if err != nil { - return nil, 0, 0, fmt.Errorf("get manifest: %w", err) + return nil, nil, 0, 0, fmt.Errorf("get manifest: %w", err) } - var compressedBytes int64 - for _, layer := range manifest.Layers { - compressedBytes += layer.Size + configDigest, err := img.ConfigName() + if err != nil { + return nil, nil, 0, 0, fmt.Errorf("get config digest: %w", err) } - // Extract metadata from config. OS/Architecture/Variant come straight from - // the pulled image config, so they reflect the manifest actually fetched - // rather than what the caller requested. meta := &containerMetadata{ OS: configFile.OS, Architecture: configFile.Architecture, @@ -443,24 +447,49 @@ func (c *ociClient) extractOCIImageDetails(layoutTag string) (*containerMetadata Labels: make(map[string]string), WorkingDir: configFile.Config.WorkingDir, } - - // Parse environment variables for _, env := range configFile.Config.Env { - for i := 0; i < len(env); i++ { - if env[i] == '=' { - key := env[:i] - val := env[i+1:] - meta.Env[key] = val - break - } + if key, value, ok := strings.Cut(env, "="); ok { + meta.Env[key] = value } } - for key, value := range configFile.Config.Labels { meta.Labels[key] = value } - return meta, len(manifest.Layers), compressedBytes, nil + model := &imageManifestModel{ + SchemaVersion: manifestModelSchemaVersion, + Digest: digestFromHex(layoutTag), + MediaType: string(manifest.MediaType), + Platform: Platform{ + OS: configFile.OS, + Architecture: configFile.Architecture, + Variant: configFile.Variant, + }.String(), + Config: manifestConfigRef{ + Digest: configDigest.String(), + MediaType: string(manifest.Config.MediaType), + DiffIDs: make([]string, 0, len(configFile.RootFS.DiffIDs)), + }, + RootFSType: configFile.RootFS.Type, + Layers: make([]layerDescriptor, 0, len(manifest.Layers)), + } + for _, diffID := range configFile.RootFS.DiffIDs { + model.Config.DiffIDs = append(model.Config.DiffIDs, diffID.String()) + } + var compressedBytes int64 + for i, descriptor := range manifest.Layers { + compressedBytes += descriptor.Size + layer := layerDescriptor{ + Digest: descriptor.Digest.String(), + Size: descriptor.Size, + MediaType: string(descriptor.MediaType), + } + if i < len(model.Config.DiffIDs) { + layer.DiffID = model.Config.DiffIDs[i] + } + model.Layers = append(model.Layers, layer) + } + return meta, model, len(manifest.Layers), compressedBytes, nil } // unpackLayers unpacks all OCI layers to a target directory using umoci diff --git a/lib/images/storage.go b/lib/images/storage.go index 8764400c1..5d4da61f1 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -14,22 +14,25 @@ import ( ) type imageMetadata struct { - Name string `json:"name"` // Normalized ref (tag or digest) - Digest string `json:"digest"` // Always present: sha256:... - Platform string `json:"platform,omitempty"` - Status string `json:"status"` - Error *string `json:"error,omitempty"` - Request *CreateImageRequest `json:"request,omitempty"` - SizeBytes int64 `json:"size_bytes"` - Entrypoint []string `json:"entrypoint,omitempty"` - Cmd []string `json:"cmd,omitempty"` - Env map[string]string `json:"env,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - Tags tags.Tags `json:"tags,omitempty"` - WorkingDir string `json:"working_dir,omitempty"` - CreatedAt time.Time `json:"created_at"` - BorrowedAuth bool `json:"borrowed_auth,omitempty"` - BuildID string `json:"build_id,omitempty"` + Name string `json:"name"` // Normalized ref (tag or digest) + Digest string `json:"digest"` // Always present: sha256:... + Platform string `json:"platform,omitempty"` + Status string `json:"status"` + Error *string `json:"error,omitempty"` + Request *CreateImageRequest `json:"request,omitempty"` + SizeBytes int64 `json:"size_bytes"` + Entrypoint []string `json:"entrypoint,omitempty"` + Cmd []string `json:"cmd,omitempty"` + Env map[string]string `json:"env,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Tags tags.Tags `json:"tags,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` + CreatedAt time.Time `json:"created_at"` + BorrowedAuth bool `json:"borrowed_auth,omitempty"` + BuildID string `json:"build_id,omitempty"` + RequestedTag string `json:"requested_tag,omitempty"` + PreviousTagDigest string `json:"previous_tag_digest,omitempty"` + TagGeneration uint64 `json:"tag_generation,omitempty"` } func (m *imageMetadata) toImage() *Image { @@ -77,16 +80,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,49 +160,54 @@ 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 { data, err := json.MarshalIndent(meta, "", " ") if err != nil { return fmt.Errorf("marshal metadata: %w", err) } + return writeJSONAtomic(path, data) +} - tempPath := metadataPath(p, repository, digestHex) + ".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) - return fmt.Errorf("rename metadata: %w", err) - } +func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata, error) { + return readMetadataAt(resolveImageLayout(p, repository, digestHex)) +} - return nil +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, + }) } -// readMetadata reads metadata for a digest -func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata, error) { - path := metadataPath(p, repository, digestHex) +func readMetadataAt(layout imageLayout) (*imageMetadata, error) { + path := layout.metadata data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -157,10 +222,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,220 +233,255 @@ func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata, return &meta, nil } -// createTagSymlink creates or updates a tag symlink to point to a digest (only -// if the digest dir exists and the build is ready). -// -// Tag ownership is Docker last-pull-wins: the most recent pull of a tag always -// owns the symlink, regardless of platform. An earlier gate only repointed for -// host-native pulls, which silently stranded emulated variants (e.g. -// `pull --platform linux/amd64 alpine:3.19` could never make `image get` report -// 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) +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 } - - // 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) - } - - return nil -} - -// resolveTag follows a tag symlink to get the digest hex -func resolveTag(p *paths.Paths, repository, tag string) (string, error) { - linkPath := tagSymlinkPath(p, repository, tag) - - // Read the symlink - target, err := os.Readlink(linkPath) - if err != nil { - if os.IsNotExist(err) { - return "", ErrNotFound + 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) } - 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, "/") { - return "", fmt.Errorf("invalid symlink target: %s", target) - } - - return target, 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 + if err := os.MkdirAll(p.ImageContentDir(digestHex), 0755); err != nil { + return fmt.Errorf("create content directory: %w", err) } - 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())) - if err != nil { - continue + contentMeta := *sourceMeta + contentMeta.Status = StatusConverting + if err := writeMetadataFile(p.ImageContentMetadata(digestHex), &contentMeta); err != nil { + return fmt.Errorf("write content metadata: %w", err) } - - if info.Mode()&os.ModeSymlink != 0 { - tags = append(tags, entry.Name()) + 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) } } - return tags, nil -} - -// listAllMetadata returns one metadata record per digest 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{}) - metas := make([]*imageMetadata, 0) - - err := filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error { + // 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 nil // Skip errors + return err } - - switch { - case info.Mode()&os.ModeSymlink != 0: - digestHex, err := os.Readlink(path) - if err != nil { - return nil // Skip invalid symlinks + staged := make([]stagedTagSymlink, 0, len(tags)) + cleanupStaged := func() { + for _, ref := range staged { + _ = os.RemoveAll(ref.tempDir) } - - repository, err := filepath.Rel(imagesDir, filepath.Dir(path)) - if err != nil { - return nil + } + defer cleanupStaged() + for _, tag := range tags { + target, err := resolveTag(p, sourceRepository, tag) + if err != nil || target != digestHex { + continue } - - return appendMetadataIfNew(p, repository, digestHex, seen, &metas) - case !info.IsDir() && info.Name() == "metadata.json": - digestHex := filepath.Base(filepath.Dir(path)) - repository, err := filepath.Rel(imagesDir, filepath.Dir(filepath.Dir(path))) + ref, err := stageTagSymlink(p, sourceRepository, tag, digestHex) if err != nil { - return nil + return fmt.Errorf("stage legacy tag %s: %w", tag, err) + } + staged = append(staged, ref) + } + for i, ref := range staged { + if err := os.Rename(ref.tempPath, ref.linkPath); err != nil { + if rollbackErr := rollbackTagSymlinks(staged[:i]); rollbackErr != nil { + return errors.Join(fmt.Errorf("promote legacy tag: %w", err), rollbackErr) + } + return fmt.Errorf("promote legacy tag: %w", err) + } + } + staleClean := true + for _, ref := range staged { + if err := removeStaleTagSymlink(p, &ref); err != nil { + staleClean = false + fmt.Fprintf(os.Stderr, "Warning: failed to remove stale tag symlink %s: %v\n", ref.tag, err) + } + } + if staleClean { + if err := os.RemoveAll(legacyDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to remove legacy digest directory %s: %v\n", digestHex, err) } - - return appendMetadataIfNew(p, repository, digestHex, seen, &metas) - default: - return nil } - }) - - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("walk images directory: %w", err) } - return metas, nil + return nil } -func appendMetadataIfNew(p *paths.Paths, repository, digestHex string, seen map[string]struct{}, metas *[]*imageMetadata) error { - key := repository + "@" + digestHex - if _, ok := seen[key]; ok { - return nil +func installAtomically(path string, install func(string) error) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err } - - meta, err := readMetadata(p, repository, digestHex) + tempDir, err := os.MkdirTemp(filepath.Dir(path), ".install-*") if err != nil { - return nil // Skip if metadata can't be read + 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) +} - seen[key] = struct{}{} - *metas = append(*metas, meta) - return nil +type symlinkState struct { + exists bool + target string } -// digestExists checks if a digest directory exists -func digestExists(p *paths.Paths, repository, digestHex string) bool { - dir := digestDir(p, repository, digestHex) - _, err := os.Stat(dir) - return err == nil +type stagedTagSymlink struct { + repository string + tag string + linkPath string + tempDir string + tempPath string + previous symlinkState } -// deleteTag removes a tag symlink (does not delete the digest directory) -func deleteTag(p *paths.Paths, repository, tag string) error { - linkPath := tagSymlinkPath(p, repository, tag) +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) + } + } + previous, err := readSymlinkState(linkPath) + if err != nil { + return stagedTagSymlink{}, 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, + previous: previous, + }, nil +} - // Check if symlink exists - if _, err := os.Lstat(linkPath); err != nil { +func readSymlinkState(path string) (symlinkState, error) { + target, err := os.Readlink(path) + if err != nil { if os.IsNotExist(err) { - return ErrNotFound + return symlinkState{}, nil } - return fmt.Errorf("stat symlink: %w", err) + return symlinkState{}, fmt.Errorf("read existing tag symlink: %w", err) } + return symlinkState{exists: true, target: target}, nil +} - // Remove symlink - if err := os.Remove(linkPath); err != nil { - return fmt.Errorf("remove symlink: %w", err) +func restoreSymlinkState(path string, state symlinkState) error { + if !state.exists { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove tag symlink during rollback: %w", err) + } + return nil + } + if err := installAtomically(path, func(tempPath string) error { + return os.Symlink(state.target, tempPath) + }); err != nil { + return fmt.Errorf("restore tag symlink during rollback: %w", err) } - return nil } -// countTagsForDigest counts how many tags in a repository point to a given digest -func countTagsForDigest(p *paths.Paths, repository, digestHex string) (int, error) { - tags, err := listTags(p, repository) - if err != nil { - return 0, err +func rollbackTagSymlinks(refs []stagedTagSymlink) error { + var rollbackErrs []error + for i := len(refs) - 1; i >= 0; i-- { + if err := restoreSymlinkState(refs[i].linkPath, refs[i].previous); err != nil { + rollbackErrs = append(rollbackErrs, err) + } } + return errors.Join(rollbackErrs...) +} - count := 0 - for _, tag := range tags { - target, err := resolveTag(p, repository, tag) - if err != nil { - continue - } - if target == digestHex { - count++ - } +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 count, nil + return nil } -func deleteTagsForDigest(p *paths.Paths, repository, digestHex string) error { - tags, err := listTags(p, repository) +// createTagSymlink creates or updates a tag symlink to point to a digest (only +// if the digest dir exists and the build is ready). +// +// Tag ownership is Docker last-pull-wins: the most recent pull of a tag always +// owns the symlink, regardless of platform. An earlier gate only repointed for +// host-native pulls, which silently stranded emulated variants (e.g. +// `pull --platform linux/amd64 alpine:3.19` could never make `image get` report +// 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 { + ref, err := stageTagSymlink(p, repository, tag, digestHex) if err != nil { - return err + return fmt.Errorf("stage tag symlink: %w", err) } - - for _, tag := range tags { - target, err := resolveTag(p, repository, tag) - if err != nil { - continue - } - if target != digestHex { - continue - } - if err := deleteTag(p, repository, tag); err != nil && !errors.Is(err, ErrNotFound) { - return 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 { + fmt.Fprintf(os.Stderr, "Warning: failed to remove stale tag symlink %s: %v\n", tag, err) + } + _ = os.RemoveAll(ref.tempDir) 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) +// resolveTag follows a tag symlink to get the digest hex +func resolveTag(p *paths.Paths, repository, tag string) (string, error) { + linkPath := tagSymlinkPath(p, repository, tag) + + // Read the symlink + target, err := os.Readlink(linkPath) + if err != nil { + if os.IsNotExist(err) { + return "", ErrNotFound + } + return "", fmt.Errorf("read symlink: %w", err) } - return nil + + // 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 digestHex, nil } diff --git a/lib/images/storage_refs.go b/lib/images/storage_refs.go new file mode 100644 index 000000000..5cba6b6f4 --- /dev/null +++ b/lib/images/storage_refs.go @@ -0,0 +1,405 @@ +package images + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kernel/hypeman/lib/paths" +) + +func ensurePendingTag(p *paths.Paths, repository, tag, digestHex string) error { + _, err := resolveTag(p, repository, tag) + if err == nil { + return nil + } + if !errors.Is(err, ErrNotFound) { + return err + } + return createTagSymlink(p, repository, tag, digestHex) +} + +// listTags returns all tags for a repository. +func listTags(p *paths.Paths, repository string) ([]string, error) { + 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 { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("read repository directory: %w", err) + } + 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 +} + +// promoteLegacyImages migrates ready legacy images into shared content storage. +// Keeping the traversal here makes storage layout migration share the same +// ownership boundary as metadata enumeration. +func promoteLegacyImages(p *paths.Paths) { + imagesDir := p.ImagesDir() + type legacyRef struct { + repository string + digestHex string + } + refs := make([]legacyRef, 0) + err := filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || info.Name() != "metadata.json" { + return nil + } + rel, relErr := filepath.Rel(imagesDir, path) + if relErr != nil { + return nil + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) < 3 || parts[0] == "content" || parts[0] == "repositories" { + return nil + } + refs = append(refs, legacyRef{ + repository: filepath.Join(parts[:len(parts)-2]...), + digestHex: parts[len(parts)-2], + }) + return nil + }) + if err != nil && !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Warning: failed to scan legacy images for promotion: %v\n", err) + return + } + + for _, ref := range refs { + layout := resolveImageLayout(p, ref.repository, ref.digestHex) + meta, readErr := readMetadataAt(layout) + if readErr != nil || meta.Status != StatusReady { + continue + } + if promoteErr := promoteImageToContent(p, ref.repository, ref.digestHex, meta); promoteErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to promote legacy image %s@%s: %v\n", ref.repository, ref.digestHex, promoteErr) + } + } +} + +// 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: + digestHex, err := os.Readlink(path) + if err != nil { + return nil // Skip invalid symlinks + } + 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 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 + } + key := repository + "@" + digestHex + if _, ok := seenMetadataRefs[key]; !ok { + seenMetadataRefs[key] = struct{}{} + metadataRefs = append(metadataRefs, metadataReference{repository: repository, digestHex: digestHex}) + } + return nil + default: + return nil + } + }) + + 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 { + return nil + } + + meta, err := readMetadata(p, repository, digestHex) + if err != nil { + return nil // Skip if metadata can't be read + } + + seen[key] = struct{}{} + *metas = append(*metas, meta) + 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 +} + +// deleteTag removes a tag symlink in either supported layout (does not delete +// the digest directory). +func deleteTag(p *paths.Paths, repository, tag string) error { + 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) + } + } + if !found { + return ErrNotFound + } + return nil +} + +// countTagsForDigest counts how many tags in a repository point to a given digest. +func countTagsForDigest(p *paths.Paths, repository, digestHex string) (int, error) { + tags, err := listTags(p, repository) + if err != nil { + return 0, err + } + + count := 0 + for _, tag := range tags { + target, err := resolveTag(p, repository, tag) + if err != nil { + continue + } + if target == digestHex { + count++ + } + } + return count, nil +} + +func deleteTagsForDigest(p *paths.Paths, repository, digestHex string) error { + tags, err := listTags(p, repository) + if err != nil { + return err + } + + for _, tag := range tags { + target, err := resolveTag(p, repository, tag) + if err != nil { + continue + } + if target != digestHex { + continue + } + if err := deleteTag(p, repository, tag); err != nil && !errors.Is(err, ErrNotFound) { + return err + } + } + + return nil +} + +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) { + status, ok := metadataStatus(p.ImageContentMetadata(digestHex)) + if !ok { + return "", os.ErrNotExist + } + return 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 { + contentDir := p.ImageContentDir(digestHex) + contentExists := false + if _, err := os.Stat(contentDir); err == nil { + contentExists = true + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat content digest directory: %w", err) + } + + if err := os.RemoveAll(p.ImageDigestDir(repository, digestHex)); err != nil { + return fmt.Errorf("remove legacy digest directory: %w", err) + } + if !contentExists { + return nil + } + + 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..1bceb066f 100644 --- a/lib/images/storage_test.go +++ b/lib/images/storage_test.go @@ -1,12 +1,197 @@ 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 TestContentLayoutResolvesDiskByDigest(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "1212121212121212121212121212121212121212121212121212121212121212" + meta := &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusReady, + } + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), meta)) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("rootfs"), 0o644)) + + got, err := GetDiskPath(p, repository+":latest", "sha256:"+digest) + require.NoError(t, err) + require.Equal(t, p.ImageContentPath(digest), got) +} + +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{ @@ -39,3 +224,69 @@ func TestImageMetadataToImage_EmptyMetadataOmitted(t *testing.T) { require.Nil(t, img.Tags) } + +func TestPromoteLegacyImagesMovesContentAndTags(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + tag := "latest" + digest := "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + + // Ready legacy image with a legacy tag symlink. + legacyDir := p.ImageDigestDir(repository, digest) + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + meta := &imageMetadata{ + Name: repository + ":" + tag, + Digest: "sha256:" + digest, + Status: StatusReady, + SizeBytes: 7, + CreatedAt: time.Now().UTC(), + } + require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digest), meta)) + require.NoError(t, os.WriteFile(p.ImageDigestPath(repository, digest), []byte("rootfs!"), 0o644)) + tagPath := p.ImageTagSymlink(repository, tag) + require.NoError(t, os.MkdirAll(filepath.Dir(tagPath), 0o755)) + require.NoError(t, os.Symlink(digest, tagPath)) + + m := &manager{paths: p} + m.promoteLegacyImages() + + // Content exists with the same bytes and is ready. + contentMeta, err := readContentMetadata(p, digest) + require.NoError(t, err) + require.Equal(t, StatusReady, contentMeta.Status) + data, err := os.ReadFile(p.ImageContentPath(digest)) + require.NoError(t, err) + require.Equal(t, "rootfs!", string(data)) + + // Legacy digest tree is retired once content is installed and tags moved. + _, err = os.Stat(legacyDir) + require.True(t, os.IsNotExist(err), "legacy digest dir should be removed") + + // The tag now resolves through the shared content layout. + resolved, err := resolveTag(p, repository, tag) + require.NoError(t, err) + require.Equal(t, digest, resolved) +} + +func TestPromoteLegacyImagesSkipsNonReady(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: StatusConverting, + CreatedAt: time.Now().UTC(), + })) + + m := &manager{paths: p} + m.promoteLegacyImages() + + _, err := os.Stat(p.ImageContentMetadata(digest)) + require.True(t, os.IsNotExist(err), "non-ready legacy image must not be promoted") + _, err = os.Stat(legacyDir) + require.NoError(t, err, "non-ready legacy tree must be preserved") +} diff --git a/lib/images/tag_test.go b/lib/images/tag_test.go new file mode 100644 index 000000000..e44e32259 --- /dev/null +++ b/lib/images/tag_test.go @@ -0,0 +1,188 @@ +package images + +import ( + "context" + "os" + "testing" + "time" + + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +// seedReadyContentImage writes a ready image directly into the shared content +// layout and tags it, without going through a pull. +func seedReadyContentImage(t *testing.T, p *paths.Paths, repository, tag, digestHex string) { + t.Helper() + require.NoError(t, os.MkdirAll(p.ImageContentDir(digestHex), 0o755)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digestHex), &imageMetadata{ + Name: repository + ":" + tag, + Digest: "sha256:" + digestHex, + Status: StatusReady, + SizeBytes: 7, + CreatedAt: time.Now().UTC(), + })) + require.NoError(t, os.WriteFile(p.ImageContentPath(digestHex), []byte("rootfs!"), 0o644)) + require.NoError(t, createTagSymlink(p, repository, tag, digestHex)) +} + +func newTagTestManager(p *paths.Paths) *manager { + return &manager{paths: p} +} + +func TestTagImageSameRepository(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + digest := "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + seedReadyContentImage(t, p, repository, "latest", digest) + + img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") + require.NoError(t, err) + require.Equal(t, repository+":stable", img.Name) + require.Equal(t, "sha256:"+digest, img.Digest) + + resolved, err := resolveTag(p, repository, "stable") + require.NoError(t, err) + require.Equal(t, digest, resolved) + + // Both tags keep the shared content alive. + require.NoError(t, m.DeleteImage(context.Background(), repository+":latest")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.NoError(t, err, "content must survive while another tag references it") + + require.NoError(t, m.DeleteImage(context.Background(), repository+":stable")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.True(t, os.IsNotExist(err), "content must be removed once unreferenced") +} + +func TestTagImageFromDigestSource(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + digest := "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" + seedReadyContentImage(t, p, repository, "latest", digest) + + img, err := m.TagImage(context.Background(), repository+"@sha256:"+digest, repository+":pinned") + require.NoError(t, err) + require.Equal(t, repository+":pinned", img.Name) + + resolved, err := resolveTag(p, repository, "pinned") + require.NoError(t, err) + require.Equal(t, digest, resolved) +} + +func TestTagImageCrossRepository(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + sourceRepo := "docker.io/library/alpine" + targetRepo := "registry.example/apps/alpine" + digest := "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" + + // Ready image still living in the legacy per-repository layout. + legacyDir := p.ImageDigestDir(sourceRepo, digest) + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(sourceRepo, digest), &imageMetadata{ + Name: sourceRepo + ":latest", + Digest: "sha256:" + digest, + Status: StatusReady, + SizeBytes: 7, + CreatedAt: time.Now().UTC(), + })) + require.NoError(t, os.WriteFile(p.ImageDigestPath(sourceRepo, digest), []byte("rootfs!"), 0o644)) + require.NoError(t, createTagSymlink(p, sourceRepo, "latest", digest)) + + img, err := m.TagImage(context.Background(), sourceRepo+":latest", targetRepo+":v1") + require.NoError(t, err) + require.Equal(t, targetRepo+":v1", img.Name) + + // Promotion moved the digest into shared content and retired the legacy tree. + data, err := os.ReadFile(p.ImageContentPath(digest)) + require.NoError(t, err) + require.Equal(t, "rootfs!", string(data)) + _, err = os.Stat(legacyDir) + require.True(t, os.IsNotExist(err), "legacy tree should be retired after promotion") + + // Both repositories resolve to the same digest. + for repo, tag := range map[string]string{sourceRepo: "latest", targetRepo: "v1"} { + resolved, err := resolveTag(p, repo, tag) + require.NoError(t, err) + require.Equal(t, digest, resolved) + } + + // Deleting the source tag leaves the cross-repository alias intact. + require.NoError(t, m.DeleteImage(context.Background(), sourceRepo+":latest")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.NoError(t, err, "cross-repository tag must keep content alive") + + require.NoError(t, m.DeleteImage(context.Background(), targetRepo+":v1")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.True(t, os.IsNotExist(err), "content must be removed once unreferenced") +} + +func TestTagImageRejectsNotReady(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + digest := "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4" + + require.NoError(t, os.MkdirAll(p.ImageContentDir(digest), 0o755)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusConverting, + CreatedAt: time.Now().UTC(), + })) + require.NoError(t, createTagSymlink(p, repository, "latest", digest)) + + _, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") + require.ErrorIs(t, err, ErrImageNotReady) + + _, err = resolveTag(p, repository, "stable") + require.ErrorIs(t, err, ErrNotFound, "failed tag must not create a reference") +} + +func TestTagImageSourceNotFound(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + + _, err := m.TagImage(context.Background(), repository+":missing", repository+":stable") + require.ErrorIs(t, err, ErrNotFound) +} + +func TestTagImageRejectsDigestTarget(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + digest := "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5" + seedReadyContentImage(t, p, repository, "latest", digest) + + _, err := m.TagImage(context.Background(), repository+":latest", repository+"@sha256:"+digest) + require.ErrorIs(t, err, ErrInvalidName) +} + +func TestTagImageReplacesExistingTag(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + first := "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6" + second := "a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7" + seedReadyContentImage(t, p, repository, "latest", first) + seedReadyContentImage(t, p, repository, "stable", second) + + // "stable" moves from the second digest to the first. + img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") + require.NoError(t, err) + require.Equal(t, repository+":stable", img.Name) + + resolved, err := resolveTag(p, repository, "stable") + require.NoError(t, err) + require.Equal(t, first, resolved) + + // The orphaned second digest is cleaned up by delete semantics, not by tag; + // it is still referenced by no tag after the repoint only if it had no other + // tag. Here "stable" was its only tag, so it remains on disk until deleted. + _, err = os.Stat(p.ImageContentDir(second)) + require.NoError(t, err) +} diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index ef827c489..bda3c0909 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -1841,6 +1841,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 @@ -2131,6 +2137,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 @@ -2315,6 +2324,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) @@ -2755,6 +2769,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 { @@ -4203,6 +4241,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 @@ -6166,6 +6251,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) @@ -6833,6 +6923,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 @@ -8065,6 +8181,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...) @@ -9463,6 +9596,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) @@ -11413,6 +11600,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) @@ -11668,6 +11858,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) { @@ -12510,6 +12706,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) { @@ -14066,6 +14293,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) }) @@ -15076,6 +15306,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 } @@ -16974,6 +17258,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) @@ -17689,6 +17976,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 @@ -18850,385 +19170,387 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/paths/paths.go b/lib/paths/paths.go index add23ff52..bc9403cd6 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -142,6 +142,41 @@ 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") +} + +// ImageContentManifestModel returns the path to the persisted OCI manifest +// model (layer descriptors, config, platform) for content-addressed image data. +func (p *Paths) ImageContentManifestModel(digestHex string) string { + return filepath.Join(p.ImageContentDir(digestHex), "manifest.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 d6016524d..e7cef8a6c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1290,6 +1290,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] @@ -2640,6 +2649,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: