diff --git a/cmd/thv/app/ai_plugin_helpers.go b/cmd/thv/app/ai_plugin_helpers.go index 78f7b9677d..2246c2a5f4 100644 --- a/cmd/thv/app/ai_plugin_helpers.go +++ b/cmd/thv/app/ai_plugin_helpers.go @@ -12,6 +12,7 @@ import ( "github.com/stacklok/toolhive/pkg/plugins" pluginclient "github.com/stacklok/toolhive/pkg/plugins/client" + "github.com/stacklok/toolhive/pkg/skills/lockfile" ) // newAIPluginClient creates a new Plugins API HTTP client using default settings. @@ -39,6 +40,36 @@ func completeAIPluginNames(cmd *cobra.Command, args []string, _ string) ([]strin return names, cobra.ShellCompDirectiveNoFileComp } +// completePluginLockNames provides shell completion for plugin names present +// in the project's lock file, which is what `thv ai-plugin upgrade` acts on. +func completePluginLockNames(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + projectRoot, err := resolveProjectRoot(aiPluginUpgradeProjectRoot) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + root, err := lockfile.OpenRoot(projectRoot) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + lf, err := lockfile.Load(root) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + requested := make(map[string]struct{}, len(args)) + for _, a := range args { + requested[a] = struct{}{} + } + names := make([]string, 0, len(lf.Plugins)) + for _, e := range lf.Plugins { + if _, dup := requested[e.Name]; dup { + continue + } + names = append(names, e.Name) + } + return names, cobra.ShellCompDirectiveNoFileComp +} + // formatAIPluginError wraps an error with contextual information, appending a // hint that matches the actual failure — a timed-out request and an absent // server need different advice. diff --git a/cmd/thv/app/ai_plugin_upgrade.go b/cmd/thv/app/ai_plugin_upgrade.go new file mode 100644 index 0000000000..077ad6b068 --- /dev/null +++ b/cmd/thv/app/ai_plugin_upgrade.go @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + + "github.com/stacklok/toolhive/pkg/plugins" +) + +var ( + aiPluginUpgradeProjectRoot string + aiPluginUpgradeClientsRaw string + aiPluginUpgradePreview bool + aiPluginUpgradeFailOnChanges bool + aiPluginUpgradeAllowRefChange bool + aiPluginUpgradeYes bool + aiPluginUpgradeFormat string +) + +var aiPluginUpgradeCmd = &cobra.Command{ + Use: "upgrade [plugin-name...]", + Short: "Upgrade project plugins to newer pinned content", + Long: `Re-resolve a project's lock entries and install newer content where available. + +Plugins pinned to an immutable reference (an OCI digest or a full git commit +hash) are reported not-upgradable — there is nothing newer to resolve to. +Use --preview to see what would change without persisting anything (OCI +sources are still fetched into the local artifact store to compare digests), +and --allow-ref-change to permit the artifact moving to a different +repository (a version bump within the same repository is not a change +this guard blocks). +--fail-on-changes evaluates the same plan and never installs: it is a CI +freshness gate. + +Unless --preview is set, upgrade prompts for confirmation before installing — +plugin content is a set of AI-followed instructions. Pass --yes to skip the +prompt (required in non-interactive contexts such as CI). + +Requires TOOLHIVE_PLUGINS_LOCK_ENABLED=true.`, + PreRunE: chainPreRunE( + ValidateFormat(&aiPluginUpgradeFormat), + ), + ValidArgsFunction: completePluginLockNames, + RunE: aiPluginUpgradeCmdFunc, +} + +func init() { + aiPluginCmd.AddCommand(aiPluginUpgradeCmd) + + aiPluginUpgradeCmd.Flags().StringVar(&aiPluginUpgradeProjectRoot, "project-root", "", + "Project root path (default: auto-detected from the current directory)") + aiPluginUpgradeCmd.Flags().StringVar(&aiPluginUpgradeClientsRaw, "clients", "", + `Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client`) + aiPluginUpgradeCmd.Flags().BoolVar(&aiPluginUpgradePreview, "preview", false, + "Report what would change without persisting anything (OCI sources are still fetched to compare digests)") + aiPluginUpgradeCmd.Flags().BoolVar(&aiPluginUpgradeFailOnChanges, "fail-on-changes", false, + "Report what would change without installing anything; a CI freshness gate") + aiPluginUpgradeCmd.Flags().BoolVar(&aiPluginUpgradeAllowRefChange, "allow-ref-change", false, + "Permit the artifact to move to a different repository during upgrade") + aiPluginUpgradeCmd.Flags().BoolVar(&aiPluginUpgradeYes, "yes", false, + "Skip the confirmation prompt (required when not running interactively)") + AddFormatFlag(aiPluginUpgradeCmd, &aiPluginUpgradeFormat) +} + +func aiPluginUpgradeCmdFunc(cmd *cobra.Command, args []string) error { + projectRoot, err := resolveProjectRoot(aiPluginUpgradeProjectRoot) + if err != nil { + return err + } + + if !aiPluginUpgradePreview && !aiPluginUpgradeFailOnChanges { + if !aiPluginUpgradeYes { + printPluginLockEntriesSummary(projectRoot) + } + confirmed, confirmErr := requireConfirmation("Upgrade plugins for "+projectRoot, aiPluginUpgradeYes) + if confirmErr != nil { + return confirmErr + } + if !confirmed { + fmt.Println("Upgrade cancelled.") + return nil + } + } + + c := newAIPluginClient(cmd.Context()) + result, err := c.Upgrade(cmd.Context(), plugins.UpgradeOptions{ + ProjectRoot: projectRoot, + Names: args, + Clients: parseSkillInstallClients(aiPluginUpgradeClientsRaw), + Preview: aiPluginUpgradePreview, + FailOnChanges: aiPluginUpgradeFailOnChanges, + AllowRefChange: aiPluginUpgradeAllowRefChange, + }) + if err != nil { + return formatAIPluginError("upgrade plugins", err) + } + + planOnly := aiPluginUpgradePreview || aiPluginUpgradeFailOnChanges + if err := printPluginUpgradeResult(result, aiPluginUpgradeFormat, planOnly); err != nil { + return err + } + return pluginUpgradeExitError(result, aiPluginUpgradePreview, aiPluginUpgradeFailOnChanges) +} + +func pluginUpgradeExitError(result *plugins.UpgradeResult, preview, failOnChanges bool) error { + tally := tallyUpgradeOutcomes(result) + failed, refBlocked, wouldChange := tally.failed, tally.refBlocked, tally.wouldChange + if failed > 0 { + return withExitCode(fmt.Errorf("upgrade failed for %d plugin(s)", failed), ExitCodePartialFailure) + } + if failOnChanges && wouldChange > 0 { + return withExitCode( + fmt.Errorf("%d plugin(s) would change; the lock file is stale", wouldChange), + ExitCodeCheckFailure, + ) + } + if !preview && !failOnChanges && refBlocked > 0 { + return withExitCode( + fmt.Errorf("%d plugin(s) blocked by a repository change; use --allow-ref-change", refBlocked), + ExitCodePolicyRejection, + ) + } + return nil +} + +func printPluginUpgradeResult(result *plugins.UpgradeResult, format string, planOnly bool) error { + if format == FormatJSON { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(data)) + return nil + } + + if len(result.Outcomes) == 0 { + fmt.Println("No plugins in the project's lock file") + return nil + } + upgradedVerb := "upgraded" + if planOnly { + upgradedVerb = "would upgrade" + } + for _, o := range result.Outcomes { + switch o.Status { + case plugins.UpgradeStatusUpgraded: + fmt.Printf("%s: %s %s -> %s\n", o.Name, upgradedVerb, o.OldDigest, o.NewDigest) + case plugins.UpgradeStatusUpToDate: + fmt.Printf("%s: up to date\n", o.Name) + case plugins.UpgradeStatusNotUpgradable: + fmt.Printf("%s: not upgradable (pinned to an immutable reference)\n", o.Name) + case plugins.UpgradeStatusRefChangeBlocked: + fmt.Printf("%s: repository change blocked (would move to %s; use --allow-ref-change)\n", + o.Name, o.NewResolvedReference) + case plugins.UpgradeStatusSignerChangeBlocked: + newSigner := o.NewSignerIdentity + if newSigner == "" { + newSigner = "unsigned" + } + fmt.Printf("%s: signer change blocked (candidate is %s; use --allow-signer-change)\n", o.Name, newSigner) + case plugins.UpgradeStatusFailed: + fmt.Printf("%s: failed [%s]: %s\n", o.Name, o.Reason, o.Error) + } + } + return nil +} diff --git a/docs/cli/thv_ai-plugin.md b/docs/cli/thv_ai-plugin.md index e2cdb2495f..c2bc6ec5f8 100644 --- a/docs/cli/thv_ai-plugin.md +++ b/docs/cli/thv_ai-plugin.md @@ -41,5 +41,6 @@ The ai-plugin command provides subcommands to manage plugins for AI tools * [thv ai-plugin push](thv_ai-plugin_push.md) - Push a built AI-tool plugin to an OCI registry * [thv ai-plugin sync](thv_ai-plugin_sync.md) - Restore project plugins to match the lock file * [thv ai-plugin uninstall](thv_ai-plugin_uninstall.md) - Uninstall an AI-tool plugin +* [thv ai-plugin upgrade](thv_ai-plugin_upgrade.md) - Upgrade project plugins to newer pinned content * [thv ai-plugin validate](thv_ai-plugin_validate.md) - Validate an AI-tool plugin directory diff --git a/docs/cli/thv_ai-plugin_upgrade.md b/docs/cli/thv_ai-plugin_upgrade.md new file mode 100644 index 0000000000..9de7e92fa0 --- /dev/null +++ b/docs/cli/thv_ai-plugin_upgrade.md @@ -0,0 +1,62 @@ +--- +title: thv ai-plugin upgrade +hide_title: true +description: Reference for ToolHive CLI command `thv ai-plugin upgrade` +last_update: + author: autogenerated +slug: thv_ai-plugin_upgrade +mdx: + format: md +--- + +## thv ai-plugin upgrade + +Upgrade project plugins to newer pinned content + +### Synopsis + +Re-resolve a project's lock entries and install newer content where available. + +Plugins pinned to an immutable reference (an OCI digest or a full git commit +hash) are reported not-upgradable — there is nothing newer to resolve to. +Use --preview to see what would change without persisting anything (OCI +sources are still fetched into the local artifact store to compare digests), +and --allow-ref-change to permit the artifact moving to a different +repository (a version bump within the same repository is not a change +this guard blocks). +--fail-on-changes evaluates the same plan and never installs: it is a CI +freshness gate. + +Unless --preview is set, upgrade prompts for confirmation before installing — +plugin content is a set of AI-followed instructions. Pass --yes to skip the +prompt (required in non-interactive contexts such as CI). + +Requires TOOLHIVE_PLUGINS_LOCK_ENABLED=true. + +``` +thv ai-plugin upgrade [plugin-name...] [flags] +``` + +### Options + +``` + --allow-ref-change Permit the artifact to move to a different repository during upgrade + --clients string Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client + --fail-on-changes Report what would change without installing anything; a CI freshness gate + --format string Output format (json, text) (default "text") + -h, --help help for upgrade + --preview Report what would change without persisting anything (OCI sources are still fetched to compare digests) + --project-root string Project root path (default: auto-detected from the current directory) + --yes Skip the confirmation prompt (required when not running interactively) +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv ai-plugin](thv_ai-plugin.md) - Manage AI-tool plugins + diff --git a/docs/server/docs.go b/docs/server/docs.go index 1d5730a6c9..7fe187190e 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1314,6 +1314,19 @@ const docTemplate = `{ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_plugins.UpgradeResult": { + "properties": { + "outcomes": { + "description": "Outcomes contains one entry per skill considered for upgrade.", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.UpgradeOutcome" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "github_com_stacklok_toolhive_pkg_plugins.ValidationResult": { "properties": { "errors": { @@ -4056,6 +4069,44 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.upgradePluginsRequest": { + "description": "Request to re-resolve a project's lock entries and install newer content", + "properties": { + "allow_ref_change": { + "description": "AllowRefChange permits resolvedReference changes during upgrade", + "type": "boolean" + }, + "clients": { + "description": "Clients lists target client identifiers. Empty means every\nplugin-supporting client detected on this host.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "fail_on_changes": { + "description": "FailOnChanges exits with an error when any mutable source would upgrade", + "type": "boolean" + }, + "names": { + "description": "Names restricts the upgrade to specific plugin names. Empty means every entry.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "preview": { + "description": "Preview reports what would change without installing (still fetches to compare digests)", + "type": "boolean" + }, + "project_root": { + "description": "ProjectRoot is the project root path whose lock file should be upgraded", + "type": "string" + } + }, + "type": "object" + }, "pkg_api_v1.upgradeRequest": { "description": "Request to apply an available upgrade to a workload. All fields are optional; an empty body applies the upgrade preserving the workload's existing configuration.", "properties": { @@ -6427,6 +6478,97 @@ const docTemplate = `{ ] } }, + "/api/v1beta/plugins/upgrade": { + "post": { + "description": "Re-resolve a project's lock entries and install newer content where available", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/pkg_api_v1.upgradePluginsRequest", + "summary": "request", + "description": "Upgrade request" + } + ] + } + } + }, + "description": "Upgrade request", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.UpgradeResult" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Forbidden (feature not enabled)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found (a requested name is not in the lock file)" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + }, + "501": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Implemented" + } + }, + "summary": "Upgrade project plugins", + "tags": [ + "plugins" + ] + } + }, "/api/v1beta/plugins/validate": { "post": { "description": "Validate a plugin definition", diff --git a/docs/server/swagger.json b/docs/server/swagger.json index a4a01f3bad..420c5c5530 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1307,6 +1307,19 @@ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_plugins.UpgradeResult": { + "properties": { + "outcomes": { + "description": "Outcomes contains one entry per skill considered for upgrade.", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.UpgradeOutcome" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "github_com_stacklok_toolhive_pkg_plugins.ValidationResult": { "properties": { "errors": { @@ -4049,6 +4062,44 @@ }, "type": "object" }, + "pkg_api_v1.upgradePluginsRequest": { + "description": "Request to re-resolve a project's lock entries and install newer content", + "properties": { + "allow_ref_change": { + "description": "AllowRefChange permits resolvedReference changes during upgrade", + "type": "boolean" + }, + "clients": { + "description": "Clients lists target client identifiers. Empty means every\nplugin-supporting client detected on this host.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "fail_on_changes": { + "description": "FailOnChanges exits with an error when any mutable source would upgrade", + "type": "boolean" + }, + "names": { + "description": "Names restricts the upgrade to specific plugin names. Empty means every entry.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "preview": { + "description": "Preview reports what would change without installing (still fetches to compare digests)", + "type": "boolean" + }, + "project_root": { + "description": "ProjectRoot is the project root path whose lock file should be upgraded", + "type": "string" + } + }, + "type": "object" + }, "pkg_api_v1.upgradeRequest": { "description": "Request to apply an available upgrade to a workload. All fields are optional; an empty body applies the upgrade preserving the workload's existing configuration.", "properties": { @@ -6420,6 +6471,97 @@ ] } }, + "/api/v1beta/plugins/upgrade": { + "post": { + "description": "Re-resolve a project's lock entries and install newer content where available", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/pkg_api_v1.upgradePluginsRequest", + "summary": "request", + "description": "Upgrade request" + } + ] + } + } + }, + "description": "Upgrade request", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.UpgradeResult" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Forbidden (feature not enabled)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found (a requested name is not in the lock file)" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + }, + "501": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Implemented" + } + }, + "summary": "Upgrade project plugins", + "tags": [ + "plugins" + ] + } + }, "/api/v1beta/plugins/validate": { "post": { "description": "Validate a plugin definition", diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 32ddfe4cd9..9b8c477b28 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1437,6 +1437,15 @@ components: type: array uniqueItems: false type: object + github_com_stacklok_toolhive_pkg_plugins.UpgradeResult: + properties: + outcomes: + description: Outcomes contains one entry per skill considered for upgrade. + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.UpgradeOutcome' + type: array + uniqueItems: false + type: object github_com_stacklok_toolhive_pkg_plugins.ValidationResult: properties: errors: @@ -3713,6 +3722,41 @@ components: result: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult' type: object + pkg_api_v1.upgradePluginsRequest: + description: Request to re-resolve a project's lock entries and install newer + content + properties: + allow_ref_change: + description: AllowRefChange permits resolvedReference changes during upgrade + type: boolean + clients: + description: |- + Clients lists target client identifiers. Empty means every + plugin-supporting client detected on this host. + items: + type: string + type: array + uniqueItems: false + fail_on_changes: + description: FailOnChanges exits with an error when any mutable source would + upgrade + type: boolean + names: + description: Names restricts the upgrade to specific plugin names. Empty + means every entry. + items: + type: string + type: array + uniqueItems: false + preview: + description: Preview reports what would change without installing (still + fetches to compare digests) + type: boolean + project_root: + description: ProjectRoot is the project root path whose lock file should + be upgraded + type: string + type: object pkg_api_v1.upgradeRequest: description: Request to apply an available upgrade to a workload. All fields are optional; an empty body applies the upgrade preserving the workload's @@ -5686,6 +5730,61 @@ paths: summary: Sync project plugins from the lock file tags: - plugins + /api/v1beta/plugins/upgrade: + post: + description: Re-resolve a project's lock entries and install newer content where + available + requestBody: + content: + application/json: + schema: + oneOf: + - type: object + - $ref: '#/components/schemas/pkg_api_v1.upgradePluginsRequest' + description: Upgrade request + summary: request + description: Upgrade request + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.UpgradeResult' + description: OK + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "403": + content: + application/json: + schema: + type: string + description: Forbidden (feature not enabled) + "404": + content: + application/json: + schema: + type: string + description: Not Found (a requested name is not in the lock file) + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + "501": + content: + application/json: + schema: + type: string + description: Not Implemented + summary: Upgrade project plugins + tags: + - plugins /api/v1beta/plugins/validate: post: description: Validate a plugin definition diff --git a/pkg/api/v1/plugins.go b/pkg/api/v1/plugins.go index a05c8e8236..311b462073 100644 --- a/pkg/api/v1/plugins.go +++ b/pkg/api/v1/plugins.go @@ -25,8 +25,8 @@ type PluginsRoutes struct { // PluginsRouter creates a new router for plugin management endpoints. If // pluginService's concrete implementation also satisfies plugins.PluginLockService -// (as pluginsvc.New's does once Sync exists), /sync is served; otherwise it -// returns 501. +// (as pluginsvc.New's does), /sync and /upgrade are served; otherwise both +// return 501. func PluginsRouter(pluginService plugins.PluginService) http.Handler { routes := PluginsRoutes{ pluginService: pluginService, @@ -54,6 +54,7 @@ func PluginsRouter(pluginService plugins.PluginService) http.Handler { r.With(stdTimeout).Delete("/builds/{tag}", apierrors.ErrorHandler(routes.deleteBuild)) r.With(stdTimeout).Get("/content", apierrors.ErrorHandler(routes.getPluginContent)) r.With(longTimeout).Post("/sync", apierrors.ErrorHandler(routes.syncPlugins)) + r.With(longTimeout).Post("/upgrade", apierrors.ErrorHandler(routes.upgradePlugins)) return r } @@ -449,3 +450,50 @@ func (s *PluginsRoutes) syncPlugins(w http.ResponseWriter, r *http.Request) erro w.Header().Set("Content-Type", "application/json") return json.NewEncoder(w).Encode(result) } + +// upgradePlugins re-resolves a project's lock entries and installs newer +// content where available. With fail_on_changes set, it evaluates the plan +// and returns the outcomes without installing anything — clients derive the +// freshness verdict from the outcome statuses, mirroring sync's check mode. +// +// @Summary Upgrade project plugins +// @Description Re-resolve a project's lock entries and install newer content where available +// @Tags plugins +// @Accept json +// @Produce json +// @Param request body upgradePluginsRequest true "Upgrade request" +// @Success 200 {object} plugins.UpgradeResult +// @Failure 400 {string} string "Bad Request" +// @Failure 403 {string} string "Forbidden (feature not enabled)" +// @Failure 404 {string} string "Not Found (a requested name is not in the lock file)" +// @Failure 500 {string} string "Internal Server Error" +// @Failure 501 {string} string "Not Implemented" +// @Router /api/v1beta/plugins/upgrade [post] +func (s *PluginsRoutes) upgradePlugins(w http.ResponseWriter, r *http.Request) error { + if s.lockService == nil { + return httperr.WithCode(errors.New("plugin upgrade is not supported by this server"), http.StatusNotImplemented) + } + + var req upgradePluginsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return httperr.WithCode( + fmt.Errorf("invalid request body: %w", err), + http.StatusBadRequest, + ) + } + + result, err := s.lockService.Upgrade(r.Context(), plugins.UpgradeOptions{ + ProjectRoot: req.ProjectRoot, + Names: req.Names, + Preview: req.Preview, + FailOnChanges: req.FailOnChanges, + AllowRefChange: req.AllowRefChange, + Clients: req.Clients, + }) + if err != nil { + return err + } + + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(result) +} diff --git a/pkg/api/v1/plugins_sync_test.go b/pkg/api/v1/plugins_sync_test.go index 7f9ff45767..e8cc34e2b7 100644 --- a/pkg/api/v1/plugins_sync_test.go +++ b/pkg/api/v1/plugins_sync_test.go @@ -115,3 +115,68 @@ func TestSyncPluginsEndpoint(t *testing.T) { }) } } + +func TestUpgradePluginsEndpoint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + service plugins.PluginService + body string + wantStatus int + wantContains string + }{ + { + name: "successful upgrade returns 200 with result", + service: &pluginServiceWithSync{ + PluginService: plugmocks.NewMockPluginService(gomock.NewController(t)), + syncFn: func(context.Context, plugins.SyncOptions) (*plugins.SyncResult, error) { return nil, nil }, + upgradeFn: func(_ context.Context, opts plugins.UpgradeOptions) (*plugins.UpgradeResult, error) { + assert.Equal(t, "/tmp/proj", opts.ProjectRoot) + assert.True(t, opts.Preview) + return &plugins.UpgradeResult{Outcomes: []plugins.UpgradeOutcome{ + {Name: "my-plugin", Status: plugins.UpgradeStatusUpToDate}, + }}, nil + }, + }, + body: `{"project_root":"/tmp/proj","preview":true}`, + wantStatus: http.StatusOK, + wantContains: `"my-plugin"`, + }, + { + name: "service without Upgrade support returns 501", + service: plugmocks.NewMockPluginService(gomock.NewController(t)), + body: `{"project_root":"/tmp/proj"}`, + wantStatus: http.StatusNotImplemented, + }, + { + name: "invalid JSON body returns 400", + service: &pluginServiceWithSync{ + PluginService: plugmocks.NewMockPluginService(gomock.NewController(t)), + syncFn: func(context.Context, plugins.SyncOptions) (*plugins.SyncResult, error) { return nil, nil }, + upgradeFn: func(context.Context, plugins.UpgradeOptions) (*plugins.UpgradeResult, error) { + t.Fatal("Upgrade must not be called for an invalid body") + return nil, nil + }, + }, + body: `{`, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/upgrade", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + PluginsRouter(tt.service).ServeHTTP(rec, req) + + assert.Equal(t, tt.wantStatus, rec.Code) + if tt.wantContains != "" { + assert.Contains(t, rec.Body.String(), tt.wantContains) + } + }) + } +} diff --git a/pkg/api/v1/plugins_types.go b/pkg/api/v1/plugins_types.go index c822e13faa..bb9e5fd48b 100644 --- a/pkg/api/v1/plugins_types.go +++ b/pkg/api/v1/plugins_types.go @@ -93,3 +93,22 @@ type syncPluginsRequest struct { // Adopt writes lock entries for existing unmanaged project-scope installs Adopt bool `json:"adopt,omitempty"` } + +// upgradePluginsRequest represents the request to upgrade a project's plugins. +// +// @Description Request to re-resolve a project's lock entries and install newer content +type upgradePluginsRequest struct { + // ProjectRoot is the project root path whose lock file should be upgraded + ProjectRoot string `json:"project_root"` + // Names restricts the upgrade to specific plugin names. Empty means every entry. + Names []string `json:"names,omitempty"` + // Preview reports what would change without installing (still fetches to compare digests) + Preview bool `json:"preview,omitempty"` + // FailOnChanges exits with an error when any mutable source would upgrade + FailOnChanges bool `json:"fail_on_changes,omitempty"` + // AllowRefChange permits resolvedReference changes during upgrade + AllowRefChange bool `json:"allow_ref_change,omitempty"` + // Clients lists target client identifiers. Empty means every + // plugin-supporting client detected on this host. + Clients []string `json:"clients,omitempty"` +} diff --git a/pkg/plugins/client/client.go b/pkg/plugins/client/client.go index 5eacd67a8e..8a58982478 100644 --- a/pkg/plugins/client/client.go +++ b/pkg/plugins/client/client.go @@ -327,6 +327,25 @@ func (c *Client) Sync(ctx context.Context, opts plugins.SyncOptions) (*plugins.S return &result, nil } +// Upgrade re-resolves a project's lock entries and installs newer content +// where available. +func (c *Client) Upgrade(ctx context.Context, opts plugins.UpgradeOptions) (*plugins.UpgradeResult, error) { + body := upgradeRequest{ + ProjectRoot: opts.ProjectRoot, + Names: opts.Names, + Preview: opts.Preview, + FailOnChanges: opts.FailOnChanges, + AllowRefChange: opts.AllowRefChange, + Clients: opts.Clients, + } + + var result plugins.UpgradeResult + if err := c.doJSONRequest(ctx, http.MethodPost, "/upgrade", nil, body, &result); err != nil { + return nil, err + } + return &result, nil +} + // --- internal helpers --- func (c *Client) buildURL(path string, query url.Values) string { diff --git a/pkg/plugins/client/dto.go b/pkg/plugins/client/dto.go index 1f5b87d9c1..bf3f2ee593 100644 --- a/pkg/plugins/client/dto.go +++ b/pkg/plugins/client/dto.go @@ -49,3 +49,12 @@ type syncRequest struct { Check bool `json:"check,omitempty"` Adopt bool `json:"adopt,omitempty"` } + +type upgradeRequest struct { + ProjectRoot string `json:"project_root"` + Names []string `json:"names,omitempty"` + Preview bool `json:"preview,omitempty"` + FailOnChanges bool `json:"fail_on_changes,omitempty"` + AllowRefChange bool `json:"allow_ref_change,omitempty"` + Clients []string `json:"clients,omitempty"` +} diff --git a/pkg/plugins/pluginsvc/install.go b/pkg/plugins/pluginsvc/install.go index 4826150a1b..b9a74c02b6 100644 --- a/pkg/plugins/pluginsvc/install.go +++ b/pkg/plugins/pluginsvc/install.go @@ -137,7 +137,7 @@ func (s *service) installFromRegistryLookup( // Use the last path segment as the search query (matching // skillsvc.resolveFromRegistry's splitQualifiedName), since // SearchPlugins matches on name substring. - _, searchName := splitQualifiedName(opts.Name) + namespace, searchName := splitQualifiedName(opts.Name) hits, err := s.pluginLookup.SearchPlugins(ctx, searchName) if err != nil { @@ -152,6 +152,9 @@ func (s *service) installFromRegistryLookup( if !strings.EqualFold(hit.Name, searchName) { continue } + if namespace != "" && !strings.EqualFold(hit.Namespace, namespace) { + continue + } matches = append(matches, hit) } diff --git a/pkg/plugins/pluginsvc/lock.go b/pkg/plugins/pluginsvc/lock.go index 4e25985b04..5a816fdace 100644 --- a/pkg/plugins/pluginsvc/lock.go +++ b/pkg/plugins/pluginsvc/lock.go @@ -47,7 +47,7 @@ func (s *service) recordLockState( } resolvedReference := opts.LockResolvedReference if resolvedReference == "" { - resolvedReference = pl.Reference + resolvedReference = lockableResolvedReference(pl.Reference) } if err := recordLockEntry(pl.ProjectRoot, lockEntryInput{ Name: pl.Metadata.Name, diff --git a/pkg/plugins/pluginsvc/sync.go b/pkg/plugins/pluginsvc/sync.go index 6986d5152d..29d0d926b8 100644 --- a/pkg/plugins/pluginsvc/sync.go +++ b/pkg/plugins/pluginsvc/sync.go @@ -16,12 +16,6 @@ import ( "github.com/stacklok/toolhive/pkg/storage" ) -// var _ ensures *service satisfies the lock service surface. Upgrade is a -// stub until the next PR in this stack lands the real implementation; the -// compile-time check still requires both methods so PluginsRouter's type -// assert succeeds and /sync can be served. -var _ plugins.PluginLockService = (*service)(nil) - // Sync restores a project's installed plugins to match its lock file: missing // or drifted entries are reinstalled at their pinned digest (never // re-resolved from source — see buildPinnedReference), unmanaged installs are @@ -75,13 +69,6 @@ func (s *service) Sync(ctx context.Context, opts plugins.SyncOptions) (*plugins. return result, nil } -// Upgrade is implemented in the next PR of this stack. The stub exists so -// *service satisfies PluginLockService (and /sync can be type-asserted) -// without exposing a half-built upgrade path. -func (*service) Upgrade(_ context.Context, _ plugins.UpgradeOptions) (*plugins.UpgradeResult, error) { - return nil, httperr.WithCode(errors.New("plugin upgrade is not implemented"), http.StatusNotImplemented) -} - // syncLockedEntry reconciles one lock file entry against installed state, // appending its outcome to result. Missing (dbOK false) and drifted (digest // or contentDigest mismatch) entries are reinstalled at the pinned reference diff --git a/pkg/plugins/pluginsvc/upgrade.go b/pkg/plugins/pluginsvc/upgrade.go new file mode 100644 index 0000000000..b38ccf6e60 --- /dev/null +++ b/pkg/plugins/pluginsvc/upgrade.go @@ -0,0 +1,361 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + nameref "github.com/google/go-containerregistry/pkg/name" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" + "github.com/stacklok/toolhive/pkg/skills/lockfile" +) + +// var _ ensures *service continues to satisfy the full lock service surface +// now that both Sync and Upgrade exist. +var _ plugins.PluginLockService = (*service)(nil) + +// Upgrade re-resolves each targeted lock entry's Source and, when the +// resolved digest has changed, installs the newer content and rewrites the +// entry (Source itself is never rewritten — see RFC THV-0080). Entries +// pinned to an immutable reference (an OCI digest or a full git commit hash) +// are reported not-upgradable: there is nothing newer to resolve to. +// +// Signer-change guarding is intentionally omitted: plugin Sigstore +// verification lands in a later PR. AllowSignerChange is accepted on the +// options type (aliased from skills) but not enforced here. +func (s *service) Upgrade(ctx context.Context, opts plugins.UpgradeOptions) (*plugins.UpgradeResult, error) { + if !plugins.LockFileFeatureEnabled() { + return nil, httperr.WithCode( + fmt.Errorf("plugin lock file is not enabled; set %s=true", plugins.LockFileEnvVar), + http.StatusForbidden, + ) + } + + _, projectRoot, err := normalizeProjectRoot(plugins.ScopeProject, opts.ProjectRoot) + if err != nil { + return nil, err + } + opts.ProjectRoot = projectRoot + + root, err := lockfile.OpenRoot(projectRoot) + if err != nil { + return nil, err + } + lf, err := lockfile.Load(root) + if err != nil { + return nil, err + } + + targets, err := selectUpgradeTargets(lf, opts.Names) + if err != nil { + return nil, err + } + + plans := make([]upgradePlan, len(targets)) + for i, entry := range targets { + plans[i] = s.planUpgrade(ctx, opts, entry) + } + + result := &plugins.UpgradeResult{Outcomes: make([]plugins.UpgradeOutcome, 0, len(plans))} + for _, p := range plans { + if opts.FailOnChanges { + result.Outcomes = append(result.Outcomes, p.outcome) + continue + } + result.Outcomes = append(result.Outcomes, s.applyUpgrade(ctx, opts, p)) + } + return result, nil +} + +// selectUpgradeTargets returns the lock entries to upgrade: every plugins: +// entry when names is empty, or the named subset in the order requested. +func selectUpgradeTargets(lf *lockfile.Lockfile, names []string) ([]lockfile.Entry, error) { + if len(names) == 0 { + return lf.Plugins, nil + } + targets := make([]lockfile.Entry, 0, len(names)) + for _, name := range names { + entry, ok := lf.GetPlugin(name) + if !ok { + return nil, httperr.WithCode( + fmt.Errorf("plugin %q is not present in the lock file", name), + http.StatusNotFound, + ) + } + targets = append(targets, entry) + } + return targets, nil +} + +// upgradePlan is entry's resolved outcome before any install happens: either +// a terminal status (not-upgradable, up-to-date, ref-change-blocked, or a +// resolution failure) that needs no further action, or the pinned reference +// (and optional local layer bytes) to install when the upgrade is applied. +type upgradePlan struct { + entry lockfile.Entry + outcome plugins.UpgradeOutcome + pinnedRef string // set only when the upgrade needs installing + resolvedRef string // the resolved reference to record as ResolvedReference + layerData []byte // set when the new content was resolved from the local OCI store +} + +// resolvedLatest is the current state of a lock entry's Source, before any +// install. layerData is set only for local-store hits so apply can install +// those bytes without reinterpreting a bare tag as Docker Hub. +type resolvedLatest struct { + ref string + digest string + layerData []byte +} + +func (s *service) planUpgrade(ctx context.Context, opts plugins.UpgradeOptions, entry lockfile.Entry) upgradePlan { + outcome := plugins.UpgradeOutcome{Name: entry.Name, OldDigest: entry.Digest} + + if isImmutableSource(entry) { + outcome.Status = plugins.UpgradeStatusNotUpgradable + return upgradePlan{entry: entry, outcome: outcome} + } + + latest, err := s.resolveLatestState(ctx, entry.Source) + if err != nil { + outcome.Status = plugins.UpgradeStatusFailed + outcome.Reason = classifySyncFailure(err) + outcome.Error = err.Error() + return upgradePlan{entry: entry, outcome: outcome} + } + outcome.NewDigest = latest.digest + + if latest.digest == entry.Digest { + outcome.Status = plugins.UpgradeStatusUpToDate + return upgradePlan{entry: entry, outcome: outcome} + } + + if len(latest.layerData) > 0 { + // Local-store hit: carry the exact artifact. buildPinnedReference + // would parse a bare tag as index.docker.io/library/@digest. + // resolvedRef is the local tag for the DB; the lock cannot store a + // bare tag, so apply keeps the previous resolvedReference if any. + outcome.Status = plugins.UpgradeStatusUpgraded + return upgradePlan{ + entry: entry, + outcome: outcome, + pinnedRef: entry.Name, + resolvedRef: latest.ref, + layerData: latest.layerData, + } + } + + if latest.ref != entry.ResolvedReference { + outcome.NewResolvedReference = latest.ref + if entry.ResolvedReference != "" && repositoryMoved(entry.ResolvedReference, latest.ref) && !opts.AllowRefChange { + outcome.Status = plugins.UpgradeStatusRefChangeBlocked + return upgradePlan{entry: entry, outcome: outcome} + } + } + + pinnedRef, err := buildPinnedReference(lockfile.Entry{ResolvedReference: latest.ref, Digest: latest.digest}) + if err != nil { + outcome.Status = plugins.UpgradeStatusFailed + outcome.Reason = plugins.FailureReasonUnknown + outcome.Error = fmt.Errorf("pinning resolved reference: %w", err).Error() + return upgradePlan{entry: entry, outcome: outcome} + } + + outcome.Status = plugins.UpgradeStatusUpgraded + return upgradePlan{entry: entry, outcome: outcome, pinnedRef: pinnedRef, resolvedRef: latest.ref} +} + +func (s *service) applyUpgrade(ctx context.Context, opts plugins.UpgradeOptions, plan upgradePlan) plugins.UpgradeOutcome { + if plan.pinnedRef == "" || opts.Preview { + return plan.outcome + } + + clients := opts.Clients + if len(clients) == 0 { + if existing, err := s.store.Get(ctx, plan.entry.Name, plugins.ScopeProject, opts.ProjectRoot); err == nil { + clients = existing.Clients + } + } + + lockResolved := lockableResolvedReference(plan.resolvedRef) + if lockResolved == "" { + lockResolved = plan.entry.ResolvedReference + } + + if _, err := s.Install(ctx, plugins.InstallOptions{ + Name: plan.pinnedRef, + Reference: plan.resolvedRef, + LayerData: plan.layerData, + Digest: plan.outcome.NewDigest, + Scope: plugins.ScopeProject, + ProjectRoot: opts.ProjectRoot, + Clients: clients, + LockSource: plan.entry.Source, + LockResolvedReference: lockResolved, + }); err != nil { + outcome := plan.outcome + outcome.Status = plugins.UpgradeStatusFailed + outcome.Reason = classifySyncFailure(err) + outcome.Error = err.Error() + return outcome + } + + return plan.outcome +} + +// resolveLatestState re-resolves source (a lock entry's original Source +// value) to its current resolvedReference, digest, and (for local-store +// hits) layer bytes, using the same dispatch order as Install (git, direct +// OCI, plain name via local store then registry), but stopping short of +// extraction or any DB/lock write. For OCI sources this still pulls the +// artifact into the local store — matching the RFC's "preview is not +// side-effect-free" note. +func (s *service) resolveLatestState(ctx context.Context, source string) (resolvedLatest, error) { + if gitresolver.IsGitReference(source) { + ref, digest, err := s.resolveGitLatest(ctx, source) + return resolvedLatest{ref: ref, digest: digest}, err + } + + ref, isOCI, parseErr := parseOCIReference(source) + if parseErr != nil { + return resolvedLatest{}, httperr.WithCode( + fmt.Errorf("invalid OCI reference %q: %w", source, parseErr), + http.StatusBadRequest, + ) + } + if isOCI { + resolvedRef, digest, err := s.resolveOCILatest(ctx, ref) + return resolvedLatest{ref: resolvedRef, digest: digest}, err + } + + return s.resolvePlainNameLatest(ctx, source) +} + +// resolvePlainNameLatest mirrors installByName: local OCI store first, then +// the registry lookup. A lock entry whose Source is a bare plugin name is +// otherwise stuck talking only to the registry, so a local rebuild would +// never be picked up by upgrade. A local hit returns the extracted layer so +// apply installs that artifact instead of a Docker Hub implicit reference. +func (s *service) resolvePlainNameLatest(ctx context.Context, source string) (resolvedLatest, error) { + if s.ociStore != nil { + opts := plugins.InstallOptions{Name: source} + resolved, err := s.resolveFromLocalStore(ctx, &opts) + if err != nil { + return resolvedLatest{}, err + } + if resolved { + return resolvedLatest{ref: opts.Reference, digest: opts.Digest, layerData: opts.LayerData}, nil + } + } + ref, digest, err := s.resolveRegistryNameLatest(ctx, source) + return resolvedLatest{ref: ref, digest: digest}, err +} + +func (s *service) resolveGitLatest(ctx context.Context, gitURL string) (string, string, error) { + gitRef, err := gitresolver.ParseGitReference(gitURL) + if err != nil { + return "", "", httperr.WithCode(fmt.Errorf("invalid git reference: %w", err), http.StatusBadRequest) + } + + ctx, cancel := context.WithTimeout(ctx, gitresolver.CloneTimeout) + defer cancel() + + cloneConfig := gitresolver.CloneConfigForRef(gitRef) + client := gitresolver.ClientForURL(gitRef.URL, s.gitClient) + repoInfo, err := client.Clone(ctx, cloneConfig) + if err != nil { + return "", "", httperr.WithCode(fmt.Errorf("resolving git plugin: %w", err), http.StatusBadGateway) + } + defer func() { _ = client.Cleanup(ctx, repoInfo) }() + + head, err := client.HeadCommit(repoInfo) + if err != nil { + return "", "", httperr.WithCode(fmt.Errorf("resolving git plugin: %w", err), http.StatusBadGateway) + } + return gitURL, head.Hash, nil +} + +func (s *service) resolveOCILatest(ctx context.Context, ref nameref.Reference) (string, string, error) { + if s.registry == nil || s.ociStore == nil { + return "", "", httperr.WithCode(errors.New("OCI registry is not configured"), http.StatusInternalServerError) + } + if err := validateOCIRegistryHost(ref); err != nil { + return "", "", err + } + + pullCtx, cancel := context.WithTimeout(ctx, ociPullTimeout) + defer cancel() + + d, err := s.registry.Pull(pullCtx, s.ociStore, qualifiedOCIRef(ref)) + if err != nil { + return "", "", httperr.WithCode(fmt.Errorf("pulling %q: %w", ref.String(), err), classifyPullError(err)) + } + return qualifiedOCIRef(ref), d.String(), nil +} + +func (s *service) resolveRegistryNameLatest(ctx context.Context, source string) (string, string, error) { + if s.pluginLookup == nil { + return "", "", httperr.WithCode( + fmt.Errorf("plugin %q not found in local store or registry", source), + http.StatusNotFound, + ) + } + + namespace, searchName := splitQualifiedName(source) + hits, err := s.pluginLookup.SearchPlugins(ctx, searchName) + if err != nil { + slog.Warn("registry plugin lookup failed, falling back to not-found", "name", source, "error", err) + return "", "", httperr.WithCode( + fmt.Errorf("plugin %q not found in local store or registry", source), + http.StatusNotFound, + ) + } + + var matches []PluginSearchHit + for _, hit := range hits { + if !strings.EqualFold(hit.Name, searchName) { + continue + } + if namespace != "" && !strings.EqualFold(hit.Namespace, namespace) { + continue + } + matches = append(matches, hit) + } + switch { + case len(matches) == 0: + return "", "", httperr.WithCode( + fmt.Errorf("plugin %q not found in local store or registry", source), + http.StatusNotFound, + ) + case len(matches) > 1: + return "", "", ambiguousPluginNameError(source, matches) + } + + pkg, pkgErr := selectOCIPluginPackage(source, matches[0].Packages) + if pkgErr != nil { + return "", "", pkgErr + } + ref, isOCIRef, parseErr := parseOCIReference(pkg.Reference) + if parseErr != nil { + return "", "", httperr.WithCode( + fmt.Errorf("registry returned invalid OCI reference %q: %w", pkg.Reference, parseErr), + http.StatusUnprocessableEntity, + ) + } + if !isOCIRef || ref == nil { + return "", "", httperr.WithCode( + fmt.Errorf("registry returned invalid OCI reference %q", pkg.Reference), + http.StatusUnprocessableEntity, + ) + } + return s.resolveOCILatest(ctx, ref) +} diff --git a/pkg/plugins/pluginsvc/upgrade_test.go b/pkg/plugins/pluginsvc/upgrade_test.go new file mode 100644 index 0000000000..18703531af --- /dev/null +++ b/pkg/plugins/pluginsvc/upgrade_test.go @@ -0,0 +1,378 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + ociplugins "github.com/stacklok/toolhive-core/oci/plugins" + ocimocks "github.com/stacklok/toolhive-core/oci/plugins/mocks" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills/lockfile" +) + +func addPluginRepoCommit(t *testing.T, repoDir, content string) { + t.Helper() + repo, err := gogit.PlainOpen(repoDir) + require.NoError(t, err) + wt, err := repo.Worktree() + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "commands", "hello.md"), []byte(content), 0o644)) + _, err = wt.Add(".") + require.NoError(t, err) + _, err = wt.Commit("update", &gogit.CommitOptions{Author: &object.Signature{Name: "T", Email: "t@e"}}) + require.NoError(t, err) +} + +func installGitTestPlugin(t *testing.T, svc plugins.PluginService, projectRoot string) { + t.Helper() + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: gitPluginRef, Scope: plugins.ScopeProject, ProjectRoot: projectRoot, Clients: []string{"claude-code"}, + }) + require.NoError(t, err) +} + +//nolint:paralleltest // uses t.Setenv via newGitLockTestService +func TestUpgrade_ReportsUpToDateWhenSourceUnchanged(t *testing.T) { + repoDir := createPluginTestRepo(t, "") + svc, projectRoot := newGitLockTestService(t, repoDir) + installGitTestPlugin(t, svc, projectRoot) + + result, err := svc.(*service).Upgrade(t.Context(), plugins.UpgradeOptions{ProjectRoot: projectRoot}) //nolint:forcetypeassert + require.NoError(t, err) + require.Len(t, result.Outcomes, 1) + assert.Equal(t, plugins.UpgradeStatusUpToDate, result.Outcomes[0].Status) +} + +//nolint:paralleltest // uses t.Setenv via newGitLockTestService +func TestUpgrade_InstallsNewerContent(t *testing.T) { + repoDir := createPluginTestRepo(t, "") + svc, projectRoot := newGitLockTestService(t, repoDir) + installGitTestPlugin(t, svc, projectRoot) + + before := readLockfile(t, projectRoot) + beforeEntry, ok := before.GetPlugin("my-plugin") + require.True(t, ok) + + addPluginRepoCommit(t, repoDir, "# hello v2") + + result, err := svc.(*service).Upgrade(t.Context(), plugins.UpgradeOptions{ProjectRoot: projectRoot}) //nolint:forcetypeassert + require.NoError(t, err) + require.Len(t, result.Outcomes, 1) + outcome := result.Outcomes[0] + assert.Equal(t, plugins.UpgradeStatusUpgraded, outcome.Status) + assert.Equal(t, beforeEntry.Digest, outcome.OldDigest) + assert.NotEqual(t, outcome.OldDigest, outcome.NewDigest) + + after := readLockfile(t, projectRoot) + afterEntry, ok := after.GetPlugin("my-plugin") + require.True(t, ok) + assert.Equal(t, outcome.NewDigest, afterEntry.Digest) + assert.Equal(t, beforeEntry.Source, afterEntry.Source, "Source must never be rewritten by upgrade") + + hello, err := os.ReadFile(filepath.Join(pluginOnDiskPath(projectRoot, "my-plugin"), "commands", "hello.md")) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(hello), "# hello v2") +} + +//nolint:paralleltest // uses t.Setenv via newGitLockTestService +func TestUpgrade_PreviewDoesNotInstall(t *testing.T) { + repoDir := createPluginTestRepo(t, "") + svc, projectRoot := newGitLockTestService(t, repoDir) + installGitTestPlugin(t, svc, projectRoot) + + before := readLockfile(t, projectRoot) + beforeEntry, _ := before.GetPlugin("my-plugin") + + addPluginRepoCommit(t, repoDir, "# hello preview") + + result, err := svc.(*service).Upgrade(t.Context(), plugins.UpgradeOptions{ //nolint:forcetypeassert + ProjectRoot: projectRoot, Preview: true, + }) + require.NoError(t, err) + require.Len(t, result.Outcomes, 1) + assert.Equal(t, plugins.UpgradeStatusUpgraded, result.Outcomes[0].Status, "preview still reports what would happen") + + after := readLockfile(t, projectRoot) + afterEntry, ok := after.GetPlugin("my-plugin") + require.True(t, ok) + assert.Equal(t, beforeEntry.Digest, afterEntry.Digest, "preview must not rewrite the lock file") +} + +//nolint:paralleltest // uses t.Setenv via newGitLockTestService +func TestUpgrade_PreservesExistingClients(t *testing.T) { + repoDir := createPluginTestRepo(t, "") + svc, projectRoot := newGitLockTestService(t, repoDir) + installGitTestPlugin(t, svc, projectRoot) + + addPluginRepoCommit(t, repoDir, "# hello clients") + + result, err := svc.(*service).Upgrade(t.Context(), plugins.UpgradeOptions{ProjectRoot: projectRoot}) //nolint:forcetypeassert + require.NoError(t, err) + require.Len(t, result.Outcomes, 1) + assert.Equal(t, plugins.UpgradeStatusUpgraded, result.Outcomes[0].Status) + + info, err := svc.Info(t.Context(), plugins.InfoOptions{Name: "my-plugin", Scope: plugins.ScopeProject, ProjectRoot: projectRoot}) + require.NoError(t, err) + require.NotNil(t, info.InstalledPlugin) + assert.Equal(t, []string{"claude-code"}, info.InstalledPlugin.Clients, + "upgrade must preserve the plugin's existing clients, not expand to every detected client") +} + +//nolint:paralleltest // uses t.Setenv via newGitLockTestService +func TestUpgrade_NotUpgradableForImmutableSource(t *testing.T) { + repoDir := createPluginTestRepo(t, "") + svc, projectRoot := newGitLockTestService(t, repoDir) + installGitTestPlugin(t, svc, projectRoot) + + lf := readLockfile(t, projectRoot) + entry, ok := lf.GetPlugin("my-plugin") + require.True(t, ok) + entry.Source = gitPluginRef + "@" + entry.Digest + lf.UpsertPlugin(entry) + require.NoError(t, lf.Save(mustOpenRoot(t, projectRoot))) + + addPluginRepoCommit(t, repoDir, "# hello immutable") + + result, err := svc.(*service).Upgrade(t.Context(), plugins.UpgradeOptions{ProjectRoot: projectRoot}) //nolint:forcetypeassert + require.NoError(t, err) + require.Len(t, result.Outcomes, 1) + assert.Equal(t, plugins.UpgradeStatusNotUpgradable, result.Outcomes[0].Status) +} + +//nolint:paralleltest // uses t.Setenv via newGitLockTestService +func TestUpgrade_UnknownNameReturnsNotFound(t *testing.T) { + repoDir := createPluginTestRepo(t, "") + svc, projectRoot := newGitLockTestService(t, repoDir) + + _, err := svc.(*service).Upgrade(t.Context(), plugins.UpgradeOptions{ //nolint:forcetypeassert + ProjectRoot: projectRoot, Names: []string{"does-not-exist"}, + }) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, httperr.Code(err)) +} + +//nolint:paralleltest // uses t.Setenv via newGitLockTestService +func TestUpgrade_FailOnChangesReportsOutcomesWithoutError(t *testing.T) { + repoDir := createPluginTestRepo(t, "") + svc, projectRoot := newGitLockTestService(t, repoDir) + installGitTestPlugin(t, svc, projectRoot) + + before := readLockfile(t, projectRoot) + beforeEntry, _ := before.GetPlugin("my-plugin") + + addPluginRepoCommit(t, repoDir, "# hello fail-on-changes") + + result, err := svc.(*service).Upgrade(t.Context(), plugins.UpgradeOptions{ //nolint:forcetypeassert + ProjectRoot: projectRoot, FailOnChanges: true, + }) + require.NoError(t, err, "fail-on-changes reports outcomes, it does not error") + require.Len(t, result.Outcomes, 1) + assert.Equal(t, plugins.UpgradeStatusUpgraded, result.Outcomes[0].Status) + + after := readLockfile(t, projectRoot) + afterEntry, ok := after.GetPlugin("my-plugin") + require.True(t, ok) + assert.Equal(t, beforeEntry.Digest, afterEntry.Digest, "fail-on-changes must not rewrite the lock file") +} + +//nolint:paralleltest // uses t.Setenv via newLockTestService +func TestUpgrade_DisabledGateReturnsForbidden(t *testing.T) { + svc, projectRoot := newLockTestService(t, false) + + _, err := svc.(*service).Upgrade(t.Context(), plugins.UpgradeOptions{ProjectRoot: projectRoot}) //nolint:forcetypeassert + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, httperr.Code(err)) +} + +type countingLookup struct { + hits []PluginSearchHit + n int +} + +func (c *countingLookup) SearchPlugins(context.Context, string) ([]PluginSearchHit, error) { + c.n++ + return c.hits, nil +} + +//nolint:paralleltest // uses t.Setenv via newLockTestService +func TestUpgrade_PlainNameResolvesLocalStoreWithoutRegistry(t *testing.T) { + svc, projectRoot := newLockTestService(t, true) + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + + lookup := &countingLookup{} + inner := svc.(*service) //nolint:forcetypeassert + inner.ociStore = ociStore + inner.pluginLookup = lookup + + _, err = svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: makePluginLayerData(t, "my-plugin"), + Digest: validLockDigest(), + Scope: plugins.ScopeProject, + ProjectRoot: projectRoot, + Clients: []string{"claude-code"}, + }) + require.NoError(t, err) + lookup.n = 0 + + d2 := buildTestPlugin(t, ociStore, "my-plugin", "2.0.0") + require.NoError(t, ociStore.Tag(t.Context(), d2, "my-plugin")) + + result, err := inner.Upgrade(t.Context(), plugins.UpgradeOptions{ProjectRoot: projectRoot, Preview: true}) + require.NoError(t, err) + require.Len(t, result.Outcomes, 1) + assert.Equal(t, plugins.UpgradeStatusUpgraded, result.Outcomes[0].Status) + assert.Equal(t, d2.String(), result.Outcomes[0].NewDigest) + assert.Equal(t, 0, lookup.n, "a local-store hit must not fall through to registry lookup") +} + +//nolint:paralleltest // uses t.Setenv via newLockTestService +func TestUpgrade_AppliesSameNameLocalTagWithoutRegistry(t *testing.T) { + svc, projectRoot := newLockTestService(t, true) + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + + lookup := &countingLookup{} + inner := svc.(*service) //nolint:forcetypeassert + inner.ociStore = ociStore + inner.pluginLookup = lookup + + _, err = svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: makePluginLayerData(t, "my-plugin"), + Digest: validLockDigest(), + Scope: plugins.ScopeProject, + ProjectRoot: projectRoot, + Clients: []string{"claude-code"}, + }) + require.NoError(t, err) + lookup.n = 0 + + prevRef := "ghcr.io/org/my-plugin@" + validLockDigest() + existing, ok := readLockfile(t, projectRoot).GetPlugin("my-plugin") + require.True(t, ok) + existing.ResolvedReference = prevRef + require.NoError(t, lockfile.UpsertPluginEntry(mustOpenRoot(t, projectRoot), existing)) + + d2 := buildTestPlugin(t, ociStore, "my-plugin", "2.0.0") + require.NoError(t, ociStore.Tag(t.Context(), d2, "my-plugin")) + + result, err := inner.Upgrade(t.Context(), plugins.UpgradeOptions{ProjectRoot: projectRoot}) + require.NoError(t, err) + require.Len(t, result.Outcomes, 1) + assert.Equal(t, plugins.UpgradeStatusUpgraded, result.Outcomes[0].Status) + assert.Equal(t, 0, lookup.n, "apply must not fall through to the registry") + + after, ok := readLockfile(t, projectRoot).GetPlugin("my-plugin") + require.True(t, ok) + assert.Equal(t, d2.String(), after.Digest) + assert.Equal(t, prevRef, after.ResolvedReference, "a local-tag apply must keep the previous restorable pin") + + info, err := svc.Info(t.Context(), plugins.InfoOptions{ + Name: "my-plugin", Scope: plugins.ScopeProject, ProjectRoot: projectRoot, + }) + require.NoError(t, err) + require.NotNil(t, info.InstalledPlugin) + assert.Equal(t, "my-plugin", info.InstalledPlugin.Reference) + + manifest, err := os.ReadFile(filepath.Join(pluginOnDiskPath(projectRoot, "my-plugin"), ".claude-plugin", "plugin.json")) //nolint:gosec + require.NoError(t, err) + assert.Contains(t, string(manifest), "2.0.0") +} + +//nolint:paralleltest // uses t.Setenv via newLockTestService +func TestUpgrade_AppliesDifferentlyNamedLocalTagWithoutRegistry(t *testing.T) { + svc, projectRoot := newLockTestService(t, true) + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + + lookup := &countingLookup{} + inner := svc.(*service) //nolint:forcetypeassert + inner.ociStore = ociStore + inner.pluginLookup = lookup + + _, err = svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: makePluginLayerData(t, "my-plugin"), + Digest: validLockDigest(), + Scope: plugins.ScopeProject, + ProjectRoot: projectRoot, + Clients: []string{"claude-code"}, + }) + require.NoError(t, err) + lookup.n = 0 + + d2 := buildTestPlugin(t, ociStore, "my-plugin", "2.0.0") + require.NoError(t, tagAsLocalBuild(t.Context(), ociStore, d2, "my-plugin-dev")) + + result, err := inner.Upgrade(t.Context(), plugins.UpgradeOptions{ProjectRoot: projectRoot}) + require.NoError(t, err) + require.Len(t, result.Outcomes, 1) + assert.Equal(t, plugins.UpgradeStatusUpgraded, result.Outcomes[0].Status) + assert.Equal(t, 0, lookup.n, "a differently named local-build tag must not fall through to the registry") + + after, ok := readLockfile(t, projectRoot).GetPlugin("my-plugin") + require.True(t, ok) + assert.Equal(t, d2.String(), after.Digest) + assert.Empty(t, after.ResolvedReference, "a bare local tag must not be written as resolvedReference") + + info, err := svc.Info(t.Context(), plugins.InfoOptions{ + Name: "my-plugin", Scope: plugins.ScopeProject, ProjectRoot: projectRoot, + }) + require.NoError(t, err) + require.NotNil(t, info.InstalledPlugin) + assert.Equal(t, "my-plugin-dev", info.InstalledPlugin.Reference) +} + +//nolint:paralleltest // uses t.Setenv via newLockTestService +func TestUpgrade_PlainNameFallsBackToRegistryWhenLocalMisses(t *testing.T) { + svc, projectRoot := newLockTestService(t, true) + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + + inner := svc.(*service) //nolint:forcetypeassert + inner.ociStore = ociStore + + _, err = svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: makePluginLayerData(t, "my-plugin"), + Digest: validLockDigest(), + Scope: plugins.ScopeProject, + ProjectRoot: projectRoot, + Clients: []string{"claude-code"}, + }) + require.NoError(t, err) + + newer := buildTestPlugin(t, ociStore, "my-plugin", "2.0.0") + + lookup := &countingLookup{hits: []PluginSearchHit{{ + Name: "my-plugin", + Packages: []PluginPackage{{Reference: "ghcr.io/org/my-plugin:v2", Type: "oci"}}, + }}} + ctrl := gomock.NewController(t) + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-plugin:v2").Return(newer, nil) + inner.pluginLookup = lookup + inner.registry = reg + + result, err := inner.Upgrade(t.Context(), plugins.UpgradeOptions{ProjectRoot: projectRoot, Preview: true}) + require.NoError(t, err) + require.Len(t, result.Outcomes, 1) + assert.Equal(t, plugins.UpgradeStatusUpgraded, result.Outcomes[0].Status) + assert.Equal(t, 1, lookup.n, "a local-store miss must fall through to registry lookup") + assert.Equal(t, newer.String(), result.Outcomes[0].NewDigest) +} diff --git a/test/e2e/cli_plugins_lock_test.go b/test/e2e/cli_plugins_lock_test.go index d5b9803c85..4fc2b3201e 100644 --- a/test/e2e/cli_plugins_lock_test.go +++ b/test/e2e/cli_plugins_lock_test.go @@ -20,6 +20,7 @@ import ( . "github.com/onsi/gomega" "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills/lockfile" "github.com/stacklok/toolhive/test/e2e" ) @@ -166,6 +167,51 @@ var _ = Describe("Plugins CLI lock file exit codes (RFC THV-0080)", Label("api", Expect(exitCodeOf(cmdErr)).To(Equal(2)) }) }) + + Describe("thv ai-plugin upgrade --fail-on-changes", func() { + It("exits 2 when a plugin would change, without installing it", func() { + projectRoot := makeE2EProjectRoot() + pluginName := "cli-lock-upgrade-fail-on-changes-plugin" + + ociRegistry := httptest.NewServer(registry.New()) + DeferCleanup(ociRegistry.Close) + ociRef := buildAndPushPlugin(apiServer, ociRegistry, pluginName, "The original description") + + installResp := installPlugin(apiServer, installPluginE2ERequest{ + Name: ociRef, Scope: "project", ProjectRoot: projectRoot, Clients: []string{"claude-code"}, + }) + defer installResp.Body.Close() + Expect(installResp.StatusCode).To(Equal(http.StatusCreated)) + + By("Republishing newer content at the same OCI reference") + newPluginDir := createTestPluginDirWithBody(pluginName, "The updated description", "# hello v2\n") + rebuildResp := buildPlugin(apiServer, newPluginDir, ociRef) + defer rebuildResp.Body.Close() + Expect(rebuildResp.StatusCode).To(Equal(http.StatusOK)) + repushResp := pushPlugin(apiServer, ociRef) + defer repushResp.Body.Close() + Expect(repushResp.StatusCode).To(Equal(http.StatusNoContent)) + + root, err := lockfile.OpenRoot(projectRoot) + Expect(err).ToNot(HaveOccurred()) + before, err := lockfile.Load(root) + Expect(err).ToNot(HaveOccurred()) + beforeEntry, ok := before.GetPlugin(pluginName) + Expect(ok).To(BeTrue()) + + _, _, err = thvPluginCmd("upgrade", "--yes", "--fail-on-changes", "--project-root", projectRoot).Run() + Expect(err).To(HaveOccurred()) + Expect(exitCodeOf(err)).To(Equal(2)) + + By("Verifying nothing was actually installed before the conflict was reported") + after, err := lockfile.Load(root) + Expect(err).ToNot(HaveOccurred()) + afterEntry, ok := after.GetPlugin(pluginName) + Expect(ok).To(BeTrue()) + Expect(afterEntry.Digest).To(Equal(beforeEntry.Digest), + "--fail-on-changes must not install a changed plugin before reporting the conflict") + }) + }) }) type installPluginE2ERequest struct { @@ -232,6 +278,10 @@ func pushPlugin(server *e2e.Server, reference string) *http.Response { } func createTestPluginDir(pluginName, description string) string { + return createTestPluginDirWithBody(pluginName, description, "# hello\n") +} + +func createTestPluginDirWithBody(pluginName, description, helloBody string) string { parentDir := GinkgoT().TempDir() pluginDir := filepath.Join(parentDir, pluginName) ExpectWithOffset(1, os.MkdirAll(filepath.Join(pluginDir, ".claude-plugin"), 0o755)).To(Succeed()) @@ -251,7 +301,7 @@ func createTestPluginDir(pluginName, description string) string { )).To(Succeed()) ExpectWithOffset(1, os.WriteFile( filepath.Join(pluginDir, "commands", "hello.md"), - []byte("# hello\n"), + []byte(helloBody), 0o644, )).To(Succeed())