Skip to content

Commit a5e2eab

Browse files
authored
Merge branch 'main' into renovate/github.com-mark3labs-mcp-go-0.x
2 parents 510368e + 08dce5c commit a5e2eab

4 files changed

Lines changed: 363 additions & 2 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ require (
3636
github.com/docker/cli v29.3.0+incompatible // indirect
3737
github.com/docker/distribution v2.8.3+incompatible // indirect
3838
github.com/docker/docker-credential-helpers v0.9.3 // indirect
39+
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
3940
github.com/go-logr/logr v1.4.3 // indirect
4041
github.com/go-logr/stdr v1.2.2 // indirect
4142
github.com/go-openapi/analysis v0.24.1 // indirect

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
109109
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
110110
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
111111
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
112-
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
113-
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
112+
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
113+
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
114114
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
115115
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
116116
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=

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)