Skip to content

Commit e928304

Browse files
committed
fix(meta): delete repo meta when the last manifest is removed
storage.maxRepos is enforced against metaDB.CountRepos(), but no code path removed a repo's meta record on delete. RemoveRepoReference drops the reference and writes the record back; DeleteRepoMeta is only called by ParseStorage at startup, for repos already absent from storage. The count therefore never decreases. Once a registry reaches maxRepos, pushes creating a new repository are rejected until restart, even after repos are deleted. GC does not help: CleanupRepo removes storage without touching metadb. OnDeleteManifest now calls DeleteRepoMeta when the repo's index has no manifests left. - Emptiness is taken from the index, not from repo meta: untagged content such as a signature referrer leaves no tag to count. - GetIndexContent expects the caller to hold the lock (as ParseStorage does), so the read is wrapped in RLock/RUnlock. Both callers of OnDeleteManifest run after DeleteImageManifest released its lock. - Errors are logged, not returned: the manifest delete already succeeded, and ParseStorage corrects a stale record on next start. - Repos still holding content keep their record and keep counting. Tests (all fail without the change): - pkg/meta: emptied repo drops its meta and stops counting; a repo with another manifest keeps both. Error paths covered via mocks. - pkg/api: partial delete keeps the slot, full delete releases it, emptied name is reusable, release survives restart, holds with GC on. - test/blackbox: end-to-end case, plus a guard that only one slot is released. Signed-off-by: Bachir Khiati <bachir.khiati@gmail.com>
1 parent d240074 commit e928304

5 files changed

Lines changed: 396 additions & 0 deletions

File tree

