v3.1.0 d676137

prune.go RawHistoryFolder

1package images
2
3import (
4	"context"
5	"crypto/sha256"
6	"encoding/hex"
7	"encoding/json"
8	"fmt"
9	"sort"
10	"time"
11
12	"github.com/bnema/zerowrap"
13
14	"github.com/bnema/gordon/internal/boundaries/out"
15	"github.com/bnema/gordon/internal/domain"
16	"github.com/bnema/gordon/internal/usecase/pruneguard"
17	"github.com/bnema/gordon/internal/usecase/registrystate"
18)
19
20// Supported OCI/Docker manifest media types. Anything else makes the
21// content behind it unknown rather than unreferenced.
22const (
23	mediaTypeDockerManifest     = "application/vnd.docker.distribution.manifest.v2+json"
24	mediaTypeDockerManifestList = "application/vnd.docker.distribution.manifest.list.v2+json"
25	mediaTypeOCIManifest        = "application/vnd.oci.image.manifest.v1+json"
26	mediaTypeOCIImageIndex      = "application/vnd.oci.image.index.v1+json"
27)
28
29// WithPrunePorts wires the prune ports: the coherent protection store,
30// the runtime inventory/deletion port, and the GC barrier. All three
31// are required for prune; a missing port fails closed.
32func (s *Service) WithPrunePorts(protection out.PruneProtectionStore, pruneRuntime out.PruneRuntime, barrier out.GCBarrier) *Service {
33	s.protection = protection
34	s.pruneRuntime = pruneRuntime
35	s.barrier = barrier
36	return s
37}
38
39// acquireExclusive takes the GC exclusive lease for the whole prune run,
40// or a no-op lease when no barrier is wired.
41func (s *Service) acquireExclusive(ctx context.Context) (out.GCLease, error) {
42	if s.barrier == nil {
43		return noopGCLease{}, nil
44	}
45	return s.barrier.AcquireExclusive(ctx)
46}
47
48// noopGCLease is the lease used when no barrier is wired.
49type noopGCLease struct{}
50
51func (noopGCLease) Release() {}
52
53// Prune plans and (unless DryRun) executes one image prune run.
54//
55// The whole plan is computed and validated before anything is deleted.
56// A valid plan with zero eligible resources succeeds: protected and
57// unknown candidates are normal outcomes, never an operation-wide
58// refusal.
59func (s *Service) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
60	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
61		zerowrap.FieldLayer:   "usecase",
62		zerowrap.FieldUseCase: "Prune",
63		"keepLast":            opts.KeepLast,
64		"pruneDangling":       opts.PruneDangling,
65		"pruneRegistry":       opts.PruneRegistry,
66		"dryRun":              opts.DryRun,
67	})
68	log := zerowrap.FromCtx(ctx)
69
70	lease, err := s.acquireExclusive(ctx)
71	if err != nil {
72		return domain.ImagePruneReport{}, fmt.Errorf("images: acquire prune lease: %w", err)
73	}
74	defer lease.Release()
75
76	if s.protection == nil {
77		return domain.ImagePruneReport{}, fmt.Errorf("images: prune requires a protection store: %w", domain.ErrPruneDisabled)
78	}
79
80	snapshot, err := s.protection.ProtectionSnapshot(ctx)
81	if err != nil {
82		return domain.ImagePruneReport{}, fmt.Errorf("images: read protection snapshot: %w", err)
83	}
84
85	plan := &domain.PrunePlan{Gaps: append([]domain.InventoryGap(nil), snapshot.Gaps...)}
86
87	if opts.PruneDangling {
88		verdicts, gaps, err := s.planRuntimeImages(ctx, snapshot)
89		if err != nil {
90			return domain.ImagePruneReport{}, err
91		}
92		plan.Images = verdicts
93		plan.Gaps = append(plan.Gaps, gaps...)
94	}
95
96	// Registry planning and deletion share one registry mutation lock,
97	// so a concurrent push can never make content look unreferenced
98	// between the plan and the deletion it authorizes. Runtime deletion
99	// runs outside that lock: it does not touch registry storage, and
100	// holding the lock across it would stall every registry push.
101	if !opts.PruneRegistry {
102		return s.finishPlan(ctx, plan, opts.DryRun, log)
103	}
104
105	s.mutationMu.Lock()
106	tags, blobs, gaps := s.planRegistry(snapshot, opts.KeepLast)
107	plan.Tags = tags
108	plan.Blobs = blobs
109	plan.Gaps = append(plan.Gaps, gaps...)
110
111	if err := plan.Validate(); err != nil {
112		s.mutationMu.Unlock()
113		return domain.ImagePruneReport{}, fmt.Errorf("images: refusing to execute an invalid prune plan: %w", err)
114	}
115	report := domain.ImagePruneReport{Plan: *domain.NewPruneReport(plan, !opts.DryRun)}
116	if opts.DryRun {
117		s.mutationMu.Unlock()
118		return report, nil
119	}
120	if err := s.executeRegistry(ctx, plan, &report, log); err != nil {
121		s.mutationMu.Unlock()
122		return report, err
123	}
124	s.mutationMu.Unlock()
125
126	s.executeRuntimeImages(ctx, plan, &report, log)
127	return report, nil
128}
129
130// finishPlan validates the complete plan and executes the runtime part.
131// Every deletion happens after the whole plan proved itself well formed.
132func (s *Service) finishPlan(ctx context.Context, plan *domain.PrunePlan, dryRun bool, log zerowrap.Logger) (domain.ImagePruneReport, error) {
133	if err := plan.Validate(); err != nil {
134		return domain.ImagePruneReport{}, fmt.Errorf("images: refusing to execute an invalid prune plan: %w", err)
135	}
136	report := domain.ImagePruneReport{Plan: *domain.NewPruneReport(plan, !dryRun)}
137	if dryRun {
138		return report, nil
139	}
140	s.executeRuntimeImages(ctx, plan, &report, log)
141	return report, nil
142}
143
144// planRuntimeImages plans runtime image deletion from one coherent
145// inventory. An unwired or unreadable runtime inventory yields gaps
146// instead of an empty (apparently safe) runtime.
147func (s *Service) planRuntimeImages(ctx context.Context, snapshot *domain.PruneProtectionSnapshot) ([]domain.RuntimeImageVerdict, []domain.InventoryGap, error) {
148	if s.pruneRuntime == nil {
149		return nil, []domain.InventoryGap{{
150			Source: domain.InventorySourceRuntimeImages,
151			Reason: domain.PruneReasonUnknownInventory,
152			Detail: "runtime prune port is not wired",
153		}}, nil
154	}
155	inventory, err := s.pruneRuntime.InventoryRuntime(ctx)
156	if err != nil {
157		return nil, nil, fmt.Errorf("images: read runtime inventory: %w", err)
158	}
159	verdicts := pruneguard.PlanRuntimeImages(pruneguard.RuntimePlanInput{
160		Images:                inventory.Images,
161		Containers:            inventory.Containers,
162		Snapshot:              snapshot,
163		ProtectedContainerIDs: snapshot.RefsOfKind(domain.ProtectionRecoveryInhibition),
164		Complete:              inventory.Complete(),
165	})
166	return verdicts, inventory.Gaps, nil
167}
168
169// executeRuntimeImages deletes exactly the planned runtime image IDs.
170// A single deletion failure is isolated: it is reported and the run
171// continues with the remaining candidates.
172func (s *Service) executeRuntimeImages(ctx context.Context, plan *domain.PrunePlan, report *domain.ImagePruneReport, log zerowrap.Logger) {
173	for _, ref := range plan.EligibleImages() {
174		if err := s.pruneRuntime.RemoveImageExact(ctx, ref); err != nil {
175			log.Warn().Err(err).Str("image", ref.ID).Msg("runtime image prune failed; skipping")
176			report.Plan.Failures = append(report.Plan.Failures, domain.PruneFailure{
177				Kind: domain.PruneResourceRuntimeImage, Ref: ref.ID, Err: err.Error(),
178			})
179			continue
180		}
181		report.Plan.Deleted = append(report.Plan.Deleted, domain.PruneCandidateReport{
182			Kind: domain.PruneResourceRuntimeImage, Ref: ref.ID, Verdict: domain.PruneVerdictEligible,
183		})
184		report.Runtime.DeletedCount++
185	}
186}
187
188// planRegistry plans tag retention and blob garbage collection from the
189// registry inventory. It never deletes: the caller executes only after
190// the complete plan validates.
191//
192// The returned gaps record every fact that could not be read. A tag
193// whose manifest is unreadable stays unknown, and a blob whose
194// reference set cannot be fully proven stays unknown too.
195func (s *Service) planRegistry(snapshot *domain.PruneProtectionSnapshot, keepLast int) ([]domain.RegistryTagVerdict, []domain.OCIBlobVerdict, []domain.InventoryGap) {
196	if keepLast <= 0 {
197		// Documented behavior: keep_last=0 skips registry tag and blob
198		// cleanup entirely.
199		return nil, nil, nil
200	}
201
202	repositories, err := s.manifestStorage.ListRepositories()
203	if err != nil {
204		return nil, nil, []domain.InventoryGap{{
205			Source: domain.InventorySourceRegistry,
206			Reason: domain.PruneReasonUnknownInventory,
207			Detail: "list repositories failed",
208		}}
209	}
210	sort.Strings(repositories)
211
212	var tags []pruneguard.RegistryTagCandidate
213	var gaps []domain.InventoryGap
214	for _, repository := range repositories {
215		repositoryTags, repositoryGaps := s.inventoryRepository(repository)
216		tags = append(tags, repositoryTags...)
217		gaps = append(gaps, repositoryGaps...)
218	}
219
220	complete := len(gaps) == 0
221	verdicts := pruneguard.PlanRegistryTags(pruneguard.RegistryPlanInput{
222		Tags:     tags,
223		Snapshot: snapshot,
224		KeepLast: keepLast,
225		Complete: complete,
226	})
227
228	blobs, blobGaps := s.planRegistryBlobs(snapshot, verdicts, tags, complete, repositories)
229	gaps = append(gaps, blobGaps...)
230	return verdicts, blobs, gaps
231}
232
233// inventoryRepository inventories one repository's tags and their
234// manifests. A tag whose manifest cannot be read is kept as a candidate
235// with ManifestKnown=false, so the planner fails closed on it.
236func (s *Service) inventoryRepository(repository string) ([]pruneguard.RegistryTagCandidate, []domain.InventoryGap) {
237	tagNames, err := s.manifestStorage.ListTags(repository)
238	if err != nil {
239		return nil, []domain.InventoryGap{{
240			Source: domain.InventorySourceRegistry,
241			Reason: domain.PruneReasonUnknownInventory,
242			Detail: "list tags failed for " + repository,
243		}}
244	}
245
246	sort.Strings(tagNames)
247	candidates := make([]pruneguard.RegistryTagCandidate, 0, len(tagNames))
248	var gaps []domain.InventoryGap
249	for _, tagName := range tagNames {
250		ref := domain.RegistryTagRef{Repository: repository, Tag: tagName}
251		if !ref.Valid() {
252			// A tag we cannot name is a tag whose content we cannot
253			// prove unreferenced, so the whole registry plan fails closed.
254			gaps = append(gaps, domain.InventoryGap{
255				Source: domain.InventorySourceRegistry,
256				Reason: domain.PruneReasonUnknownIdentity,
257				Detail: "unnamed or malformed tag in " + repository,
258			})
259			continue
260		}
261		candidate := pruneguard.RegistryTagCandidate{Ref: ref}
262		modTime, err := s.manifestStorage.GetManifestModTime(repository, tagName)
263		if err != nil {
264			gaps = append(gaps, domain.InventoryGap{
265				Source: domain.InventorySourceRegistry,
266				Reason: domain.PruneReasonUnknownInventory,
267				Detail: "manifest modification time unreadable for " + ref.String(),
268			})
269		}
270		candidate.ModTime = modTime
271
272		data, _, err := s.manifestStorage.GetManifest(repository, tagName)
273		if err != nil {
274			gaps = append(gaps, domain.InventoryGap{
275				Source: domain.InventorySourceManifests,
276				Reason: domain.PruneReasonUnknownManifest,
277				Detail: "manifest unreadable for " + ref.String(),
278			})
279			candidates = append(candidates, candidate)
280			continue
281		}
282		digest := digestOfManifest(data)
283		_, mediaType, err := parseManifestDocument(data)
284		if err != nil {
285			gaps = append(gaps, domain.InventoryGap{
286				Source: domain.InventorySourceManifests,
287				Reason: domain.PruneReasonUnknownManifest,
288				Detail: "manifest corrupt for " + ref.String(),
289			})
290			candidates = append(candidates, candidate)
291			continue
292		}
293		if !supportedManifestMediaType(mediaType) {
294			gaps = append(gaps, domain.InventoryGap{
295				Source: domain.InventorySourceManifests,
296				Reason: domain.PruneReasonUnknownMediaType,
297				Detail: "unsupported manifest media type for " + ref.String(),
298			})
299			candidates = append(candidates, candidate)
300			continue
301		}
302		candidate.Digest = digest
303		candidate.ManifestKnown = true
304		candidates = append(candidates, candidate)
305	}
306	return candidates, gaps
307}
308
309// planRegistryBlobs computes one verdict per stored blob.
310//
311// Blobs reachable from the retained (protected or kept) manifest set
312// are protected. When any retained closure could not be read or parsed
313// completely, unreferenced blobs become unknown instead of eligible: an
314// incomplete reference set is never proof of unreachability.
315func (s *Service) planRegistryBlobs(
316	snapshot *domain.PruneProtectionSnapshot,
317	verdicts []domain.RegistryTagVerdict,
318	tags []pruneguard.RegistryTagCandidate,
319	inventoryComplete bool,
320	repositories []string,
321) ([]domain.OCIBlobVerdict, []domain.InventoryGap) {
322	now := time.Now().UTC()
323
324	// Roots are every tag the plan does not delete: retained tags and
325	// any tag whose manifest could not be read at all (which has no
326	// verdict entry yet still must keep protecting its content).
327	planned := make(map[string]struct{}, len(verdicts))
328	var roots []retainedRoot
329	for _, verdict := range verdicts {
330		planned[verdict.Ref.Key()] = struct{}{}
331		if verdict.Verdict == domain.PruneVerdictEligible {
332			continue
333		}
334		roots = append(roots, retainedRoot{Repository: verdict.Ref.Repository, Reference: verdict.Ref.Tag})
335	}
336	for _, tag := range tags {
337		if _, hasVerdict := planned[tag.Ref.Key()]; hasVerdict {
338			continue
339		}
340		roots = append(roots, retainedRoot{Repository: tag.Ref.Repository, Reference: tag.Ref.Tag})
341	}
342
343	closure := newClosure()
344	// A tag the inventory could not read may reference any blob, so an
345	// incomplete tag inventory makes the whole reference set unprovable.
346	closure.complete = inventoryComplete
347	for _, root := range roots {
348		s.traverseClosure(root, closure)
349	}
350	// Durable roots qualified by repository and digest keep the FULL OCI
351	// closure of their manifest: a moved tag must not let prune delete the
352	// config or layers of a digest an active deployment still pins. Roots
353	// whose repository has no local content are skipped: the image was
354	// never pushed here, so there is no local closure to preserve. An
355	// unresolved root in a local repository marks the closure incomplete,
356	// so unreferenced blobs stay unknown instead of eligible.
357	localRepositories := make(map[string]struct{}, len(repositories))
358	for _, repository := range repositories {
359		localRepositories[repository] = struct{}{}
360	}
361	for _, root := range snapshot.Roots {
362		if root.Repository == "" || !domain.ValidOCIDigest(root.Ref) {
363			continue
364		}
365		if _, local := localRepositories[root.Repository]; !local {
366			continue
367		}
368		s.traverseClosure(retainedRoot{Repository: root.Repository, Reference: root.Ref}, closure)
369	}
370
371	// Durable roots claim digests directly as well as through tag
372	// closure: an app service, apply intent, or unfinished operation
373	// can pin content by digest alone. Those digests must never be
374	// judged unreferenced just because no retained tag reaches them.
375	snapshotComplete := snapshot.Complete()
376
377	pending := s.registryState.PendingDigests(now)
378
379	blobs, err := s.blobStorage.ListBlobs()
380	if err != nil {
381		return nil, append(closure.gaps, domain.InventoryGap{
382			Source: domain.InventorySourceRegistry,
383			Reason: domain.PruneReasonUnknownInventory,
384			Detail: "list blobs failed",
385		})
386	}
387	sort.Strings(blobs)
388
389	gaps := closure.gaps
390	decisions := blobDecision{
391		snapshot:         snapshot,
392		closure:          closure,
393		pending:          pending,
394		now:              now,
395		snapshotComplete: snapshotComplete,
396	}
397	out := make([]domain.OCIBlobVerdict, 0, len(blobs))
398	for _, digest := range blobs {
399		out = append(out, s.decideBlob(digest, decisions))
400	}
401	return out, gaps
402}
403
404// blobDecision carries the shared inputs of one blob verdict.
405type blobDecision struct {
406	snapshot         *domain.PruneProtectionSnapshot
407	closure          *closure
408	pending          map[string]struct{}
409	now              time.Time
410	snapshotComplete bool
411}
412
413// decideBlob resolves one stored blob, strongest protection first.
414func (s *Service) decideBlob(digest string, in blobDecision) domain.OCIBlobVerdict {
415	verdict := domain.OCIBlobVerdict{Ref: domain.OCIRef{Digest: digest}}
416	protect := func(reason domain.PruneReason) domain.OCIBlobVerdict {
417		verdict.Verdict = domain.PruneVerdictProtected
418		verdict.Reasons = domain.CanonicalReasons(reason)
419		return verdict
420	}
421	unknown := func(reason domain.PruneReason) domain.OCIBlobVerdict {
422		verdict.Verdict = domain.PruneVerdictUnknown
423		verdict.Reasons = domain.CanonicalReasons(reason)
424		return verdict
425	}
426
427	if !verdict.Ref.Valid() {
428		return unknown(domain.PruneReasonUnknownIdentity)
429	}
430	// Durable state can claim a digest directly, without any retained
431	// tag reaching it.
432	if matched, reasons := rootReasonsForDigest(in.snapshot, digest); matched {
433		verdict.Verdict = domain.PruneVerdictProtected
434		verdict.Reasons = reasons
435		return verdict
436	}
437	if in.closure.references(digest) {
438		return protect(domain.PruneReasonProtectedSharedContent)
439	}
440	if _, isPending := in.pending[digest]; isPending {
441		return protect(domain.PruneReasonProtectedPendingUpload)
442	}
443	if !in.closure.complete || !in.snapshotComplete {
444		return unknown(domain.PruneReasonUnknownManifest)
445	}
446	// A blob that cannot be aged cannot be proven past the upload TTL,
447	// so it stays unknown instead of becoming eligible.
448	modTime, err := s.blobStorage.GetBlobModTime(digest)
449	if err != nil {
450		return unknown(domain.PruneReasonUnknownInventory)
451	}
452	if in.now.Sub(modTime) <= registrystate.PendingBlobTTL {
453		return protect(domain.PruneReasonProtectedPendingUpload)
454	}
455	verdict.Verdict = domain.PruneVerdictEligible
456	verdict.Reasons = domain.CanonicalReasons(domain.PruneReasonEligibleUnreferencedBlob)
457	return verdict
458}
459
460// rootReasonsForDigest reports whether the durable snapshot claims the
461// digest directly, and with which verdict reasons.
462func rootReasonsForDigest(snapshot *domain.PruneProtectionSnapshot, digest string) (bool, []domain.PruneReason) {
463	if snapshot == nil {
464		return false, nil
465	}
466	matched := false
467	for _, root := range snapshot.Roots {
468		if root.Ref == digest {
469			matched = true
470			break
471		}
472	}
473	if !matched {
474		return false, nil
475	}
476	reasons := snapshot.RootReasons(digest)
477	if len(reasons) == 0 {
478		reasons = domain.CanonicalReasons(domain.PruneReasonProtectedSharedContent)
479	}
480	return true, reasons
481}
482
483// retainedRoot is one manifest reference whose closure must survive.
484type retainedRoot struct {
485	Repository string
486	Reference  string
487}
488
489// closure is the transitive reference set of every retained manifest.
490type closure struct {
491	digests  map[string]struct{}
492	visited  map[string]struct{}
493	complete bool
494	gaps     []domain.InventoryGap
495}
496
497func newClosure() *closure {
498	return &closure{
499		digests:  make(map[string]struct{}),
500		visited:  make(map[string]struct{}),
501		complete: true,
502	}
503}
504
505func (c *closure) references(digest string) bool {
506	_, ok := c.digests[digest]
507	return ok
508}
509
510// traverseClosure walks one retained reference transitively. A missing,
511// corrupt, or unsupported node marks the closure incomplete: the caller
512// then treats unreferenced blobs as unknown instead of eligible.
513func (s *Service) traverseClosure(root retainedRoot, c *closure) {
514	visitKey := root.Repository + "\x00" + root.Reference
515	if _, seen := c.visited[visitKey]; seen {
516		return
517	}
518	c.visited[visitKey] = struct{}{}
519
520	data, _, err := s.manifestStorage.GetManifest(root.Repository, root.Reference)
521	if err != nil {
522		c.complete = false
523		c.gaps = append(c.gaps, domain.InventoryGap{
524			Source: domain.InventorySourceManifests,
525			Reason: domain.PruneReasonUnknownManifest,
526			Detail: "retained manifest unreadable for " + root.Repository + "@" + root.Reference,
527		})
528		return
529	}
530	references, mediaType, err := parseManifestDocument(data)
531	if err != nil {
532		c.complete = false
533		c.gaps = append(c.gaps, domain.InventoryGap{
534			Source: domain.InventorySourceManifests,
535			Reason: domain.PruneReasonUnknownManifest,
536			Detail: "retained manifest corrupt for " + root.Repository + "@" + root.Reference,
537		})
538		return
539	}
540	if !supportedManifestMediaType(mediaType) {
541		c.complete = false
542		c.gaps = append(c.gaps, domain.InventoryGap{
543			Source: domain.InventorySourceManifests,
544			Reason: domain.PruneReasonUnknownMediaType,
545			Detail: "retained manifest media type unsupported for " + root.Repository + "@" + root.Reference,
546		})
547		return
548	}
549
550	// The manifest body itself is addressable content: record its digest
551	// so a stored manifest copy is never judged unreferenced.
552	c.digests[digestOfManifest(data)] = struct{}{}
553
554	for _, digest := range references.blobs {
555		c.digests[digest] = struct{}{}
556	}
557	for _, child := range references.childManifests {
558		c.digests[child] = struct{}{}
559		s.traverseClosure(retainedRoot{Repository: root.Repository, Reference: child}, c)
560	}
561}
562
563// executeRegistry deletes exactly the planned tag references and then
564// exactly the planned unreferenced blobs. It runs after the complete
565// plan has been validated, never before.
566func (s *Service) executeRegistry(ctx context.Context, plan *domain.PrunePlan, report *domain.ImagePruneReport, log zerowrap.Logger) error {
567	for _, ref := range plan.EligibleTags() {
568		if err := s.manifestStorage.DeleteManifest(ref.Repository, ref.Tag); err != nil {
569			log.Warn().Err(err).Str("tag", ref.String()).Msg("failed to delete manifest, skipping")
570			report.Plan.Failures = append(report.Plan.Failures, domain.PruneFailure{
571				Kind: domain.PruneResourceRegistryTag, Ref: ref.String(), Err: err.Error(),
572			})
573			continue
574		}
575		report.Plan.Deleted = append(report.Plan.Deleted, domain.PruneCandidateReport{
576			Kind: domain.PruneResourceRegistryTag, Ref: ref.String(), Verdict: domain.PruneVerdictEligible,
577		})
578		report.Registry.TagsRemoved++
579	}
580
581	for _, ref := range plan.EligibleBlobs() {
582		size, err := s.blobStorage.DeleteBlob(ref.Digest)
583		if err != nil {
584			log.Warn().Err(err).Str("digest", ref.Digest).Msg("failed to delete blob, skipping")
585			report.Plan.Failures = append(report.Plan.Failures, domain.PruneFailure{
586				Kind: domain.PruneResourceOCIBlob, Ref: ref.String(), Err: err.Error(),
587			})
588			continue
589		}
590		report.Plan.Deleted = append(report.Plan.Deleted, domain.PruneCandidateReport{
591			Kind: domain.PruneResourceOCIBlob, Ref: ref.String(), Verdict: domain.PruneVerdictEligible,
592		})
593		report.Registry.BlobsRemoved++
594		// Each stored blob is deleted once, so its size is exact.
595		report.Registry.SpaceReclaimed += size
596		report.Plan.ReclaimedBytes += size
597		report.Plan.ReclaimedKnown = true
598	}
599
600	uploadsRemoved, uploadBytes, err := s.blobStorage.CleanupStaleUploads(registrystate.PendingBlobTTL)
601	if err != nil {
602		log.Warn().Err(err).Msg("failed to clean up stale uploads, continuing")
603		return nil
604	}
605	report.Registry.UploadsRemoved = uploadsRemoved
606	report.Registry.UploadSpaceReclaimed = uploadBytes
607	return nil
608}
609
610// manifestReferences is the parsed reference set of one manifest.
611type manifestReferences struct {
612	blobs          []string
613	childManifests []string
614}
615
616// parseManifestDocument parses one manifest and reports its media type.
617func parseManifestDocument(data []byte) (manifestReferences, string, error) {
618	var document struct {
619		MediaType     string `json:"mediaType"`
620		SchemaVersion int    `json:"schemaVersion"`
621		Config        struct {
622			Digest string `json:"digest"`
623		} `json:"config"`
624		Layers []struct {
625			Digest string `json:"digest"`
626		} `json:"layers"`
627		Manifests []struct {
628			Digest string `json:"digest"`
629		} `json:"manifests"`
630		Subject *struct {
631			Digest string `json:"digest"`
632		} `json:"subject"`
633	}
634	if err := json.Unmarshal(data, &document); err != nil {
635		return manifestReferences{}, "", err
636	}
637
638	refs := manifestReferences{
639		blobs:          make([]string, 0, len(document.Layers)+1),
640		childManifests: make([]string, 0, len(document.Manifests)+1),
641	}
642	if document.Config.Digest != "" {
643		refs.blobs = append(refs.blobs, document.Config.Digest)
644	}
645	for _, layer := range document.Layers {
646		if layer.Digest != "" {
647			refs.blobs = append(refs.blobs, layer.Digest)
648		}
649	}
650	for _, child := range document.Manifests {
651		if child.Digest != "" {
652			refs.childManifests = append(refs.childManifests, child.Digest)
653		}
654	}
655	if document.Subject != nil && document.Subject.Digest != "" {
656		refs.childManifests = append(refs.childManifests, document.Subject.Digest)
657	}
658
659	mediaType := document.MediaType
660	if mediaType == "" {
661		// Older clients omit mediaType. Infer from the shape: a
662		// document with manifests is an index, one with layers and a
663		// config is a manifest. Anything else is unsupported.
664		switch {
665		case len(document.Manifests) > 0:
666			mediaType = mediaTypeOCIImageIndex
667		case document.Config.Digest != "" || len(document.Layers) > 0:
668			mediaType = mediaTypeOCIManifest
669		}
670	}
671	return refs, mediaType, nil
672}
673
674// supportedManifestMediaType reports whether the closure walker can
675// trust the reference set of a manifest with this media type.
676func supportedManifestMediaType(mediaType string) bool {
677	switch mediaType {
678	case mediaTypeDockerManifest, mediaTypeDockerManifestList, mediaTypeOCIManifest, mediaTypeOCIImageIndex:
679		return true
680	default:
681		// digest-addressed content often carries a content type that
682		// does not name a media type; treat it as supported only when
683		// it is one of the known values above.
684		return false
685	}
686}
687
688// digestOfManifest returns the sha256 digest of a stored manifest body.
689func digestOfManifest(data []byte) string {
690	sum := sha256.Sum256(data)
691	return "sha256:" + hex.EncodeToString(sum[:])
692}

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 7

Tags 106

↑↓ moveenter openesc close