|
4 | 4 | package app |
5 | 5 |
|
6 | 6 | import ( |
| 7 | + "bufio" |
7 | 8 | "context" |
8 | 9 | "encoding/json" |
9 | 10 | "fmt" |
10 | 11 | "log/slog" |
11 | 12 | "os" |
| 13 | + "strings" |
12 | 14 | "text/tabwriter" |
13 | 15 |
|
14 | 16 | "github.com/spf13/cobra" |
| 17 | + "golang.org/x/term" |
15 | 18 |
|
| 19 | + "github.com/stacklok/toolhive/pkg/config" |
16 | 20 | "github.com/stacklok/toolhive/pkg/core" |
| 21 | + "github.com/stacklok/toolhive/pkg/environment" |
17 | 22 | "github.com/stacklok/toolhive/pkg/registry" |
18 | 23 | "github.com/stacklok/toolhive/pkg/runner" |
| 24 | + "github.com/stacklok/toolhive/pkg/runner/retriever" |
19 | 25 | "github.com/stacklok/toolhive/pkg/workloads" |
20 | 26 | "github.com/stacklok/toolhive/pkg/workloads/upgrade" |
21 | 27 | ) |
@@ -54,15 +60,69 @@ Examples: |
54 | 60 | RunE: upgradeCheckCmdFunc, |
55 | 61 | } |
56 | 62 |
|
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 | +) |
58 | 103 |
|
59 | 104 | func init() { |
60 | 105 | upgradeCmd.AddCommand(upgradeCheckCmd) |
| 106 | + upgradeCmd.AddCommand(upgradeApplyCmd) |
61 | 107 |
|
62 | 108 | AddFormatFlag(upgradeCheckCmd, &upgradeCheckFormat, FormatJSON, FormatText) |
63 | 109 | upgradeCheckCmd.PreRunE = chainPreRunE( |
64 | 110 | ValidateFormat(&upgradeCheckFormat, FormatJSON, FormatText), |
65 | 111 | ) |
| 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") |
66 | 126 | } |
67 | 127 |
|
68 | 128 | func upgradeCheckCmdFunc(cmd *cobra.Command, args []string) error { |
@@ -105,6 +165,132 @@ func upgradeCheckCmdFunc(cmd *cobra.Command, args []string) error { |
105 | 165 | return nil |
106 | 166 | } |
107 | 167 |
|
| 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 | + |
108 | 294 | // newUpgradeChecker builds an upgrade.Checker backed by the default registry |
109 | 295 | // provider used throughout the CLI. |
110 | 296 | func newUpgradeChecker() (*upgrade.Checker, error) { |
|
0 commit comments