Remove unused registry manifests and layers

Deleting a tag or moving it to a new image left the old manifest
and its layers on disk. Now a manifest that loses its last tag is
deleted. The layers that no other manifest of the image uses are
unlinked, and their files are removed when no repo uses them.
Children of a deleted index follow the same rule. This applies to
the UI tag delete, the registry DELETE endpoint, and tag moves on
push, including CI imports.

A startup sweep removes untagged manifests, unused blob links, and
blob files that no row references.

- storeManifest holds registryMu during its blob checks. A cleanup
  can no longer unlink a layer between check and store.
- The cleanup stops when a manifest cannot be read, so a used layer
  never looks unused.
- Cleanup errors are only logged. The tag change already succeeded.
- DELETE by digest returns 409 while an index uses the manifest.
- Add indexes on the digest column of registry_blobs and
  registry_manifests.
AuthorKonata <konata@posteo.jp>
Date
Commit392ef013792ef5cfc71387b3a2d66440a43028ec
Parent31e7abc
9 files changed, 476 insertions(+), 100 deletions(-)
▾MREADME.md
@@ -147,6 +147,10 @@ existing repository.
- Blobs are stored once by digest under `DATA_DIR/registry/`. Deleting a
repository removes its image index. Blob files shared with other images
stay.
- A manifest that loses its last tag is deleted, together with the layers no
other manifest of the image uses. This happens when you delete a tag or
push a tag to a new image. A startup sweep also removes untagged manifests,
unused layers, and files no image references.
- `BASE_URL` must be HTTPS for Docker and Podman to talk to the registry
without an insecure-registry exception.
▾Mcmd/hearthforge/main.go
@@ -115,6 +115,9 @@ func main() {
if err := srv.SyncReleases(ctx); err != nil {
log.Printf("release sync: %v", err)
}
if err := srv.SweepRegistry(ctx); err != nil {
log.Printf("registry sweep: %v", err)
}
if err := runner.CancelStaleRuns(ctx); err != nil {
log.Printf("cancel stale ci runs: %v", err)
}
▾Minternal/db/registry.go
@@ -50,21 +50,28 @@ func (d *DB) DigestReferenced(ctx context.Context, digest string) (bool, error)
}
// PutManifest stores the manifest row and, for a tag reference, points the
// tag at it.
func (d *DB) PutManifest(ctx context.Context, repoID int64, image string, m Manifest, tag, now string) error {
// tag at it. It returns the digest the tag pointed at before, or "".
func (d *DB) PutManifest(ctx context.Context, repoID int64, image string, m Manifest, tag, now string) (string, error) {
if _, err := d.ExecContext(ctx,
`INSERT OR REPLACE INTO registry_manifests (repo_id, image, digest, media_type, created_at)
VALUES (?, ?, ?, ?, ?)`,
repoID, image, m.Digest, m.MediaType, now); err != nil {
return err
return "", err
}
if tag == "" {
return nil
return "", nil
}
_, err := d.ExecContext(ctx,
var prev string
err := d.QueryRowContext(ctx,
`SELECT digest FROM registry_tags WHERE repo_id = ? AND image = ? AND tag = ?`,
repoID, image, tag).Scan(&prev)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
_, err = d.ExecContext(ctx,
`INSERT OR REPLACE INTO registry_tags (repo_id, image, tag, digest, updated_at) VALUES (?, ?, ?, ?, ?)`,
repoID, image, tag, m.Digest, now)
return err
return prev, err
}
// ManifestByDigest returns nil when the image has no such manifest.
@@ -111,32 +118,75 @@ func (d *DB) DeleteManifest(ctx context.Context, repoID int64, image, digest str
return n > 0, nil
}
// DeleteTag removes one tag and reports whether it existed.
func (d *DB) DeleteTag(ctx context.Context, repoID int64, image, tag string) (bool, error) {
res, err := d.ExecContext(ctx,
`DELETE FROM registry_tags WHERE repo_id = ? AND image = ? AND tag = ?`, repoID, image, tag)
if err != nil {
return false, err
// DeleteTag removes one tag and returns the digest it pointed at. It
// returns "" when the tag did not exist.
func (d *DB) DeleteTag(ctx context.Context, repoID int64, image, tag string) (string, error) {
var digest string
err := d.QueryRowContext(ctx,
`DELETE FROM registry_tags WHERE repo_id = ? AND image = ? AND tag = ? RETURNING digest`,
repoID, image, tag).Scan(&digest)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
n, _ := res.RowsAffected()
return n > 0, nil
return digest, err
}
// ListTags returns the image's tags in lexical order.
func (d *DB) ListTags(ctx context.Context, repoID int64, image string) ([]string, error) {
rows, err := d.QueryContext(ctx,
return d.queryStrings(ctx,
`SELECT tag FROM registry_tags WHERE repo_id = ? AND image = ? ORDER BY tag`, repoID, image)
}
// ManifestDigests lists the manifest digests of one image.
func (d *DB) ManifestDigests(ctx context.Context, repoID int64, image string) ([]string, error) {
return d.queryStrings(ctx,
`SELECT digest FROM registry_manifests WHERE repo_id = ? AND image = ?`, repoID, image)
}
// BlobDigests lists the blob digests linked to one image.
func (d *DB) BlobDigests(ctx context.Context, repoID int64, image string) ([]string, error) {
return d.queryStrings(ctx,
`SELECT digest FROM registry_blobs WHERE repo_id = ? AND image = ?`, repoID, image)
}
// ImageRef names one image path of a repository.
type ImageRef struct {
RepoID int64
Image string
}
// RegistryImages lists every image path that has a blob or a manifest.
func (d *DB) RegistryImages(ctx context.Context) ([]ImageRef, error) {
rows, err := d.QueryContext(ctx,
`SELECT repo_id, image FROM registry_blobs UNION SELECT repo_id, image FROM registry_manifests`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ImageRef
for rows.Next() {
var r ImageRef
if err := rows.Scan(&r.RepoID, &r.Image); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (d *DB) queryStrings(ctx context.Context, query string, args ...any) ([]string, error) {
rows, err := d.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
var v string
if err := rows.Scan(&v); err != nil {
return nil, err
}
out = append(out, t)
out = append(out, v)
}
return out, rows.Err()
}
@@ -208,23 +258,10 @@ func (d *DB) DeleteUntaggedManifest(ctx context.Context, repoID int64, image, di
// image narrows it to one image path; pass nil for the whole repo.
func (d *DB) RepoDigests(ctx context.Context, repoID int64, image *string) ([]string, error) {
// A nil image binds as NULL and matches every row.
rows, err := d.QueryContext(ctx,
return d.queryStrings(ctx,
`SELECT digest FROM registry_blobs WHERE repo_id = ?1 AND (?2 IS NULL OR image = ?2)
UNION SELECT digest FROM registry_manifests WHERE repo_id = ?1 AND (?2 IS NULL OR image = ?2)`,
repoID, image)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var dg string
if err := rows.Scan(&dg); err != nil {
return nil, err
}
out = append(out, dg)
}
return out, rows.Err()
}
// DeleteImages removes the index rows of one image path, or of every image
▾Minternal/db/schema.sql
@@ -249,3 +249,5 @@ CREATE INDEX IF NOT EXISTS idx_ssh_keys_user_id ON ssh_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_labels_repo_id ON labels(repo_id);
CREATE INDEX IF NOT EXISTS idx_issue_labels_label_id ON issue_labels(label_id);
CREATE INDEX IF NOT EXISTS idx_patch_labels_label_id ON patch_labels(label_id);
CREATE INDEX IF NOT EXISTS idx_registry_blobs_digest ON registry_blobs(digest);
CREATE INDEX IF NOT EXISTS idx_registry_manifests_digest ON registry_manifests(digest);
▾Minternal/web/e2e/images_test.go
@@ -1,6 +1,7 @@
package e2e
import (
"fmt"
"net/http"
"net/url"
"os"
@@ -134,3 +135,153 @@ func TestImagesTabPublicAndPrivate(t *testing.T) {
}
admin.get("/priv-img/images").mustStatus(200)
}
func TestImageLayerCleanup(t *testing.T) {
e := newEnv(t)
admin := e.admin()
e.createRepo(admin, "gc-repo")
cfgData := []byte(`{"cfg":"shared"}`)
cfg := regUpload(t, e, "gc-repo", cfgData)
// push stores a manifest that shares cfg and has its own layer. An
// empty tag pushes it by digest.
push := func(seed, tag string) (layer, manifest string) {
t.Helper()
lay := []byte("layer-" + seed)
layer = regUpload(t, e, "gc-repo", lay)
body := ociManifest(cfg, len(cfgData), layer, len(lay))
manifest = regDigest(body)
if tag == "" {
tag = manifest
}
regAdmin(t, e, http.MethodPut, "/v2/gc-repo/manifests/"+tag, body,
"Content-Type", ociManifestType).mustStatus(201)
return layer, manifest
}
pushIndex := func(tag string, children ...string) string {
t.Helper()
var list []string
for _, c := range children {
list = append(list, fmt.Sprintf(`{"mediaType":%q,"digest":%q,"size":1}`, ociManifestType, c))
}
body := []byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[` +
strings.Join(list, ",") + `]}`)
regAdmin(t, e, http.MethodPut, "/v2/gc-repo/manifests/"+tag, body,
"Content-Type", "application/vnd.oci.image.index.v1+json").mustStatus(201)
return regDigest(body)
}
exists := func(d string) bool {
_, err := os.Stat(e.blobFile(d))
return err == nil
}
t.Run("deleting a tag removes the layers only it used", func(t *testing.T) {
lay1, man1 := push("1", "v1")
lay2, _ := push("2", "v2")
admin.post("/gc-repo/images/delete", url.Values{"image": {""}, "tag": {"v1"}}).mustRedirect("/gc-repo/images")
if exists(lay1) || exists(man1) {
t.Error("files of the deleted tag survived")
}
regAdmin(t, e, http.MethodHead, "/v2/gc-repo/blobs/"+lay1, nil).mustStatus(404)
if !exists(cfg) || !exists(lay2) {
t.Error("files still used by v2 were removed")
}
regAdmin(t, e, http.MethodHead, "/v2/gc-repo/blobs/"+cfg, nil).mustStatus(200)
})
t.Run("moving a tag removes the old manifest", func(t *testing.T) {
lay3, man3 := push("3", "moving")
lay4, _ := push("4", "moving")
if exists(lay3) || exists(man3) {
t.Error("files of the replaced manifest survived")
}
if !exists(lay4) {
t.Error("new layer missing")
}
})
t.Run("moving an index tag keeps children the new index uses", func(t *testing.T) {
lay5, man5 := push("5", "")
lay6, man6 := push("6", "")
pushIndex("multi", man5, man6)
pushIndex("multi", man5)
if exists(lay6) || exists(man6) {
t.Error("child dropped from the index survived")
}
if !exists(lay5) || !exists(man5) {
t.Error("child still in the index was removed")
}
})
t.Run("a layer shared with another repo stays", func(t *testing.T) {
e.createRepo(admin, "gc-other")
_, shared, _ := pushImage(t, e, "gc-other", "v1", "shared")
lay, _ := push("shared", "s1")
if lay != shared {
t.Fatal("expected identical layer digests")
}
admin.post("/gc-repo/images/delete", url.Values{"image": {""}, "tag": {"s1"}}).mustRedirect("/gc-repo/images")
regAdmin(t, e, http.MethodHead, "/v2/gc-repo/blobs/"+lay, nil).mustStatus(404)
regAdmin(t, e, http.MethodHead, "/v2/gc-other/blobs/"+lay, nil).mustStatus(200)
})
t.Run("a child used by another index stays", func(t *testing.T) {
lay8, man8 := push("8", "")
pushIndex("a", man8)
pushIndex("b", man8)
r := regAdmin(t, e, http.MethodDelete, "/v2/gc-repo/manifests/"+man8, nil).mustStatus(409)
if code := regErrCode(r); code != "DENIED" {
t.Errorf("error code = %q", code)
}
admin.post("/gc-repo/images/delete", url.Values{"image": {""}, "tag": {"a"}}).mustRedirect("/gc-repo/images")
if !exists(lay8) || !exists(man8) {
t.Error("child still used by index b was removed")
}
})
t.Run("the startup sweep removes leftovers", func(t *testing.T) {
stray := regUpload(t, e, "gc-repo", []byte("never used by a manifest"))
lay7, man7 := push("7", "")
junk := filepath.Join(filepath.Dir(e.blobFile(cfg)), ".manifest-123")
if err := os.WriteFile(junk, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := e.Srv.SweepRegistry(t.Context()); err != nil {
t.Fatal(err)
}
for _, d := range []string{stray, lay7, man7} {
if exists(d) {
t.Errorf("%s survived the sweep", d)
}
}
if _, err := os.Stat(junk); !os.IsNotExist(err) {
t.Error("stray file survived the sweep")
}
regAdmin(t, e, http.MethodGet, "/v2/gc-repo/manifests/multi", nil).mustStatus(200)
regAdmin(t, e, http.MethodGet, "/v2/gc-repo/manifests/v2", nil).mustStatus(200)
if !exists(cfg) {
t.Error("shared config removed")
}
})
t.Run("an unreadable manifest stops the cleanup", func(t *testing.T) {
const img = "gc-repo/broken"
layer := regUpload(t, e, img, []byte("shared-layer"))
var mans []string
for _, tag := range []string{"keep", "drop"} {
c := []byte(`{"cfg":"` + tag + `"}`)
body := ociManifest(regUpload(t, e, img, c), len(c), layer, len("shared-layer"))
regAdmin(t, e, http.MethodPut, "/v2/"+img+"/manifests/"+tag, body,
"Content-Type", ociManifestType).mustStatus(201)
mans = append(mans, regDigest(body))
}
if err := os.WriteFile(e.blobFile(mans[0]), []byte("not json"), 0o644); err != nil {
t.Fatal(err)
}
admin.post("/gc-repo/images/delete", url.Values{"image": {"broken"}, "tag": {"drop"}}).mustRedirect("/gc-repo/images")
regAdmin(t, e, http.MethodHead, "/v2/"+img+"/blobs/"+layer, nil).mustStatus(200)
if !exists(layer) {
t.Error("layer of the unreadable manifest was removed")
}
})
}
▾Minternal/web/e2e/registry_test.go
@@ -305,17 +305,19 @@ func TestRegistry(t *testing.T) {
regAdmin(t, e, http.MethodDelete, "/v2/reg-repo/manifests/latest", nil).mustStatus(404)
})
t.Run("deleting a manifest by digest drops its tags", func(t *testing.T) {
t.Run("deleting a manifest by digest drops its tags and layers", func(t *testing.T) {
regAdmin(t, e, http.MethodDelete, "/v2/reg-repo/manifests/"+manifestDigest, nil).mustStatus(202)
regAdmin(t, e, http.MethodGet, "/v2/reg-repo/manifests/v1", nil).mustStatus(404)
if got := regTags(t, e, "reg-repo", ""); len(got) != 0 {
t.Errorf("tags = %v", got)
}
regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+layerDigest, nil).mustStatus(404)
})
t.Run("deleting a blob unlinks it from the image", func(t *testing.T) {
regAdmin(t, e, http.MethodDelete, "/v2/reg-repo/blobs/"+layerDigest, nil).mustStatus(202)
r := regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+layerDigest, nil).mustStatus(404)
d := regUpload(t, e, "reg-repo", layerBlob)
regAdmin(t, e, http.MethodDelete, "/v2/reg-repo/blobs/"+d, nil).mustStatus(202)
r := regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+d, nil).mustStatus(404)
if code := regErrCode(r); code != "" && code != "BLOB_UNKNOWN" {
t.Errorf("error code = %q", code)
}
▾Minternal/web/images.go
@@ -1,7 +1,15 @@
package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"github.com/go-chi/chi/v5"
@@ -43,8 +51,7 @@ func (s *Server) imageList(w http.ResponseWriter, r *http.Request) {
}
// deleteImage removes one tag, or the whole image path when no tag is given.
// A manifest left without tags goes too. Layer blobs stay linked to the
// image until the image itself is deleted.
// A manifest left without tags goes too, with the layers only it used.
func (s *Server) deleteImage(w http.ResponseWriter, r *http.Request) {
repo, ok := s.adminRepo(w, r)
if !ok {
@@ -60,22 +67,16 @@ func (s *Server) deleteImage(w http.ResponseWriter, r *http.Request) {
redirectTo(w, r, "/"+repo.Name+"/images")
return
}
m, err := s.DB.ManifestByTag(r.Context(), repo.ID, image, tag)
s.registryMu.Lock()
digest, err := s.DB.DeleteTag(r.Context(), repo.ID, image, tag)
if err == nil && digest != "" {
s.dropUnused(r.Context(), repo.ID, image, []string{digest}, nil)
}
s.registryMu.Unlock()
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if m != nil {
if _, err := s.DB.DeleteTag(r.Context(), repo.ID, image, tag); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
s.registryMu.Lock()
if gone, err := s.DB.DeleteUntaggedManifest(r.Context(), repo.ID, image, m.Digest); err == nil && gone {
s.removeUnreferenced(r, m.Digest)
}
s.registryMu.Unlock()
}
redirectTo(w, r, "/"+repo.Name+"/images")
}
@@ -104,7 +105,7 @@ func (s *Server) purgeImages(r *http.Request, repoID int64, image *string) error
return err
}
for _, d := range digests {
s.removeUnreferenced(r, d)
s.removeUnreferenced(r.Context(), d)
}
return nil
}
@@ -123,7 +124,149 @@ func (s *Server) deleteRepoRow(r *http.Request, repoID int64) error {
return err
}
for _, d := range digests {
s.removeUnreferenced(r, d)
s.removeUnreferenced(r.Context(), d)
}
return nil
}
// dropUnused deletes the given manifests that no tag and no other manifest
// of the image use. Then it unlinks the given blobs and the blobs of the
// deleted manifests that no remaining manifest uses. Children of a deleted
// index go the same way. The caller holds registryMu. Errors are only
// logged: the change that triggered the cleanup is already stored, and the
// startup sweep removes what is left.
//
// ponytail: a push in flight that shares a layer only with a dropped
// manifest fails its manifest PUT with MANIFEST_BLOB_UNKNOWN. Two CI
// pipelines that push the same image path can hit this. A retry works.
func (s *Server) dropUnused(ctx context.Context, repoID int64, image string, manifests, blobs []string) {
if err := s.tryDropUnused(context.WithoutCancel(ctx), repoID, image, manifests, blobs); err != nil {
log.Printf("registry cleanup of repo %d image %q: %v", repoID, image, err)
}
}
func (s *Server) tryDropUnused(ctx context.Context, repoID int64, image string, manifests, blobs []string) error {
for len(manifests) > 0 {
used, err := s.imageRefs(ctx, repoID, image)
if err != nil {
return err
}
var children []string
for _, d := range manifests {
if used[d] {
continue
}
// A broken file only leaves its blobs linked until the sweep.
refs, _ := s.readManifestRefs(d)
gone, err := s.DB.DeleteUntaggedManifest(ctx, repoID, image, d)
if err != nil {
return err
}
if !gone {
continue
}
s.removeUnreferenced(ctx, d)
blobs = append(blobs, refs.blobs()...)
children = append(children, refs.children()...)
}
manifests = children
}
if len(blobs) == 0 {
return nil
}
used, err := s.imageRefs(ctx, repoID, image)
if err != nil {
return err
}
for _, b := range blobs {
if used[b] {
continue
}
if err := s.DB.UnlinkBlob(ctx, repoID, image, b); err != nil {
return err
}
s.removeUnreferenced(ctx, b)
}
return nil
}
// imageRefs collects every blob and child manifest digest that the image's
// manifests reference. It fails on a manifest it cannot read, because a
// missing entry would make a used blob look unused.
func (s *Server) imageRefs(ctx context.Context, repoID int64, image string) (map[string]bool, error) {
digests, err := s.DB.ManifestDigests(ctx, repoID, image)
if err != nil {
return nil, err
}
used := map[string]bool{}
for _, d := range digests {
refs, err := s.readManifestRefs(d)
if err != nil {
return nil, fmt.Errorf("manifest %s: %w", d, err)
}
for _, r := range append(refs.blobs(), refs.children()...) {
used[r] = true
}
}
return used, nil
}
func (s *Server) readManifestRefs(digest string) (manifestRefs, error) {
var refs manifestRefs
body, err := os.ReadFile(s.blobPath(digest))
if err != nil {
return refs, err
}
return refs, json.Unmarshal(body, &refs)
}
// SweepRegistry drops untagged manifests and unused blob links of every
// image, then deletes blob files that no index row references. It must run
// when no push is in flight, because a push links its layers before its
// manifest.
func (s *Server) SweepRegistry(ctx context.Context) error {
s.registryMu.Lock()
defer s.registryMu.Unlock()
images, err := s.DB.RegistryImages(ctx)
if err != nil {
return err
}
for _, img := range images {
manifests, err := s.DB.ManifestDigests(ctx, img.RepoID, img.Image)
if err != nil {
return err
}
blobs, err := s.DB.BlobDigests(ctx, img.RepoID, img.Image)
if err != nil {
return err
}
s.dropUnused(ctx, img.RepoID, img.Image, manifests, blobs)
}
root, err := os.OpenRoot(filepath.Join(s.Cfg.RegistryDir(), "blobs"))
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
defer func() { _ = root.Close() }()
files := 0
err = fs.WalkDir(root.FS(), ".", func(path string, e fs.DirEntry, err error) error {
if err != nil || e.IsDir() {
return err
}
// Keep in sync with blobPath: blobs/<algorithm>/<hex>.
digest := filepath.Base(filepath.Dir(path)) + ":" + e.Name()
if used, err := s.DB.DigestReferenced(ctx, digest); err != nil || used {
return nil
}
if root.Remove(path) == nil {
files++
}
return nil
})
if files > 0 {
log.Printf("registry sweep: removed %d unused files", files)
}
return err
}
▾Minternal/web/registry.go
@@ -470,7 +470,7 @@ func (s *Server) registryBlob(w http.ResponseWriter, r *http.Request, repo *db.R
s.registryMu.Lock()
err = s.DB.UnlinkBlob(r.Context(), repo.ID, image, digest)
if err == nil {
s.removeUnreferenced(r, digest)
s.removeUnreferenced(r.Context(), digest)
}
s.registryMu.Unlock()
if err != nil {
@@ -486,8 +486,8 @@ func (s *Server) registryBlob(w http.ResponseWriter, r *http.Request, repo *db.R
// removeUnreferenced deletes the file behind digest once no image uses it.
// The caller holds registryMu. The lookup failing keeps the file; a stray
// file is cheaper than a broken pull.
func (s *Server) removeUnreferenced(r *http.Request, digest string) {
used, err := s.DB.DigestReferenced(r.Context(), digest)
func (s *Server) removeUnreferenced(ctx context.Context, digest string) {
used, err := s.DB.DigestReferenced(ctx, digest)
if err == nil && !used {
_ = os.Remove(s.blobPath(digest))
}
@@ -531,6 +531,27 @@ type manifestRefs struct {
} `json:"manifests"`
}
// blobs lists the config and layer digests.
func (m manifestRefs) blobs() []string {
var out []string
if m.Config != nil {
out = append(out, m.Config.Digest)
}
for _, l := range m.Layers {
out = append(out, l.Digest)
}
return out
}
// children lists the manifest digests of an index.
func (m manifestRefs) children() []string {
var out []string
for _, c := range m.Manifests {
out = append(out, c.Digest)
}
return out
}
func (s *Server) registryManifest(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, ref string) {
isDigest := digestRe.MatchString(ref)
if !isDigest && !util.ValidImageTag(ref) {
@@ -542,33 +563,7 @@ func (s *Server) registryManifest(w http.ResponseWriter, r *http.Request, repo *
s.putManifest(w, r, repo, image, ref, isDigest)
return
case http.MethodDelete:
if isDigest {
s.registryMu.Lock()
found, err := s.DB.DeleteManifest(r.Context(), repo.ID, image, ref)
if err == nil && found {
s.removeUnreferenced(r, ref)
}
s.registryMu.Unlock()
if err != nil {
registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
if !found {
registryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest unknown")
return
}
} else {
found, err := s.DB.DeleteTag(r.Context(), repo.ID, image, ref)
if err != nil {
registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
if !found {
registryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "tag unknown")
return
}
}
w.WriteHeader(http.StatusAccepted)
s.deleteManifest(w, r, repo, image, ref, isDigest)
return
case http.MethodGet, http.MethodHead:
default:
@@ -594,6 +589,51 @@ func (s *Server) registryManifest(w http.ResponseWriter, r *http.Request, repo *
s.serveDigest(w, r, m.Digest, m.MediaType)
}
// deleteManifest removes a tag, or a manifest with its tags, and then the
// manifests and blobs nothing uses any more.
func (s *Server) deleteManifest(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, ref string, isDigest bool) {
ctx := r.Context()
s.registryMu.Lock()
defer s.registryMu.Unlock()
if !isDigest {
digest, err := s.DB.DeleteTag(ctx, repo.ID, image, ref)
if err != nil {
registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
if digest == "" {
registryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "tag unknown")
return
}
s.dropUnused(ctx, repo.ID, image, []string{digest}, nil)
w.WriteHeader(http.StatusAccepted)
return
}
used, err := s.imageRefs(ctx, repo.ID, image)
if err != nil {
registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
if used[ref] {
registryError(w, http.StatusConflict, "DENIED", "an index still uses this manifest")
return
}
// A broken file only leaves its blobs linked until the startup sweep.
refs, _ := s.readManifestRefs(ref)
found, err := s.DB.DeleteManifest(ctx, repo.ID, image, ref)
if err != nil {
registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
if !found {
registryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest unknown")
return
}
s.removeUnreferenced(ctx, ref)
s.dropUnused(ctx, repo.ID, image, refs.children(), refs.blobs())
w.WriteHeader(http.StatusAccepted)
}
// putManifest stores a manifest after checking that everything it points
// at is already in this image. That is what makes a pull reliable.
func (s *Server) putManifest(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, ref string, isDigest bool) {
@@ -658,14 +698,11 @@ func (s *Server) storeManifest(ctx context.Context, repo *db.Repo, image string,
// Every layer and config blob must be linked, every child manifest
// stored. Otherwise a client could pull a manifest whose parts 404.
var blobs []string
if refs.Config != nil {
blobs = append(blobs, refs.Config.Digest)
}
for _, l := range refs.Layers {
blobs = append(blobs, l.Digest)
}
for _, d := range blobs {
// The lock covers the checks, so a cleanup cannot unlink a part
// between check and store.
s.registryMu.Lock()
defer s.registryMu.Unlock()
for _, d := range refs.blobs() {
if !digestRe.MatchString(d) {
return "", &manifestRefused{"MANIFEST_INVALID", "unsupported digest " + d}
}
@@ -692,12 +729,16 @@ func (s *Server) storeManifest(ctx context.Context, repo *db.Repo, image string,
digest := bodyDigest(body)
m := db.Manifest{Digest: digest, MediaType: mediaType}
s.registryMu.Lock()
defer s.registryMu.Unlock()
if err := s.writeBlob(digest, body); err != nil {
return "", err
}
return digest, s.DB.PutManifest(ctx, repo.ID, image, m, tag, db.NowISO())
prev, err := s.DB.PutManifest(ctx, repo.ID, image, m, tag, db.NowISO())
if err != nil || prev == "" || prev == digest {
return digest, err
}
// A moved tag can leave the old manifest and its layers unused.
s.dropUnused(ctx, repo.ID, image, []string{prev}, nil)
return digest, nil
}
// writeBlob stores small content (a manifest) by digest.
▾Minternal/web/registry_import.go
@@ -88,16 +88,9 @@ func (s *Server) ImportImage(ctx context.Context, repoName, image string, tags [
if err := json.Unmarshal(manifest, &refs); err != nil {
return "", errors.New("manifest is not valid JSON")
}
var digests []string
if refs.Config != nil {
digests = append(digests, refs.Config.Digest)
}
for _, l := range refs.Layers {
digests = append(digests, l.Digest)
}
// A digest missing from the archive is left to storeManifest, which
// accepts it only when this image already links it.
for _, d := range digests {
for _, d := range refs.blobs() {
p, ok := staged[d]
if !ok {
continue