pkg/api/quota_test.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import (
66
"net/http"
77
"sync"
88
"testing"
9+
"time"
910

11+
ispec "github.com/opencontainers/image-spec/specs-go/v1"
1012
. "github.com/smartystreets/goconvey/convey"
1113
"gopkg.in/resty.v1"
1214

@@ -136,3 +138,172 @@ func TestQuotaConcurrency(t *testing.T) {
136138
})
137139
})
138140
}
141+
142+
// startQuotaServerAt starts a quota-enforcing registry on a given root directory, so a test can restart
143+
// the server over the same storage.
144+
func startQuotaServerAt(t *testing.T, maxRepos int, rootDir string, gc bool) (string, func()) {
145+
t.Helper()
146+
147+
conf := config.New()
148+
conf.HTTP.Port = "0"
149+
conf.Storage.RootDirectory = rootDir
150+
conf.Storage.MaxRepos = maxRepos
151+
// Dedupe keeps its cache open past Shutdown, blocking a restart on the same dir.
152+
conf.Storage.Dedupe = false
153+
154+
if gc {
155+
conf.Storage.GC = true
156+
conf.Storage.GCDelay = 1 * time.Second
157+
conf.Storage.GCInterval = 2 * time.Second
158+
}
159+
160+
ctlr := api.NewController(conf)
161+
ctlrManager := test.NewControllerManager(ctlr)
162+
baseURL := ctlrManager.StartAndWait()
163+
164+
return baseURL, func() { ctlrManager.StopServer() }
165+
}
166+
167+
func manifestDigest(t *testing.T, baseURL, repo, ref string) string {
168+
t.Helper()
169+
170+
resp, err := resty.R().
171+
SetHeader("Accept", ispec.MediaTypeImageManifest).
172+
Get(baseURL + "/v2/" + repo + "/manifests/" + ref)
173+
So(err, ShouldBeNil)
174+
175+
return resp.Header().Get("Docker-Content-Digest")
176+
}
177+
178+
// uploadNewRepo pushes a complete image into a new repo.
179+
func uploadNewRepo(t *testing.T, baseURL, repo string) error {
180+
t.Helper()
181+
182+
return UploadImage(CreateRandomImage(), baseURL, repo, "v1")
183+
}
184+
185+
// pushNewRepoStatus reports how the quota answers a new-repo push. Only a refusal is meaningful: the
186+
// manifest is sent without blobs, so an allowed push fails later in the handler. Use uploadNewRepo to
187+
// assert acceptance.
188+
func pushNewRepoStatus(t *testing.T, baseURL, repo string) int {
189+
t.Helper()
190+
191+
img := CreateRandomImage()
192+
193+
manifestBody, err := json.Marshal(img.Manifest)
194+
So(err, ShouldBeNil)
195+
196+
resp, err := resty.R().
197+
SetHeader("Content-Type", ispec.MediaTypeImageManifest).
198+
SetBody(manifestBody).
199+
Put(baseURL + "/v2/" + repo + "/manifests/v1")
200+
So(err, ShouldBeNil)
201+
202+
return resp.StatusCode()
203+
}
204+
205+
// TestQuotaSlotReleasedOnDelete checks that a partial delete keeps the slot, a full delete releases it,
206+
// and the remaining images stay pullable.
207+
func TestQuotaSlotReleasedOnDelete(t *testing.T) {
208+
Convey("Given a registry at its repo limit", t, func() {
209+
rootDir := t.TempDir()
210+
baseURL, stop := startQuotaServerAt(t, 3, rootDir, false)
211+
defer stop()
212+
213+
first := CreateRandomImage()
214+
second := CreateRandomImage()
215+
216+
So(UploadImage(first, baseURL, "shared", "v1"), ShouldBeNil)
217+
So(UploadImage(second, baseURL, "shared", "v2"), ShouldBeNil)
218+
So(UploadImage(CreateRandomImage(), baseURL, "second", "v1"), ShouldBeNil)
219+
So(UploadImage(CreateRandomImage(), baseURL, "third", "v1"), ShouldBeNil)
220+
221+
So(pushNewRepoStatus(t, baseURL, "overflow"), ShouldEqual, http.StatusTooManyRequests)
222+
223+
Convey("Deleting one tag of a two-tag repo releases nothing", func() {
224+
resp, err := resty.R().Delete(baseURL + "/v2/shared/manifests/" + first.DigestStr())
225+
So(err, ShouldBeNil)
226+
So(resp.StatusCode(), ShouldEqual, http.StatusAccepted)
227+
228+
// The sibling tag is untouched, and still serves the same bytes.
229+
So(manifestDigest(t, baseURL, "shared", "v2"), ShouldEqual, second.DigestStr())
230+
231+
// The repo still holds content, so it still holds its slot.
232+
So(pushNewRepoStatus(t, baseURL, "overflow"), ShouldEqual, http.StatusTooManyRequests)
233+
234+
Convey("Deleting the last tag releases the slot, and the name is reusable", func() {
235+
resp, err := resty.R().Delete(baseURL + "/v2/shared/manifests/" + second.DigestStr())
236+
So(err, ShouldBeNil)
237+
So(resp.StatusCode(), ShouldEqual, http.StatusAccepted)
238+
239+
Convey("A differently named repo can take the freed slot", func() {
240+
So(uploadNewRepo(t, baseURL, "afterfree"), ShouldBeNil)
241+
})
242+
243+
Convey("The emptied name can be reused, and round-trips intact", func() {
244+
reused := CreateRandomImage()
245+
So(UploadImage(reused, baseURL, "shared", "again"), ShouldBeNil)
246+
So(manifestDigest(t, baseURL, "shared", "again"), ShouldEqual, reused.DigestStr())
247+
})
248+
})
249+
})
250+
})
251+
}
252+
253+
// TestQuotaSlotStaysReleasedAcrossRestart checks the startup reparse does not hand back a slot that a
254+
// delete released.
255+
func TestQuotaSlotStaysReleasedAcrossRestart(t *testing.T) {
256+
Convey("Given a repo emptied while at the limit", t, func() {
257+
rootDir := t.TempDir()
258+
baseURL, stop := startQuotaServerAt(t, 2, rootDir, false)
259+
260+
doomed := CreateRandomImage()
261+
keeper := CreateRandomImage()
262+
263+
So(UploadImage(doomed, baseURL, "doomed", "v1"), ShouldBeNil)
264+
So(UploadImage(keeper, baseURL, "keeper", "v1"), ShouldBeNil)
265+
So(pushNewRepoStatus(t, baseURL, "extra"), ShouldEqual, http.StatusTooManyRequests)
266+
267+
resp, err := resty.R().Delete(baseURL + "/v2/doomed/manifests/" + doomed.DigestStr())
268+
So(err, ShouldBeNil)
269+
So(resp.StatusCode(), ShouldEqual, http.StatusAccepted)
270+
271+
stop()
272+
273+
Convey("The slot is still free after a restart, and the survivor is intact", func() {
274+
restartedURL, stopAgain := startQuotaServerAt(t, 2, rootDir, false)
275+
defer stopAgain()
276+
277+
So(manifestDigest(t, restartedURL, "keeper", "v1"), ShouldEqual, keeper.DigestStr())
278+
So(uploadNewRepo(t, restartedURL, "extra"), ShouldBeNil)
279+
})
280+
})
281+
}
282+
283+
// TestQuotaSlotReleaseWithGCEnabled runs the release path with GC active, since GC also writes to
284+
// metadata.
285+
func TestQuotaSlotReleaseWithGCEnabled(t *testing.T) {
286+
Convey("Given a registry with GC enabled and at its limit", t, func() {
287+
rootDir := t.TempDir()
288+
baseURL, stop := startQuotaServerAt(t, 2, rootDir, true)
289+
defer stop()
290+
291+
doomed := CreateRandomImage()
292+
keeper := CreateRandomImage()
293+
294+
So(UploadImage(doomed, baseURL, "doomed", "v1"), ShouldBeNil)
295+
So(UploadImage(keeper, baseURL, "keeper", "v1"), ShouldBeNil)
296+
297+
resp, err := resty.R().Delete(baseURL + "/v2/doomed/manifests/" + doomed.DigestStr())
298+
So(err, ShouldBeNil)
299+
So(resp.StatusCode(), ShouldEqual, http.StatusAccepted)
300+
301+
So(uploadNewRepo(t, baseURL, "extra"), ShouldBeNil)
302+
303+
// Let several GC cycles run over the emptied repo.
304+
time.Sleep(6 * time.Second)
305+
306+
So(manifestDigest(t, baseURL, "keeper", "v1"), ShouldEqual, keeper.DigestStr())
307+
So(pushNewRepoStatus(t, baseURL, "yetanother"), ShouldEqual, http.StatusTooManyRequests)
308+
})
309+
}

