detached eb72c2f

feat(releases): release, asset and sweep use cases

commit
eb72c2fe9eb65b71e0830a1f649edd145cf14022
author
brice · · 2026-10-21 14:45 UTC
committer
brice · · 2026-10-21 17:53 UTC
parent
0c4e1e3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +1758 -0
M internal/usecase/ci.go +2 -0
@@ -186,6 +186,8 @@ type CIConfig struct {
186186 // ScheduleVisit, when set, is called with each repository the scheduler
187187 // looks at. Tests use it.
188188 ScheduleVisit func(repo domain.RepoName)
189+ // ReleaseSweeper collects abandoned release uploads during the daily sweep.
190+ ReleaseSweeper ports.ReleaseSweeper
189191 }
190192
191193 func (c CIConfig) withDefaults() CIConfig {
@@ -186,6 +186,8 @@ type CIConfig struct {
186 // ScheduleVisit, when set, is called with each repository the scheduler186 // ScheduleVisit, when set, is called with each repository the scheduler
187 // looks at. Tests use it.187 // looks at. Tests use it.
188 ScheduleVisit func(repo domain.RepoName)188 ScheduleVisit func(repo domain.RepoName)
189+ // ReleaseSweeper collects abandoned release uploads during the daily sweep.
190+ ReleaseSweeper ports.ReleaseSweeper
189 }191 }
190 192
191 func (c CIConfig) withDefaults() CIConfig {193 func (c CIConfig) withDefaults() CIConfig {
M internal/usecase/ci_watch.go +3 -0
@@ -276,6 +276,9 @@ func (c *CI) sweep(ctx context.Context) {
276276 c.removeOrphans(ctx, "logs", "log directories", c.cfg.Logs.RunIDs, c.cfg.Logs.RemoveRuns)
277277 c.removeOrphans(ctx, "summaries", "summary directories", c.cfg.Summaries.RunIDs, c.cfg.Summaries.RemoveRuns)
278278 c.sweepArtifacts(ctx)
279+ if c.cfg.ReleaseSweeper != nil {
280+ c.cfg.ReleaseSweeper.Sweep(ctx)
281+ }
279282 }
280283
281284 // removeOrphans deletes what a file store (logs, summaries) holds for runs that
@@ -276,6 +276,9 @@ func (c *CI) sweep(ctx context.Context) {
276 c.removeOrphans(ctx, "logs", "log directories", c.cfg.Logs.RunIDs, c.cfg.Logs.RemoveRuns)276 c.removeOrphans(ctx, "logs", "log directories", c.cfg.Logs.RunIDs, c.cfg.Logs.RemoveRuns)
277 c.removeOrphans(ctx, "summaries", "summary directories", c.cfg.Summaries.RunIDs, c.cfg.Summaries.RemoveRuns)277 c.removeOrphans(ctx, "summaries", "summary directories", c.cfg.Summaries.RunIDs, c.cfg.Summaries.RemoveRuns)
278 c.sweepArtifacts(ctx)278 c.sweepArtifacts(ctx)
279+ if c.cfg.ReleaseSweeper != nil {
280+ c.cfg.ReleaseSweeper.Sweep(ctx)
281+ }
279 }282 }
280 283
281 // removeOrphans deletes what a file store (logs, summaries) holds for runs that284 // removeOrphans deletes what a file store (logs, summaries) holds for runs that
M internal/usecase/events.go +16 -0
@@ -171,6 +171,14 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
171171 return repoSnapshot(ev.Visibility, ev.CreatedAt)
172172 case domain.MirrorRecovered:
173173 return repoSnapshot(ev.Visibility, ev.CreatedAt)
174+ case domain.ReleasePublished:
175+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
176+ case domain.ReleaseUpdated:
177+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
178+ case domain.ReleaseDeleted:
179+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
180+ case domain.ReleaseUnpublished:
181+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
174182 case domain.PushEvent:
175183 if ev.CreatedAt.IsZero() {
176184 return bufferedEvent{} // repository unknown at publish time: nobody is told
@@ -531,6 +539,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
531539 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
532540 case domain.MirrorRecovered:
533541 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542+ case domain.ReleasePublished:
543+ return e.deliverRepo(ctx, w, ev.Release.Repo, false, be, out, send)
544+ case domain.ReleaseUpdated:
545+ return e.deliverRepo(ctx, w, ev.Release.Repo, false, be, out, send)
546+ case domain.ReleaseDeleted:
547+ return e.deliverRepo(ctx, w, ev.Release.Repo, false, be, out, send)
548+ case domain.ReleaseUnpublished:
549+ return e.deliverRepo(ctx, w, ev.Release.Repo, false, be, out, send)
534550 case domain.PushEvent:
535551 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
536552 case domain.MergeRequestOpened:
@@ -171,6 +171,14 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
171 return repoSnapshot(ev.Visibility, ev.CreatedAt)171 return repoSnapshot(ev.Visibility, ev.CreatedAt)
172 case domain.MirrorRecovered:172 case domain.MirrorRecovered:
173 return repoSnapshot(ev.Visibility, ev.CreatedAt)173 return repoSnapshot(ev.Visibility, ev.CreatedAt)
174+ case domain.ReleasePublished:
175+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
176+ case domain.ReleaseUpdated:
177+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
178+ case domain.ReleaseDeleted:
179+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
180+ case domain.ReleaseUnpublished:
181+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
174 case domain.PushEvent:182 case domain.PushEvent:
175 if ev.CreatedAt.IsZero() {183 if ev.CreatedAt.IsZero() {
176 return bufferedEvent{} // repository unknown at publish time: nobody is told184 return bufferedEvent{} // repository unknown at publish time: nobody is told
@@ -531,6 +539,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
531 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)539 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
532 case domain.MirrorRecovered:540 case domain.MirrorRecovered:
533 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)541 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542+ case domain.ReleasePublished:
543+ return e.deliverRepo(ctx, w, ev.Release.Repo, false, be, out, send)
544+ case domain.ReleaseUpdated:
545+ return e.deliverRepo(ctx, w, ev.Release.Repo, false, be, out, send)
546+ case domain.ReleaseDeleted:
547+ return e.deliverRepo(ctx, w, ev.Release.Repo, false, be, out, send)
548+ case domain.ReleaseUnpublished:
549+ return e.deliverRepo(ctx, w, ev.Release.Repo, false, be, out, send)
534 case domain.PushEvent:550 case domain.PushEvent:
535 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)551 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
536 case domain.MergeRequestOpened:552 case domain.MergeRequestOpened:
A internal/usecase/releases.go +489 -0
@@ -0,0 +1,489 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+ "sync"
13+ "time"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// Releases manages release metadata and blob contents. Construct once per server.
20+type Releases struct {
21+ store ports.ReleaseStore
22+ blobs ports.BlobStore
23+ repos ports.RepoMetaStore
24+ reader ports.RepoReader
25+ tags ports.ReleaseTagWriter
26+ pushes ports.PushHookService
27+ bus ports.EventBus
28+ clock ports.Clock
29+ limits ports.ReleaseLimits
30+ uploads chan struct{}
31+ sweepMu sync.Mutex
32+ misses map[int64]int
33+}
34+
35+var _ ports.ReleaseService = (*Releases)(nil)
36+var _ ports.ReleaseTagTrigger = (*Releases)(nil)
37+var _ ports.ReleaseRepoCleaner = (*Releases)(nil)
38+var _ ports.ReleaseSweeper = (*Releases)(nil)
39+
40+// NewReleases constructs the release service. Zero limits select the defaults.
41+// pushes must be the shared Pushes instance, so created tags notify every hook.
42+func NewReleases(store ports.ReleaseStore, blobs ports.BlobStore, repos ports.RepoMetaStore, reader ports.RepoReader, tags ports.ReleaseTagWriter, pushes ports.PushHookService, bus ports.EventBus, clock ports.Clock, limits ports.ReleaseLimits) *Releases {
43+ if limits.AssetMax == 0 {
44+ limits.AssetMax = domain.DefaultReleaseAssetMax
45+ }
46+ if limits.AssetsMax == 0 {
47+ limits.AssetsMax = domain.DefaultReleaseAssetsMax
48+ }
49+ if limits.Quota == 0 {
50+ limits.Quota = domain.DefaultReleaseQuota
51+ }
52+ return &Releases{store: store, blobs: blobs, repos: repos, reader: reader, tags: tags, pushes: pushes, bus: bus, clock: clock, limits: limits, uploads: make(chan struct{}, 16), misses: make(map[int64]int)}
53+}
54+
55+func (r *Releases) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
56+ repo, err := lookupRepo(ctx, r.repos, name)
57+ if err == nil && !canRead(p, repo) {
58+ err = domain.ErrNotFound
59+ }
60+ return repo, err
61+}
62+func releaseWriter(p domain.Principal, name domain.RepoName) bool {
63+ return !p.IsBranchLimited() && (p.Allows(domain.ScopeReleaseWrite, name) || p.JobPermits(domain.PermContents, domain.PermissionWrite, name))
64+}
65+func (r *Releases) writable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
66+ repo, err := r.readable(ctx, p, name)
67+ if err != nil {
68+ return repo, err
69+ }
70+ if p.IsBranchLimited() {
71+ return repo, fmt.Errorf("%w: a branch-limited token cannot manage releases", domain.ErrForbidden)
72+ }
73+ if releaseWriter(p, name) {
74+ return repo, nil
75+ }
76+ if p.IsAnonymous() {
77+ return repo, domain.ErrUnauthenticated
78+ }
79+ return repo, domain.ErrForbidden
80+}
81+func (r *Releases) updateReleases(ctx context.Context, repo domain.RepoName, fn func(*ports.ReleaseWrite) error) error {
82+ const releaseTries = 256
83+ for range releaseTries {
84+ if err := ctx.Err(); err != nil {
85+ return err
86+ }
87+ used, v, err := r.store.ReleaseUsage(ctx, repo)
88+ if err != nil {
89+ return err
90+ }
91+ w := ports.ReleaseWrite{Repo: repo, Used: used, UsageVersion: v}
92+ if err = fn(&w); err != nil {
93+ return err
94+ }
95+ err = r.store.ApplyReleases(ctx, w)
96+ if errors.Is(err, domain.ErrConflict) {
97+ continue
98+ }
99+ return err
100+ }
101+ return fmt.Errorf("%w: releases of %s keep changing, try again", domain.ErrUnavailable, repo)
102+}
103+func (r *Releases) resolve(ctx context.Context, repo domain.RepoName, rev string) (domain.ObjectID, error) {
104+ v, err := domain.ParseRevision(rev)
105+ if err != nil {
106+ return domain.ObjectID{}, err
107+ }
108+ got, err := r.reader.ResolveRevision(ctx, repo, v)
109+ return got.CommitID, err
110+}
111+func (r *Releases) bind(ctx context.Context, p domain.Principal, rel *domain.Release, target string, create bool) error {
112+ oid, err := r.resolve(ctx, rel.Repo, "refs/tags/"+rel.Tag)
113+ if errors.Is(err, domain.ErrNotFound) {
114+ if rel.Draft {
115+ rel.TargetCommitish = ""
116+ return nil
117+ }
118+ if !create || target == "" {
119+ return fmt.Errorf("%w: tag %q does not exist; give target_commitish to create it", domain.ErrPreconditionFailed, rel.Tag)
120+ }
121+ if r.pushes == nil {
122+ return fmt.Errorf("%w: release tag notifications are not configured", domain.ErrUnavailable)
123+ }
124+ oid, err = r.resolve(ctx, rel.Repo, target)
125+ if err != nil {
126+ return err
127+ }
128+ err = r.tags.CreateTag(ctx, rel.Repo, rel.Tag, oid)
129+ if errors.Is(err, domain.ErrAlreadyExists) {
130+ oid, err = r.resolve(ctx, rel.Repo, "refs/tags/"+rel.Tag)
131+ } else if err == nil {
132+ err = r.pushes.Record(ctx, p, rel.Repo, []domain.RefUpdate{{Ref: "refs/tags/" + rel.Tag, Old: domain.ZeroOID, New: oid.String()}})
133+ }
134+ }
135+ if err != nil {
136+ return err
137+ }
138+ rel.TargetCommitish = oid.String()
139+ return nil
140+}
141+func (r *Releases) pin(ctx context.Context, w *ports.ReleaseWrite, rel domain.Release) error {
142+ if !rel.MakeLatest {
143+ return nil
144+ }
145+ all, err := r.store.ListReleases(ctx, rel.Repo)
146+ if err != nil {
147+ return err
148+ }
149+ for _, other := range all {
150+ if other.ID != rel.ID && other.MakeLatest {
151+ other.MakeLatest = false
152+ w.PutReleases = append(w.PutReleases, other)
153+ }
154+ }
155+ return nil
156+}
157+func (r *Releases) emit(ctx context.Context, repo domain.Repo, rel domain.Release, kind string) {
158+ at := r.clock.Now()
159+ var ev domain.Event
160+ switch kind {
161+ case "published":
162+ ev = domain.ReleasePublished{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}
163+ case "updated":
164+ ev = domain.ReleaseUpdated{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}
165+ case "deleted":
166+ ev = domain.ReleaseDeleted{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}
167+ case "unpublished":
168+ ev = domain.ReleaseUnpublished{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}
169+ }
170+ r.bus.Publish(ctx, ev)
171+}
172+
173+// CreateRelease creates a release, optionally creating its tag when publishing.
174+func (r *Releases) CreateRelease(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateReleaseParams) (domain.Release, error) {
175+ repo, err := r.writable(ctx, p, name)
176+ if err != nil {
177+ return domain.Release{}, err
178+ }
179+ rel := domain.Release{Repo: name, Draft: params.Draft, Prerelease: params.Prerelease, MakeLatest: params.MakeLatest, Author: domain.AuthorName(p), CreatedAt: r.clock.Now(), UpdatedAt: r.clock.Now()}
180+ if rel.Tag, err = domain.ParseReleaseTag(params.Tag); err != nil {
181+ return rel, err
182+ }
183+ if rel.Title, err = domain.ParseReleaseTitle(params.Title); err != nil {
184+ return rel, err
185+ }
186+ if rel.Notes, err = domain.ParseReleaseNotes(params.Notes); err != nil {
187+ return rel, err
188+ }
189+ if _, err = r.store.GetReleaseByTag(ctx, name, rel.Tag); err == nil {
190+ return rel, domain.ErrAlreadyExists
191+ } else if !errors.Is(err, domain.ErrNotFound) {
192+ return rel, err
193+ }
194+ if err = r.bind(ctx, p, &rel, params.TargetCommitish, true); err != nil {
195+ return rel, err
196+ }
197+ if rel.Draft {
198+ rel.MakeLatest = false
199+ } else {
200+ rel.PublishedAt = r.clock.Now()
201+ }
202+ rel.ID, err = r.store.NextReleaseID(ctx)
203+ if err != nil {
204+ return rel, err
205+ }
206+ err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
207+ if err := r.pin(ctx, w, rel); err != nil {
208+ return err
209+ }
210+ w.PutReleases = append(w.PutReleases, rel)
211+ return nil
212+ })
213+ if err != nil {
214+ return domain.Release{}, err
215+ }
216+ if !rel.Draft {
217+ r.emit(ctx, repo, rel, "published")
218+ }
219+ return r.store.GetRelease(ctx, name, rel.ID)
220+}
221+
222+// GetRelease resolves a visible release by exactly one selector.
223+func (r *Releases) GetRelease(ctx context.Context, p domain.Principal, name domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
224+ if _, err := r.readable(ctx, p, name); err != nil {
225+ return domain.Release{}, err
226+ }
227+ n := 0
228+ if ref.ID != 0 {
229+ n++
230+ }
231+ if ref.Tag != "" {
232+ n++
233+ }
234+ if ref.Latest {
235+ n++
236+ }
237+ if n != 1 {
238+ return domain.Release{}, domain.ErrInvalid
239+ }
240+ var rel domain.Release
241+ var err error
242+ switch {
243+ case ref.ID != 0:
244+ rel, err = r.store.GetRelease(ctx, name, ref.ID)
245+ case ref.Tag != "":
246+ rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag)
247+ default:
248+ var all []domain.Release
249+ all, err = r.store.ListReleases(ctx, name)
250+ if err == nil {
251+ var ok bool
252+ rel, ok = domain.LatestRelease(all)
253+ if !ok {
254+ err = domain.ErrNotFound
255+ }
256+ }
257+ }
258+ if err == nil && rel.Draft && !releaseWriter(p, name) {
259+ err = domain.ErrNotFound
260+ }
261+ return rel, err
262+}
263+
264+// ListReleases lists visible releases newest first, with an ID cursor.
265+func (r *Releases) ListReleases(ctx context.Context, p domain.Principal, name domain.RepoName, size int, token string) (ports.ReleasePage, error) {
266+ var out ports.ReleasePage
267+ if _, err := r.readable(ctx, p, name); err != nil {
268+ return out, err
269+ }
270+ if size == 0 {
271+ size = 30
272+ }
273+ n, err := pageSize(size)
274+ if err != nil {
275+ return out, err
276+ }
277+ var cursor int64
278+ if token != "" {
279+ cursor, err = strconv.ParseInt(token, 10, 64)
280+ if err != nil || cursor <= 0 {
281+ return out, domain.ErrInvalidPageToken
282+ }
283+ }
284+ all, err := r.store.ListReleases(ctx, name)
285+ if err != nil {
286+ return out, err
287+ }
288+ slices.SortFunc(all, func(a, b domain.Release) int {
289+ if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 {
290+ return c
291+ }
292+ if a.ID > b.ID {
293+ return -1
294+ }
295+ if a.ID < b.ID {
296+ return 1
297+ }
298+ return 0
299+ })
300+ start := token == ""
301+ for _, rel := range all {
302+ if !start {
303+ if rel.ID == cursor {
304+ start = true
305+ }
306+ continue
307+ }
308+ if rel.Draft && !releaseWriter(p, name) {
309+ continue
310+ }
311+ if len(out.Releases) == n {
312+ out.NextPageToken = strconv.FormatInt(out.Releases[n-1].ID, 10)
313+ break
314+ }
315+ out.Releases = append(out.Releases, rel)
316+ }
317+ if !start {
318+ return out, domain.ErrInvalidPageToken
319+ }
320+ return out, nil
321+}
322+
323+// UpdateRelease changes only fields whose pointers are non-nil.
324+func (r *Releases) UpdateRelease(ctx context.Context, p domain.Principal, name domain.RepoName, id int64, params ports.UpdateReleaseParams) (domain.Release, error) {
325+ repo, err := r.writable(ctx, p, name)
326+ if err != nil {
327+ return domain.Release{}, err
328+ }
329+ var rel domain.Release
330+ kind := "updated"
331+ err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
332+ var err error
333+ rel, err = r.store.GetRelease(ctx, name, id)
334+ if err != nil {
335+ return err
336+ }
337+ wasDraft := rel.Draft
338+ if params.Tag != nil {
339+ rel.Tag, err = domain.ParseReleaseTag(*params.Tag)
340+ if err != nil {
341+ return err
342+ }
343+ }
344+ if params.Title != nil {
345+ rel.Title, err = domain.ParseReleaseTitle(*params.Title)
346+ if err != nil {
347+ return err
348+ }
349+ }
350+ if params.Notes != nil {
351+ rel.Notes, err = domain.ParseReleaseNotes(*params.Notes)
352+ if err != nil {
353+ return err
354+ }
355+ }
356+ if params.Draft != nil {
357+ rel.Draft = *params.Draft
358+ }
359+ if params.Prerelease != nil {
360+ rel.Prerelease = *params.Prerelease
361+ }
362+ if params.MakeLatest != nil {
363+ rel.MakeLatest = *params.MakeLatest
364+ }
365+ if err = r.bind(ctx, p, &rel, "", false); err != nil {
366+ return err
367+ }
368+ kind = "updated"
369+ if rel.Draft {
370+ rel.PublishedAt = time.Time{}
371+ rel.MakeLatest = false
372+ } else if wasDraft {
373+ rel.PublishedAt = r.clock.Now()
374+ kind = "published"
375+ }
376+ rel.UpdatedAt = r.clock.Now()
377+ if err = r.pin(ctx, w, rel); err != nil {
378+ return err
379+ }
380+ w.PutReleases = append(w.PutReleases, rel)
381+ return nil
382+ })
383+ if err != nil {
384+ return domain.Release{}, err
385+ }
386+ r.emit(ctx, repo, rel, kind)
387+ return r.store.GetRelease(ctx, name, id)
388+}
389+
390+// DeleteRelease removes metadata first, then its contents.
391+func (r *Releases) DeleteRelease(ctx context.Context, p domain.Principal, name domain.RepoName, id int64) error {
392+ repo, err := r.writable(ctx, p, name)
393+ if err != nil {
394+ return err
395+ }
396+ var rel domain.Release
397+ var assets []domain.ReleaseAsset
398+ err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
399+ var err error
400+ rel, err = r.store.GetRelease(ctx, name, id)
401+ if err != nil {
402+ return err
403+ }
404+ assets, err = r.store.ListAssets(ctx, name, id)
405+ if err != nil {
406+ return err
407+ }
408+ w.DeleteReleases = []domain.Release{rel}
409+ w.DeleteAssets = assets
410+ for _, a := range assets {
411+ w.Used -= a.Charged
412+ }
413+ return nil
414+ })
415+ if err != nil {
416+ return err
417+ }
418+ r.removeAssetBlobs(context.WithoutCancel(ctx), assets)
419+ r.emit(ctx, repo, rel, "deleted")
420+ return nil
421+}
422+
423+// OnRefsMoved unpublishes releases of deleted tags; recreated tags stay drafts.
424+func (r *Releases) OnRefsMoved(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) {
425+ for _, u := range updates {
426+ if !strings.HasPrefix(u.Ref, "refs/tags/") || u.New != domain.ZeroOID {
427+ continue
428+ }
429+ var changed bool
430+ var rel domain.Release
431+ err := r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
432+ changed = false
433+ var err error
434+ rel, err = r.store.GetReleaseByTag(ctx, name, strings.TrimPrefix(u.Ref, "refs/tags/"))
435+ if err != nil {
436+ return err
437+ }
438+ if rel.Draft {
439+ return nil
440+ }
441+ rel.Draft = true
442+ rel.MakeLatest = false
443+ rel.PublishedAt = time.Time{}
444+ rel.UpdatedAt = r.clock.Now()
445+ w.PutReleases = []domain.Release{rel}
446+ changed = true
447+ return nil
448+ })
449+ if errors.Is(err, domain.ErrNotFound) {
450+ continue
451+ }
452+ if err != nil {
453+ ctxLog(ctx).Error().Err(err).Msg("unpublish release after tag deletion")
454+ continue
455+ }
456+ if changed {
457+ repo, err := lookupRepo(ctx, r.repos, name)
458+ if err == nil {
459+ r.emit(ctx, repo, rel, "unpublished")
460+ }
461+ }
462+ }
463+}
464+
465+// AssetsOf snapshots assets before the repository's metadata is removed.
466+func (r *Releases) AssetsOf(ctx context.Context, name domain.RepoName) ([]domain.ReleaseAsset, error) {
467+ all, err := r.store.AllAssets(ctx)
468+ if err != nil {
469+ return nil, err
470+ }
471+ return slices.DeleteFunc(all, func(a domain.ReleaseAsset) bool { return a.Repo != name }), nil
472+}
473+
474+// OnRepoDeleted removes contents after repository metadata has been deleted.
475+func (r *Releases) OnRepoDeleted(ctx context.Context, _ domain.RepoName, assets []domain.ReleaseAsset) {
476+ r.removeAssetBlobs(ctx, assets)
477+}
478+func (r *Releases) removeAssetBlobs(ctx context.Context, assets []domain.ReleaseAsset) {
479+ for _, a := range assets {
480+ if a.MultipartID != "" {
481+ if err := r.blobs.AbortMultipart(ctx, a.BlobKey(), a.MultipartID); err != nil {
482+ ctxLog(ctx).Error().Err(err).Int64("asset", a.ID).Msg("abort release asset multipart")
483+ }
484+ }
485+ if err := r.blobs.Delete(ctx, a.BlobKey()); err != nil {
486+ ctxLog(ctx).Error().Err(err).Int64("asset", a.ID).Msg("remove release asset blob")
487+ }
488+ }
489+}
@@ -0,0 +1,489 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+ "sync"
13+ "time"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// Releases manages release metadata and blob contents. Construct once per server.
20+type Releases struct {
21+ store ports.ReleaseStore
22+ blobs ports.BlobStore
23+ repos ports.RepoMetaStore
24+ reader ports.RepoReader
25+ tags ports.ReleaseTagWriter
26+ pushes ports.PushHookService
27+ bus ports.EventBus
28+ clock ports.Clock
29+ limits ports.ReleaseLimits
30+ uploads chan struct{}
31+ sweepMu sync.Mutex
32+ misses map[int64]int
33+}
34+
35+var _ ports.ReleaseService = (*Releases)(nil)
36+var _ ports.ReleaseTagTrigger = (*Releases)(nil)
37+var _ ports.ReleaseRepoCleaner = (*Releases)(nil)
38+var _ ports.ReleaseSweeper = (*Releases)(nil)
39+
40+// NewReleases constructs the release service. Zero limits select the defaults.
41+// pushes must be the shared Pushes instance, so created tags notify every hook.
42+func NewReleases(store ports.ReleaseStore, blobs ports.BlobStore, repos ports.RepoMetaStore, reader ports.RepoReader, tags ports.ReleaseTagWriter, pushes ports.PushHookService, bus ports.EventBus, clock ports.Clock, limits ports.ReleaseLimits) *Releases {
43+ if limits.AssetMax == 0 {
44+ limits.AssetMax = domain.DefaultReleaseAssetMax
45+ }
46+ if limits.AssetsMax == 0 {
47+ limits.AssetsMax = domain.DefaultReleaseAssetsMax
48+ }
49+ if limits.Quota == 0 {
50+ limits.Quota = domain.DefaultReleaseQuota
51+ }
52+ return &Releases{store: store, blobs: blobs, repos: repos, reader: reader, tags: tags, pushes: pushes, bus: bus, clock: clock, limits: limits, uploads: make(chan struct{}, 16), misses: make(map[int64]int)}
53+}
54+
55+func (r *Releases) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
56+ repo, err := lookupRepo(ctx, r.repos, name)
57+ if err == nil && !canRead(p, repo) {
58+ err = domain.ErrNotFound
59+ }
60+ return repo, err
61+}
62+func releaseWriter(p domain.Principal, name domain.RepoName) bool {
63+ return !p.IsBranchLimited() && (p.Allows(domain.ScopeReleaseWrite, name) || p.JobPermits(domain.PermContents, domain.PermissionWrite, name))
64+}
65+func (r *Releases) writable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
66+ repo, err := r.readable(ctx, p, name)
67+ if err != nil {
68+ return repo, err
69+ }
70+ if p.IsBranchLimited() {
71+ return repo, fmt.Errorf("%w: a branch-limited token cannot manage releases", domain.ErrForbidden)
72+ }
73+ if releaseWriter(p, name) {
74+ return repo, nil
75+ }
76+ if p.IsAnonymous() {
77+ return repo, domain.ErrUnauthenticated
78+ }
79+ return repo, domain.ErrForbidden
80+}
81+func (r *Releases) updateReleases(ctx context.Context, repo domain.RepoName, fn func(*ports.ReleaseWrite) error) error {
82+ const releaseTries = 256
83+ for range releaseTries {
84+ if err := ctx.Err(); err != nil {
85+ return err
86+ }
87+ used, v, err := r.store.ReleaseUsage(ctx, repo)
88+ if err != nil {
89+ return err
90+ }
91+ w := ports.ReleaseWrite{Repo: repo, Used: used, UsageVersion: v}
92+ if err = fn(&w); err != nil {
93+ return err
94+ }
95+ err = r.store.ApplyReleases(ctx, w)
96+ if errors.Is(err, domain.ErrConflict) {
97+ continue
98+ }
99+ return err
100+ }
101+ return fmt.Errorf("%w: releases of %s keep changing, try again", domain.ErrUnavailable, repo)
102+}
103+func (r *Releases) resolve(ctx context.Context, repo domain.RepoName, rev string) (domain.ObjectID, error) {
104+ v, err := domain.ParseRevision(rev)
105+ if err != nil {
106+ return domain.ObjectID{}, err
107+ }
108+ got, err := r.reader.ResolveRevision(ctx, repo, v)
109+ return got.CommitID, err
110+}
111+func (r *Releases) bind(ctx context.Context, p domain.Principal, rel *domain.Release, target string, create bool) error {
112+ oid, err := r.resolve(ctx, rel.Repo, "refs/tags/"+rel.Tag)
113+ if errors.Is(err, domain.ErrNotFound) {
114+ if rel.Draft {
115+ rel.TargetCommitish = ""
116+ return nil
117+ }
118+ if !create || target == "" {
119+ return fmt.Errorf("%w: tag %q does not exist; give target_commitish to create it", domain.ErrPreconditionFailed, rel.Tag)
120+ }
121+ if r.pushes == nil {
122+ return fmt.Errorf("%w: release tag notifications are not configured", domain.ErrUnavailable)
123+ }
124+ oid, err = r.resolve(ctx, rel.Repo, target)
125+ if err != nil {
126+ return err
127+ }
128+ err = r.tags.CreateTag(ctx, rel.Repo, rel.Tag, oid)
129+ if errors.Is(err, domain.ErrAlreadyExists) {
130+ oid, err = r.resolve(ctx, rel.Repo, "refs/tags/"+rel.Tag)
131+ } else if err == nil {
132+ err = r.pushes.Record(ctx, p, rel.Repo, []domain.RefUpdate{{Ref: "refs/tags/" + rel.Tag, Old: domain.ZeroOID, New: oid.String()}})
133+ }
134+ }
135+ if err != nil {
136+ return err
137+ }
138+ rel.TargetCommitish = oid.String()
139+ return nil
140+}
141+func (r *Releases) pin(ctx context.Context, w *ports.ReleaseWrite, rel domain.Release) error {
142+ if !rel.MakeLatest {
143+ return nil
144+ }
145+ all, err := r.store.ListReleases(ctx, rel.Repo)
146+ if err != nil {
147+ return err
148+ }
149+ for _, other := range all {
150+ if other.ID != rel.ID && other.MakeLatest {
151+ other.MakeLatest = false
152+ w.PutReleases = append(w.PutReleases, other)
153+ }
154+ }
155+ return nil
156+}
157+func (r *Releases) emit(ctx context.Context, repo domain.Repo, rel domain.Release, kind string) {
158+ at := r.clock.Now()
159+ var ev domain.Event
160+ switch kind {
161+ case "published":
162+ ev = domain.ReleasePublished{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}
163+ case "updated":
164+ ev = domain.ReleaseUpdated{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}
165+ case "deleted":
166+ ev = domain.ReleaseDeleted{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}
167+ case "unpublished":
168+ ev = domain.ReleaseUnpublished{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}
169+ }
170+ r.bus.Publish(ctx, ev)
171+}
172+
173+// CreateRelease creates a release, optionally creating its tag when publishing.
174+func (r *Releases) CreateRelease(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateReleaseParams) (domain.Release, error) {
175+ repo, err := r.writable(ctx, p, name)
176+ if err != nil {
177+ return domain.Release{}, err
178+ }
179+ rel := domain.Release{Repo: name, Draft: params.Draft, Prerelease: params.Prerelease, MakeLatest: params.MakeLatest, Author: domain.AuthorName(p), CreatedAt: r.clock.Now(), UpdatedAt: r.clock.Now()}
180+ if rel.Tag, err = domain.ParseReleaseTag(params.Tag); err != nil {
181+ return rel, err
182+ }
183+ if rel.Title, err = domain.ParseReleaseTitle(params.Title); err != nil {
184+ return rel, err
185+ }
186+ if rel.Notes, err = domain.ParseReleaseNotes(params.Notes); err != nil {
187+ return rel, err
188+ }
189+ if _, err = r.store.GetReleaseByTag(ctx, name, rel.Tag); err == nil {
190+ return rel, domain.ErrAlreadyExists
191+ } else if !errors.Is(err, domain.ErrNotFound) {
192+ return rel, err
193+ }
194+ if err = r.bind(ctx, p, &rel, params.TargetCommitish, true); err != nil {
195+ return rel, err
196+ }
197+ if rel.Draft {
198+ rel.MakeLatest = false
199+ } else {
200+ rel.PublishedAt = r.clock.Now()
201+ }
202+ rel.ID, err = r.store.NextReleaseID(ctx)
203+ if err != nil {
204+ return rel, err
205+ }
206+ err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
207+ if err := r.pin(ctx, w, rel); err != nil {
208+ return err
209+ }
210+ w.PutReleases = append(w.PutReleases, rel)
211+ return nil
212+ })
213+ if err != nil {
214+ return domain.Release{}, err
215+ }
216+ if !rel.Draft {
217+ r.emit(ctx, repo, rel, "published")
218+ }
219+ return r.store.GetRelease(ctx, name, rel.ID)
220+}
221+
222+// GetRelease resolves a visible release by exactly one selector.
223+func (r *Releases) GetRelease(ctx context.Context, p domain.Principal, name domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
224+ if _, err := r.readable(ctx, p, name); err != nil {
225+ return domain.Release{}, err
226+ }
227+ n := 0
228+ if ref.ID != 0 {
229+ n++
230+ }
231+ if ref.Tag != "" {
232+ n++
233+ }
234+ if ref.Latest {
235+ n++
236+ }
237+ if n != 1 {
238+ return domain.Release{}, domain.ErrInvalid
239+ }
240+ var rel domain.Release
241+ var err error
242+ switch {
243+ case ref.ID != 0:
244+ rel, err = r.store.GetRelease(ctx, name, ref.ID)
245+ case ref.Tag != "":
246+ rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag)
247+ default:
248+ var all []domain.Release
249+ all, err = r.store.ListReleases(ctx, name)
250+ if err == nil {
251+ var ok bool
252+ rel, ok = domain.LatestRelease(all)
253+ if !ok {
254+ err = domain.ErrNotFound
255+ }
256+ }
257+ }
258+ if err == nil && rel.Draft && !releaseWriter(p, name) {
259+ err = domain.ErrNotFound
260+ }
261+ return rel, err
262+}
263+
264+// ListReleases lists visible releases newest first, with an ID cursor.
265+func (r *Releases) ListReleases(ctx context.Context, p domain.Principal, name domain.RepoName, size int, token string) (ports.ReleasePage, error) {
266+ var out ports.ReleasePage
267+ if _, err := r.readable(ctx, p, name); err != nil {
268+ return out, err
269+ }
270+ if size == 0 {
271+ size = 30
272+ }
273+ n, err := pageSize(size)
274+ if err != nil {
275+ return out, err
276+ }
277+ var cursor int64
278+ if token != "" {
279+ cursor, err = strconv.ParseInt(token, 10, 64)
280+ if err != nil || cursor <= 0 {
281+ return out, domain.ErrInvalidPageToken
282+ }
283+ }
284+ all, err := r.store.ListReleases(ctx, name)
285+ if err != nil {
286+ return out, err
287+ }
288+ slices.SortFunc(all, func(a, b domain.Release) int {
289+ if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 {
290+ return c
291+ }
292+ if a.ID > b.ID {
293+ return -1
294+ }
295+ if a.ID < b.ID {
296+ return 1
297+ }
298+ return 0
299+ })
300+ start := token == ""
301+ for _, rel := range all {
302+ if !start {
303+ if rel.ID == cursor {
304+ start = true
305+ }
306+ continue
307+ }
308+ if rel.Draft && !releaseWriter(p, name) {
309+ continue
310+ }
311+ if len(out.Releases) == n {
312+ out.NextPageToken = strconv.FormatInt(out.Releases[n-1].ID, 10)
313+ break
314+ }
315+ out.Releases = append(out.Releases, rel)
316+ }
317+ if !start {
318+ return out, domain.ErrInvalidPageToken
319+ }
320+ return out, nil
321+}
322+
323+// UpdateRelease changes only fields whose pointers are non-nil.
324+func (r *Releases) UpdateRelease(ctx context.Context, p domain.Principal, name domain.RepoName, id int64, params ports.UpdateReleaseParams) (domain.Release, error) {
325+ repo, err := r.writable(ctx, p, name)
326+ if err != nil {
327+ return domain.Release{}, err
328+ }
329+ var rel domain.Release
330+ kind := "updated"
331+ err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
332+ var err error
333+ rel, err = r.store.GetRelease(ctx, name, id)
334+ if err != nil {
335+ return err
336+ }
337+ wasDraft := rel.Draft
338+ if params.Tag != nil {
339+ rel.Tag, err = domain.ParseReleaseTag(*params.Tag)
340+ if err != nil {
341+ return err
342+ }
343+ }
344+ if params.Title != nil {
345+ rel.Title, err = domain.ParseReleaseTitle(*params.Title)
346+ if err != nil {
347+ return err
348+ }
349+ }
350+ if params.Notes != nil {
351+ rel.Notes, err = domain.ParseReleaseNotes(*params.Notes)
352+ if err != nil {
353+ return err
354+ }
355+ }
356+ if params.Draft != nil {
357+ rel.Draft = *params.Draft
358+ }
359+ if params.Prerelease != nil {
360+ rel.Prerelease = *params.Prerelease
361+ }
362+ if params.MakeLatest != nil {
363+ rel.MakeLatest = *params.MakeLatest
364+ }
365+ if err = r.bind(ctx, p, &rel, "", false); err != nil {
366+ return err
367+ }
368+ kind = "updated"
369+ if rel.Draft {
370+ rel.PublishedAt = time.Time{}
371+ rel.MakeLatest = false
372+ } else if wasDraft {
373+ rel.PublishedAt = r.clock.Now()
374+ kind = "published"
375+ }
376+ rel.UpdatedAt = r.clock.Now()
377+ if err = r.pin(ctx, w, rel); err != nil {
378+ return err
379+ }
380+ w.PutReleases = append(w.PutReleases, rel)
381+ return nil
382+ })
383+ if err != nil {
384+ return domain.Release{}, err
385+ }
386+ r.emit(ctx, repo, rel, kind)
387+ return r.store.GetRelease(ctx, name, id)
388+}
389+
390+// DeleteRelease removes metadata first, then its contents.
391+func (r *Releases) DeleteRelease(ctx context.Context, p domain.Principal, name domain.RepoName, id int64) error {
392+ repo, err := r.writable(ctx, p, name)
393+ if err != nil {
394+ return err
395+ }
396+ var rel domain.Release
397+ var assets []domain.ReleaseAsset
398+ err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
399+ var err error
400+ rel, err = r.store.GetRelease(ctx, name, id)
401+ if err != nil {
402+ return err
403+ }
404+ assets, err = r.store.ListAssets(ctx, name, id)
405+ if err != nil {
406+ return err
407+ }
408+ w.DeleteReleases = []domain.Release{rel}
409+ w.DeleteAssets = assets
410+ for _, a := range assets {
411+ w.Used -= a.Charged
412+ }
413+ return nil
414+ })
415+ if err != nil {
416+ return err
417+ }
418+ r.removeAssetBlobs(context.WithoutCancel(ctx), assets)
419+ r.emit(ctx, repo, rel, "deleted")
420+ return nil
421+}
422+
423+// OnRefsMoved unpublishes releases of deleted tags; recreated tags stay drafts.
424+func (r *Releases) OnRefsMoved(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) {
425+ for _, u := range updates {
426+ if !strings.HasPrefix(u.Ref, "refs/tags/") || u.New != domain.ZeroOID {
427+ continue
428+ }
429+ var changed bool
430+ var rel domain.Release
431+ err := r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
432+ changed = false
433+ var err error
434+ rel, err = r.store.GetReleaseByTag(ctx, name, strings.TrimPrefix(u.Ref, "refs/tags/"))
435+ if err != nil {
436+ return err
437+ }
438+ if rel.Draft {
439+ return nil
440+ }
441+ rel.Draft = true
442+ rel.MakeLatest = false
443+ rel.PublishedAt = time.Time{}
444+ rel.UpdatedAt = r.clock.Now()
445+ w.PutReleases = []domain.Release{rel}
446+ changed = true
447+ return nil
448+ })
449+ if errors.Is(err, domain.ErrNotFound) {
450+ continue
451+ }
452+ if err != nil {
453+ ctxLog(ctx).Error().Err(err).Msg("unpublish release after tag deletion")
454+ continue
455+ }
456+ if changed {
457+ repo, err := lookupRepo(ctx, r.repos, name)
458+ if err == nil {
459+ r.emit(ctx, repo, rel, "unpublished")
460+ }
461+ }
462+ }
463+}
464+
465+// AssetsOf snapshots assets before the repository's metadata is removed.
466+func (r *Releases) AssetsOf(ctx context.Context, name domain.RepoName) ([]domain.ReleaseAsset, error) {
467+ all, err := r.store.AllAssets(ctx)
468+ if err != nil {
469+ return nil, err
470+ }
471+ return slices.DeleteFunc(all, func(a domain.ReleaseAsset) bool { return a.Repo != name }), nil
472+}
473+
474+// OnRepoDeleted removes contents after repository metadata has been deleted.
475+func (r *Releases) OnRepoDeleted(ctx context.Context, _ domain.RepoName, assets []domain.ReleaseAsset) {
476+ r.removeAssetBlobs(ctx, assets)
477+}
478+func (r *Releases) removeAssetBlobs(ctx context.Context, assets []domain.ReleaseAsset) {
479+ for _, a := range assets {
480+ if a.MultipartID != "" {
481+ if err := r.blobs.AbortMultipart(ctx, a.BlobKey(), a.MultipartID); err != nil {
482+ ctxLog(ctx).Error().Err(err).Int64("asset", a.ID).Msg("abort release asset multipart")
483+ }
484+ }
485+ if err := r.blobs.Delete(ctx, a.BlobKey()); err != nil {
486+ ctxLog(ctx).Error().Err(err).Int64("asset", a.ID).Msg("remove release asset blob")
487+ }
488+ }
489+}
A internal/usecase/releases_assets.go +432 -0
@@ -0,0 +1,432 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/sha256"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "slices"
13+ "time"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// UploadAsset reserves quota before reading, stores bytes, then publishes metadata.
20+func (r *Releases) UploadAsset(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.UploadAssetParams, body io.Reader) (result domain.ReleaseAsset, err error) {
21+ repo, err := r.writable(ctx, p, name)
22+ if err != nil {
23+ return result, err
24+ }
25+ if params.Size < 0 || params.Size > r.limits.AssetMax {
26+ return result, fmt.Errorf("%w: asset size must be between zero and %s", domain.ErrResourceExhausted, sizeText(r.limits.AssetMax))
27+ }
28+ a := domain.ReleaseAsset{Repo: name, ReleaseID: params.ReleaseID, Size: params.Size, Charged: params.Size, State: domain.ReleaseAssetUploading, Uploader: domain.AuthorName(p), CreatedAt: r.clock.Now(), UpdatedAt: r.clock.Now()}
29+ if a.Name, err = domain.ParseReleaseAssetName(params.Name); err != nil {
30+ return result, err
31+ }
32+ if a.Label, err = domain.ParseReleaseAssetLabel(params.Label); err != nil {
33+ return result, err
34+ }
35+ if a.ContentType, err = domain.ParseReleaseContentType(params.ContentType); err != nil {
36+ return result, err
37+ }
38+ select {
39+ case r.uploads <- struct{}{}:
40+ defer func() { <-r.uploads }()
41+ default:
42+ return result, fmt.Errorf("%w: too many uploads in progress, retry", domain.ErrResourceExhausted)
43+ }
44+ a.ID, err = r.store.NextReleaseID(ctx)
45+ if err != nil {
46+ return result, err
47+ }
48+ var replaced []domain.ReleaseAsset
49+ err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
50+ replaced = nil
51+ if _, err := r.store.GetRelease(ctx, name, a.ReleaseID); err != nil {
52+ if errors.Is(err, domain.ErrNotFound) {
53+ owner, lookupErr := r.store.ReleaseRepo(ctx, a.ReleaseID)
54+ if lookupErr == nil && owner != name {
55+ return fmt.Errorf("%w: release belongs to another repository", domain.ErrPreconditionFailed)
56+ }
57+ }
58+ return err
59+ }
60+ all, err := r.store.ListAssets(ctx, name, a.ReleaseID)
61+ if err != nil {
62+ return err
63+ }
64+ for _, old := range all {
65+ if old.Name != a.Name {
66+ continue
67+ }
68+ if !(old.State == domain.ReleaseAssetUploading && r.clock.Now().Sub(old.UpdatedAt) > 60*time.Second) && !(old.State == domain.ReleaseAssetUploaded && params.Replace) {
69+ return fmt.Errorf("%w: an asset named %q already exists", domain.ErrAlreadyExists, a.Name)
70+ }
71+ replaced = append(replaced, old)
72+ w.DeleteAssets = append(w.DeleteAssets, old)
73+ w.Used -= old.Charged
74+ }
75+ if int64(len(all)-len(replaced)) >= r.limits.AssetsMax {
76+ return fmt.Errorf("%w: release asset count limit is %d", domain.ErrResourceExhausted, r.limits.AssetsMax)
77+ }
78+ if a.Size > r.limits.Quota-w.Used {
79+ return fmt.Errorf("%w: release quota is %s", domain.ErrResourceExhausted, sizeText(r.limits.Quota))
80+ }
81+ w.Used += a.Charged
82+ w.PutAssets = []domain.ReleaseAsset{a}
83+ return nil
84+ })
85+ if err != nil {
86+ return result, err
87+ }
88+ r.removeAssetBlobs(context.WithoutCancel(ctx), replaced)
89+ body = releaseContextReader{ctx: ctx, reader: body}
90+ // From here every failure removes the reservation and any completed blob.
91+ defer func() {
92+ if err != nil {
93+ clean := context.WithoutCancel(ctx)
94+ if e := r.removeAssetRecord(clean, a, nil); e != nil && !errors.Is(e, domain.ErrNotFound) {
95+ ctxLog(ctx).Error().Err(e).Int64("asset", a.ID).Msg("refund failed release upload")
96+ }
97+ r.removeAssetBlobs(clean, []domain.ReleaseAsset{a})
98+ }
99+ }()
100+ if a.Size <= ports.BlobPartSize {
101+ var info ports.BlobInfo
102+ limited := &io.LimitedReader{R: body, N: a.Size}
103+ info, err = r.blobs.Put(ctx, a.BlobKey(), limited, a.Size)
104+ if err != nil {
105+ if limited.N > 0 && errors.Is(err, domain.ErrInvalid) {
106+ err = fmt.Errorf("%w: body shorter than size", domain.ErrInvalid)
107+ }
108+ return result, err
109+ }
110+ a.Digest = info.Digest
111+ if !validReleaseDigest(a.Digest) {
112+ return result, fmt.Errorf("%w: blob store returned no SHA-256 digest", domain.ErrInvalid)
113+ }
114+ } else {
115+ var upload ports.MultipartUpload
116+ upload, err = r.blobs.NewMultipart(ctx, a.BlobKey())
117+ if err != nil {
118+ return result, err
119+ }
120+ completed := false
121+ defer func() {
122+ if !completed {
123+ if e := upload.Abort(context.WithoutCancel(ctx)); e != nil {
124+ ctxLog(ctx).Error().Err(e).Msg("abort failed release upload")
125+ }
126+ }
127+ }()
128+ a.MultipartID = upload.ID()
129+ err = r.changeAsset(ctx, a.ID, name, func(current *domain.ReleaseAsset) error { current.MultipartID = a.MultipartID; return nil })
130+ if err != nil {
131+ return result, err
132+ }
133+ hash := sha256.New()
134+ buf := make([]byte, ports.BlobPartSize)
135+ remaining := a.Size
136+ for part := 1; remaining > 0; part++ {
137+ if part > ports.BlobMaxParts {
138+ return result, domain.ErrResourceExhausted
139+ }
140+ want := min(remaining, int64(len(buf)))
141+ var n int
142+ n, err = io.ReadFull(body, buf[:want])
143+ if err != nil {
144+ if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
145+ err = fmt.Errorf("%w: body shorter than size", domain.ErrInvalid)
146+ }
147+ return result, err
148+ }
149+ hash.Write(buf[:n])
150+ err = upload.UploadPart(ctx, part, bytes.NewReader(buf[:n]), int64(n))
151+ if err != nil {
152+ return result, err
153+ }
154+ remaining -= int64(n)
155+ }
156+ if err = releaseBodyEnd(body); err != nil {
157+ return result, err
158+ }
159+ _, err = upload.Complete(ctx)
160+ if err != nil {
161+ return result, err
162+ }
163+ completed = true
164+ a.Digest = fmt.Sprintf("sha256:%x", hash.Sum(nil))
165+ a.MultipartID = ""
166+ }
167+ if a.Size <= ports.BlobPartSize {
168+ if err = releaseBodyEnd(body); err != nil {
169+ return result, err
170+ }
171+ }
172+ err = r.changeAsset(ctx, a.ID, name, func(current *domain.ReleaseAsset) error {
173+ if current.State != domain.ReleaseAssetUploading {
174+ return domain.ErrConflict
175+ }
176+ current.State = domain.ReleaseAssetUploaded
177+ current.Digest = a.Digest
178+ current.MultipartID = ""
179+ current.UpdatedAt = r.clock.Now()
180+ return nil
181+ })
182+ if err != nil {
183+ return result, err
184+ }
185+ result, err = r.store.GetAsset(ctx, a.ID)
186+ if err != nil {
187+ return result, err
188+ }
189+ rel, e := r.store.GetRelease(ctx, name, a.ReleaseID)
190+ if e == nil {
191+ r.emit(ctx, repo, rel, "updated")
192+ }
193+ ctxLog(ctx).Info().Int64("release", a.ReleaseID).Int64("asset", a.ID).Int64("size", a.Size).Msg("release asset uploaded")
194+ return result, nil
195+}
196+func validReleaseDigest(s string) bool {
197+ if len(s) != 71 || s[:7] != "sha256:" {
198+ return false
199+ }
200+ for _, c := range s[7:] {
201+ if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
202+ return false
203+ }
204+ }
205+ return true
206+}
207+
208+// releaseContextReader checks cancellation between reads, including multipart
209+// buffering and the final extra-byte check. The transport owns interrupting a
210+// body.Read already in progress.
211+type releaseContextReader struct {
212+ ctx context.Context
213+ reader io.Reader
214+}
215+
216+func (r releaseContextReader) Read(p []byte) (int, error) {
217+ if err := r.ctx.Err(); err != nil {
218+ return 0, err
219+ }
220+ return r.reader.Read(p)
221+}
222+
223+func releaseBodyEnd(body io.Reader) error {
224+ var extra [1]byte
225+ n, err := io.ReadFull(body, extra[:])
226+ if n > 0 {
227+ return fmt.Errorf("%w: body longer than size", domain.ErrInvalid)
228+ }
229+ if errors.Is(err, io.EOF) {
230+ return nil
231+ }
232+ return err
233+}
234+func (r *Releases) changeAsset(ctx context.Context, id int64, repo domain.RepoName, fn func(*domain.ReleaseAsset) error) error {
235+ return r.updateReleases(ctx, repo, func(w *ports.ReleaseWrite) error {
236+ a, err := r.store.GetAsset(ctx, id)
237+ if err != nil {
238+ return err
239+ }
240+ if a.Repo != repo {
241+ return domain.ErrNotFound
242+ }
243+ if err = fn(&a); err != nil {
244+ return err
245+ }
246+ w.PutAssets = []domain.ReleaseAsset{a}
247+ return nil
248+ })
249+}
250+
251+// removeAssetRecord conditionally removes the current record; sweep predicates
252+// are evaluated again after a conflict so a just-completed upload is protected.
253+func (r *Releases) removeAssetRecord(ctx context.Context, a domain.ReleaseAsset, predicate func(domain.ReleaseAsset) bool) error {
254+ return r.updateReleases(ctx, a.Repo, func(w *ports.ReleaseWrite) error {
255+ current, err := r.store.GetAsset(ctx, a.ID)
256+ if err != nil {
257+ return err
258+ }
259+ if current.Repo != a.Repo {
260+ return domain.ErrNotFound
261+ }
262+ if predicate != nil && !predicate(current) {
263+ return domain.ErrConflict
264+ }
265+ w.DeleteAssets = []domain.ReleaseAsset{current}
266+ w.Used -= current.Charged
267+ return nil
268+ })
269+}
270+
271+// ListAssets returns completed assets only, ordered by name.
272+func (r *Releases) ListAssets(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) ([]domain.ReleaseAsset, error) {
273+ if _, err := r.GetRelease(ctx, p, repo, ports.ReleaseRef{ID: id}); err != nil {
274+ return nil, err
275+ }
276+ all, err := r.store.ListAssets(ctx, repo, id)
277+ if err != nil {
278+ return nil, err
279+ }
280+ return slices.DeleteFunc(all, func(a domain.ReleaseAsset) bool { return a.State != domain.ReleaseAssetUploaded }), nil
281+}
282+
283+// GetAsset resolves an uploaded asset, enforcing its release's draft rule.
284+func (r *Releases) GetAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) {
285+ var a domain.ReleaseAsset
286+ var err error
287+ if ref.ID != 0 {
288+ if ref.Tag != "" || ref.Latest || ref.Name != "" {
289+ return a, domain.ErrInvalid
290+ }
291+ if _, err = r.readable(ctx, p, repo); err != nil {
292+ return a, err
293+ }
294+ a, err = r.store.GetAsset(ctx, ref.ID)
295+ if err != nil {
296+ return a, err
297+ }
298+ if a.Repo != repo {
299+ return domain.ReleaseAsset{}, domain.ErrNotFound
300+ }
301+ _, err = r.GetRelease(ctx, p, repo, ports.ReleaseRef{ID: a.ReleaseID})
302+ } else {
303+ if ref.Name == "" {
304+ return a, domain.ErrInvalid
305+ }
306+ var rel domain.Release
307+ rel, err = r.GetRelease(ctx, p, repo, ports.ReleaseRef{Tag: ref.Tag, Latest: ref.Latest})
308+ if err != nil {
309+ return a, err
310+ }
311+ a, err = r.store.GetAssetByName(ctx, repo, rel.ID, ref.Name)
312+ }
313+ if err == nil && a.State != domain.ReleaseAssetUploaded {
314+ err = domain.ErrNotFound
315+ }
316+ return a, err
317+}
318+
319+// UpdateAsset changes an uploaded asset's name and label without moving bytes.
320+func (r *Releases) UpdateAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64, params ports.UpdateAssetParams) (domain.ReleaseAsset, error) {
321+ meta, err := r.writable(ctx, p, repo)
322+ if err != nil {
323+ return domain.ReleaseAsset{}, err
324+ }
325+ a, err := r.GetAsset(ctx, p, repo, ports.AssetRef{ID: id})
326+ if err != nil {
327+ return a, err
328+ }
329+ var newName, newLabel string
330+ if params.Name != nil {
331+ newName, err = domain.ParseReleaseAssetName(*params.Name)
332+ if err != nil {
333+ return a, err
334+ }
335+ }
336+ if params.Label != nil {
337+ newLabel, err = domain.ParseReleaseAssetLabel(*params.Label)
338+ if err != nil {
339+ return a, err
340+ }
341+ }
342+ err = r.changeAsset(ctx, id, repo, func(current *domain.ReleaseAsset) error {
343+ if current.State != domain.ReleaseAssetUploaded {
344+ return domain.ErrNotFound
345+ }
346+ if params.Name != nil {
347+ current.Name = newName
348+ }
349+ if params.Label != nil {
350+ current.Label = newLabel
351+ }
352+ current.UpdatedAt = r.clock.Now()
353+ return nil
354+ })
355+ if err != nil {
356+ return domain.ReleaseAsset{}, err
357+ }
358+ a, err = r.store.GetAsset(ctx, id)
359+ if err != nil {
360+ return a, err
361+ }
362+ if rel, e := r.store.GetRelease(ctx, repo, a.ReleaseID); e == nil {
363+ r.emit(ctx, meta, rel, "updated")
364+ }
365+ return a, nil
366+}
367+
368+// DeleteAsset refunds quota before deleting the blob.
369+func (r *Releases) DeleteAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) error {
370+ meta, err := r.writable(ctx, p, repo)
371+ if err != nil {
372+ return err
373+ }
374+ a, err := r.GetAsset(ctx, p, repo, ports.AssetRef{ID: id})
375+ if err != nil {
376+ return err
377+ }
378+ if err = r.removeAssetRecord(ctx, a, nil); err != nil {
379+ return err
380+ }
381+ r.removeAssetBlobs(context.WithoutCancel(ctx), []domain.ReleaseAsset{a})
382+ if rel, e := r.store.GetRelease(ctx, repo, a.ReleaseID); e == nil {
383+ r.emit(ctx, meta, rel, "updated")
384+ }
385+ return nil
386+}
387+
388+// DownloadAsset sends metadata followed by bounded chunks of the requested range.
389+func (r *Releases) DownloadAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
390+ a, err := r.GetAsset(ctx, p, repo, ref)
391+ if err != nil {
392+ return err
393+ }
394+ body, _, err := r.blobs.Open(ctx, a.BlobKey(), offset, length)
395+ if err != nil {
396+ if errors.Is(err, domain.ErrNotFound) {
397+ ctxLog(ctx).Error().Int64("asset", a.ID).Msg("asset blob missing")
398+ }
399+ return err
400+ }
401+ defer body.Close()
402+ if err = send(ports.AssetChunk{Asset: &a}); err != nil {
403+ return err
404+ }
405+ buf := make([]byte, domain.CIArtifactChunk)
406+ for {
407+ if err = ctx.Err(); err != nil {
408+ return err
409+ }
410+ n, e := body.Read(buf)
411+ if n > 0 {
412+ if err = send(ports.AssetChunk{Data: buf[:n]}); err != nil {
413+ return err
414+ }
415+ }
416+ if errors.Is(e, io.EOF) {
417+ return nil
418+ }
419+ if e != nil {
420+ return e
421+ }
422+ }
423+}
424+
425+// Usage reports quota and configured limits to a repository reader.
426+func (r *Releases) Usage(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.ReleaseUsage, error) {
427+ if _, err := r.readable(ctx, p, repo); err != nil {
428+ return ports.ReleaseUsage{}, err
429+ }
430+ used, _, err := r.store.ReleaseUsage(ctx, repo)
431+ return ports.ReleaseUsage{Used: used, Limits: r.limits}, err
432+}
@@ -0,0 +1,432 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/sha256"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "slices"
13+ "time"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// UploadAsset reserves quota before reading, stores bytes, then publishes metadata.
20+func (r *Releases) UploadAsset(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.UploadAssetParams, body io.Reader) (result domain.ReleaseAsset, err error) {
21+ repo, err := r.writable(ctx, p, name)
22+ if err != nil {
23+ return result, err
24+ }
25+ if params.Size < 0 || params.Size > r.limits.AssetMax {
26+ return result, fmt.Errorf("%w: asset size must be between zero and %s", domain.ErrResourceExhausted, sizeText(r.limits.AssetMax))
27+ }
28+ a := domain.ReleaseAsset{Repo: name, ReleaseID: params.ReleaseID, Size: params.Size, Charged: params.Size, State: domain.ReleaseAssetUploading, Uploader: domain.AuthorName(p), CreatedAt: r.clock.Now(), UpdatedAt: r.clock.Now()}
29+ if a.Name, err = domain.ParseReleaseAssetName(params.Name); err != nil {
30+ return result, err
31+ }
32+ if a.Label, err = domain.ParseReleaseAssetLabel(params.Label); err != nil {
33+ return result, err
34+ }
35+ if a.ContentType, err = domain.ParseReleaseContentType(params.ContentType); err != nil {
36+ return result, err
37+ }
38+ select {
39+ case r.uploads <- struct{}{}:
40+ defer func() { <-r.uploads }()
41+ default:
42+ return result, fmt.Errorf("%w: too many uploads in progress, retry", domain.ErrResourceExhausted)
43+ }
44+ a.ID, err = r.store.NextReleaseID(ctx)
45+ if err != nil {
46+ return result, err
47+ }
48+ var replaced []domain.ReleaseAsset
49+ err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error {
50+ replaced = nil
51+ if _, err := r.store.GetRelease(ctx, name, a.ReleaseID); err != nil {
52+ if errors.Is(err, domain.ErrNotFound) {
53+ owner, lookupErr := r.store.ReleaseRepo(ctx, a.ReleaseID)
54+ if lookupErr == nil && owner != name {
55+ return fmt.Errorf("%w: release belongs to another repository", domain.ErrPreconditionFailed)
56+ }
57+ }
58+ return err
59+ }
60+ all, err := r.store.ListAssets(ctx, name, a.ReleaseID)
61+ if err != nil {
62+ return err
63+ }
64+ for _, old := range all {
65+ if old.Name != a.Name {
66+ continue
67+ }
68+ if !(old.State == domain.ReleaseAssetUploading && r.clock.Now().Sub(old.UpdatedAt) > 60*time.Second) && !(old.State == domain.ReleaseAssetUploaded && params.Replace) {
69+ return fmt.Errorf("%w: an asset named %q already exists", domain.ErrAlreadyExists, a.Name)
70+ }
71+ replaced = append(replaced, old)
72+ w.DeleteAssets = append(w.DeleteAssets, old)
73+ w.Used -= old.Charged
74+ }
75+ if int64(len(all)-len(replaced)) >= r.limits.AssetsMax {
76+ return fmt.Errorf("%w: release asset count limit is %d", domain.ErrResourceExhausted, r.limits.AssetsMax)
77+ }
78+ if a.Size > r.limits.Quota-w.Used {
79+ return fmt.Errorf("%w: release quota is %s", domain.ErrResourceExhausted, sizeText(r.limits.Quota))
80+ }
81+ w.Used += a.Charged
82+ w.PutAssets = []domain.ReleaseAsset{a}
83+ return nil
84+ })
85+ if err != nil {
86+ return result, err
87+ }
88+ r.removeAssetBlobs(context.WithoutCancel(ctx), replaced)
89+ body = releaseContextReader{ctx: ctx, reader: body}
90+ // From here every failure removes the reservation and any completed blob.
91+ defer func() {
92+ if err != nil {
93+ clean := context.WithoutCancel(ctx)
94+ if e := r.removeAssetRecord(clean, a, nil); e != nil && !errors.Is(e, domain.ErrNotFound) {
95+ ctxLog(ctx).Error().Err(e).Int64("asset", a.ID).Msg("refund failed release upload")
96+ }
97+ r.removeAssetBlobs(clean, []domain.ReleaseAsset{a})
98+ }
99+ }()
100+ if a.Size <= ports.BlobPartSize {
101+ var info ports.BlobInfo
102+ limited := &io.LimitedReader{R: body, N: a.Size}
103+ info, err = r.blobs.Put(ctx, a.BlobKey(), limited, a.Size)
104+ if err != nil {
105+ if limited.N > 0 && errors.Is(err, domain.ErrInvalid) {
106+ err = fmt.Errorf("%w: body shorter than size", domain.ErrInvalid)
107+ }
108+ return result, err
109+ }
110+ a.Digest = info.Digest
111+ if !validReleaseDigest(a.Digest) {
112+ return result, fmt.Errorf("%w: blob store returned no SHA-256 digest", domain.ErrInvalid)
113+ }
114+ } else {
115+ var upload ports.MultipartUpload
116+ upload, err = r.blobs.NewMultipart(ctx, a.BlobKey())
117+ if err != nil {
118+ return result, err
119+ }
120+ completed := false
121+ defer func() {
122+ if !completed {
123+ if e := upload.Abort(context.WithoutCancel(ctx)); e != nil {
124+ ctxLog(ctx).Error().Err(e).Msg("abort failed release upload")
125+ }
126+ }
127+ }()
128+ a.MultipartID = upload.ID()
129+ err = r.changeAsset(ctx, a.ID, name, func(current *domain.ReleaseAsset) error { current.MultipartID = a.MultipartID; return nil })
130+ if err != nil {
131+ return result, err
132+ }
133+ hash := sha256.New()
134+ buf := make([]byte, ports.BlobPartSize)
135+ remaining := a.Size
136+ for part := 1; remaining > 0; part++ {
137+ if part > ports.BlobMaxParts {
138+ return result, domain.ErrResourceExhausted
139+ }
140+ want := min(remaining, int64(len(buf)))
141+ var n int
142+ n, err = io.ReadFull(body, buf[:want])
143+ if err != nil {
144+ if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
145+ err = fmt.Errorf("%w: body shorter than size", domain.ErrInvalid)
146+ }
147+ return result, err
148+ }
149+ hash.Write(buf[:n])
150+ err = upload.UploadPart(ctx, part, bytes.NewReader(buf[:n]), int64(n))
151+ if err != nil {
152+ return result, err
153+ }
154+ remaining -= int64(n)
155+ }
156+ if err = releaseBodyEnd(body); err != nil {
157+ return result, err
158+ }
159+ _, err = upload.Complete(ctx)
160+ if err != nil {
161+ return result, err
162+ }
163+ completed = true
164+ a.Digest = fmt.Sprintf("sha256:%x", hash.Sum(nil))
165+ a.MultipartID = ""
166+ }
167+ if a.Size <= ports.BlobPartSize {
168+ if err = releaseBodyEnd(body); err != nil {
169+ return result, err
170+ }
171+ }
172+ err = r.changeAsset(ctx, a.ID, name, func(current *domain.ReleaseAsset) error {
173+ if current.State != domain.ReleaseAssetUploading {
174+ return domain.ErrConflict
175+ }
176+ current.State = domain.ReleaseAssetUploaded
177+ current.Digest = a.Digest
178+ current.MultipartID = ""
179+ current.UpdatedAt = r.clock.Now()
180+ return nil
181+ })
182+ if err != nil {
183+ return result, err
184+ }
185+ result, err = r.store.GetAsset(ctx, a.ID)
186+ if err != nil {
187+ return result, err
188+ }
189+ rel, e := r.store.GetRelease(ctx, name, a.ReleaseID)
190+ if e == nil {
191+ r.emit(ctx, repo, rel, "updated")
192+ }
193+ ctxLog(ctx).Info().Int64("release", a.ReleaseID).Int64("asset", a.ID).Int64("size", a.Size).Msg("release asset uploaded")
194+ return result, nil
195+}
196+func validReleaseDigest(s string) bool {
197+ if len(s) != 71 || s[:7] != "sha256:" {
198+ return false
199+ }
200+ for _, c := range s[7:] {
201+ if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
202+ return false
203+ }
204+ }
205+ return true
206+}
207+
208+// releaseContextReader checks cancellation between reads, including multipart
209+// buffering and the final extra-byte check. The transport owns interrupting a
210+// body.Read already in progress.
211+type releaseContextReader struct {
212+ ctx context.Context
213+ reader io.Reader
214+}
215+
216+func (r releaseContextReader) Read(p []byte) (int, error) {
217+ if err := r.ctx.Err(); err != nil {
218+ return 0, err
219+ }
220+ return r.reader.Read(p)
221+}
222+
223+func releaseBodyEnd(body io.Reader) error {
224+ var extra [1]byte
225+ n, err := io.ReadFull(body, extra[:])
226+ if n > 0 {
227+ return fmt.Errorf("%w: body longer than size", domain.ErrInvalid)
228+ }
229+ if errors.Is(err, io.EOF) {
230+ return nil
231+ }
232+ return err
233+}
234+func (r *Releases) changeAsset(ctx context.Context, id int64, repo domain.RepoName, fn func(*domain.ReleaseAsset) error) error {
235+ return r.updateReleases(ctx, repo, func(w *ports.ReleaseWrite) error {
236+ a, err := r.store.GetAsset(ctx, id)
237+ if err != nil {
238+ return err
239+ }
240+ if a.Repo != repo {
241+ return domain.ErrNotFound
242+ }
243+ if err = fn(&a); err != nil {
244+ return err
245+ }
246+ w.PutAssets = []domain.ReleaseAsset{a}
247+ return nil
248+ })
249+}
250+
251+// removeAssetRecord conditionally removes the current record; sweep predicates
252+// are evaluated again after a conflict so a just-completed upload is protected.
253+func (r *Releases) removeAssetRecord(ctx context.Context, a domain.ReleaseAsset, predicate func(domain.ReleaseAsset) bool) error {
254+ return r.updateReleases(ctx, a.Repo, func(w *ports.ReleaseWrite) error {
255+ current, err := r.store.GetAsset(ctx, a.ID)
256+ if err != nil {
257+ return err
258+ }
259+ if current.Repo != a.Repo {
260+ return domain.ErrNotFound
261+ }
262+ if predicate != nil && !predicate(current) {
263+ return domain.ErrConflict
264+ }
265+ w.DeleteAssets = []domain.ReleaseAsset{current}
266+ w.Used -= current.Charged
267+ return nil
268+ })
269+}
270+
271+// ListAssets returns completed assets only, ordered by name.
272+func (r *Releases) ListAssets(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) ([]domain.ReleaseAsset, error) {
273+ if _, err := r.GetRelease(ctx, p, repo, ports.ReleaseRef{ID: id}); err != nil {
274+ return nil, err
275+ }
276+ all, err := r.store.ListAssets(ctx, repo, id)
277+ if err != nil {
278+ return nil, err
279+ }
280+ return slices.DeleteFunc(all, func(a domain.ReleaseAsset) bool { return a.State != domain.ReleaseAssetUploaded }), nil
281+}
282+
283+// GetAsset resolves an uploaded asset, enforcing its release's draft rule.
284+func (r *Releases) GetAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) {
285+ var a domain.ReleaseAsset
286+ var err error
287+ if ref.ID != 0 {
288+ if ref.Tag != "" || ref.Latest || ref.Name != "" {
289+ return a, domain.ErrInvalid
290+ }
291+ if _, err = r.readable(ctx, p, repo); err != nil {
292+ return a, err
293+ }
294+ a, err = r.store.GetAsset(ctx, ref.ID)
295+ if err != nil {
296+ return a, err
297+ }
298+ if a.Repo != repo {
299+ return domain.ReleaseAsset{}, domain.ErrNotFound
300+ }
301+ _, err = r.GetRelease(ctx, p, repo, ports.ReleaseRef{ID: a.ReleaseID})
302+ } else {
303+ if ref.Name == "" {
304+ return a, domain.ErrInvalid
305+ }
306+ var rel domain.Release
307+ rel, err = r.GetRelease(ctx, p, repo, ports.ReleaseRef{Tag: ref.Tag, Latest: ref.Latest})
308+ if err != nil {
309+ return a, err
310+ }
311+ a, err = r.store.GetAssetByName(ctx, repo, rel.ID, ref.Name)
312+ }
313+ if err == nil && a.State != domain.ReleaseAssetUploaded {
314+ err = domain.ErrNotFound
315+ }
316+ return a, err
317+}
318+
319+// UpdateAsset changes an uploaded asset's name and label without moving bytes.
320+func (r *Releases) UpdateAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64, params ports.UpdateAssetParams) (domain.ReleaseAsset, error) {
321+ meta, err := r.writable(ctx, p, repo)
322+ if err != nil {
323+ return domain.ReleaseAsset{}, err
324+ }
325+ a, err := r.GetAsset(ctx, p, repo, ports.AssetRef{ID: id})
326+ if err != nil {
327+ return a, err
328+ }
329+ var newName, newLabel string
330+ if params.Name != nil {
331+ newName, err = domain.ParseReleaseAssetName(*params.Name)
332+ if err != nil {
333+ return a, err
334+ }
335+ }
336+ if params.Label != nil {
337+ newLabel, err = domain.ParseReleaseAssetLabel(*params.Label)
338+ if err != nil {
339+ return a, err
340+ }
341+ }
342+ err = r.changeAsset(ctx, id, repo, func(current *domain.ReleaseAsset) error {
343+ if current.State != domain.ReleaseAssetUploaded {
344+ return domain.ErrNotFound
345+ }
346+ if params.Name != nil {
347+ current.Name = newName
348+ }
349+ if params.Label != nil {
350+ current.Label = newLabel
351+ }
352+ current.UpdatedAt = r.clock.Now()
353+ return nil
354+ })
355+ if err != nil {
356+ return domain.ReleaseAsset{}, err
357+ }
358+ a, err = r.store.GetAsset(ctx, id)
359+ if err != nil {
360+ return a, err
361+ }
362+ if rel, e := r.store.GetRelease(ctx, repo, a.ReleaseID); e == nil {
363+ r.emit(ctx, meta, rel, "updated")
364+ }
365+ return a, nil
366+}
367+
368+// DeleteAsset refunds quota before deleting the blob.
369+func (r *Releases) DeleteAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) error {
370+ meta, err := r.writable(ctx, p, repo)
371+ if err != nil {
372+ return err
373+ }
374+ a, err := r.GetAsset(ctx, p, repo, ports.AssetRef{ID: id})
375+ if err != nil {
376+ return err
377+ }
378+ if err = r.removeAssetRecord(ctx, a, nil); err != nil {
379+ return err
380+ }
381+ r.removeAssetBlobs(context.WithoutCancel(ctx), []domain.ReleaseAsset{a})
382+ if rel, e := r.store.GetRelease(ctx, repo, a.ReleaseID); e == nil {
383+ r.emit(ctx, meta, rel, "updated")
384+ }
385+ return nil
386+}
387+
388+// DownloadAsset sends metadata followed by bounded chunks of the requested range.
389+func (r *Releases) DownloadAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
390+ a, err := r.GetAsset(ctx, p, repo, ref)
391+ if err != nil {
392+ return err
393+ }
394+ body, _, err := r.blobs.Open(ctx, a.BlobKey(), offset, length)
395+ if err != nil {
396+ if errors.Is(err, domain.ErrNotFound) {
397+ ctxLog(ctx).Error().Int64("asset", a.ID).Msg("asset blob missing")
398+ }
399+ return err
400+ }
401+ defer body.Close()
402+ if err = send(ports.AssetChunk{Asset: &a}); err != nil {
403+ return err
404+ }
405+ buf := make([]byte, domain.CIArtifactChunk)
406+ for {
407+ if err = ctx.Err(); err != nil {
408+ return err
409+ }
410+ n, e := body.Read(buf)
411+ if n > 0 {
412+ if err = send(ports.AssetChunk{Data: buf[:n]}); err != nil {
413+ return err
414+ }
415+ }
416+ if errors.Is(e, io.EOF) {
417+ return nil
418+ }
419+ if e != nil {
420+ return e
421+ }
422+ }
423+}
424+
425+// Usage reports quota and configured limits to a repository reader.
426+func (r *Releases) Usage(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.ReleaseUsage, error) {
427+ if _, err := r.readable(ctx, p, repo); err != nil {
428+ return ports.ReleaseUsage{}, err
429+ }
430+ used, _, err := r.store.ReleaseUsage(ctx, repo)
431+ return ports.ReleaseUsage{Used: used, Limits: r.limits}, err
432+}
A internal/usecase/releases_assets_test.go +289 -0
@@ -0,0 +1,289 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/sha256"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "sync"
13+ "testing"
14+ "time"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+ "bnema.dev/ermite/internal/usecase"
20+ "github.com/stretchr/testify/mock"
21+ "github.com/stretchr/testify/require"
22+)
23+
24+func (r *releaseRig) upload(t *testing.T, rel domain.Release, n string, data []byte) domain.ReleaseAsset {
25+ t.Helper()
26+ a, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: n, Size: int64(len(data))}, bytes.NewReader(data))
27+ require.NoError(t, err)
28+ return a
29+}
30+func TestReleaseAssetRoundTrip(t *testing.T) {
31+ r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 10, Quota: 10})
32+ rel := r.create(t, "v1", false)
33+ a := r.upload(t, rel, "binary", []byte("0123456789"))
34+ require.Equal(t, fmt.Sprintf("sha256:%x", sha256.Sum256([]byte("0123456789"))), a.Digest)
35+ usage, err := r.svc.Usage(r.ctx, domain.Anonymous(), r.repo)
36+ require.NoError(t, err)
37+ require.EqualValues(t, 10, usage.Used)
38+ for _, tc := range []struct {
39+ offset, length int64
40+ want string
41+ err error
42+ }{{0, -1, "0123456789", nil}, {3, 4, "3456", nil}, {9, 1, "9", nil}, {10, -1, "", domain.ErrOutOfRange}, {11, -1, "", domain.ErrOutOfRange}} {
43+ var got bytes.Buffer
44+ metadata := false
45+ err := r.svc.DownloadAsset(r.ctx, domain.Anonymous(), r.repo, ports.AssetRef{Latest: true, Name: a.Name}, tc.offset, tc.length, func(c ports.AssetChunk) error {
46+ if c.Asset != nil {
47+ require.False(t, metadata)
48+ metadata = true
49+ require.Equal(t, a.ID, c.Asset.ID)
50+ } else {
51+ require.True(t, metadata)
52+ got.Write(c.Data)
53+ }
54+ return nil
55+ })
56+ require.ErrorIs(t, err, tc.err)
57+ require.Equal(t, tc.want, got.String())
58+ }
59+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "large", Size: 11}, bytes.NewReader(nil))
60+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
61+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "quota", Size: 1}, bytes.NewReader([]byte("x")))
62+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
63+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: a.Name, Size: 0}, bytes.NewReader(nil))
64+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
65+ replacement, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: a.Name, Size: 2, Replace: true}, bytes.NewReader([]byte("hi")))
66+ require.NoError(t, err)
67+ require.NotEqual(t, a.ID, replacement.ID)
68+ _, err = r.blobs.Stat(r.ctx, a.BlobKey())
69+ require.ErrorIs(t, err, domain.ErrNotFound)
70+ renamed := "new"
71+ label := "label"
72+ replacement, err = r.svc.UpdateAsset(r.ctx, domain.Root(), r.repo, replacement.ID, ports.UpdateAssetParams{Name: &renamed, Label: &label})
73+ require.NoError(t, err)
74+ require.Equal(t, label, replacement.Label)
75+ list, err := r.svc.ListAssets(r.ctx, domain.Anonymous(), r.repo, rel.ID)
76+ require.NoError(t, err)
77+ require.Len(t, list, 1)
78+ require.Equal(t, renamed, list[0].Name)
79+ require.NoError(t, r.svc.DeleteAsset(r.ctx, domain.Root(), r.repo, replacement.ID))
80+ usage, err = r.svc.Usage(r.ctx, domain.Root(), r.repo)
81+ require.NoError(t, err)
82+ require.Zero(t, usage.Used)
83+}
84+func TestReleaseAssetCountBoundary(t *testing.T) {
85+ r := newReleaseRig(t, ports.ReleaseLimits{})
86+ rel := r.create(t, "v1", false)
87+ // Seed 999 records in one batch, then exercise the exact public boundary.
88+ used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo)
89+ require.NoError(t, err)
90+ w := ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v}
91+ for range 999 {
92+ id, err := r.store.Releases().NextReleaseID(r.ctx)
93+ require.NoError(t, err)
94+ w.PutAssets = append(w.PutAssets, domain.ReleaseAsset{ID: id, ReleaseID: rel.ID, Repo: r.repo, Name: fmt.Sprint(id), State: domain.ReleaseAssetUploaded})
95+ }
96+ require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, w))
97+ r.upload(t, rel, "thousandth", nil)
98+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "1001"}, bytes.NewReader(nil))
99+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
100+}
101+func TestReleaseAssetBadBodies(t *testing.T) {
102+ bodyErr := errors.New("reader failed")
103+ for _, size := range []int64{4, 9 << 20} {
104+ for _, kind := range []string{"short", "long", "error", "cancel"} {
105+ t.Run(fmt.Sprint(size, "/", kind), func(t *testing.T) {
106+ r := newReleaseRig(t, ports.ReleaseLimits{})
107+ rel := r.create(t, "v1", false)
108+ var body io.Reader
109+ switch kind {
110+ case "short":
111+ body = bytes.NewReader(make([]byte, size-1))
112+ case "long":
113+ body = bytes.NewReader(make([]byte, size+1))
114+ case "error":
115+ body = errReader{bodyErr}
116+ case "cancel":
117+ body = errReader{context.Canceled}
118+ }
119+ _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "bad", Size: size}, body)
120+ switch kind {
121+ case "error":
122+ require.ErrorIs(t, err, bodyErr)
123+ case "cancel":
124+ require.ErrorIs(t, err, context.Canceled)
125+ default:
126+ require.ErrorIs(t, err, domain.ErrInvalid)
127+ }
128+ all, err := r.store.Releases().AllAssets(r.ctx)
129+ require.NoError(t, err)
130+ require.Empty(t, all)
131+ u, err := r.svc.Usage(r.ctx, domain.Root(), r.repo)
132+ require.NoError(t, err)
133+ require.Zero(t, u.Used)
134+ require.NoError(t, r.blobs.List(r.ctx, "release/", func(ports.BlobInfo) error { t.Error("blob left behind"); return nil }))
135+ require.NoError(t, r.blobs.ListMultipart(r.ctx, "release/", func(ports.MultipartInfo) error { t.Error("multipart left behind"); return nil }))
136+ })
137+ }
138+ }
139+}
140+
141+type releaseBlobSpy struct {
142+ ports.BlobStore
143+ put, multipart int
144+ parts []int64
145+}
146+
147+func (s *releaseBlobSpy) Put(ctx context.Context, key string, b io.Reader, size int64) (ports.BlobInfo, error) {
148+ s.put++
149+ return s.BlobStore.Put(ctx, key, b, size)
150+}
151+func (s *releaseBlobSpy) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
152+ s.multipart++
153+ u, e := s.BlobStore.NewMultipart(ctx, key)
154+ return &releasePartSpy{MultipartUpload: u, spy: s}, e
155+}
156+
157+type releasePartSpy struct {
158+ ports.MultipartUpload
159+ spy *releaseBlobSpy
160+}
161+
162+func (s *releasePartSpy) UploadPart(ctx context.Context, n int, b io.Reader, size int64) error {
163+ s.spy.parts = append(s.spy.parts, size)
164+ return s.MultipartUpload.UploadPart(ctx, n, b, size)
165+}
166+func TestReleaseAssetUploadPaths(t *testing.T) {
167+ for _, size := range []int{1 << 20, 9 << 20} {
168+ t.Run(fmt.Sprint(size), func(t *testing.T) {
169+ r := newReleaseRig(t, ports.ReleaseLimits{})
170+ rel := r.create(t, "v1", false)
171+ spy := &releaseBlobSpy{BlobStore: r.blobs}
172+ bus := mocks.NewEventBus(t)
173+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Return().Maybe()
174+ r.svc = usecase.NewReleases(r.store.Releases(), spy, r.store, r.reader, r.tags, r.pushes, bus, r.clock, ports.ReleaseLimits{})
175+ data := bytes.Repeat([]byte("z"), size)
176+ a := r.upload(t, rel, "blob", data)
177+ require.Equal(t, fmt.Sprintf("sha256:%x", sha256.Sum256(data)), a.Digest)
178+ if size == 1<<20 {
179+ require.Equal(t, 1, spy.put)
180+ require.Zero(t, spy.multipart)
181+ } else {
182+ require.Zero(t, spy.put)
183+ require.Equal(t, 1, spy.multipart)
184+ require.Equal(t, []int64{8 << 20, 1 << 20}, spy.parts)
185+ }
186+ })
187+ }
188+}
189+func TestReleaseAssetStaleReplacementAndReleaseDelete(t *testing.T) {
190+ r := newReleaseRig(t, ports.ReleaseLimits{})
191+ rel := r.create(t, "v1", false)
192+ a := r.upload(t, rel, "pending", []byte("old"))
193+ current, err := r.store.Releases().GetAsset(r.ctx, a.ID)
194+ require.NoError(t, err)
195+ current.State = domain.ReleaseAssetUploading
196+ current.UpdatedAt = now.Add(-61 * time.Second)
197+ used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo)
198+ require.NoError(t, err)
199+ require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v, PutAssets: []domain.ReleaseAsset{current}}))
200+ b := r.upload(t, rel, "pending", []byte("new"))
201+ require.NotEqual(t, a.ID, b.ID)
202+ require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, rel.ID))
203+ _, err = r.blobs.Stat(r.ctx, b.BlobKey())
204+ require.ErrorIs(t, err, domain.ErrNotFound)
205+ u, err := r.svc.Usage(r.ctx, domain.Root(), r.repo)
206+ require.NoError(t, err)
207+ require.Zero(t, u.Used)
208+}
209+
210+type releaseBlockingReader struct {
211+ entered chan<- struct{}
212+ release <-chan struct{}
213+}
214+
215+func (b releaseBlockingReader) Read([]byte) (int, error) {
216+ b.entered <- struct{}{}
217+ <-b.release
218+ return 0, io.EOF
219+}
220+func TestReleaseAssetConcurrencyCap(t *testing.T) {
221+ r := newReleaseRig(t, ports.ReleaseLimits{})
222+ rel := r.create(t, "v1", false)
223+ entered := make(chan struct{}, 16)
224+ release := make(chan struct{})
225+ var wg sync.WaitGroup
226+ errs := make(chan error, 16)
227+ for i := range 16 {
228+ wg.Go(func() {
229+ _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: fmt.Sprint(i), Size: 1}, releaseBlockingReader{entered, release})
230+ errs <- err
231+ })
232+ }
233+ // Every reader has a reservation and holds its slot before the 17th call.
234+ for range 16 {
235+ <-entered
236+ }
237+ _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "excess", Size: 0}, bytes.NewReader(nil))
238+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
239+ close(release)
240+ wg.Wait()
241+ close(errs)
242+ for err := range errs {
243+ require.ErrorIs(t, err, domain.ErrInvalid)
244+ }
245+}
246+
247+type errReader struct{ err error }
248+
249+func (r errReader) Read([]byte) (int, error) { return 0, r.err }
250+
251+func TestReleaseAssetDraftAccessAndRenameCollision(t *testing.T) {
252+ r := newReleaseRig(t, ports.ReleaseLimits{})
253+ rel := r.create(t, "draft", true)
254+ a := r.upload(t, rel, "a", []byte("abc"))
255+ b := r.upload(t, rel, "b", nil)
256+ _, err := r.svc.GetAsset(r.ctx, domain.Anonymous(), r.repo, ports.AssetRef{ID: a.ID})
257+ require.ErrorIs(t, err, domain.ErrNotFound)
258+ _, err = r.svc.ListAssets(r.ctx, domain.Anonymous(), r.repo, rel.ID)
259+ require.ErrorIs(t, err, domain.ErrNotFound)
260+ _, err = r.svc.UpdateAsset(r.ctx, domain.Root(), r.repo, b.ID, ports.UpdateAssetParams{Name: &a.Name})
261+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
262+ got, err := r.svc.GetAsset(r.ctx, domain.Root(), r.repo, ports.AssetRef{Tag: rel.Tag, Name: a.Name})
263+ require.NoError(t, err)
264+ require.Equal(t, a.ID, got.ID)
265+ assets, err := r.svc.AssetsOf(r.ctx, r.repo)
266+ require.NoError(t, err)
267+ require.Len(t, assets, 2)
268+ require.NoError(t, r.store.Delete(r.ctx, r.repo))
269+ r.svc.OnRepoDeleted(r.ctx, r.repo, assets)
270+ _, err = r.blobs.Stat(r.ctx, a.BlobKey())
271+ require.ErrorIs(t, err, domain.ErrNotFound)
272+ all, err := r.store.Releases().AllAssets(r.ctx)
273+ require.NoError(t, err)
274+ require.Empty(t, all)
275+}
276+
277+func TestReleaseAssetQuotaAcrossReleasesAndWrongRepository(t *testing.T) {
278+ r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 3, Quota: 5})
279+ a := r.create(t, "a", false)
280+ b := r.create(t, "b", false)
281+ r.upload(t, a, "a", []byte("abc"))
282+ r.upload(t, b, "b", []byte("de"))
283+ _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: b.ID, Name: "extra", Size: 1}, bytes.NewReader([]byte("x")))
284+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
285+ other := name(t, "other")
286+ require.NoError(t, r.store.Create(r.ctx, domain.Repo{Name: other, Visibility: domain.VisibilityPublic, CreatedAt: now}))
287+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), other, ports.UploadAssetParams{ReleaseID: b.ID, Name: "wrong"}, bytes.NewReader(nil))
288+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
289+}
@@ -0,0 +1,289 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/sha256"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "sync"
13+ "testing"
14+ "time"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+ "bnema.dev/ermite/internal/usecase"
20+ "github.com/stretchr/testify/mock"
21+ "github.com/stretchr/testify/require"
22+)
23+
24+func (r *releaseRig) upload(t *testing.T, rel domain.Release, n string, data []byte) domain.ReleaseAsset {
25+ t.Helper()
26+ a, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: n, Size: int64(len(data))}, bytes.NewReader(data))
27+ require.NoError(t, err)
28+ return a
29+}
30+func TestReleaseAssetRoundTrip(t *testing.T) {
31+ r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 10, Quota: 10})
32+ rel := r.create(t, "v1", false)
33+ a := r.upload(t, rel, "binary", []byte("0123456789"))
34+ require.Equal(t, fmt.Sprintf("sha256:%x", sha256.Sum256([]byte("0123456789"))), a.Digest)
35+ usage, err := r.svc.Usage(r.ctx, domain.Anonymous(), r.repo)
36+ require.NoError(t, err)
37+ require.EqualValues(t, 10, usage.Used)
38+ for _, tc := range []struct {
39+ offset, length int64
40+ want string
41+ err error
42+ }{{0, -1, "0123456789", nil}, {3, 4, "3456", nil}, {9, 1, "9", nil}, {10, -1, "", domain.ErrOutOfRange}, {11, -1, "", domain.ErrOutOfRange}} {
43+ var got bytes.Buffer
44+ metadata := false
45+ err := r.svc.DownloadAsset(r.ctx, domain.Anonymous(), r.repo, ports.AssetRef{Latest: true, Name: a.Name}, tc.offset, tc.length, func(c ports.AssetChunk) error {
46+ if c.Asset != nil {
47+ require.False(t, metadata)
48+ metadata = true
49+ require.Equal(t, a.ID, c.Asset.ID)
50+ } else {
51+ require.True(t, metadata)
52+ got.Write(c.Data)
53+ }
54+ return nil
55+ })
56+ require.ErrorIs(t, err, tc.err)
57+ require.Equal(t, tc.want, got.String())
58+ }
59+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "large", Size: 11}, bytes.NewReader(nil))
60+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
61+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "quota", Size: 1}, bytes.NewReader([]byte("x")))
62+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
63+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: a.Name, Size: 0}, bytes.NewReader(nil))
64+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
65+ replacement, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: a.Name, Size: 2, Replace: true}, bytes.NewReader([]byte("hi")))
66+ require.NoError(t, err)
67+ require.NotEqual(t, a.ID, replacement.ID)
68+ _, err = r.blobs.Stat(r.ctx, a.BlobKey())
69+ require.ErrorIs(t, err, domain.ErrNotFound)
70+ renamed := "new"
71+ label := "label"
72+ replacement, err = r.svc.UpdateAsset(r.ctx, domain.Root(), r.repo, replacement.ID, ports.UpdateAssetParams{Name: &renamed, Label: &label})
73+ require.NoError(t, err)
74+ require.Equal(t, label, replacement.Label)
75+ list, err := r.svc.ListAssets(r.ctx, domain.Anonymous(), r.repo, rel.ID)
76+ require.NoError(t, err)
77+ require.Len(t, list, 1)
78+ require.Equal(t, renamed, list[0].Name)
79+ require.NoError(t, r.svc.DeleteAsset(r.ctx, domain.Root(), r.repo, replacement.ID))
80+ usage, err = r.svc.Usage(r.ctx, domain.Root(), r.repo)
81+ require.NoError(t, err)
82+ require.Zero(t, usage.Used)
83+}
84+func TestReleaseAssetCountBoundary(t *testing.T) {
85+ r := newReleaseRig(t, ports.ReleaseLimits{})
86+ rel := r.create(t, "v1", false)
87+ // Seed 999 records in one batch, then exercise the exact public boundary.
88+ used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo)
89+ require.NoError(t, err)
90+ w := ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v}
91+ for range 999 {
92+ id, err := r.store.Releases().NextReleaseID(r.ctx)
93+ require.NoError(t, err)
94+ w.PutAssets = append(w.PutAssets, domain.ReleaseAsset{ID: id, ReleaseID: rel.ID, Repo: r.repo, Name: fmt.Sprint(id), State: domain.ReleaseAssetUploaded})
95+ }
96+ require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, w))
97+ r.upload(t, rel, "thousandth", nil)
98+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "1001"}, bytes.NewReader(nil))
99+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
100+}
101+func TestReleaseAssetBadBodies(t *testing.T) {
102+ bodyErr := errors.New("reader failed")
103+ for _, size := range []int64{4, 9 << 20} {
104+ for _, kind := range []string{"short", "long", "error", "cancel"} {
105+ t.Run(fmt.Sprint(size, "/", kind), func(t *testing.T) {
106+ r := newReleaseRig(t, ports.ReleaseLimits{})
107+ rel := r.create(t, "v1", false)
108+ var body io.Reader
109+ switch kind {
110+ case "short":
111+ body = bytes.NewReader(make([]byte, size-1))
112+ case "long":
113+ body = bytes.NewReader(make([]byte, size+1))
114+ case "error":
115+ body = errReader{bodyErr}
116+ case "cancel":
117+ body = errReader{context.Canceled}
118+ }
119+ _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "bad", Size: size}, body)
120+ switch kind {
121+ case "error":
122+ require.ErrorIs(t, err, bodyErr)
123+ case "cancel":
124+ require.ErrorIs(t, err, context.Canceled)
125+ default:
126+ require.ErrorIs(t, err, domain.ErrInvalid)
127+ }
128+ all, err := r.store.Releases().AllAssets(r.ctx)
129+ require.NoError(t, err)
130+ require.Empty(t, all)
131+ u, err := r.svc.Usage(r.ctx, domain.Root(), r.repo)
132+ require.NoError(t, err)
133+ require.Zero(t, u.Used)
134+ require.NoError(t, r.blobs.List(r.ctx, "release/", func(ports.BlobInfo) error { t.Error("blob left behind"); return nil }))
135+ require.NoError(t, r.blobs.ListMultipart(r.ctx, "release/", func(ports.MultipartInfo) error { t.Error("multipart left behind"); return nil }))
136+ })
137+ }
138+ }
139+}
140+
141+type releaseBlobSpy struct {
142+ ports.BlobStore
143+ put, multipart int
144+ parts []int64
145+}
146+
147+func (s *releaseBlobSpy) Put(ctx context.Context, key string, b io.Reader, size int64) (ports.BlobInfo, error) {
148+ s.put++
149+ return s.BlobStore.Put(ctx, key, b, size)
150+}
151+func (s *releaseBlobSpy) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
152+ s.multipart++
153+ u, e := s.BlobStore.NewMultipart(ctx, key)
154+ return &releasePartSpy{MultipartUpload: u, spy: s}, e
155+}
156+
157+type releasePartSpy struct {
158+ ports.MultipartUpload
159+ spy *releaseBlobSpy
160+}
161+
162+func (s *releasePartSpy) UploadPart(ctx context.Context, n int, b io.Reader, size int64) error {
163+ s.spy.parts = append(s.spy.parts, size)
164+ return s.MultipartUpload.UploadPart(ctx, n, b, size)
165+}
166+func TestReleaseAssetUploadPaths(t *testing.T) {
167+ for _, size := range []int{1 << 20, 9 << 20} {
168+ t.Run(fmt.Sprint(size), func(t *testing.T) {
169+ r := newReleaseRig(t, ports.ReleaseLimits{})
170+ rel := r.create(t, "v1", false)
171+ spy := &releaseBlobSpy{BlobStore: r.blobs}
172+ bus := mocks.NewEventBus(t)
173+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Return().Maybe()
174+ r.svc = usecase.NewReleases(r.store.Releases(), spy, r.store, r.reader, r.tags, r.pushes, bus, r.clock, ports.ReleaseLimits{})
175+ data := bytes.Repeat([]byte("z"), size)
176+ a := r.upload(t, rel, "blob", data)
177+ require.Equal(t, fmt.Sprintf("sha256:%x", sha256.Sum256(data)), a.Digest)
178+ if size == 1<<20 {
179+ require.Equal(t, 1, spy.put)
180+ require.Zero(t, spy.multipart)
181+ } else {
182+ require.Zero(t, spy.put)
183+ require.Equal(t, 1, spy.multipart)
184+ require.Equal(t, []int64{8 << 20, 1 << 20}, spy.parts)
185+ }
186+ })
187+ }
188+}
189+func TestReleaseAssetStaleReplacementAndReleaseDelete(t *testing.T) {
190+ r := newReleaseRig(t, ports.ReleaseLimits{})
191+ rel := r.create(t, "v1", false)
192+ a := r.upload(t, rel, "pending", []byte("old"))
193+ current, err := r.store.Releases().GetAsset(r.ctx, a.ID)
194+ require.NoError(t, err)
195+ current.State = domain.ReleaseAssetUploading
196+ current.UpdatedAt = now.Add(-61 * time.Second)
197+ used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo)
198+ require.NoError(t, err)
199+ require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v, PutAssets: []domain.ReleaseAsset{current}}))
200+ b := r.upload(t, rel, "pending", []byte("new"))
201+ require.NotEqual(t, a.ID, b.ID)
202+ require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, rel.ID))
203+ _, err = r.blobs.Stat(r.ctx, b.BlobKey())
204+ require.ErrorIs(t, err, domain.ErrNotFound)
205+ u, err := r.svc.Usage(r.ctx, domain.Root(), r.repo)
206+ require.NoError(t, err)
207+ require.Zero(t, u.Used)
208+}
209+
210+type releaseBlockingReader struct {
211+ entered chan<- struct{}
212+ release <-chan struct{}
213+}
214+
215+func (b releaseBlockingReader) Read([]byte) (int, error) {
216+ b.entered <- struct{}{}
217+ <-b.release
218+ return 0, io.EOF
219+}
220+func TestReleaseAssetConcurrencyCap(t *testing.T) {
221+ r := newReleaseRig(t, ports.ReleaseLimits{})
222+ rel := r.create(t, "v1", false)
223+ entered := make(chan struct{}, 16)
224+ release := make(chan struct{})
225+ var wg sync.WaitGroup
226+ errs := make(chan error, 16)
227+ for i := range 16 {
228+ wg.Go(func() {
229+ _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: fmt.Sprint(i), Size: 1}, releaseBlockingReader{entered, release})
230+ errs <- err
231+ })
232+ }
233+ // Every reader has a reservation and holds its slot before the 17th call.
234+ for range 16 {
235+ <-entered
236+ }
237+ _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "excess", Size: 0}, bytes.NewReader(nil))
238+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
239+ close(release)
240+ wg.Wait()
241+ close(errs)
242+ for err := range errs {
243+ require.ErrorIs(t, err, domain.ErrInvalid)
244+ }
245+}
246+
247+type errReader struct{ err error }
248+
249+func (r errReader) Read([]byte) (int, error) { return 0, r.err }
250+
251+func TestReleaseAssetDraftAccessAndRenameCollision(t *testing.T) {
252+ r := newReleaseRig(t, ports.ReleaseLimits{})
253+ rel := r.create(t, "draft", true)
254+ a := r.upload(t, rel, "a", []byte("abc"))
255+ b := r.upload(t, rel, "b", nil)
256+ _, err := r.svc.GetAsset(r.ctx, domain.Anonymous(), r.repo, ports.AssetRef{ID: a.ID})
257+ require.ErrorIs(t, err, domain.ErrNotFound)
258+ _, err = r.svc.ListAssets(r.ctx, domain.Anonymous(), r.repo, rel.ID)
259+ require.ErrorIs(t, err, domain.ErrNotFound)
260+ _, err = r.svc.UpdateAsset(r.ctx, domain.Root(), r.repo, b.ID, ports.UpdateAssetParams{Name: &a.Name})
261+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
262+ got, err := r.svc.GetAsset(r.ctx, domain.Root(), r.repo, ports.AssetRef{Tag: rel.Tag, Name: a.Name})
263+ require.NoError(t, err)
264+ require.Equal(t, a.ID, got.ID)
265+ assets, err := r.svc.AssetsOf(r.ctx, r.repo)
266+ require.NoError(t, err)
267+ require.Len(t, assets, 2)
268+ require.NoError(t, r.store.Delete(r.ctx, r.repo))
269+ r.svc.OnRepoDeleted(r.ctx, r.repo, assets)
270+ _, err = r.blobs.Stat(r.ctx, a.BlobKey())
271+ require.ErrorIs(t, err, domain.ErrNotFound)
272+ all, err := r.store.Releases().AllAssets(r.ctx)
273+ require.NoError(t, err)
274+ require.Empty(t, all)
275+}
276+
277+func TestReleaseAssetQuotaAcrossReleasesAndWrongRepository(t *testing.T) {
278+ r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 3, Quota: 5})
279+ a := r.create(t, "a", false)
280+ b := r.create(t, "b", false)
281+ r.upload(t, a, "a", []byte("abc"))
282+ r.upload(t, b, "b", []byte("de"))
283+ _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: b.ID, Name: "extra", Size: 1}, bytes.NewReader([]byte("x")))
284+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
285+ other := name(t, "other")
286+ require.NoError(t, r.store.Create(r.ctx, domain.Repo{Name: other, Visibility: domain.VisibilityPublic, CreatedAt: now}))
287+ _, err = r.svc.UploadAsset(r.ctx, domain.Root(), other, ports.UploadAssetParams{ReleaseID: b.ID, Name: "wrong"}, bytes.NewReader(nil))
288+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
289+}
A internal/usecase/releases_integration_test.go +275 -0
@@ -0,0 +1,275 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "fmt"
8+ "testing"
9+ "testing/synctest"
10+
11+ "bnema.dev/ermite/internal/adapters/blobdisk"
12+ "bnema.dev/ermite/internal/adapters/eventbus"
13+ "bnema.dev/ermite/internal/adapters/kvstore"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+ "bnema.dev/ermite/internal/usecase"
18+ "github.com/stretchr/testify/mock"
19+ "github.com/stretchr/testify/require"
20+)
21+
22+type releaseRig struct {
23+ ctx context.Context
24+ repo domain.RepoName
25+ store *kvstore.Store
26+ blobs ports.BlobStore
27+ reader *mocks.RepoReader
28+ tags *mocks.ReleaseTagWriter
29+ pushes *mocks.PushHookService
30+ clock *mocks.Clock
31+ events <-chan domain.Event
32+ svc *usecase.Releases
33+}
34+
35+func newReleaseRig(t *testing.T, limits ports.ReleaseLimits) *releaseRig {
36+ t.Helper()
37+ ctx := t.Context()
38+ store, err := kvstore.Open(ctx, t.TempDir())
39+ require.NoError(t, err)
40+ t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
41+ n := name(t, "releases")
42+ require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now}))
43+ clock := mocks.NewClock(t)
44+ clock.EXPECT().Now().Return(now).Maybe()
45+ reader := mocks.NewRepoReader(t)
46+ tags := mocks.NewReleaseTagWriter(t)
47+ pushes := mocks.NewPushHookService(t)
48+ bus := eventbus.NewWithBuffer(1024)
49+ b := blobdisk.New(t.TempDir())
50+ r := &releaseRig{ctx: ctx, repo: n, store: store, blobs: b, reader: reader, tags: tags, pushes: pushes, clock: clock, events: bus.Subscribe(ctx)}
51+ r.svc = usecase.NewReleases(store.Releases(), b, store, reader, tags, pushes, bus, clock, limits)
52+ return r
53+}
54+func (r *releaseRig) resolve(t *testing.T, tag string, err error) {
55+ t.Helper()
56+ v, e := domain.ParseRevision("refs/tags/" + tag)
57+ require.NoError(t, e)
58+ oid, e := domain.ParseObjectID(oidA)
59+ require.NoError(t, e)
60+ r.reader.EXPECT().ResolveRevision(mock.Anything, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, err).Maybe()
61+}
62+func (r *releaseRig) create(t *testing.T, tag string, draft bool) domain.Release {
63+ t.Helper()
64+ r.resolve(t, tag, nil)
65+ rel, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: tag, Draft: draft})
66+ require.NoError(t, err)
67+ return rel
68+}
69+func TestReleaseAccessMatrix(t *testing.T) {
70+ for _, private := range []bool{false, true} {
71+ t.Run(fmt.Sprint("private=", private), func(t *testing.T) {
72+ r := newReleaseRig(t, ports.ReleaseLimits{})
73+ published := r.create(t, "v1", false)
74+ draft := r.create(t, "draft", true)
75+ if private {
76+ _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Visibility = domain.VisibilityPrivate; return true, nil })
77+ require.NoError(t, err)
78+ }
79+ token := func(scopes []domain.Scope, branches ...string) domain.Principal {
80+ return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: scopes, BranchPrefixes: branches})
81+ }
82+ for _, tc := range []struct {
83+ name string
84+ p domain.Principal
85+ read, write bool
86+ }{
87+ {"anonymous", domain.Anonymous(), !private, false},
88+ {"reader", token([]domain.Scope{domain.ScopeRepoRead}), true, false},
89+ {"writer", token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}), true, true},
90+ {"write-only", token([]domain.Scope{domain.ScopeReleaseWrite}), !private, !private},
91+ {"branch", token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}, "x/"), true, false},
92+ {"job-read", domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: r.repo, Permissions: map[string]string{"contents": "read"}}), true, false},
93+ {"job-write", domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: r.repo, Permissions: map[string]string{"contents": "write"}}), true, true},
94+ {"owner", domain.Owner(), true, true},
95+ } {
96+ t.Run(tc.name, func(t *testing.T) {
97+ _, err := r.svc.GetRelease(r.ctx, tc.p, r.repo, ports.ReleaseRef{ID: published.ID})
98+ if tc.read {
99+ require.NoError(t, err)
100+ } else {
101+ require.ErrorIs(t, err, domain.ErrNotFound)
102+ }
103+ _, err = r.svc.GetRelease(r.ctx, tc.p, r.repo, ports.ReleaseRef{ID: draft.ID})
104+ if tc.write {
105+ require.NoError(t, err)
106+ } else {
107+ require.ErrorIs(t, err, domain.ErrNotFound)
108+ }
109+ title := "changed"
110+ _, err = r.svc.UpdateRelease(r.ctx, tc.p, r.repo, published.ID, ports.UpdateReleaseParams{Title: &title})
111+ switch {
112+ case tc.write:
113+ require.NoError(t, err)
114+ case !tc.read:
115+ require.ErrorIs(t, err, domain.ErrNotFound)
116+ case tc.p.IsAnonymous():
117+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
118+ default:
119+ require.ErrorIs(t, err, domain.ErrForbidden)
120+ }
121+ })
122+ }
123+ })
124+ }
125+}
126+func TestReleaseLifecycle(t *testing.T) {
127+ r := newReleaseRig(t, ports.ReleaseLimits{})
128+ first := r.create(t, "v1", false)
129+ second := r.create(t, "v2", false)
130+ r.create(t, "draft", true)
131+ got, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true})
132+ require.NoError(t, err)
133+ require.Equal(t, second.ID, got.ID)
134+ yes := true
135+ _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, first.ID, ports.UpdateReleaseParams{MakeLatest: &yes})
136+ require.NoError(t, err)
137+ got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{Latest: true})
138+ require.NoError(t, err)
139+ require.Equal(t, first.ID, got.ID)
140+ _, err = r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "v1"})
141+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
142+ page, err := r.svc.ListReleases(r.ctx, domain.Anonymous(), r.repo, 1, "")
143+ require.NoError(t, err)
144+ require.Len(t, page.Releases, 1)
145+ require.NotEmpty(t, page.NextPageToken)
146+ next, err := r.svc.ListReleases(r.ctx, domain.Anonymous(), r.repo, 1, page.NextPageToken)
147+ require.NoError(t, err)
148+ require.Equal(t, first.ID, next.Releases[0].ID)
149+ require.Empty(t, next.NextPageToken)
150+ r.svc.OnRefsMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/tags/v1", New: domain.ZeroOID}})
151+ got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID})
152+ require.NoError(t, err)
153+ require.True(t, got.Draft)
154+ require.True(t, got.PublishedAt.IsZero())
155+ require.False(t, got.MakeLatest)
156+ r.svc.OnRefsMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/tags/v1", New: oidA}})
157+ got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID})
158+ require.NoError(t, err)
159+ require.True(t, got.Draft)
160+ var kinds []string
161+ for len(r.events) > 0 {
162+ kinds = append(kinds, (<-r.events).EventKind())
163+ }
164+ require.Contains(t, kinds, "release.unpublished")
165+ require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, first.ID))
166+ _, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID})
167+ require.ErrorIs(t, err, domain.ErrNotFound)
168+}
169+func TestReleaseMissingTag(t *testing.T) {
170+ r := newReleaseRig(t, ports.ReleaseLimits{})
171+ r.resolve(t, "missing", domain.ErrNotFound)
172+ draft, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "missing", Draft: true})
173+ require.NoError(t, err)
174+ require.Empty(t, draft.TargetCommitish)
175+ no := false
176+ _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, draft.ID, ports.UpdateReleaseParams{Draft: &no})
177+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
178+ r.resolve(t, "new", domain.ErrNotFound)
179+ _, err = r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "new"})
180+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
181+ v, err := domain.ParseRevision("main")
182+ require.NoError(t, err)
183+ oid, err := domain.ParseObjectID(oidA)
184+ require.NoError(t, err)
185+ r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once()
186+ r.tags.EXPECT().CreateTag(r.ctx, r.repo, "new", oid).Return(nil).Once()
187+ r.pushes.EXPECT().Record(r.ctx, domain.Root(), r.repo, []domain.RefUpdate{{Ref: "refs/tags/new", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once()
188+ rel, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "new", TargetCommitish: "main"})
189+ require.NoError(t, err)
190+ require.Equal(t, oidA, rel.TargetCommitish)
191+}
192+
193+func TestReleaseLatestPinsAndPrereleases(t *testing.T) {
194+ r := newReleaseRig(t, ports.ReleaseLimits{})
195+ a := r.create(t, "a", false)
196+ b := r.create(t, "b", false)
197+ yes := true
198+ _, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, b.ID, ports.UpdateReleaseParams{Prerelease: &yes})
199+ require.NoError(t, err)
200+ latest, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true})
201+ require.NoError(t, err)
202+ require.Equal(t, a.ID, latest.ID)
203+ _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, a.ID, ports.UpdateReleaseParams{MakeLatest: &yes})
204+ require.NoError(t, err)
205+ r.resolve(t, "c", nil)
206+ c, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "c", MakeLatest: true})
207+ require.NoError(t, err)
208+ a, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: a.ID})
209+ require.NoError(t, err)
210+ require.False(t, a.MakeLatest)
211+ target := "b"
212+ _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, c.ID, ports.UpdateReleaseParams{Tag: &target})
213+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
214+ require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, c.ID))
215+ latest, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{Latest: true})
216+ require.NoError(t, err)
217+ require.Equal(t, a.ID, latest.ID)
218+}
219+
220+func TestReleaseEventsReachHomeAndRepositoryTopics(t *testing.T) {
221+ synctest.Test(t, func(t *testing.T) {
222+ r := newEventRig(t, 0)
223+ pub := r.repo("pub", domain.VisibilityPublic)
224+ priv := r.repo("priv", domain.VisibilityPrivate)
225+ ctx, cancel := context.WithCancel(context.Background())
226+ defer cancel()
227+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"home"}})
228+ owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
229+ synctest.Wait()
230+ for _, n := range []domain.RepoName{priv, pub} {
231+ vis := domain.VisibilityPublic
232+ if n == priv {
233+ vis = domain.VisibilityPrivate
234+ }
235+ rel := domain.Release{ID: 1, Repo: n, Tag: "v1"}
236+ r.feed <- domain.ReleasePublished{Release: rel, At: now, CreatedAt: now, Visibility: vis}
237+ r.feed <- domain.ReleaseUpdated{Release: rel, At: now, CreatedAt: now, Visibility: vis}
238+ r.feed <- domain.ReleaseDeleted{Release: rel, At: now, CreatedAt: now, Visibility: vis}
239+ r.feed <- domain.ReleaseUnpublished{Release: rel, At: now, CreatedAt: now, Visibility: vis}
240+ }
241+ for _, w := range []*watcher{anon, owner} {
242+ for _, kind := range []string{"release.published", "release.updated", "release.deleted", "release.unpublished"} {
243+ require.Equal(t, kind, w.next(t).Event.EventKind())
244+ }
245+ }
246+ anon.quiet(t, r, pub)
247+ owner.quiet(t, r, priv)
248+ })
249+}
250+
251+func TestReleaseWriteRetriesUsageConflict(t *testing.T) {
252+ ctx := t.Context()
253+ repo := name(t, "retry")
254+ store := mocks.NewReleaseStore(t)
255+ meta := mocks.NewRepoMetaStore(t)
256+ reader := mocks.NewRepoReader(t)
257+ clock := mocks.NewClock(t)
258+ bus := mocks.NewEventBus(t)
259+ meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic, CreatedAt: now}, nil)
260+ clock.EXPECT().Now().Return(now).Maybe()
261+ rev, err := domain.ParseRevision("refs/tags/v1")
262+ require.NoError(t, err)
263+ reader.EXPECT().ResolveRevision(ctx, repo, rev).Return(domain.ResolvedRevision{}, domain.ErrNotFound)
264+ store.EXPECT().GetReleaseByTag(ctx, repo, "v1").Return(domain.Release{}, domain.ErrNotFound)
265+ store.EXPECT().NextReleaseID(ctx).Return(int64(1), nil)
266+ store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(1), nil).Once()
267+ store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 1 && len(w.PutReleases) == 1 })).Return(domain.ErrConflict).Once()
268+ store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once()
269+ store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 2 && len(w.PutReleases) == 1 })).Return(nil).Once()
270+ store.EXPECT().GetRelease(ctx, repo, int64(1)).Return(domain.Release{ID: 1, Repo: repo, Tag: "v1", Draft: true}, nil)
271+ svc := usecase.NewReleases(store, nil, meta, reader, nil, nil, bus, clock, ports.ReleaseLimits{})
272+ rel, err := svc.CreateRelease(ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "v1", Draft: true})
273+ require.NoError(t, err)
274+ require.EqualValues(t, 1, rel.ID)
275+}
@@ -0,0 +1,275 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "fmt"
8+ "testing"
9+ "testing/synctest"
10+
11+ "bnema.dev/ermite/internal/adapters/blobdisk"
12+ "bnema.dev/ermite/internal/adapters/eventbus"
13+ "bnema.dev/ermite/internal/adapters/kvstore"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+ "bnema.dev/ermite/internal/usecase"
18+ "github.com/stretchr/testify/mock"
19+ "github.com/stretchr/testify/require"
20+)
21+
22+type releaseRig struct {
23+ ctx context.Context
24+ repo domain.RepoName
25+ store *kvstore.Store
26+ blobs ports.BlobStore
27+ reader *mocks.RepoReader
28+ tags *mocks.ReleaseTagWriter
29+ pushes *mocks.PushHookService
30+ clock *mocks.Clock
31+ events <-chan domain.Event
32+ svc *usecase.Releases
33+}
34+
35+func newReleaseRig(t *testing.T, limits ports.ReleaseLimits) *releaseRig {
36+ t.Helper()
37+ ctx := t.Context()
38+ store, err := kvstore.Open(ctx, t.TempDir())
39+ require.NoError(t, err)
40+ t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
41+ n := name(t, "releases")
42+ require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now}))
43+ clock := mocks.NewClock(t)
44+ clock.EXPECT().Now().Return(now).Maybe()
45+ reader := mocks.NewRepoReader(t)
46+ tags := mocks.NewReleaseTagWriter(t)
47+ pushes := mocks.NewPushHookService(t)
48+ bus := eventbus.NewWithBuffer(1024)
49+ b := blobdisk.New(t.TempDir())
50+ r := &releaseRig{ctx: ctx, repo: n, store: store, blobs: b, reader: reader, tags: tags, pushes: pushes, clock: clock, events: bus.Subscribe(ctx)}
51+ r.svc = usecase.NewReleases(store.Releases(), b, store, reader, tags, pushes, bus, clock, limits)
52+ return r
53+}
54+func (r *releaseRig) resolve(t *testing.T, tag string, err error) {
55+ t.Helper()
56+ v, e := domain.ParseRevision("refs/tags/" + tag)
57+ require.NoError(t, e)
58+ oid, e := domain.ParseObjectID(oidA)
59+ require.NoError(t, e)
60+ r.reader.EXPECT().ResolveRevision(mock.Anything, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, err).Maybe()
61+}
62+func (r *releaseRig) create(t *testing.T, tag string, draft bool) domain.Release {
63+ t.Helper()
64+ r.resolve(t, tag, nil)
65+ rel, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: tag, Draft: draft})
66+ require.NoError(t, err)
67+ return rel
68+}
69+func TestReleaseAccessMatrix(t *testing.T) {
70+ for _, private := range []bool{false, true} {
71+ t.Run(fmt.Sprint("private=", private), func(t *testing.T) {
72+ r := newReleaseRig(t, ports.ReleaseLimits{})
73+ published := r.create(t, "v1", false)
74+ draft := r.create(t, "draft", true)
75+ if private {
76+ _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Visibility = domain.VisibilityPrivate; return true, nil })
77+ require.NoError(t, err)
78+ }
79+ token := func(scopes []domain.Scope, branches ...string) domain.Principal {
80+ return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: scopes, BranchPrefixes: branches})
81+ }
82+ for _, tc := range []struct {
83+ name string
84+ p domain.Principal
85+ read, write bool
86+ }{
87+ {"anonymous", domain.Anonymous(), !private, false},
88+ {"reader", token([]domain.Scope{domain.ScopeRepoRead}), true, false},
89+ {"writer", token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}), true, true},
90+ {"write-only", token([]domain.Scope{domain.ScopeReleaseWrite}), !private, !private},
91+ {"branch", token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}, "x/"), true, false},
92+ {"job-read", domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: r.repo, Permissions: map[string]string{"contents": "read"}}), true, false},
93+ {"job-write", domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: r.repo, Permissions: map[string]string{"contents": "write"}}), true, true},
94+ {"owner", domain.Owner(), true, true},
95+ } {
96+ t.Run(tc.name, func(t *testing.T) {
97+ _, err := r.svc.GetRelease(r.ctx, tc.p, r.repo, ports.ReleaseRef{ID: published.ID})
98+ if tc.read {
99+ require.NoError(t, err)
100+ } else {
101+ require.ErrorIs(t, err, domain.ErrNotFound)
102+ }
103+ _, err = r.svc.GetRelease(r.ctx, tc.p, r.repo, ports.ReleaseRef{ID: draft.ID})
104+ if tc.write {
105+ require.NoError(t, err)
106+ } else {
107+ require.ErrorIs(t, err, domain.ErrNotFound)
108+ }
109+ title := "changed"
110+ _, err = r.svc.UpdateRelease(r.ctx, tc.p, r.repo, published.ID, ports.UpdateReleaseParams{Title: &title})
111+ switch {
112+ case tc.write:
113+ require.NoError(t, err)
114+ case !tc.read:
115+ require.ErrorIs(t, err, domain.ErrNotFound)
116+ case tc.p.IsAnonymous():
117+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
118+ default:
119+ require.ErrorIs(t, err, domain.ErrForbidden)
120+ }
121+ })
122+ }
123+ })
124+ }
125+}
126+func TestReleaseLifecycle(t *testing.T) {
127+ r := newReleaseRig(t, ports.ReleaseLimits{})
128+ first := r.create(t, "v1", false)
129+ second := r.create(t, "v2", false)
130+ r.create(t, "draft", true)
131+ got, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true})
132+ require.NoError(t, err)
133+ require.Equal(t, second.ID, got.ID)
134+ yes := true
135+ _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, first.ID, ports.UpdateReleaseParams{MakeLatest: &yes})
136+ require.NoError(t, err)
137+ got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{Latest: true})
138+ require.NoError(t, err)
139+ require.Equal(t, first.ID, got.ID)
140+ _, err = r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "v1"})
141+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
142+ page, err := r.svc.ListReleases(r.ctx, domain.Anonymous(), r.repo, 1, "")
143+ require.NoError(t, err)
144+ require.Len(t, page.Releases, 1)
145+ require.NotEmpty(t, page.NextPageToken)
146+ next, err := r.svc.ListReleases(r.ctx, domain.Anonymous(), r.repo, 1, page.NextPageToken)
147+ require.NoError(t, err)
148+ require.Equal(t, first.ID, next.Releases[0].ID)
149+ require.Empty(t, next.NextPageToken)
150+ r.svc.OnRefsMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/tags/v1", New: domain.ZeroOID}})
151+ got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID})
152+ require.NoError(t, err)
153+ require.True(t, got.Draft)
154+ require.True(t, got.PublishedAt.IsZero())
155+ require.False(t, got.MakeLatest)
156+ r.svc.OnRefsMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/tags/v1", New: oidA}})
157+ got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID})
158+ require.NoError(t, err)
159+ require.True(t, got.Draft)
160+ var kinds []string
161+ for len(r.events) > 0 {
162+ kinds = append(kinds, (<-r.events).EventKind())
163+ }
164+ require.Contains(t, kinds, "release.unpublished")
165+ require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, first.ID))
166+ _, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID})
167+ require.ErrorIs(t, err, domain.ErrNotFound)
168+}
169+func TestReleaseMissingTag(t *testing.T) {
170+ r := newReleaseRig(t, ports.ReleaseLimits{})
171+ r.resolve(t, "missing", domain.ErrNotFound)
172+ draft, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "missing", Draft: true})
173+ require.NoError(t, err)
174+ require.Empty(t, draft.TargetCommitish)
175+ no := false
176+ _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, draft.ID, ports.UpdateReleaseParams{Draft: &no})
177+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
178+ r.resolve(t, "new", domain.ErrNotFound)
179+ _, err = r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "new"})
180+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
181+ v, err := domain.ParseRevision("main")
182+ require.NoError(t, err)
183+ oid, err := domain.ParseObjectID(oidA)
184+ require.NoError(t, err)
185+ r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once()
186+ r.tags.EXPECT().CreateTag(r.ctx, r.repo, "new", oid).Return(nil).Once()
187+ r.pushes.EXPECT().Record(r.ctx, domain.Root(), r.repo, []domain.RefUpdate{{Ref: "refs/tags/new", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once()
188+ rel, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "new", TargetCommitish: "main"})
189+ require.NoError(t, err)
190+ require.Equal(t, oidA, rel.TargetCommitish)
191+}
192+
193+func TestReleaseLatestPinsAndPrereleases(t *testing.T) {
194+ r := newReleaseRig(t, ports.ReleaseLimits{})
195+ a := r.create(t, "a", false)
196+ b := r.create(t, "b", false)
197+ yes := true
198+ _, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, b.ID, ports.UpdateReleaseParams{Prerelease: &yes})
199+ require.NoError(t, err)
200+ latest, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true})
201+ require.NoError(t, err)
202+ require.Equal(t, a.ID, latest.ID)
203+ _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, a.ID, ports.UpdateReleaseParams{MakeLatest: &yes})
204+ require.NoError(t, err)
205+ r.resolve(t, "c", nil)
206+ c, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "c", MakeLatest: true})
207+ require.NoError(t, err)
208+ a, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: a.ID})
209+ require.NoError(t, err)
210+ require.False(t, a.MakeLatest)
211+ target := "b"
212+ _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, c.ID, ports.UpdateReleaseParams{Tag: &target})
213+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
214+ require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, c.ID))
215+ latest, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{Latest: true})
216+ require.NoError(t, err)
217+ require.Equal(t, a.ID, latest.ID)
218+}
219+
220+func TestReleaseEventsReachHomeAndRepositoryTopics(t *testing.T) {
221+ synctest.Test(t, func(t *testing.T) {
222+ r := newEventRig(t, 0)
223+ pub := r.repo("pub", domain.VisibilityPublic)
224+ priv := r.repo("priv", domain.VisibilityPrivate)
225+ ctx, cancel := context.WithCancel(context.Background())
226+ defer cancel()
227+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"home"}})
228+ owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
229+ synctest.Wait()
230+ for _, n := range []domain.RepoName{priv, pub} {
231+ vis := domain.VisibilityPublic
232+ if n == priv {
233+ vis = domain.VisibilityPrivate
234+ }
235+ rel := domain.Release{ID: 1, Repo: n, Tag: "v1"}
236+ r.feed <- domain.ReleasePublished{Release: rel, At: now, CreatedAt: now, Visibility: vis}
237+ r.feed <- domain.ReleaseUpdated{Release: rel, At: now, CreatedAt: now, Visibility: vis}
238+ r.feed <- domain.ReleaseDeleted{Release: rel, At: now, CreatedAt: now, Visibility: vis}
239+ r.feed <- domain.ReleaseUnpublished{Release: rel, At: now, CreatedAt: now, Visibility: vis}
240+ }
241+ for _, w := range []*watcher{anon, owner} {
242+ for _, kind := range []string{"release.published", "release.updated", "release.deleted", "release.unpublished"} {
243+ require.Equal(t, kind, w.next(t).Event.EventKind())
244+ }
245+ }
246+ anon.quiet(t, r, pub)
247+ owner.quiet(t, r, priv)
248+ })
249+}
250+
251+func TestReleaseWriteRetriesUsageConflict(t *testing.T) {
252+ ctx := t.Context()
253+ repo := name(t, "retry")
254+ store := mocks.NewReleaseStore(t)
255+ meta := mocks.NewRepoMetaStore(t)
256+ reader := mocks.NewRepoReader(t)
257+ clock := mocks.NewClock(t)
258+ bus := mocks.NewEventBus(t)
259+ meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic, CreatedAt: now}, nil)
260+ clock.EXPECT().Now().Return(now).Maybe()
261+ rev, err := domain.ParseRevision("refs/tags/v1")
262+ require.NoError(t, err)
263+ reader.EXPECT().ResolveRevision(ctx, repo, rev).Return(domain.ResolvedRevision{}, domain.ErrNotFound)
264+ store.EXPECT().GetReleaseByTag(ctx, repo, "v1").Return(domain.Release{}, domain.ErrNotFound)
265+ store.EXPECT().NextReleaseID(ctx).Return(int64(1), nil)
266+ store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(1), nil).Once()
267+ store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 1 && len(w.PutReleases) == 1 })).Return(domain.ErrConflict).Once()
268+ store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once()
269+ store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 2 && len(w.PutReleases) == 1 })).Return(nil).Once()
270+ store.EXPECT().GetRelease(ctx, repo, int64(1)).Return(domain.Release{ID: 1, Repo: repo, Tag: "v1", Draft: true}, nil)
271+ svc := usecase.NewReleases(store, nil, meta, reader, nil, nil, bus, clock, ports.ReleaseLimits{})
272+ rel, err := svc.CreateRelease(ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "v1", Draft: true})
273+ require.NoError(t, err)
274+ require.EqualValues(t, 1, rel.ID)
275+}
A internal/usecase/releases_sweep.go +105 -0
@@ -0,0 +1,105 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "strconv"
9+ "strings"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// Sweep collects abandoned uploads, missing contents, and old orphan blobs.
16+// A failed metadata scan never authorizes blob deletion. Sweeps are serialized
17+// so the two observations of a missing blob are always distinct sweeps.
18+func (r *Releases) Sweep(ctx context.Context) {
19+ r.sweepMu.Lock()
20+ defer r.sweepMu.Unlock()
21+ log := ctxLog(ctx)
22+ now := r.clock.Now()
23+ all, err := r.store.AllAssets(ctx)
24+ if err != nil {
25+ log.Error().Err(err).Msg("scan release assets")
26+ return
27+ }
28+ seen := make(map[int64]bool, len(all))
29+ for _, a := range all {
30+ seen[a.ID] = true
31+ remove := false
32+ if a.State == domain.ReleaseAssetUploading {
33+ remove = now.Sub(a.UpdatedAt) > domain.ReleaseAssetPendingMax
34+ } else if a.State == domain.ReleaseAssetUploaded {
35+ _, err := r.blobs.Stat(ctx, a.BlobKey())
36+ switch {
37+ case errors.Is(err, domain.ErrNotFound):
38+ r.misses[a.ID]++
39+ remove = r.misses[a.ID] >= 2
40+ if !remove {
41+ log.Error().Int64("asset", a.ID).Msg("asset blob missing")
42+ }
43+ case err != nil:
44+ log.Error().Err(err).Int64("asset", a.ID).Msg("stat release asset")
45+ default:
46+ delete(r.misses, a.ID)
47+ }
48+ }
49+ if !remove {
50+ continue
51+ }
52+ err := r.removeAssetRecord(ctx, a, func(current domain.ReleaseAsset) bool { return current.Version == a.Version })
53+ if err != nil {
54+ if !errors.Is(err, domain.ErrNotFound) && !errors.Is(err, domain.ErrConflict) {
55+ log.Error().Err(err).Int64("asset", a.ID).Msg("remove stale release asset")
56+ }
57+ continue
58+ }
59+ delete(r.misses, a.ID)
60+ r.removeAssetBlobs(ctx, []domain.ReleaseAsset{a})
61+ }
62+ for id := range r.misses {
63+ if !seen[id] {
64+ delete(r.misses, id)
65+ }
66+ }
67+ // Re-check the store for each old key: concurrent reservations must not be
68+ // classified as orphans from a stale snapshot.
69+ err = r.blobs.List(ctx, "release/", func(info ports.BlobInfo) error {
70+ if now.Sub(info.ModTime) <= domain.ReleaseAssetPendingMax {
71+ return nil
72+ }
73+ // Only a canonical key can name an asset. Check its record directly
74+ // rather than repeating the full metadata scan for every object.
75+ id, parseErr := strconv.ParseInt(strings.TrimPrefix(info.Key, "release/"), 10, 64)
76+ if parseErr == nil && (domain.ReleaseAsset{ID: id}).BlobKey() == info.Key {
77+ _, e := r.store.GetAsset(ctx, id)
78+ if e == nil {
79+ return nil
80+ }
81+ if !errors.Is(e, domain.ErrNotFound) {
82+ log.Error().Err(e).Msg("check orphan release blob")
83+ return nil
84+ }
85+ }
86+ if e := r.blobs.Delete(ctx, info.Key); e != nil {
87+ log.Error().Err(e).Str("key", info.Key).Msg("remove orphan release blob")
88+ }
89+ return nil
90+ })
91+ if err != nil {
92+ log.Error().Err(err).Msg("list release blobs")
93+ }
94+ err = r.blobs.ListMultipart(ctx, "release/", func(info ports.MultipartInfo) error {
95+ if now.Sub(info.Started) > domain.ReleaseAssetPendingMax {
96+ if e := r.blobs.AbortMultipart(ctx, info.Key, info.ID); e != nil {
97+ log.Error().Err(e).Msg("abort abandoned release multipart")
98+ }
99+ }
100+ return nil
101+ })
102+ if err != nil {
103+ log.Error().Err(err).Msg("list release multipart uploads")
104+ }
105+}
@@ -0,0 +1,105 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "strconv"
9+ "strings"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// Sweep collects abandoned uploads, missing contents, and old orphan blobs.
16+// A failed metadata scan never authorizes blob deletion. Sweeps are serialized
17+// so the two observations of a missing blob are always distinct sweeps.
18+func (r *Releases) Sweep(ctx context.Context) {
19+ r.sweepMu.Lock()
20+ defer r.sweepMu.Unlock()
21+ log := ctxLog(ctx)
22+ now := r.clock.Now()
23+ all, err := r.store.AllAssets(ctx)
24+ if err != nil {
25+ log.Error().Err(err).Msg("scan release assets")
26+ return
27+ }
28+ seen := make(map[int64]bool, len(all))
29+ for _, a := range all {
30+ seen[a.ID] = true
31+ remove := false
32+ if a.State == domain.ReleaseAssetUploading {
33+ remove = now.Sub(a.UpdatedAt) > domain.ReleaseAssetPendingMax
34+ } else if a.State == domain.ReleaseAssetUploaded {
35+ _, err := r.blobs.Stat(ctx, a.BlobKey())
36+ switch {
37+ case errors.Is(err, domain.ErrNotFound):
38+ r.misses[a.ID]++
39+ remove = r.misses[a.ID] >= 2
40+ if !remove {
41+ log.Error().Int64("asset", a.ID).Msg("asset blob missing")
42+ }
43+ case err != nil:
44+ log.Error().Err(err).Int64("asset", a.ID).Msg("stat release asset")
45+ default:
46+ delete(r.misses, a.ID)
47+ }
48+ }
49+ if !remove {
50+ continue
51+ }
52+ err := r.removeAssetRecord(ctx, a, func(current domain.ReleaseAsset) bool { return current.Version == a.Version })
53+ if err != nil {
54+ if !errors.Is(err, domain.ErrNotFound) && !errors.Is(err, domain.ErrConflict) {
55+ log.Error().Err(err).Int64("asset", a.ID).Msg("remove stale release asset")
56+ }
57+ continue
58+ }
59+ delete(r.misses, a.ID)
60+ r.removeAssetBlobs(ctx, []domain.ReleaseAsset{a})
61+ }
62+ for id := range r.misses {
63+ if !seen[id] {
64+ delete(r.misses, id)
65+ }
66+ }
67+ // Re-check the store for each old key: concurrent reservations must not be
68+ // classified as orphans from a stale snapshot.
69+ err = r.blobs.List(ctx, "release/", func(info ports.BlobInfo) error {
70+ if now.Sub(info.ModTime) <= domain.ReleaseAssetPendingMax {
71+ return nil
72+ }
73+ // Only a canonical key can name an asset. Check its record directly
74+ // rather than repeating the full metadata scan for every object.
75+ id, parseErr := strconv.ParseInt(strings.TrimPrefix(info.Key, "release/"), 10, 64)
76+ if parseErr == nil && (domain.ReleaseAsset{ID: id}).BlobKey() == info.Key {
77+ _, e := r.store.GetAsset(ctx, id)
78+ if e == nil {
79+ return nil
80+ }
81+ if !errors.Is(e, domain.ErrNotFound) {
82+ log.Error().Err(e).Msg("check orphan release blob")
83+ return nil
84+ }
85+ }
86+ if e := r.blobs.Delete(ctx, info.Key); e != nil {
87+ log.Error().Err(e).Str("key", info.Key).Msg("remove orphan release blob")
88+ }
89+ return nil
90+ })
91+ if err != nil {
92+ log.Error().Err(err).Msg("list release blobs")
93+ }
94+ err = r.blobs.ListMultipart(ctx, "release/", func(info ports.MultipartInfo) error {
95+ if now.Sub(info.Started) > domain.ReleaseAssetPendingMax {
96+ if e := r.blobs.AbortMultipart(ctx, info.Key, info.ID); e != nil {
97+ log.Error().Err(e).Msg("abort abandoned release multipart")
98+ }
99+ }
100+ return nil
101+ })
102+ if err != nil {
103+ log.Error().Err(err).Msg("list release multipart uploads")
104+ }
105+}
A internal/usecase/releases_sweep_test.go +147 -0
@@ -0,0 +1,147 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "testing"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "bnema.dev/ermite/internal/ports/mocks"
15+ "bnema.dev/ermite/internal/usecase"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+func (r *releaseRig) setClock(t *testing.T, at time.Time, blobs ports.BlobStore) {
21+ t.Helper()
22+ clock := mocks.NewClock(t)
23+ clock.EXPECT().Now().Return(at).Maybe()
24+ bus := mocks.NewEventBus(t)
25+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Return().Maybe()
26+ r.clock = clock
27+ r.svc = usecase.NewReleases(r.store.Releases(), blobs, r.store, r.reader, r.tags, r.pushes, bus, clock, ports.ReleaseLimits{})
28+}
29+func TestReleaseSweepPendingAndOrphans(t *testing.T) {
30+ r := newReleaseRig(t, ports.ReleaseLimits{})
31+ rel := r.create(t, "v1", false)
32+ pending := r.upload(t, rel, "pending", []byte("abc"))
33+ good := r.upload(t, rel, "good", []byte("xyz"))
34+ fresh := r.upload(t, rel, "fresh", nil)
35+ old, err := r.store.Releases().GetAsset(r.ctx, pending.ID)
36+ require.NoError(t, err)
37+ old.State = domain.ReleaseAssetUploading
38+ mp, err := r.blobs.NewMultipart(r.ctx, old.BlobKey())
39+ require.NoError(t, err)
40+ old.MultipartID = mp.ID()
41+ fresh.State = domain.ReleaseAssetUploading
42+ fresh.UpdatedAt = time.Now().Add(25 * time.Hour)
43+ used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo)
44+ require.NoError(t, err)
45+ require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v, PutAssets: []domain.ReleaseAsset{old, fresh}}))
46+ _, err = r.blobs.Put(r.ctx, "release/99999999999999999999", bytes.NewReader([]byte("orphan")), 6)
47+ require.NoError(t, err)
48+ // The clock is advanced beyond the disk adapter's real modification times.
49+ at := time.Now().Add(26 * time.Hour)
50+ r.setClock(t, at, r.blobs)
51+ r.svc.Sweep(r.ctx)
52+ _, err = r.store.Releases().GetAsset(r.ctx, pending.ID)
53+ require.ErrorIs(t, err, domain.ErrNotFound)
54+ _, err = r.blobs.Stat(r.ctx, pending.BlobKey())
55+ require.ErrorIs(t, err, domain.ErrNotFound)
56+ _, err = r.blobs.Stat(r.ctx, "release/99999999999999999999")
57+ require.ErrorIs(t, err, domain.ErrNotFound)
58+ _, err = r.store.Releases().GetAsset(r.ctx, good.ID)
59+ require.NoError(t, err)
60+ // The pending record inside the grace period is retained.
61+ _, err = r.store.Releases().GetAsset(r.ctx, fresh.ID)
62+ require.NoError(t, err)
63+ require.NoError(t, r.blobs.ListMultipart(r.ctx, "release/", func(ports.MultipartInfo) error { t.Error("multipart survived"); return nil }))
64+}
65+func TestReleaseSweepMissingBlobTwoObservations(t *testing.T) {
66+ r := newReleaseRig(t, ports.ReleaseLimits{})
67+ rel := r.create(t, "v1", false)
68+ a := r.upload(t, rel, "binary", []byte("abc"))
69+ require.NoError(t, r.blobs.Delete(r.ctx, a.BlobKey()))
70+ r.svc.Sweep(r.ctx)
71+ _, err := r.store.Releases().GetAsset(r.ctx, a.ID)
72+ require.NoError(t, err)
73+ r.svc.Sweep(r.ctx)
74+ _, err = r.store.Releases().GetAsset(r.ctx, a.ID)
75+ require.ErrorIs(t, err, domain.ErrNotFound)
76+ usage, err := r.svc.Usage(r.ctx, domain.Root(), r.repo)
77+ require.NoError(t, err)
78+ require.Zero(t, usage.Used)
79+}
80+
81+type releaseStatOverride struct {
82+ ports.BlobStore
83+ err error
84+}
85+
86+func (b *releaseStatOverride) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
87+ if b.err != nil {
88+ return ports.BlobInfo{}, b.err
89+ }
90+ return b.BlobStore.Stat(ctx, key)
91+}
92+func TestReleaseSweepTransientStatAndRecovery(t *testing.T) {
93+ for _, recover := range []bool{false, true} {
94+ t.Run(map[bool]string{false: "transient preserves miss", true: "success resets miss"}[recover], func(t *testing.T) {
95+ r := newReleaseRig(t, ports.ReleaseLimits{})
96+ rel := r.create(t, "v1", false)
97+ a := r.upload(t, rel, "binary", []byte("abc"))
98+ b := &releaseStatOverride{BlobStore: r.blobs, err: domain.ErrNotFound}
99+ r.setClock(t, now, b)
100+ r.svc.Sweep(r.ctx)
101+ b.err = errors.New("outage")
102+ r.svc.Sweep(r.ctx)
103+ _, err := r.store.Releases().GetAsset(r.ctx, a.ID)
104+ require.NoError(t, err)
105+ if recover {
106+ b.err = nil
107+ r.svc.Sweep(r.ctx)
108+ }
109+ b.err = domain.ErrNotFound
110+ r.svc.Sweep(r.ctx)
111+ _, err = r.store.Releases().GetAsset(r.ctx, a.ID)
112+ if recover {
113+ require.NoError(t, err)
114+ } else {
115+ require.ErrorIs(t, err, domain.ErrNotFound)
116+ }
117+ })
118+ }
119+}
120+func TestReleaseSweepGraceAndFailureIsolation(t *testing.T) {
121+ r := newReleaseRig(t, ports.ReleaseLimits{})
122+ rel := r.create(t, "v1", false)
123+ a := r.upload(t, rel, "pending", nil)
124+ a.State = domain.ReleaseAssetUploading
125+ used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo)
126+ require.NoError(t, err)
127+ require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v, PutAssets: []domain.ReleaseAsset{a}}))
128+ b := mocks.NewBlobStore(t)
129+ b.EXPECT().List(r.ctx, "release/", mock.Anything).RunAndReturn(func(_ context.Context, _ string, fn func(ports.BlobInfo) error) error {
130+ require.NoError(t, fn(ports.BlobInfo{Key: "release/old", ModTime: now.Add(-25 * time.Hour)}))
131+ require.NoError(t, fn(ports.BlobInfo{Key: "release/new", ModTime: now.Add(23 * time.Hour)}))
132+ return nil
133+ }).Once()
134+ b.EXPECT().Delete(r.ctx, "release/old").Return(errors.New("failed deletion")).Once()
135+ b.EXPECT().ListMultipart(r.ctx, "release/", mock.Anything).RunAndReturn(func(_ context.Context, _ string, fn func(ports.MultipartInfo) error) error {
136+ require.NoError(t, fn(ports.MultipartInfo{Key: "release/old", ID: "old", Started: now.Add(-25 * time.Hour)}))
137+ require.NoError(t, fn(ports.MultipartInfo{Key: "release/new", ID: "new", Started: now}))
138+ return nil
139+ }).Once()
140+ b.EXPECT().AbortMultipart(r.ctx, "release/old", "old").Return(nil).Once()
141+ r.setClock(t, now.Add(24*time.Hour), b)
142+ // Use times relative to the sweep, keeping both new objects within grace.
143+ // pending exactly 24h old survives; failures in orphan deletion do not stop multipart GC.
144+ r.svc.Sweep(r.ctx)
145+ _, err = r.store.Releases().GetAsset(r.ctx, a.ID)
146+ require.NoError(t, err)
147+}
@@ -0,0 +1,147 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "testing"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "bnema.dev/ermite/internal/ports/mocks"
15+ "bnema.dev/ermite/internal/usecase"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+func (r *releaseRig) setClock(t *testing.T, at time.Time, blobs ports.BlobStore) {
21+ t.Helper()
22+ clock := mocks.NewClock(t)
23+ clock.EXPECT().Now().Return(at).Maybe()
24+ bus := mocks.NewEventBus(t)
25+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Return().Maybe()
26+ r.clock = clock
27+ r.svc = usecase.NewReleases(r.store.Releases(), blobs, r.store, r.reader, r.tags, r.pushes, bus, clock, ports.ReleaseLimits{})
28+}
29+func TestReleaseSweepPendingAndOrphans(t *testing.T) {
30+ r := newReleaseRig(t, ports.ReleaseLimits{})
31+ rel := r.create(t, "v1", false)
32+ pending := r.upload(t, rel, "pending", []byte("abc"))
33+ good := r.upload(t, rel, "good", []byte("xyz"))
34+ fresh := r.upload(t, rel, "fresh", nil)
35+ old, err := r.store.Releases().GetAsset(r.ctx, pending.ID)
36+ require.NoError(t, err)
37+ old.State = domain.ReleaseAssetUploading
38+ mp, err := r.blobs.NewMultipart(r.ctx, old.BlobKey())
39+ require.NoError(t, err)
40+ old.MultipartID = mp.ID()
41+ fresh.State = domain.ReleaseAssetUploading
42+ fresh.UpdatedAt = time.Now().Add(25 * time.Hour)
43+ used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo)
44+ require.NoError(t, err)
45+ require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v, PutAssets: []domain.ReleaseAsset{old, fresh}}))
46+ _, err = r.blobs.Put(r.ctx, "release/99999999999999999999", bytes.NewReader([]byte("orphan")), 6)
47+ require.NoError(t, err)
48+ // The clock is advanced beyond the disk adapter's real modification times.
49+ at := time.Now().Add(26 * time.Hour)
50+ r.setClock(t, at, r.blobs)
51+ r.svc.Sweep(r.ctx)
52+ _, err = r.store.Releases().GetAsset(r.ctx, pending.ID)
53+ require.ErrorIs(t, err, domain.ErrNotFound)
54+ _, err = r.blobs.Stat(r.ctx, pending.BlobKey())
55+ require.ErrorIs(t, err, domain.ErrNotFound)
56+ _, err = r.blobs.Stat(r.ctx, "release/99999999999999999999")
57+ require.ErrorIs(t, err, domain.ErrNotFound)
58+ _, err = r.store.Releases().GetAsset(r.ctx, good.ID)
59+ require.NoError(t, err)
60+ // The pending record inside the grace period is retained.
61+ _, err = r.store.Releases().GetAsset(r.ctx, fresh.ID)
62+ require.NoError(t, err)
63+ require.NoError(t, r.blobs.ListMultipart(r.ctx, "release/", func(ports.MultipartInfo) error { t.Error("multipart survived"); return nil }))
64+}
65+func TestReleaseSweepMissingBlobTwoObservations(t *testing.T) {
66+ r := newReleaseRig(t, ports.ReleaseLimits{})
67+ rel := r.create(t, "v1", false)
68+ a := r.upload(t, rel, "binary", []byte("abc"))
69+ require.NoError(t, r.blobs.Delete(r.ctx, a.BlobKey()))
70+ r.svc.Sweep(r.ctx)
71+ _, err := r.store.Releases().GetAsset(r.ctx, a.ID)
72+ require.NoError(t, err)
73+ r.svc.Sweep(r.ctx)
74+ _, err = r.store.Releases().GetAsset(r.ctx, a.ID)
75+ require.ErrorIs(t, err, domain.ErrNotFound)
76+ usage, err := r.svc.Usage(r.ctx, domain.Root(), r.repo)
77+ require.NoError(t, err)
78+ require.Zero(t, usage.Used)
79+}
80+
81+type releaseStatOverride struct {
82+ ports.BlobStore
83+ err error
84+}
85+
86+func (b *releaseStatOverride) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
87+ if b.err != nil {
88+ return ports.BlobInfo{}, b.err
89+ }
90+ return b.BlobStore.Stat(ctx, key)
91+}
92+func TestReleaseSweepTransientStatAndRecovery(t *testing.T) {
93+ for _, recover := range []bool{false, true} {
94+ t.Run(map[bool]string{false: "transient preserves miss", true: "success resets miss"}[recover], func(t *testing.T) {
95+ r := newReleaseRig(t, ports.ReleaseLimits{})
96+ rel := r.create(t, "v1", false)
97+ a := r.upload(t, rel, "binary", []byte("abc"))
98+ b := &releaseStatOverride{BlobStore: r.blobs, err: domain.ErrNotFound}
99+ r.setClock(t, now, b)
100+ r.svc.Sweep(r.ctx)
101+ b.err = errors.New("outage")
102+ r.svc.Sweep(r.ctx)
103+ _, err := r.store.Releases().GetAsset(r.ctx, a.ID)
104+ require.NoError(t, err)
105+ if recover {
106+ b.err = nil
107+ r.svc.Sweep(r.ctx)
108+ }
109+ b.err = domain.ErrNotFound
110+ r.svc.Sweep(r.ctx)
111+ _, err = r.store.Releases().GetAsset(r.ctx, a.ID)
112+ if recover {
113+ require.NoError(t, err)
114+ } else {
115+ require.ErrorIs(t, err, domain.ErrNotFound)
116+ }
117+ })
118+ }
119+}
120+func TestReleaseSweepGraceAndFailureIsolation(t *testing.T) {
121+ r := newReleaseRig(t, ports.ReleaseLimits{})
122+ rel := r.create(t, "v1", false)
123+ a := r.upload(t, rel, "pending", nil)
124+ a.State = domain.ReleaseAssetUploading
125+ used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo)
126+ require.NoError(t, err)
127+ require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v, PutAssets: []domain.ReleaseAsset{a}}))
128+ b := mocks.NewBlobStore(t)
129+ b.EXPECT().List(r.ctx, "release/", mock.Anything).RunAndReturn(func(_ context.Context, _ string, fn func(ports.BlobInfo) error) error {
130+ require.NoError(t, fn(ports.BlobInfo{Key: "release/old", ModTime: now.Add(-25 * time.Hour)}))
131+ require.NoError(t, fn(ports.BlobInfo{Key: "release/new", ModTime: now.Add(23 * time.Hour)}))
132+ return nil
133+ }).Once()
134+ b.EXPECT().Delete(r.ctx, "release/old").Return(errors.New("failed deletion")).Once()
135+ b.EXPECT().ListMultipart(r.ctx, "release/", mock.Anything).RunAndReturn(func(_ context.Context, _ string, fn func(ports.MultipartInfo) error) error {
136+ require.NoError(t, fn(ports.MultipartInfo{Key: "release/old", ID: "old", Started: now.Add(-25 * time.Hour)}))
137+ require.NoError(t, fn(ports.MultipartInfo{Key: "release/new", ID: "new", Started: now}))
138+ return nil
139+ }).Once()
140+ b.EXPECT().AbortMultipart(r.ctx, "release/old", "old").Return(nil).Once()
141+ r.setClock(t, now.Add(24*time.Hour), b)
142+ // Use times relative to the sweep, keeping both new objects within grace.
143+ // pending exactly 24h old survives; failures in orphan deletion do not stop multipart GC.
144+ r.svc.Sweep(r.ctx)
145+ _, err = r.store.Releases().GetAsset(r.ctx, a.ID)
146+ require.NoError(t, err)
147+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close