Skip to content

Commit 74e4064

Browse files
JAORMXclaude
andcommitted
Add upgrade apply for the CLI and API
With the Applier in place, expose it to users. This lets CLI users and API clients apply an upgrade while preserving their configuration, instead of manually re-running a workload with a new image. Add a thv upgrade apply <name> command. It runs the check, shows the candidate image, new env vars, and any permission/transport/network posture drift, then prompts for confirmation. --dry-run reports the plan without applying; --env/--secret supply values for newly required variables; --yes (or a non-interactive shell) skips the prompt and fails loudly on missing required values; --image-verification mirrors thv run. Add POST /api/v1beta/workloads/{name}/upgrade, delegating to the same Applier so all clients share one apply path. The API path is always non-interactive (detached validator) and sources image verification from server config; the request body can only supply env/secret values, never redirect the image or weaken verification. Apply failures return a sanitized 422 with the detailed cause logged server-side, so secret references in an error chain are never echoed to the caller. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a93ff85 commit 74e4064

11 files changed

Lines changed: 1007 additions & 17 deletions

File tree

cmd/thv/app/upgrade.go

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,24 @@
44
package app
55

66
import (
7+
"bufio"
78
"context"
89
"encoding/json"
910
"fmt"
1011
"log/slog"
1112
"os"
13+
"strings"
1214
"text/tabwriter"
1315

1416
"github.com/spf13/cobra"
17+
"golang.org/x/term"
1518

19+
"github.com/stacklok/toolhive/pkg/config"
1620
"github.com/stacklok/toolhive/pkg/core"
21+
"github.com/stacklok/toolhive/pkg/environment"
1722
"github.com/stacklok/toolhive/pkg/registry"
1823
"github.com/stacklok/toolhive/pkg/runner"
24+
"github.com/stacklok/toolhive/pkg/runner/retriever"
1925
"github.com/stacklok/toolhive/pkg/workloads"
2026
"github.com/stacklok/toolhive/pkg/workloads/upgrade"
2127
)
@@ -54,15 +60,69 @@ Examples:
5460
RunE: upgradeCheckCmdFunc,
5561
}
5662

57-
var upgradeCheckFormat string
63+
var upgradeApplyCmd = &cobra.Command{
64+
Use: "apply <workload-name>",
65+
Short: "Apply an available upgrade to a workload",
66+
Long: `Apply the upgrade the registry reports for a registry-sourced workload.
67+
68+
The candidate image is resolved, verified, and pulled BEFORE the existing
69+
workload is touched. The existing workload is then stopped and replaced with one
70+
running the candidate image; the rest of the workload's configuration (env vars,
71+
secrets, posture, middleware) is preserved. There is no automatic rollback: if
72+
recreation fails the previous workload is not restored, so recovery is a forward
73+
operation.
74+
75+
New environment variables the candidate declares can be supplied with --env and
76+
--secret. When run interactively, missing required values are prompted for; with
77+
--yes (or in a non-interactive shell) the command runs non-interactively and
78+
fails if a required value is missing.
79+
80+
Examples:
81+
# Apply the available upgrade, prompting for confirmation
82+
thv upgrade apply my-server
83+
84+
# Apply non-interactively, supplying a new env var
85+
thv upgrade apply my-server --yes --env NEW_FLAG=true
86+
87+
# Preview what an upgrade would change without applying it
88+
thv upgrade apply my-server --dry-run`,
89+
Args: cobra.ExactArgs(1),
90+
ValidArgsFunction: completeMCPServerNames,
91+
RunE: upgradeApplyCmdFunc,
92+
}
93+
94+
var (
95+
upgradeCheckFormat string
96+
upgradeApplyYes bool
97+
upgradeApplyDryRun bool
98+
upgradeApplyEnv []string
99+
upgradeApplySecrets []string
100+
upgradeApplyVerify string
101+
upgradeApplyCACert string
102+
)
58103

