From 6c68c9d5ad78a15413c084df3806fb92cfeec415 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Apr 2026 16:53:04 +0200 Subject: [PATCH 1/9] Bump toolhive-core to v0.0.14 --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index f8aca4522d..c2310861cd 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/shirou/gopsutil/v4 v4.26.2 github.com/spf13/viper v1.21.0 github.com/stacklok/toolhive-catalog v0.20260406.0 - github.com/stacklok/toolhive-core v0.0.13 + github.com/stacklok/toolhive-core v0.0.14 github.com/stretchr/testify v1.11.1 github.com/swaggo/swag/v2 v2.0.0-rc5 github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd diff --git a/go.sum b/go.sum index fd4dcd29d3..f0d927c69f 100644 --- a/go.sum +++ b/go.sum @@ -802,6 +802,8 @@ github.com/stacklok/toolhive-catalog v0.20260406.0 h1:MzcSoYJmjwf+sOXiw9uw6wzfz/ github.com/stacklok/toolhive-catalog v0.20260406.0/go.mod h1:VeHaVQx4dP484wxQT947GA+ocP4YiuKCTN+v7upEPiU= github.com/stacklok/toolhive-core v0.0.13 h1:iKMnI7VIVeBXkeCccF5UdOMy5RUPeL+Bmg4DjgFeiHU= github.com/stacklok/toolhive-core v0.0.13/go.mod h1:AAeOC8CxDVtburJEkVVdR2bO5z2fiY9dgYRnhcjkHvI= +github.com/stacklok/toolhive-core v0.0.14 h1:/tyTrtoAMDPH66q1aeKIDDe50P4RGxKGP+bG+7MZ7gs= +github.com/stacklok/toolhive-core v0.0.14/go.mod h1:MQ+SN7cUwoKj5TX/LmuY1WLgDBm2vRpRwwwYOlT3hug= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= From 84ac614b0fe426e4ec36b6a298106889f7cccada Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Apr 2026 16:53:11 +0200 Subject: [PATCH 2/9] Add DeleteBuild to SkillService interface and implementation --- pkg/skills/service.go | 2 ++ pkg/skills/skillsvc/skillsvc.go | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/pkg/skills/service.go b/pkg/skills/service.go index 8b401bd04f..6228eec2ed 100644 --- a/pkg/skills/service.go +++ b/pkg/skills/service.go @@ -25,4 +25,6 @@ type SkillService interface { Push(ctx context.Context, opts PushOptions) error // ListBuilds returns all locally-built OCI skill artifacts in the local store. ListBuilds(ctx context.Context) ([]LocalBuild, error) + // DeleteBuild removes a locally-built OCI skill artifact from the local store. + DeleteBuild(ctx context.Context, tag string) error } diff --git a/pkg/skills/skillsvc/skillsvc.go b/pkg/skills/skillsvc/skillsvc.go index ec2faf8467..ef29fbe74a 100644 --- a/pkg/skills/skillsvc/skillsvc.go +++ b/pkg/skills/skillsvc/skillsvc.go @@ -552,6 +552,19 @@ func (s *service) ListBuilds(ctx context.Context) ([]skills.LocalBuild, error) { return builds, nil } +// DeleteBuild removes a locally-built OCI skill artifact from the local store. +// It deletes the tag and, when no other tag shares the same digest, also +// garbage-collects all associated blobs. +func (s *service) DeleteBuild(ctx context.Context, tag string) error { + if s.ociStore == nil { + return httperr.WithCode( + errors.New("OCI packaging is not configured"), + http.StatusInternalServerError, + ) + } + return s.ociStore.DeleteBuild(ctx, tag) +} + // ociPullTimeout is the maximum time allowed for pulling an OCI artifact. const ociPullTimeout = 5 * time.Minute From 1155f1120ca19070872427863ae3edc5db503804 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Apr 2026 16:53:16 +0200 Subject: [PATCH 3/9] Regenerate SkillService mock for DeleteBuild --- pkg/skills/mocks/mock_service.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/skills/mocks/mock_service.go b/pkg/skills/mocks/mock_service.go index c7007bd5fe..b37d3fb563 100644 --- a/pkg/skills/mocks/mock_service.go +++ b/pkg/skills/mocks/mock_service.go @@ -56,6 +56,20 @@ func (mr *MockSkillServiceMockRecorder) Build(ctx, opts any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Build", reflect.TypeOf((*MockSkillService)(nil).Build), ctx, opts) } +// DeleteBuild mocks base method. +func (m *MockSkillService) DeleteBuild(ctx context.Context, tag string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteBuild", ctx, tag) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteBuild indicates an expected call of DeleteBuild. +func (mr *MockSkillServiceMockRecorder) DeleteBuild(ctx, tag any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBuild", reflect.TypeOf((*MockSkillService)(nil).DeleteBuild), ctx, tag) +} + // Info mocks base method. func (m *MockSkillService) Info(ctx context.Context, opts skills.InfoOptions) (*skills.SkillInfo, error) { m.ctrl.T.Helper() From cc11cf77c4d0933759707b52b7221e3b9fb518ef Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Apr 2026 16:53:25 +0200 Subject: [PATCH 4/9] Add DELETE /api/v1beta/skills/builds/{tag} endpoint --- pkg/api/v1/skills.go | 20 ++++++++++++++++++++ pkg/api/v1/skills_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/pkg/api/v1/skills.go b/pkg/api/v1/skills.go index 99f0b201fa..7b00092f4a 100644 --- a/pkg/api/v1/skills.go +++ b/pkg/api/v1/skills.go @@ -35,6 +35,7 @@ func SkillsRouter(skillService skills.SkillService) http.Handler { r.Post("/build", apierrors.ErrorHandler(routes.buildSkill)) r.Post("/push", apierrors.ErrorHandler(routes.pushSkill)) r.Get("/builds", apierrors.ErrorHandler(routes.listBuilds)) + r.Delete("/builds/{tag}", apierrors.ErrorHandler(routes.deleteBuild)) return r } @@ -302,3 +303,22 @@ func (s *SkillsRoutes) listBuilds(w http.ResponseWriter, r *http.Request) error w.Header().Set("Content-Type", "application/json") return json.NewEncoder(w).Encode(buildListResponse{Builds: builds}) } + +// deleteBuild removes a locally-built OCI skill artifact from the local store. +// +// @Summary Delete a locally-built skill artifact +// @Description Remove a locally-built OCI skill artifact and its blobs from the local store +// @Tags skills +// @Param tag path string true "Artifact tag" +// @Success 204 {string} string "No Content" +// @Failure 404 {string} string "Not Found" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1beta/skills/builds/{tag} [delete] +func (s *SkillsRoutes) deleteBuild(w http.ResponseWriter, r *http.Request) error { + tag := chi.URLParam(r, "tag") + if err := s.skillService.DeleteBuild(r.Context(), tag); err != nil { + return err + } + w.WriteHeader(http.StatusNoContent) + return nil +} diff --git a/pkg/api/v1/skills_test.go b/pkg/api/v1/skills_test.go index 3b5e2bc5b9..be2efb0dee 100644 --- a/pkg/api/v1/skills_test.go +++ b/pkg/api/v1/skills_test.go @@ -525,6 +525,36 @@ func TestSkillsRouter(t *testing.T) { expectedStatus: http.StatusInternalServerError, expectedBody: "Internal Server Error", }, + { + name: "delete build success", + method: "DELETE", + path: "/builds/my-skill", + setupMock: func(svc *skillsmocks.MockSkillService, _ string) { + svc.EXPECT().DeleteBuild(gomock.Any(), "my-skill").Return(nil) + }, + expectedStatus: http.StatusNoContent, + }, + { + name: "delete build not found", + method: "DELETE", + path: "/builds/missing", + setupMock: func(svc *skillsmocks.MockSkillService, _ string) { + svc.EXPECT().DeleteBuild(gomock.Any(), "missing"). + Return(httperr.WithCode(fmt.Errorf("tag not found"), http.StatusNotFound)) + }, + expectedStatus: http.StatusNotFound, + }, + { + name: "delete build service error", + method: "DELETE", + path: "/builds/my-skill", + setupMock: func(svc *skillsmocks.MockSkillService, _ string) { + svc.EXPECT().DeleteBuild(gomock.Any(), "my-skill"). + Return(httperr.WithCode(fmt.Errorf("oci store not configured"), http.StatusInternalServerError)) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "Internal Server Error", + }, } for _, tt := range tests { From ce8ae13c49b3941ad1e0fc6bbfa2e75564e8632f Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Apr 2026 16:53:39 +0200 Subject: [PATCH 5/9] Add DeleteBuild to skills HTTP client --- pkg/skills/client/client.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/skills/client/client.go b/pkg/skills/client/client.go index 6b4ddb79c7..a7342b2cd4 100644 --- a/pkg/skills/client/client.go +++ b/pkg/skills/client/client.go @@ -242,6 +242,11 @@ func (c *Client) ListBuilds(ctx context.Context) ([]skills.LocalBuild, error) { return resp.Builds, nil } +// DeleteBuild removes a locally-built OCI skill artifact from the local store. +func (c *Client) DeleteBuild(ctx context.Context, tag string) error { + return c.doJSONRequest(ctx, http.MethodDelete, "/builds/"+url.PathEscape(tag), nil, nil, nil) +} + // --- internal helpers --- func (c *Client) buildURL(path string, query url.Values) string { From 918c789138debd6120aa19954843e5e148d956cd Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Apr 2026 16:53:47 +0200 Subject: [PATCH 6/9] Add thv skill builds remove command --- cmd/thv/app/skill_builds_remove.go | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 cmd/thv/app/skill_builds_remove.go diff --git a/cmd/thv/app/skill_builds_remove.go b/cmd/thv/app/skill_builds_remove.go new file mode 100644 index 0000000000..bbda76960a --- /dev/null +++ b/cmd/thv/app/skill_builds_remove.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var skillBuildsRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a locally-built skill artifact", + Long: `Remove a locally-built OCI skill artifact and its blobs from the local OCI store.`, + Args: cobra.ExactArgs(1), + RunE: skillBuildsRemoveCmdFunc, +} + +func init() { + skillBuildsCmd.AddCommand(skillBuildsRemoveCmd) +} + +func skillBuildsRemoveCmdFunc(cmd *cobra.Command, args []string) error { + c := newSkillClient(cmd.Context()) + if err := c.DeleteBuild(cmd.Context(), args[0]); err != nil { + return formatSkillError("remove build", err) + } + fmt.Printf("Removed build %q\n", args[0]) + return nil +} From abdb46f8321f8fbbef975c5ba1b2d82495211622 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Apr 2026 16:53:54 +0200 Subject: [PATCH 7/9] Add unit tests for DeleteBuild service method --- pkg/skills/skillsvc/skillsvc_test.go | 59 ++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/pkg/skills/skillsvc/skillsvc_test.go b/pkg/skills/skillsvc/skillsvc_test.go index 02c708969d..5bab335567 100644 --- a/pkg/skills/skillsvc/skillsvc_test.go +++ b/pkg/skills/skillsvc/skillsvc_test.go @@ -2858,3 +2858,62 @@ func TestListBuilds(t *testing.T) { assert.Equal(t, "real-skill", artifacts[0].Tag) }) } + +func TestDeleteBuild(t *testing.T) { + t.Parallel() + + t.Run("nil oci store returns 500", func(t *testing.T) { + t.Parallel() + svc := New(&storage.NoopSkillStore{}) + err := svc.DeleteBuild(t.Context(), "my-skill") + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) + }) + + t.Run("removes tag and blobs", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + d := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), d, "my-skill")) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + require.NoError(t, svc.DeleteBuild(t.Context(), "my-skill")) + + // Tag should be gone — ListBuilds should return empty. + builds, listErr := svc.ListBuilds(t.Context()) + require.NoError(t, listErr) + assert.Empty(t, builds) + }) + + t.Run("tag does not exist returns 404", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + err = svc.DeleteBuild(t.Context(), "nonexistent") + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, httperr.Code(err)) + }) + + t.Run("blobs retained when another tag shares the same digest", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + d := buildTestArtifact(t, ociStore, "shared-skill", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), d, "tag-a")) + require.NoError(t, ociStore.Tag(t.Context(), d, "tag-b")) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + require.NoError(t, svc.DeleteBuild(t.Context(), "tag-a")) + + // tag-b still exists and the shared artifact is accessible. + builds, listErr := svc.ListBuilds(t.Context()) + require.NoError(t, listErr) + require.Len(t, builds, 1) + assert.Equal(t, "tag-b", builds[0].Tag) + }) +} From 090663a0af13ad3588c5443a396250cc47c3cb06 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Apr 2026 17:16:45 +0200 Subject: [PATCH 8/9] Regenerate CLI docs with skill builds remove command --- docs/cli/thv_skill_builds.md | 1 + docs/cli/thv_skill_builds_remove.md | 39 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 docs/cli/thv_skill_builds_remove.md diff --git a/docs/cli/thv_skill_builds.md b/docs/cli/thv_skill_builds.md index 7df97e9dfc..5d882498ba 100644 --- a/docs/cli/thv_skill_builds.md +++ b/docs/cli/thv_skill_builds.md @@ -37,4 +37,5 @@ thv skill builds [flags] ### SEE ALSO * [thv skill](thv_skill.md) - Manage skills +* [thv skill builds remove](thv_skill_builds_remove.md) - Remove a locally-built skill artifact diff --git a/docs/cli/thv_skill_builds_remove.md b/docs/cli/thv_skill_builds_remove.md new file mode 100644 index 0000000000..3caf9fd796 --- /dev/null +++ b/docs/cli/thv_skill_builds_remove.md @@ -0,0 +1,39 @@ +--- +title: thv skill builds remove +hide_title: true +description: Reference for ToolHive CLI command `thv skill builds remove` +last_update: + author: autogenerated +slug: thv_skill_builds_remove +mdx: + format: md +--- + +## thv skill builds remove + +Remove a locally-built skill artifact + +### Synopsis + +Remove a locally-built OCI skill artifact and its blobs from the local OCI store. + +``` +thv skill builds remove [flags] +``` + +### Options + +``` + -h, --help help for remove +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv skill builds](thv_skill_builds.md) - List locally-built skill artifacts + From 6cc32883be86b3252dd301b1496d3f8f2882603b Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Apr 2026 17:16:53 +0200 Subject: [PATCH 9/9] Regenerate OpenAPI spec with DELETE /builds/{tag} endpoint --- docs/server/docs.go | 52 ++++++++++++++++++++++++++++++++++++++++ docs/server/swagger.json | 52 ++++++++++++++++++++++++++++++++++++++++ docs/server/swagger.yaml | 33 +++++++++++++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/docs/server/docs.go b/docs/server/docs.go index 0d58afe8d1..b426134cf9 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -4739,6 +4739,58 @@ const docTemplate = `{ ] } }, + "/api/v1beta/skills/builds/{tag}": { + "delete": { + "description": "Remove a locally-built OCI skill artifact and its blobs from the local store", + "parameters": [ + { + "description": "Artifact tag", + "in": "path", + "name": "tag", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "No Content" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Delete a locally-built skill artifact", + "tags": [ + "skills" + ] + } + }, "/api/v1beta/skills/push": { "post": { "description": "Push a built skill artifact to a remote registry", diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 707ad9f48c..2637af02a2 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -4732,6 +4732,58 @@ ] } }, + "/api/v1beta/skills/builds/{tag}": { + "delete": { + "description": "Remove a locally-built OCI skill artifact and its blobs from the local store", + "parameters": [ + { + "description": "Artifact tag", + "in": "path", + "name": "tag", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "No Content" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Delete a locally-built skill artifact", + "tags": [ + "skills" + ] + } + }, "/api/v1beta/skills/push": { "post": { "description": "Push a built skill artifact to a remote registry", diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 510d806f4c..7bf946a5e0 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -3828,6 +3828,39 @@ paths: summary: List locally-built skill artifacts tags: - skills + /api/v1beta/skills/builds/{tag}: + delete: + description: Remove a locally-built OCI skill artifact and its blobs from the + local store + parameters: + - description: Artifact tag + in: path + name: tag + required: true + schema: + type: string + responses: + "204": + content: + application/json: + schema: + type: string + description: No Content + "404": + content: + application/json: + schema: + type: string + description: Not Found + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + summary: Delete a locally-built skill artifact + tags: + - skills /api/v1beta/skills/push: post: description: Push a built skill artifact to a remote registry