Skip to content

Commit 19372f0

Browse files
authored
Merge pull request #73 from stacklok/ocisills-delete-tag
feat(oci/skills): add DeleteTag and DeleteBuild for tag and artifact removal
2 parents faff956 + a775c18 commit 19372f0

2 files changed

Lines changed: 360 additions & 0 deletions

File tree

oci/skills/store.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"errors"
1111
"fmt"
1212
"io"
13+
"net/http"
14+
"os"
1315
"path/filepath"
1416

1517
"github.com/adrg/xdg"
@@ -18,6 +20,8 @@ import (
1820
"oras.land/oras-go/v2"
1921
"oras.land/oras-go/v2/content/oci"
2022
"oras.land/oras-go/v2/errdef"
23+
24+
"github.com/stacklok/toolhive-core/httperr"
2125
)
2226

2327
// Store provides local OCI artifact storage backed by an OCI Image Layout.
@@ -129,6 +133,47 @@ func (s *Store) Tag(ctx context.Context, d digest.Digest, tag string) error {
129133
return nil
130134
}
131135

136+
// DeleteTag removes a tag from the store index without deleting the underlying blobs.
137+
func (s *Store) DeleteTag(ctx context.Context, tag string) error {
138+
if err := s.inner.Untag(ctx, tag); err != nil {
139+
if errors.Is(err, errdef.ErrNotFound) {
140+
return httperr.WithCode(
141+
fmt.Errorf("tag not found: %s: %w", tag, err),
142+
http.StatusNotFound,
143+
)
144+
}
145+
return fmt.Errorf("removing tag: %w", err)
146+
}
147+
return nil
148+
}
149+
150+
// DeleteBuild removes a tag and, if no other tag shares the same digest,
151+
// deletes all associated blobs (config, layers, manifest, and index if applicable).
152+
// Use DeleteTag when tag-only removal is desired and blob cleanup is not needed.
153+
func (s *Store) DeleteBuild(ctx context.Context, tag string) error {
154+
d, err := s.Resolve(ctx, tag)
155+
if err != nil {
156+
return httperr.WithCode(
157+
fmt.Errorf("tag not found: %s: %w", tag, err),
158+
http.StatusNotFound,
159+
)
160+
}
161+
162+
if err := s.DeleteTag(ctx, tag); err != nil {
163+
return err
164+
}
165+
166+
shared, err := s.isDigestReferenced(ctx, d)
167+
if err != nil {
168+
return fmt.Errorf("checking remaining references: %w", err)
169+
}
170+
if shared {
171+
return nil
172+
}
173+
174+
return s.deleteOrphanedBlobs(ctx, d)
175+
}
176+
132177
// Resolve resolves a tag to a manifest digest.
133178
func (s *Store) Resolve(ctx context.Context, tag string) (digest.Digest, error) {
134179
desc, err := s.inner.Resolve(ctx, tag)
@@ -208,3 +253,84 @@ func (s *Store) fetchContent(ctx context.Context, d digest.Digest) ([]byte, erro
208253

209254
return data, nil
210255
}
256+
257+
// isDigestReferenced checks whether any remaining tag still resolves to d.
258+
func (s *Store) isDigestReferenced(ctx context.Context, d digest.Digest) (bool, error) {
259+
tags, err := s.ListTags(ctx)
260+
if err != nil {
261+
return false, err
262+
}
263+
for _, tag := range tags {
264+
resolved, err := s.Resolve(ctx, tag)
265+
if err != nil {
266+
continue
267+
}
268+
if resolved == d {
269+
return true, nil
270+
}
271+
}
272+
return false, nil
273+
}
274+
275+
// deleteOrphanedBlobs removes all blobs reachable from d (index or manifest),
276+
// including d itself. Callers must ensure no remaining tag references d.
277+
func (s *Store) deleteOrphanedBlobs(ctx context.Context, d digest.Digest) error {
278+
isIdx, err := s.IsIndex(ctx, d)
279+
if err != nil {
280+
return fmt.Errorf("inspecting orphaned digest: %w", err)
281+
}
282+
283+
if isIdx {
284+
idx, err := s.GetIndex(ctx, d)
285+
if err != nil {
286+
return fmt.Errorf("fetching orphaned index: %w", err)
287+
}
288+
for _, m := range idx.Manifests {
289+
if err := s.deleteManifestBlobs(ctx, m.Digest); err != nil {
290+
return err
291+
}
292+
}
293+
} else {
294+
if err := s.deleteManifestBlobs(ctx, d); err != nil {
295+
return err
296+
}
297+
// deleteManifestBlobs already deletes d when it's a plain manifest.
298+
return nil
299+
}
300+
301+
return s.deleteBlob(d)
302+
}
303+
304+
// deleteManifestBlobs fetches the manifest at d, deletes its config and layer
305+
// blobs, then deletes the manifest blob itself.
306+
func (s *Store) deleteManifestBlobs(ctx context.Context, d digest.Digest) error {
307+
data, err := s.fetchContent(ctx, d)
308+
if err != nil {
309+
return fmt.Errorf("fetching manifest %s: %w", d, err)
310+
}
311+
312+
var m ocispec.Manifest
313+
if err := json.Unmarshal(data, &m); err != nil {
314+
return fmt.Errorf("parsing manifest %s: %w", d, err)
315+
}
316+
317+
if err := s.deleteBlob(m.Config.Digest); err != nil {
318+
return err
319+
}
320+
for _, layer := range m.Layers {
321+
if err := s.deleteBlob(layer.Digest); err != nil {
322+
return err
323+
}
324+
}
325+
return s.deleteBlob(d)
326+
}
327+
328+
// deleteBlob removes the blob file for d from the local OCI layout.
329+
// A missing file is treated as success (idempotent).
330+
func (s *Store) deleteBlob(d digest.Digest) error {
331+
path := filepath.Join(s.root, "blobs", d.Algorithm().String(), d.Encoded())
332+
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
333+
return fmt.Errorf("deleting blob %s: %w", d, err)
334+
}
335+
return nil
336+
}

0 commit comments

Comments
 (0)