59104
func init() {
60105
upgradeCmd.AddCommand(upgradeCheckCmd)
106+
upgradeCmd.AddCommand(upgradeApplyCmd)
61107

62108
AddFormatFlag(upgradeCheckCmd, &upgradeCheckFormat, FormatJSON, FormatText)
63109
upgradeCheckCmd.PreRunE = chainPreRunE(
64110
ValidateFormat(&upgradeCheckFormat, FormatJSON, FormatText),
65111
)
112+
113+
upgradeApplyCmd.Flags().BoolVarP(&upgradeApplyYes, "yes", "y", false,
114+
"Skip the confirmation prompt and run non-interactively (fail if required values are missing)")
115+
upgradeApplyCmd.Flags().BoolVar(&upgradeApplyDryRun, "dry-run", false,
116+
"Print what the upgrade would change without applying it")
117+
upgradeApplyCmd.Flags().StringArrayVarP(&upgradeApplyEnv, "env", "e", nil,
118+
"Environment variables to set on the upgraded workload (format: KEY=VALUE, repeatable)")
119+
upgradeApplyCmd.Flags().StringArrayVar(&upgradeApplySecrets, "secret", nil,
120+
"Secrets to set on the upgraded workload (format: NAME,target=TARGET, repeatable)")
121+
upgradeApplyCmd.Flags().StringVar(&upgradeApplyVerify, "image-verification", retriever.VerifyImageWarn,
122+
fmt.Sprintf("Set image verification mode (%s, %s, %s)",
123+
retriever.VerifyImageWarn, retriever.VerifyImageEnabled, retriever.VerifyImageDisabled))
124+
upgradeApplyCmd.Flags().StringVar(&upgradeApplyCACert, "ca-cert", "",
125+
"Path to a custom CA certificate file to use when resolving the candidate image")
66126
}
67127