pkg/meta/hooks.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package meta
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
78
"slices"
9+
"time"
810

911
godigest "github.com/opencontainers/go-digest"
1012
v1 "github.com/opencontainers/image-spec/specs-go/v1"
@@ -15,6 +17,7 @@ import (
1517
"zotregistry.dev/zot/v2/pkg/log"
1618
mTypes "zotregistry.dev/zot/v2/pkg/meta/types"
1719
"zotregistry.dev/zot/v2/pkg/storage"
20+
storageTypes "zotregistry.dev/zot/v2/pkg/storage/types"
1821
)
1922

2023
// priorTagManifest records where MetaDB believed each tag pointed before a digest PUT with tag=
@@ -280,9 +283,52 @@ func OnDeleteManifest(repo, reference, mediaType string, digest godigest.Digest,
280283
return err
281284
}
282285

286+
deleteRepoMetaIfEmpty(repo, imgStore, metaDB, log)
287+
283288
return nil
284289
}
285290

291+
// deleteRepoMetaIfEmpty removes a repo's meta once its last manifest is gone. RemoveRepoReference only
292+
// drops the reference, so the record would otherwise keep counting towards maxRepos until the startup
293+
// reparse. Emptiness comes from the index, since untagged content leaves no tag behind.
294+
func deleteRepoMetaIfEmpty(repo string, imgStore storageTypes.ImageStore, metaDB mTypes.MetaDB, log log.Logger) {
295+
var lockLatency time.Time
296+
297+
imgStore.RLock(&lockLatency)
298+
indexContent, err := imgStore.GetIndexContent(repo)
299+
imgStore.RUnlock(&lockLatency)
300+
301+
if err != nil {
302+
log.Debug().Err(err).Str("repository", repo).Str("component", "metadb").
303+
Msg("failed to read index while checking whether repo is empty")
304+
305+
return
306+
}
307+
308+
var index v1.Index
309+
310+
if err := json.Unmarshal(indexContent, &index); err != nil {
311+
log.Debug().Err(err).Str("repository", repo).Str("component", "metadb").
312+
Msg("failed to parse index while checking whether repo is empty")
313+
314+
return
315+
}
316+
317+
if len(index.Manifests) > 0 {
318+
return
319+
}
320+
321+
if err := metaDB.DeleteRepoMeta(repo); err != nil {
322+
log.Error().Err(err).Str("repository", repo).Str("component", "metadb").
323+
Msg("failed to delete meta for emptied repo")
324+
325+
return
326+
}
327+
328+
log.Debug().Str("repository", repo).Str("component", "metadb").
329+
Msg("removed meta for emptied repo")
330+
}
331+
286332
// OnGetManifest is called when a manifest is downloaded. It increments the download counter on that manifest.
287333
func OnGetManifest(name, reference, mediaType string, body []byte,
288334
storeController storage.StoreController, metaDB mTypes.MetaDB, log log.Logger,

pkg/meta/hooks_internal_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,60 @@ func TestRollbackDigestManifestTags(t *testing.T) {
260260
})
261261
})
262262
}
263+
264+
func TestDeleteRepoMetaIfEmpty(t *testing.T) {
265+
Convey("deleteRepoMetaIfEmpty", t, func() {
266+
logger := log.NewTestLogger()
267+
emptyIndex := []byte(`{"schemaVersion":2,"manifests":[]}`)
268+
269+
Convey("An unreadable index leaves the meta alone", func() {
270+
called := false
271+
imgStore := mocks.MockedImageStore{
272+
GetIndexContentFn: func(repo string) ([]byte, error) { return nil, errHookInternal },
273+
}
274+
metaDB := mocks.MetaDBMock{
275+
DeleteRepoMetaFn: func(repo string) error { called = true; return nil },
276+
}
277+
278+
deleteRepoMetaIfEmpty("repo", imgStore, metaDB, logger)
279+
So(called, ShouldBeFalse)
280+
})
281+
282+
Convey("An unparseable index leaves the meta alone", func() {
283+
called := false
284+
imgStore := mocks.MockedImageStore{
285+
GetIndexContentFn: func(repo string) ([]byte, error) { return []byte("not json"), nil },
286+
}
287+
metaDB := mocks.MetaDBMock{
288+
DeleteRepoMetaFn: func(repo string) error { called = true; return nil },
289+
}
290+
291+
deleteRepoMetaIfEmpty("repo", imgStore, metaDB, logger)
292+
So(called, ShouldBeFalse)
293+
})
294+
295+
Convey("A failed delete is swallowed, since the manifest is already gone", func() {
296+
imgStore := mocks.MockedImageStore{
297+
GetIndexContentFn: func(repo string) ([]byte, error) { return emptyIndex, nil },
298+
}
299+
metaDB := mocks.MetaDBMock{
300+
DeleteRepoMetaFn: func(repo string) error { return errHookInternal },
301+
}
302+
303+
So(func() { deleteRepoMetaIfEmpty("repo", imgStore, metaDB, logger) }, ShouldNotPanic)
304+
})
305+
306+
Convey("An empty index deletes the meta for that repo", func() {
307+
var deleted string
308+
imgStore := mocks.MockedImageStore{
309+
GetIndexContentFn: func(repo string) ([]byte, error) { return emptyIndex, nil },
310+
}
311+
metaDB := mocks.MetaDBMock{
312+
DeleteRepoMetaFn: func(repo string) error { deleted = repo; return nil },
313+
}
314+
315+
deleteRepoMetaIfEmpty("repo", imgStore, metaDB, logger)
316+
So(deleted, ShouldEqual, "repo")
317+
})
318+
})
319+
}

0 commit comments

Comments
 (0)