diff --git a/cmd/thv/app/commands.go b/cmd/thv/app/commands.go index c575c00de0..b9b61fb8af 100644 --- a/cmd/thv/app/commands.go +++ b/cmd/thv/app/commands.go @@ -76,6 +76,7 @@ func NewRootCmd(enableUpdates bool) *cobra.Command { rootCmd.AddCommand(skillCmd) rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(tuiCmd) + rootCmd.AddCommand(upgradeCmd) // Silence printing the usage on error rootCmd.SilenceUsage = true diff --git a/cmd/thv/app/list.go b/cmd/thv/app/list.go index a3943bbd60..7473891c5f 100644 --- a/cmd/thv/app/list.go +++ b/cmd/thv/app/list.go @@ -4,6 +4,7 @@ package app import ( + "context" "encoding/json" "fmt" "log/slog" @@ -13,7 +14,9 @@ import ( "github.com/spf13/cobra" "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/runner" "github.com/stacklok/toolhive/pkg/workloads" + "github.com/stacklok/toolhive/pkg/workloads/upgrade" ) var listCmd = &cobra.Command{ @@ -41,10 +44,11 @@ Examples: } var ( - listAll bool - listFormat string - listLabelFilter []string - listGroupFilter string + listAll bool + listFormat string + listLabelFilter []string + listGroupFilter string + listCheckUpgrades bool ) func init() { @@ -52,13 +56,29 @@ func init() { AddFormatFlag(listCmd, &listFormat, FormatJSON, FormatText, "mcpservers") listCmd.Flags().StringArrayVarP(&listLabelFilter, "label", "l", []string{}, "Filter workloads by labels (format: key=value)") AddGroupFlag(listCmd, &listGroupFilter, false) + listCmd.Flags().BoolVar(&listCheckUpgrades, "check-upgrades", false, + "Check each workload for available upgrades against its source registry (performs a registry lookup)") listCmd.PreRunE = chainPreRunE( validateGroupFlag(), ValidateFormat(&listFormat, FormatJSON, FormatText, "mcpservers"), + validateCheckUpgradesFormat(), ) } +// validateCheckUpgradesFormat rejects --check-upgrades with --format mcpservers. +// The mcpservers format emits client configuration and has no upgrade column, so +// the flag combination would perform a registry lookup per workload and then +// discard the result. Fail loudly rather than do hidden, wasted work. +func validateCheckUpgradesFormat() func(*cobra.Command, []string) error { + return func(_ *cobra.Command, _ []string) error { + if listCheckUpgrades && listFormat == "mcpservers" { + return fmt.Errorf("--check-upgrades is not supported with --format mcpservers; use --format text or json") + } + return nil + } +} + func listCmdFunc(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() @@ -81,10 +101,20 @@ func listCmdFunc(cmd *cobra.Command, _ []string) error { } } + // Optionally compute upgrade status for each workload. This is the only path + // that performs a registry lookup; the default list stays offline-friendly. + var upgrades map[string]*upgrade.CheckResult + if listCheckUpgrades { + upgrades, err = checkUpgradesForWorkloads(ctx, workloadList) + if err != nil { + return err + } + } + // Output based on format switch listFormat { case FormatJSON: - return printJSONOutput(workloadList) + return printJSONOutput(workloadList, upgrades) case "mcpservers": return printMCPServersOutput(workloadList) default: @@ -97,13 +127,49 @@ func listCmdFunc(cmd *cobra.Command, _ []string) error { } return nil } - printTextOutput(workloadList) + printTextOutput(workloadList, upgrades) return nil } } -// printJSONOutput prints workload information in JSON format -func printJSONOutput(workloadList []core.Workload) error { +// checkUpgradesForWorkloads builds a single Checker, loads each workload's saved +// RunConfig, and returns the upgrade result keyed by workload name. Workloads +// whose config cannot be loaded are omitted from the map. The comparison logic +// lives entirely in pkg/workloads/upgrade; this only collects inputs. +func checkUpgradesForWorkloads(ctx context.Context, workloadList []core.Workload) (map[string]*upgrade.CheckResult, error) { + checker, err := newUpgradeChecker() + if err != nil { + return nil, err + } + + configs := make([]*runner.RunConfig, 0, len(workloadList)) + for _, wl := range workloadList { + cfg, err := runner.LoadState(ctx, wl.Name) + if err != nil { + slog.Debug("skipping upgrade check for workload with unloadable config", "workload", wl.Name, "error", err) + continue + } + configs = append(configs, cfg) + } + + results := checker.CheckAll(ctx, configs) + byName := make(map[string]*upgrade.CheckResult, len(results)) + for _, r := range results { + byName[r.WorkloadName] = r + } + return byName, nil +} + +// workloadWithUpgrade augments a workload with its optional upgrade-check +// result for JSON output when --check-upgrades is set. +type workloadWithUpgrade struct { + core.Workload + Upgrade *upgrade.CheckResult `json:"upgrade,omitempty"` +} + +// printJSONOutput prints workload information in JSON format. When upgrades is +// non-nil, each workload is augmented with its upgrade-check result. +func printJSONOutput(workloadList []core.Workload, upgrades map[string]*upgrade.CheckResult) error { // Ensure we have a non-nil slice to avoid null in JSON output if workloadList == nil { workloadList = []core.Workload{} @@ -112,13 +178,26 @@ func printJSONOutput(workloadList []core.Workload) error { // Sort workloads alphabetically by name for deterministic output core.SortWorkloadsByName(workloadList) - // Marshal to JSON - jsonData, err := json.MarshalIndent(workloadList, "", " ") + // Without upgrade data, marshal the workloads directly to preserve the + // existing output shape. + if upgrades == nil { + jsonData, err := json.MarshalIndent(workloadList, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonData)) + return nil + } + + augmented := make([]workloadWithUpgrade, 0, len(workloadList)) + for _, wl := range workloadList { + augmented = append(augmented, workloadWithUpgrade{Workload: wl, Upgrade: upgrades[wl.Name]}) + } + + jsonData, err := json.MarshalIndent(augmented, "", " ") if err != nil { return fmt.Errorf("failed to marshal JSON: %w", err) } - - // Print JSON directly to stdout fmt.Println(string(jsonData)) return nil } @@ -150,14 +229,19 @@ func printMCPServersOutput(workloadList []core.Workload) error { return nil } -// printTextOutput prints workload information in text format -func printTextOutput(workloadList []core.Workload) { +// printTextOutput prints workload information in text format. When upgrades is +// non-nil, an additional UPGRADE column reports each workload's upgrade status. +func printTextOutput(workloadList []core.Workload, upgrades map[string]*upgrade.CheckResult) { // Sort workloads alphabetically by name for deterministic output core.SortWorkloadsByName(workloadList) // Create a tabwriter for pretty output w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) - if _, err := fmt.Fprintln(w, "NAME\tPACKAGE\tSTATUS\tURL\tPORT\tGROUP\tCREATED"); err != nil { + header := "NAME\tPACKAGE\tSTATUS\tURL\tPORT\tGROUP\tCREATED" + if upgrades != nil { + header += "\tUPGRADE" + } + if _, err := fmt.Fprintln(w, header); err != nil { slog.Warn(fmt.Sprintf("Failed to write output header: %v", err)) return } @@ -168,7 +252,7 @@ func printTextOutput(workloadList []core.Workload) { status := workloadStatusIndicator(c.Status) // Print workload information - if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n", + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\t%s", c.Name, c.Package, status, @@ -179,6 +263,18 @@ func printTextOutput(workloadList []core.Workload) { ); err != nil { slog.Debug(fmt.Sprintf("Failed to write workload information: %v", err)) } + if upgrades != nil { + upgradeStatus := "-" + if r, ok := upgrades[c.Name]; ok { + upgradeStatus = string(r.Status) + } + if _, err := fmt.Fprintf(w, "\t%s", upgradeStatus); err != nil { + slog.Debug(fmt.Sprintf("Failed to write upgrade status: %v", err)) + } + } + if _, err := fmt.Fprintln(w); err != nil { + slog.Debug(fmt.Sprintf("Failed to write newline: %v", err)) + } } // Flush the tabwriter diff --git a/cmd/thv/app/upgrade.go b/cmd/thv/app/upgrade.go new file mode 100644 index 0000000000..e0334c8b18 --- /dev/null +++ b/cmd/thv/app/upgrade.go @@ -0,0 +1,267 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/registry" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/workloads" + "github.com/stacklok/toolhive/pkg/workloads/upgrade" +) + +var upgradeCmd = &cobra.Command{ + Use: "upgrade", + Short: "Manage upgrades for MCP server workloads", + Long: `Inspect and apply upgrades for registry-sourced MCP server workloads. + +Upgrade checks compare each workload's current image and configuration against +the metadata reported by its source registry. Checks are an offline metadata +comparison and never pull images.`, +} + +var upgradeCheckCmd = &cobra.Command{ + Use: "check [workload-name]", + Short: "Check workloads for available upgrades", + Long: `Check whether registry-sourced workloads have a newer image available. + +With no arguments, all workloads are checked (including stopped ones) and a +summary table is printed. When a workload name is given, a detailed report for +that single workload is printed, including any new environment variables the +candidate image declares and any configuration (posture) drift. + +Examples: + # Check all workloads + thv upgrade check + + # Check a single workload with detailed output + thv upgrade check my-server + + # Check all workloads in JSON format + thv upgrade check --format json`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: completeMCPServerNames, + RunE: upgradeCheckCmdFunc, +} + +var upgradeCheckFormat string + +func init() { + upgradeCmd.AddCommand(upgradeCheckCmd) + + AddFormatFlag(upgradeCheckCmd, &upgradeCheckFormat, FormatJSON, FormatText) + upgradeCheckCmd.PreRunE = chainPreRunE( + ValidateFormat(&upgradeCheckFormat, FormatJSON, FormatText), + ) +} + +func upgradeCheckCmdFunc(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + checker, err := newUpgradeChecker() + if err != nil { + return err + } + + // Single-workload mode: detailed report. + if len(args) == 1 { + cfg, err := runner.LoadState(ctx, args[0]) + if err != nil { + return fmt.Errorf("failed to load configuration for workload %q: %w", args[0], err) + } + result, err := checker.Check(ctx, cfg) + if err != nil { + return fmt.Errorf("failed to check workload %q for upgrade: %w", args[0], err) + } + + if upgradeCheckFormat == FormatJSON { + return printUpgradeJSON([]*upgrade.CheckResult{result}) + } + printUpgradeDetail(result) + return nil + } + + // Bulk mode: enumerate all workloads and check each. + configs, err := loadWorkloadRunConfigs(ctx) + if err != nil { + return err + } + results := checker.CheckAll(ctx, configs) + + if upgradeCheckFormat == FormatJSON { + return printUpgradeJSON(results) + } + printUpgradeTable(results) + return nil +} + +// newUpgradeChecker builds an upgrade.Checker backed by the default registry +// provider used throughout the CLI. +func newUpgradeChecker() (*upgrade.Checker, error) { + provider, err := registry.GetDefaultProvider() + if err != nil { + return nil, fmt.Errorf("failed to get registry provider: %w", err) + } + checker, err := upgrade.NewChecker(provider) + if err != nil { + return nil, fmt.Errorf("failed to create upgrade checker: %w", err) + } + return checker, nil +} + +// loadWorkloadRunConfigs enumerates all workloads (mirroring the list command's +// path) and loads each workload's saved RunConfig. Configs that fail to load are +// skipped so a single corrupt entry does not abort the whole check. +func loadWorkloadRunConfigs(ctx context.Context) ([]*runner.RunConfig, error) { + manager, err := workloads.NewManager(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create workload manager: %w", err) + } + + workloadList, err := manager.ListWorkloads(ctx, true) + if err != nil { + return nil, fmt.Errorf("failed to list workloads: %w", err) + } + + // Sort by name for deterministic output, matching the `thv list` ordering. + core.SortWorkloadsByName(workloadList) + + configs := make([]*runner.RunConfig, 0, len(workloadList)) + for _, wl := range workloadList { + cfg, err := runner.LoadState(ctx, wl.Name) + if err != nil { + slog.Debug("skipping workload with unloadable config", "workload", wl.Name, "error", err) + continue + } + configs = append(configs, cfg) + } + return configs, nil +} + +// printUpgradeJSON prints upgrade check results as indented JSON. +func printUpgradeJSON(results []*upgrade.CheckResult) error { + if results == nil { + results = []*upgrade.CheckResult{} + } + data, err := json.MarshalIndent(results, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal upgrade check results: %w", err) + } + fmt.Println(string(data)) + return nil +} + +// printUpgradeTable prints a one-line-per-workload summary of upgrade results. +func printUpgradeTable(results []*upgrade.CheckResult) { + if len(results) == 0 { + fmt.Println("No MCP servers found") + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + if _, err := fmt.Fprintln(w, "NAME\tSTATUS\tCURRENT\tCANDIDATE\tNEW-ENV\tPOSTURE"); err != nil { + slog.Warn(fmt.Sprintf("Failed to write output header: %v", err)) + return + } + + for _, r := range results { + if r == nil { + // CheckAll does not return nil entries today; guard so this stays + // robust if that ever changes. + continue + } + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n", + r.WorkloadName, + r.Status, + dashIfEmpty(r.CurrentImage), + dashIfEmpty(r.CandidateImage), + newEnvCount(r), + postureMarker(r), + ); err != nil { + slog.Debug(fmt.Sprintf("Failed to write upgrade result: %v", err)) + } + } + + if err := w.Flush(); err != nil { + slog.Error(fmt.Sprintf("Failed to flush tabwriter: %v", err)) + } +} + +// printUpgradeDetail prints a verbose, single-workload upgrade report. +func printUpgradeDetail(r *upgrade.CheckResult) { + fmt.Printf("Workload: %s\n", r.WorkloadName) + fmt.Printf("Status: %s\n", r.Status) + if r.RegistryServer != "" { + fmt.Printf("Registry: %s\n", r.RegistryServer) + } + if r.CurrentImage != "" { + fmt.Printf("Current: %s\n", r.CurrentImage) + } + if r.CandidateImage != "" { + fmt.Printf("Candidate: %s\n", r.CandidateImage) + } + if r.Reason != "" { + fmt.Printf("Reason: %s\n", r.Reason) + } + + if r.EnvVarDrift != nil && len(r.EnvVarDrift.Added) > 0 { + fmt.Println("\nNew environment variables declared by the candidate:") + for _, ev := range r.EnvVarDrift.Added { + required := "" + if ev.Required { + required = " (required)" + } + desc := "" + if ev.Description != "" { + desc = ": " + ev.Description + } + fmt.Printf(" - %s%s%s\n", ev.Name, required, desc) + } + } + + if r.ConfigDrift != nil { + fmt.Println("\nConfiguration (posture) drift:") + if c := r.ConfigDrift.Transport; c != nil { + fmt.Printf(" ⚠ transport: %s -> %s\n", c.From, c.To) + } + if c := r.ConfigDrift.PermissionProfile; c != nil { + fmt.Printf(" ⚠ permission profile: %s -> %s\n", c.From, c.To) + } + } +} + +// newEnvCount returns the number of new environment variables the candidate +// declares that the workload does not currently satisfy. +func newEnvCount(r *upgrade.CheckResult) int { + if r.EnvVarDrift == nil { + return 0 + } + return len(r.EnvVarDrift.Added) +} + +// postureMarker returns a warning marker when the candidate's posture differs +// from the workload's current configuration, or "-" otherwise. +func postureMarker(r *upgrade.CheckResult) string { + if r.ConfigDrift != nil { + return "⚠ drift" + } + return "-" +} + +// dashIfEmpty returns "-" for an empty string, so columns stay aligned. +func dashIfEmpty(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/cmd/thv/app/upgrade_test.go b/cmd/thv/app/upgrade_test.go new file mode 100644 index 0000000000..71e5e861a5 --- /dev/null +++ b/cmd/thv/app/upgrade_test.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/stacklok/toolhive/pkg/workloads/upgrade" +) + +func TestNewEnvCount(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result *upgrade.CheckResult + want int + }{ + { + name: "nil drift", + result: &upgrade.CheckResult{}, + want: 0, + }, + { + name: "empty drift", + result: &upgrade.CheckResult{EnvVarDrift: &upgrade.EnvVarDrift{}}, + want: 0, + }, + { + name: "two added", + result: &upgrade.CheckResult{EnvVarDrift: &upgrade.EnvVarDrift{ + Added: []upgrade.EnvVarInfo{{Name: "A"}, {Name: "B"}}, + }}, + want: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, newEnvCount(tt.result)) + }) + } +} + +func TestPostureMarker(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result *upgrade.CheckResult + want string + }{ + { + name: "no config drift", + result: &upgrade.CheckResult{}, + want: "-", + }, + { + name: "with config drift", + result: &upgrade.CheckResult{ConfigDrift: &upgrade.ConfigDrift{ + Transport: &upgrade.StringChange{From: "stdio", To: "sse"}, + }}, + want: "⚠ drift", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, postureMarker(tt.result)) + }) + } +} + +func TestDashIfEmpty(t *testing.T) { + t.Parallel() + + assert.Equal(t, "-", dashIfEmpty("")) + assert.Equal(t, "x", dashIfEmpty("x")) +} diff --git a/docs/cli/thv.md b/docs/cli/thv.md index 2749ca7656..74824dfad8 100644 --- a/docs/cli/thv.md +++ b/docs/cli/thv.md @@ -58,6 +58,7 @@ thv [flags] * [thv status](thv_status.md) - Show detailed status of an MCP server * [thv stop](thv_stop.md) - Stop one or more MCP servers * [thv tui](thv_tui.md) - Open the interactive TUI dashboard (experimental) +* [thv upgrade](thv_upgrade.md) - Manage upgrades for MCP server workloads * [thv version](thv_version.md) - Show the version of ToolHive * [thv vmcp](thv_vmcp.md) - Run and manage a Virtual MCP Server locally diff --git a/docs/cli/thv_list.md b/docs/cli/thv_list.md index c0a3acfc3d..4b76992187 100644 --- a/docs/cli/thv_list.md +++ b/docs/cli/thv_list.md @@ -41,6 +41,7 @@ thv list [flags] ``` -a, --all Show all workloads (default shows just running) + --check-upgrades Check each workload for available upgrades against its source registry (performs a registry lookup) --format string Output format (json, text, mcpservers) (default "text") --group string Filter by group -h, --help help for list diff --git a/docs/cli/thv_upgrade.md b/docs/cli/thv_upgrade.md new file mode 100644 index 0000000000..8dd494ac38 --- /dev/null +++ b/docs/cli/thv_upgrade.md @@ -0,0 +1,40 @@ +--- +title: thv upgrade +hide_title: true +description: Reference for ToolHive CLI command `thv upgrade` +last_update: + author: autogenerated +slug: thv_upgrade +mdx: + format: md +--- + +## thv upgrade + +Manage upgrades for MCP server workloads + +### Synopsis + +Inspect and apply upgrades for registry-sourced MCP server workloads. + +Upgrade checks compare each workload's current image and configuration against +the metadata reported by its source registry. Checks are an offline metadata +comparison and never pull images. + +### Options + +``` + -h, --help help for upgrade +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers +* [thv upgrade check](thv_upgrade_check.md) - Check workloads for available upgrades + diff --git a/docs/cli/thv_upgrade_check.md b/docs/cli/thv_upgrade_check.md new file mode 100644 index 0000000000..be1f501d4e --- /dev/null +++ b/docs/cli/thv_upgrade_check.md @@ -0,0 +1,55 @@ +--- +title: thv upgrade check +hide_title: true +description: Reference for ToolHive CLI command `thv upgrade check` +last_update: + author: autogenerated +slug: thv_upgrade_check +mdx: + format: md +--- + +## thv upgrade check + +Check workloads for available upgrades + +### Synopsis + +Check whether registry-sourced workloads have a newer image available. + +With no arguments, all workloads are checked (including stopped ones) and a +summary table is printed. When a workload name is given, a detailed report for +that single workload is printed, including any new environment variables the +candidate image declares and any configuration (posture) drift. + +Examples: + # Check all workloads + thv upgrade check + + # Check a single workload with detailed output + thv upgrade check my-server + + # Check all workloads in JSON format + thv upgrade check --format json + +``` +thv upgrade check [workload-name] [flags] +``` + +### Options + +``` + --format string Output format (json, text) (default "text") + -h, --help help for check +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv upgrade](thv_upgrade.md) - Manage upgrades for MCP server workloads +