68128
func upgradeCheckCmdFunc(cmd *cobra.Command, args []string) error {
@@ -105,6 +165,132 @@ func upgradeCheckCmdFunc(cmd *cobra.Command, args []string) error {
105165
return nil
106166
}
107167

168+
func upgradeApplyCmdFunc(cmd *cobra.Command, args []string) error {
169+
ctx := cmd.Context()
170+
name := args[0]
171+
172+
checker, err := newUpgradeChecker()
173+
if err != nil {
174+
return err
175+
}
176+
177+
// 1. Load the workload's saved config and run the check. This is an offline
178+
// metadata comparison; the Applier re-checks (and re-resolves) before
179+
// applying, so this result drives messaging/confirmation only.
180+
cfg, err := runner.LoadState(ctx, name)
181+
if err != nil {
182+
return fmt.Errorf("failed to load configuration for workload %q: %w", name, err)
183+
}
184+
result, err := checker.Check(ctx, cfg)
185+
if err != nil {
186+
return fmt.Errorf("failed to check workload %q for upgrade: %w", name, err)
187+
}
188+
189+
// 2. Nothing to apply: report the status and exit successfully.
190+
if result.Status != upgrade.StatusUpgradeAvailable {
191+
printNoUpgradeMessage(result)
192+
return nil
193+
}
194+
195+
// 3. Dry-run: print the planned changes and stop before building the applier.
196+
if upgradeApplyDryRun {
197+
fmt.Printf("Dry run: %s would be upgraded.\n\n", name)
198+
printUpgradeDetail(result)
199+
return nil
200+
}
201+
202+
// 4. Determine interactivity and pick the matching env-var validator.
203+
configProvider := config.NewDefaultProvider()
204+
interactive := term.IsTerminal(int(os.Stdin.Fd())) && !upgradeApplyYes
205+
envVarValidator := func() runner.EnvVarValidator {
206+
if interactive {
207+
return runner.NewCLIEnvVarValidator(configProvider)
208+
}
209+
return &runner.DetachedEnvVarValidator{}
210+
}()
211+
212+
// 5. Interactive confirmation: show the summary and prompt before applying.
213+
if interactive {
214+
printUpgradeDetail(result)
215+
confirmed, err := confirmUpgrade()
216+
if err != nil {
217+
return err
218+
}
219+
if !confirmed {
220+
fmt.Println("Upgrade cancelled.")
221+
return nil
222+
}
223+
}
224+
225+
// 6. Build the applier and parse the new env/secret inputs into ApplyOptions.
226+
envVars, err := environment.ParseEnvironmentVariables(upgradeApplyEnv)
227+
if err != nil {
228+
return fmt.Errorf("failed to parse environment variables: %w", err)
229+
}
230+
231+
manager, err := workloads.NewManager(ctx)
232+
if err != nil {
233+
return fmt.Errorf("failed to create workload manager: %w", err)
234+
}
235+
applier, err := upgrade.NewApplier(manager, checker, configProvider)
236+
if err != nil {
237+
return fmt.Errorf("failed to create upgrade applier: %w", err)
238+
}
239+
240+
applied, err := applier.Apply(ctx, name, upgrade.ApplyOptions{
241+
EnvVars: envVars,
242+
Secrets: upgradeApplySecrets,
243+
EnvVarValidator: envVarValidator,
244+
VerifySetting: upgradeApplyVerify,
245+
CACertPath: upgradeApplyCACert,
246+
})
247+
if err != nil {
248+
return fmt.Errorf("failed to upgrade workload %q: %w", name, err)
249+
}
250+
251+
// 7. Confirm what was applied.
252+
fmt.Printf("%s upgraded to %s\n", name, applied.CandidateImage)
253+
return nil
254+
}
255+
256+
// printNoUpgradeMessage prints a friendly, non-error explanation of why there
257+
// is nothing to apply for the given check result.
258+
func printNoUpgradeMessage(r *upgrade.CheckResult) {
259+
switch r.Status {
260+
case upgrade.StatusUpToDate:
261+
fmt.Printf("%s is already up to date.\n", r.WorkloadName)
262+
case upgrade.StatusNotRegistrySourced:
263+
fmt.Printf("%s was not created from a registry entry; no upgrade can be applied.\n", r.WorkloadName)
264+
case upgrade.StatusServerNotFound:
265+
fmt.Printf("%s references a registry server that no longer exists; no upgrade can be applied.\n", r.WorkloadName)
266+
case upgrade.StatusUnknown:
267+
msg := "the upgrade status could not be determined"
268+
if r.Reason != "" {
269+
msg = r.Reason
270+
}
271+
fmt.Printf("%s: %s; no upgrade can be applied.\n", r.WorkloadName, msg)
272+
case upgrade.StatusUpgradeAvailable:
273+
// Unreachable: callers only invoke this for non-upgrade-available results.
274+
fmt.Printf("%s has an upgrade available.\n", r.WorkloadName)
275+
default:
276+
fmt.Printf("%s: no upgrade can be applied.\n", r.WorkloadName)
277+
}
278+
}
279+
280+
// confirmUpgrade prompts the user to confirm an upgrade and returns whether they
281+
// accepted. It reads a single line from stdin and treats only "y"/"yes" as
282+
// confirmation.
283+
func confirmUpgrade() (bool, error) {
284+
fmt.Printf("\nApply? [y/N]: ")
285+
reader := bufio.NewReader(os.Stdin)
286+
response, err := reader.ReadString('\n')
287+
if err != nil {
288+
return false, fmt.Errorf("failed to read user input: %w", err)
289+
}
290+
response = strings.TrimSpace(strings.ToLower(response))
291+
return response == "y" || response == "yes", nil
292+
}
293+
108294
// newUpgradeChecker builds an upgrade.Checker backed by the default registry
109295
// provider used throughout the CLI.
110296
func newUpgradeChecker() (*upgrade.Checker, error) {

cmd/thv/app/upgrade_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,45 @@ func TestDashIfEmpty(t *testing.T) {
8282
assert.Equal(t, "-", dashIfEmpty(""))
8383
assert.Equal(t, "x", dashIfEmpty("x"))
8484
}
85+
86+
//nolint:paralleltest // Test captures os.Stdout which cannot be done in parallel
87+
func TestPrintNoUpgradeMessage(t *testing.T) {
88+
tests := []struct {
89+
name string
90+
result *upgrade.CheckResult
91+
want string
92+
}{
93+
{
94+
name: "up to date",
95+
result: &upgrade.CheckResult{WorkloadName: "srv", Status: upgrade.StatusUpToDate},
96+
want: "srv is already up to date.\n",
97+
},
98+
{
99+
name: "not registry sourced",
100+
result: &upgrade.CheckResult{WorkloadName: "srv", Status: upgrade.StatusNotRegistrySourced},
101+
want: "srv was not created from a registry entry; no upgrade can be applied.\n",
102+
},
103+
{
104+
name: "server not found",
105+
result: &upgrade.CheckResult{WorkloadName: "srv", Status: upgrade.StatusServerNotFound},
106+
want: "srv references a registry server that no longer exists; no upgrade can be applied.\n",
107+
},
108+
{
109+
name: "unknown with reason",
110+
result: &upgrade.CheckResult{WorkloadName: "srv", Status: upgrade.StatusUnknown, Reason: "tags not comparable"},
111+
want: "srv: tags not comparable; no upgrade can be applied.\n",
112+
},
113+
{
114+
name: "unknown without reason",
115+
result: &upgrade.CheckResult{WorkloadName: "srv", Status: upgrade.StatusUnknown},
116+
want: "srv: the upgrade status could not be determined; no upgrade can be applied.\n",
117+
},
118+
}
119+
120+
for _, tt := range tests {
121+
t.Run(tt.name, func(t *testing.T) {
122+
got := captureStdout(t, func() { printNoUpgradeMessage(tt.result) })
123+
assert.Equal(t, tt.want, got)
124+
})
125+
}
126+
}

docs/cli/thv_upgrade.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli/thv_upgrade_apply.md

Lines changed: 67 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)