From 99f50ebdeefca6e6feced22ab8e78002da1f7e1a Mon Sep 17 00:00:00 2001 From: Vladimir Iliakov Date: Tue, 2 Sep 2025 17:38:04 +0200 Subject: [PATCH 1/2] STAC-23287: stackpack install and upgrade commands optionally wait for operations to complete --- cmd/stackpack/common.go | 143 ++++++++++ cmd/stackpack/common_test.go | 269 ++++++++++++++++++ .../stackpack_confirm_manual_steps_test.go | 2 +- cmd/stackpack/stackpack_install.go | 90 +++++- cmd/stackpack/stackpack_install_test.go | 76 +++++ cmd/stackpack/stackpack_list.go | 15 - cmd/stackpack/stackpack_upgrade.go | 82 +++++- cmd/stackpack/stackpack_upgrade_test.go | 85 +++++- 8 files changed, 725 insertions(+), 37 deletions(-) create mode 100644 cmd/stackpack/common.go create mode 100644 cmd/stackpack/common_test.go diff --git a/cmd/stackpack/common.go b/cmd/stackpack/common.go new file mode 100644 index 00000000..391c6e1c --- /dev/null +++ b/cmd/stackpack/common.go @@ -0,0 +1,143 @@ +package stackpack + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/stackvista/stackstate-cli/generated/stackstate_api" + "github.com/stackvista/stackstate-cli/internal/common" + "github.com/stackvista/stackstate-cli/internal/di" +) + +const ( + // StackPack configuration status constants for wait operations + StatusInstalled = "INSTALLED" // Configuration is successfully installed + StatusProvisioning = "PROVISIONING" // Configuration is still being processed + StatusError = "ERROR" // Configuration failed with errors + + // Default wait operation settings + DefaultPollInterval = 5 * time.Second // How often to check status during wait + DefaultTimeout = 1 * time.Minute // Default timeout for wait operations +) + +// OperationWaiter provides functionality to wait for StackPack operations to complete +// by polling the API and monitoring configuration status changes +type OperationWaiter struct { + cli *di.Deps + api *stackstate_api.APIClient +} + +// WaitOptions configures how the wait operation should behave +type WaitOptions struct { + StackPackName string // Name of the StackPack to monitor + Timeout time.Duration // Maximum time to wait before giving up + PollInterval time.Duration // How often to check the status +} + +func NewOperationWaiter(cli *di.Deps, api *stackstate_api.APIClient) *OperationWaiter { + return &OperationWaiter{ + cli: cli, + api: api, + } +} + +// WaitForCompletion polls the StackPack API until all configurations are installed or an error occurs. +// Returns nil on success, error on timeout or configuration failures. +func (w *OperationWaiter) WaitForCompletion(options WaitOptions) error { + // Set up timeout context for the entire wait operation + ctx, cancel := context.WithTimeout(w.cli.Context, options.Timeout) + defer cancel() + + // Set up ticker for periodic polling + ticker := time.NewTicker(options.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for stackpack '%s' operation to complete after %v", options.StackPackName, options.Timeout) + case <-ticker.C: + // Poll the API to check current status + stackPackList, cliErr := fetchAllStackPacks(w.cli, w.api) + if cliErr != nil { + return fmt.Errorf("failed to check stackpack status: %v", cliErr) + } + + stackPack, err := findStackPackByName(stackPackList, options.StackPackName) + if err != nil { + return fmt.Errorf("stackpack '%s' not found: %v", options.StackPackName, err) + } + + // Check the status of all configurations for this StackPack + allInstalled := true + hasProvisioning := false + var errorMessages []string + + for _, config := range stackPack.GetConfigurations() { + status := config.GetStatus() + switch status { + case StatusError: + // Extract detailed error message from the API response + errorMsg := fmt.Sprintf("Configuration %d failed", config.GetId()) + if config.HasError() { + stackPackError := config.GetError() + apiError := stackPackError.GetError() + if message, ok := apiError["message"]; ok { + if msgStr, ok := message.(string); ok { + errorMsg = fmt.Sprintf("Configuration %d failed: %s", config.GetId(), msgStr) + } + } + } + errorMessages = append(errorMessages, errorMsg) + case StatusProvisioning: + hasProvisioning = true + allInstalled = false + case StatusInstalled: + // Continue checking other configs + default: + // Unknown status, treat as still in progress + allInstalled = false + } + } + + // Return immediately if any configuration has failed + if len(errorMessages) > 0 { + return fmt.Errorf("stackpack '%s' installation failed:\n%s", options.StackPackName, strings.Join(errorMessages, "\n")) + } + + // Success: all configurations are installed and none are provisioning + if allInstalled && !hasProvisioning { + return nil + } + + // Continue polling - some configurations are still in progress + } + } +} + +func findStackPackByName(stacks []stackstate_api.FullStackPack, name string) (stackstate_api.FullStackPack, error) { + for _, v := range stacks { + if v.GetName() == name { + return v, nil + } + } + return stackstate_api.FullStackPack{}, fmt.Errorf("stackpack %s does not exist", name) +} + +// fetchAllStackPacks retrieves all StackPacks from the API and returns them sorted by name. +// This function was moved from stackpack_list.go to common.go for reuse in wait operations. +func fetchAllStackPacks(cli *di.Deps, api *stackstate_api.APIClient) ([]stackstate_api.FullStackPack, common.CLIError) { + stackPackList, resp, err := api.StackpackApi.StackPackList(cli.Context).Execute() + if err != nil { + return nil, common.NewResponseError(err, resp) + } + + // Sort by name for consistent ordering + sort.SliceStable(stackPackList, func(i, j int) bool { + return stackPackList[i].Name < stackPackList[j].Name + }) + return stackPackList, nil +} diff --git a/cmd/stackpack/common_test.go b/cmd/stackpack/common_test.go new file mode 100644 index 00000000..208456fa --- /dev/null +++ b/cmd/stackpack/common_test.go @@ -0,0 +1,269 @@ +package stackpack + +import ( + "fmt" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/generated/stackstate_api" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stretchr/testify/assert" +) + +const ( + testStackPackName = "test-stackpack" + successfulResponseResult = "successful" +) + +//nolint:funlen +func TestOperationWaiter_WaitForCompletion_Success(t *testing.T) { + cli := di.NewMockDeps(t) + api, _, _ := cli.MockClient.Connect() + + configID := int64(12345) + timestamp := int64(1438167001716) + + // Mock successful completion + cli.MockClient.ApiMocks.StackpackApi.StackPackListResponse.Result = []stackstate_api.FullStackPack{ + { + Name: testStackPackName, + Configurations: []stackstate_api.StackPackConfiguration{ + { + Id: &configID, + Status: StatusInstalled, + StackPackVersion: "1.0.0", + LastUpdateTimestamp: ×tamp, + Config: map[string]interface{}{}, + }, + }, + }, + } + + waiter := NewOperationWaiter(&cli.Deps, api) + options := WaitOptions{ + StackPackName: testStackPackName, + Timeout: 5 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := waiter.WaitForCompletion(options) + + assert.NoError(t, err) + assert.True(t, len(*cli.MockClient.ApiMocks.StackpackApi.StackPackListCalls) >= 1) +} + +//nolint:funlen +func TestOperationWaiter_WaitForCompletion_Error(t *testing.T) { + cli := di.NewMockDeps(t) + api, _, _ := cli.MockClient.Connect() + + configID := int64(12345) + timestamp := int64(1438167001716) + errorMessage := "Object is missing required member 'function'" + + // Mock error response + stackPackError := stackstate_api.StackPackError{ + Retryable: false, + Error: map[string]interface{}{ + "message": errorMessage, + }, + } + + cli.MockClient.ApiMocks.StackpackApi.StackPackListResponse.Result = []stackstate_api.FullStackPack{ + { + Name: testStackPackName, + Configurations: []stackstate_api.StackPackConfiguration{ + { + Id: &configID, + Status: StatusError, + StackPackVersion: "1.0.0", + LastUpdateTimestamp: ×tamp, + Config: map[string]interface{}{}, + Error: &stackPackError, + }, + }, + }, + } + + waiter := NewOperationWaiter(&cli.Deps, api) + options := WaitOptions{ + StackPackName: testStackPackName, + Timeout: 5 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := waiter.WaitForCompletion(options) + + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("Configuration %d failed: %s", configID, errorMessage)) + assert.Contains(t, err.Error(), testStackPackName) +} + +//nolint:funlen +func TestOperationWaiter_WaitForCompletion_Timeout(t *testing.T) { + cli := di.NewMockDeps(t) + api, _, _ := cli.MockClient.Connect() + + configID := int64(12345) + timestamp := int64(1438167001716) + + // Mock provisioning state that never completes + cli.MockClient.ApiMocks.StackpackApi.StackPackListResponse.Result = []stackstate_api.FullStackPack{ + { + Name: testStackPackName, + Configurations: []stackstate_api.StackPackConfiguration{ + { + Id: &configID, + Status: StatusProvisioning, + StackPackVersion: "1.0.0", + LastUpdateTimestamp: ×tamp, + Config: map[string]interface{}{}, + }, + }, + }, + } + + waiter := NewOperationWaiter(&cli.Deps, api) + options := WaitOptions{ + StackPackName: testStackPackName, + Timeout: 50 * time.Millisecond, + PollInterval: 10 * time.Millisecond, + } + + err := waiter.WaitForCompletion(options) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "timeout waiting for stackpack") + assert.Contains(t, err.Error(), testStackPackName) +} + +func TestOperationWaiter_WaitForCompletion_StackpackNotFound(t *testing.T) { + cli := di.NewMockDeps(t) + api, _, _ := cli.MockClient.Connect() + + stackpackName := "nonexistent-stackpack" + + // Mock empty stackpack list + cli.MockClient.ApiMocks.StackpackApi.StackPackListResponse.Result = []stackstate_api.FullStackPack{} + + waiter := NewOperationWaiter(&cli.Deps, api) + options := WaitOptions{ + StackPackName: stackpackName, + Timeout: 5 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := waiter.WaitForCompletion(options) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "stackpack 'nonexistent-stackpack' not found") +} + +//nolint:funlen +func TestOperationWaiter_WaitForCompletion_MultipleConfigurations(t *testing.T) { + cli := di.NewMockDeps(t) + api, _, _ := cli.MockClient.Connect() + + configID1 := int64(12345) + configID2 := int64(67890) + timestamp := int64(1438167001716) + + // Mock multiple configurations - one installed, one provisioning + cli.MockClient.ApiMocks.StackpackApi.StackPackListResponse.Result = []stackstate_api.FullStackPack{ + { + Name: testStackPackName, + Configurations: []stackstate_api.StackPackConfiguration{ + { + Id: &configID1, + Status: StatusInstalled, + StackPackVersion: "1.0.0", + LastUpdateTimestamp: ×tamp, + Config: map[string]interface{}{}, + }, + { + Id: &configID2, + Status: StatusProvisioning, + StackPackVersion: "1.0.0", + LastUpdateTimestamp: ×tamp, + Config: map[string]interface{}{}, + }, + }, + }, + } + + waiter := NewOperationWaiter(&cli.Deps, api) + options := WaitOptions{ + StackPackName: testStackPackName, + Timeout: 50 * time.Millisecond, + PollInterval: 10 * time.Millisecond, + } + + err := waiter.WaitForCompletion(options) + + // Should timeout because one config is still provisioning + assert.Error(t, err) + assert.Contains(t, err.Error(), "timeout waiting for stackpack") +} + +func TestFindStackPackByName_Found(t *testing.T) { + stackpackName := "zabbix" + stacks := []stackstate_api.FullStackPack{ + {Name: "mysql"}, + {Name: stackpackName}, + {Name: "redis"}, + } + + result, err := findStackPackByName(stacks, stackpackName) + + assert.NoError(t, err) + assert.Equal(t, stackpackName, result.GetName()) +} + +func TestFindStackPackByName_NotFound(t *testing.T) { + stacks := []stackstate_api.FullStackPack{ + {Name: "mysql"}, + {Name: "redis"}, + } + + _, err := findStackPackByName(stacks, "nonexistent") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "stackpack nonexistent does not exist") +} + +func TestNewOperationWaiter(t *testing.T) { + cli := di.NewMockDeps(t) + api, _, _ := cli.MockClient.Connect() + + waiter := NewOperationWaiter(&cli.Deps, api) + + assert.NotNil(t, waiter) + assert.Equal(t, &cli.Deps, waiter.cli) + assert.Equal(t, api, waiter.api) +} + +// validateWaitFlags is a helper function to test wait flag functionality across commands. +// This reduces code duplication between install and upgrade test files. +func validateWaitFlags(t *testing.T, cmd *cobra.Command) { + // Test that the wait flag exists and has correct defaults + waitFlag := cmd.Flags().Lookup("wait") + assert.NotNil(t, waitFlag, "wait flag should exist") + assert.Equal(t, "false", waitFlag.DefValue, "wait flag default should be false") + + timeoutFlag := cmd.Flags().Lookup("timeout") + assert.NotNil(t, timeoutFlag, "timeout flag should exist") + assert.Equal(t, "1m0s", timeoutFlag.DefValue, "timeout flag default should be 1m0s") + + // Verify the flags can be set + err := cmd.ParseFlags([]string{"--wait", "--timeout", "30s"}) + assert.NoError(t, err) + + waitValue, err := cmd.Flags().GetBool("wait") + assert.NoError(t, err) + assert.True(t, waitValue) + + timeoutValue, err := cmd.Flags().GetDuration("timeout") + assert.NoError(t, err) + assert.Equal(t, 30*time.Second, timeoutValue) +} diff --git a/cmd/stackpack/stackpack_confirm_manual_steps_test.go b/cmd/stackpack/stackpack_confirm_manual_steps_test.go index 8fdb9e1d..3b9a72d9 100644 --- a/cmd/stackpack/stackpack_confirm_manual_steps_test.go +++ b/cmd/stackpack/stackpack_confirm_manual_steps_test.go @@ -13,7 +13,7 @@ import ( func setupStackPacConfirmManualStepsCmd(t *testing.T) (*di.MockDeps, *cobra.Command) { cli := di.NewMockDeps(t) cmd := StackpackConfirmManualStepsCommand(&cli.Deps) - cli.MockClient.ApiMocks.StackpackApi.ConfirmManualStepsResponse.Result = "successful" + cli.MockClient.ApiMocks.StackpackApi.ConfirmManualStepsResponse.Result = successfulResponseResult return &cli, cmd } diff --git a/cmd/stackpack/stackpack_install.go b/cmd/stackpack/stackpack_install.go index 7ab3394f..4c83cac9 100644 --- a/cmd/stackpack/stackpack_install.go +++ b/cmd/stackpack/stackpack_install.go @@ -17,6 +17,8 @@ type InstallArgs struct { Name string UnlockedStrategy string Params map[string]string + Wait bool // New: whether to wait for installation to complete + Timeout time.Duration // New: timeout for wait operation } func StackpackInstallCommand(cli *di.Deps) *cobra.Command { @@ -42,6 +44,8 @@ func StackpackInstallCommand(cli *di.Deps) *cobra.Command { fmt.Sprintf(" (must be { %s })", strings.Join(UnlockedStrategyChoices, " | ")), ) cmd.Flags().StringToStringVarP(&args.Params, ParameterFlag, "p", args.Params, "List of parameters of the form \"key=value\"") + cmd.Flags().BoolVar(&args.Wait, "wait", false, "Wait for installation to complete") + cmd.Flags().DurationVar(&args.Timeout, "timeout", DefaultTimeout, "Timeout for waiting") return cmd } @@ -57,21 +61,83 @@ func RunStackpackInstallCommand(args *InstallArgs) di.CmdWithApiFn { return common.NewResponseError(err, resp) } - if cli.IsJson() { - cli.Printer.PrintJson(map[string]interface{}{ - "instance": instance, + // New wait functionality: monitor installation until completion + if args.Wait { + if !cli.IsJson() { + cli.Printer.PrintLn("Waiting for installation to complete...") + } + + // Use OperationWaiter to poll until all configurations are installed + waiter := NewOperationWaiter(cli, api) + waitErr := waiter.WaitForCompletion(WaitOptions{ + StackPackName: args.Name, + Timeout: args.Timeout, + PollInterval: DefaultPollInterval, }) + if waitErr != nil { + // Use NewRuntimeError to avoid showing usage on operation failures + return common.NewRuntimeError(waitErr) + } + + // Re-fetch final status for display after successful completion + stackPackList, cliErr := fetchAllStackPacks(cli, api) + if cliErr != nil { + return cliErr + } + + finalStackPack, err := findStackPackByName(stackPackList, args.Name) + if err != nil { + return common.NewNotFoundError(err) + } + + // Display final status + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "stackpack": finalStackPack, + "status": "completed", + }) + } else { + cli.Printer.Success("StackPack installation completed successfully") + + // Show configurations status + data := make([][]interface{}, 0) + for _, config := range finalStackPack.GetConfigurations() { + lastUpdateTime := time.UnixMilli(config.GetLastUpdateTimestamp()) + data = append(data, []interface{}{ + config.GetId(), + finalStackPack.GetName(), + config.GetStatus(), + config.GetStackPackVersion(), + lastUpdateTime, + }) + } + + cli.Printer.Table( + printer.TableData{ + Header: []string{"id", "name", "status", "version", "last updated"}, + Data: data, + MissingTableDataMsg: printer.NotFoundMsg{Types: "configurations for " + args.Name}, + }, + ) + } } else { - lastUpdateTime := time.UnixMilli(instance.GetLastUpdateTimestamp()) + // Original behavior - just show the immediate response + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "instance": instance, + }) + } else { + lastUpdateTime := time.UnixMilli(instance.GetLastUpdateTimestamp()) - cli.Printer.Success("StackPack instance installed") - cli.Printer.Table( - printer.TableData{ - Header: []string{"id", "name", "status", "version", "last updated"}, - Data: [][]interface{}{{instance.Id, instance.Name, instance.Status, instance.StackPackVersion, lastUpdateTime}}, - MissingTableDataMsg: printer.NotFoundMsg{Types: "provision details of " + args.Name}, - }, - ) + cli.Printer.Success("StackPack instance installation triggered") + cli.Printer.Table( + printer.TableData{ + Header: []string{"id", "name", "status", "version", "last updated"}, + Data: [][]interface{}{{instance.Id, instance.Name, instance.Status, instance.StackPackVersion, lastUpdateTime}}, + MissingTableDataMsg: printer.NotFoundMsg{Types: "provision details of " + args.Name}, + }, + ) + } } return nil diff --git a/cmd/stackpack/stackpack_install_test.go b/cmd/stackpack/stackpack_install_test.go index 9e51f020..1cf1114c 100644 --- a/cmd/stackpack/stackpack_install_test.go +++ b/cmd/stackpack/stackpack_install_test.go @@ -125,3 +125,79 @@ func TestStackpackInstallPrintsToJson(t *testing.T) { ) assert.False(t, cli.MockPrinter.HasNonJsonCalls) } + +func TestStackpackInstallHasWaitFlags(t *testing.T) { + cli := di.NewMockDeps(t) + cmd := StackpackInstallCommand(&cli.Deps) + + // Test that the wait and timeout flags exist + waitFlag := cmd.Flags().Lookup("wait") + assert.NotNil(t, waitFlag, "wait flag should exist") + assert.Equal(t, "false", waitFlag.DefValue, "wait flag default should be false") + + timeoutFlag := cmd.Flags().Lookup("timeout") + assert.NotNil(t, timeoutFlag, "timeout flag should exist") + assert.Equal(t, "1m0s", timeoutFlag.DefValue, "timeout flag default should be 1m0s") +} + +func TestStackpackInstallWithWaitError(t *testing.T) { + cli := di.NewMockDeps(t) + cmd := StackpackInstallCommand(&cli.Deps) + + validateWaitFlags(t, cmd) +} + +func TestStackpackInstallWithWaitTimeout(t *testing.T) { + cli := di.NewMockDeps(t) + cmd := StackpackInstallCommand(&cli.Deps) + + configID := int64(12345) + timestamp := int64(1438167001716) + + // Setup provision response + cli.MockClient.ApiMocks.StackpackApi.ProvisionDetailsResponse.Result = *mockProvisionResponse + + // Setup stackpack list response with provisioning state (never completes) + cli.MockClient.ApiMocks.StackpackApi.StackPackListResponse.Result = []stackstate_api.FullStackPack{ + { + Name: name, + Configurations: []stackstate_api.StackPackConfiguration{ + { + Id: &configID, + Status: StatusProvisioning, + StackPackVersion: provisionVersion, + LastUpdateTimestamp: ×tamp, + Config: map[string]interface{}{}, + }, + }, + }, + } + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "install", "--name", name, "--wait", "--timeout", "50ms") + + // Should return timeout error + assert.Error(t, err) + assert.Contains(t, err.Error(), "timeout waiting for stackpack") +} + +func TestStackpackInstallWithWaitJsonFlags(t *testing.T) { + cli := di.NewMockDeps(t) + cmd := StackpackInstallCommand(&cli.Deps) + + // Test that wait and timeout flags can be parsed with other flags + err := cmd.ParseFlags([]string{"--name", "test", "--wait", "--timeout", "100ms"}) + assert.NoError(t, err) + + // Verify flag values + waitValue, err := cmd.Flags().GetBool("wait") + assert.NoError(t, err) + assert.True(t, waitValue) + + timeoutValue, err := cmd.Flags().GetDuration("timeout") + assert.NoError(t, err) + assert.Equal(t, 100*time.Millisecond, timeoutValue) + + nameValue, err := cmd.Flags().GetString("name") + assert.NoError(t, err) + assert.Equal(t, "test", nameValue) +} diff --git a/cmd/stackpack/stackpack_list.go b/cmd/stackpack/stackpack_list.go index 0986292f..159c0169 100644 --- a/cmd/stackpack/stackpack_list.go +++ b/cmd/stackpack/stackpack_list.go @@ -1,8 +1,6 @@ package stackpack import ( - "sort" - "github.com/spf13/cobra" "github.com/stackvista/stackstate-cli/generated/stackstate_api" "github.com/stackvista/stackstate-cli/internal/common" @@ -26,19 +24,6 @@ func StackpackListCommand(cli *di.Deps) *cobra.Command { return cmd } -func fetchAllStackPacks(cli *di.Deps, api *stackstate_api.APIClient) ([]stackstate_api.FullStackPack, common.CLIError) { - stackPackList, resp, err := api.StackpackApi.StackPackList(cli.Context).Execute() - if err != nil { - return nil, common.NewResponseError(err, resp) - } - - sort.SliceStable(stackPackList, func(i, j int) bool { - return stackPackList[i].Name < stackPackList[j].Name - }) - - return stackPackList, nil -} - func RunStackpackListCommand(args *ListArgs) di.CmdWithApiFn { return func( cmd *cobra.Command, diff --git a/cmd/stackpack/stackpack_upgrade.go b/cmd/stackpack/stackpack_upgrade.go index 0b2ef2af..b8c3934f 100644 --- a/cmd/stackpack/stackpack_upgrade.go +++ b/cmd/stackpack/stackpack_upgrade.go @@ -3,6 +3,7 @@ package stackpack import ( "fmt" "strings" + "time" "github.com/stackvista/stackstate-cli/pkg/pflags" @@ -10,6 +11,7 @@ import ( "github.com/stackvista/stackstate-cli/generated/stackstate_api" "github.com/stackvista/stackstate-cli/internal/common" "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stackvista/stackstate-cli/internal/printer" ) var ( @@ -19,6 +21,8 @@ var ( type UpgradeArgs struct { TypeName string UnlockedStrategy string + Wait bool // New: whether to wait for upgrade to complete + Timeout time.Duration // New: timeout for wait operation } func StackpackUpgradeCommand(cli *di.Deps) *cobra.Command { @@ -37,6 +41,8 @@ func StackpackUpgradeCommand(cli *di.Deps) *cobra.Command { "Strategy use to upgrade StackPack instance"+ fmt.Sprintf(" (must be { %s })", strings.Join(UnlockedStrategyChoices, " | ")), ) + cmd.Flags().BoolVar(&args.Wait, "wait", false, "Wait for upgrade to complete") + cmd.Flags().DurationVar(&args.Timeout, "timeout", DefaultTimeout, "Timeout for waiting") return cmd } func RunStackpackUpgradeCommand(args *UpgradeArgs) di.CmdWithApiFn { @@ -61,14 +67,78 @@ func RunStackpackUpgradeCommand(args *UpgradeArgs) di.CmdWithApiFn { if err != nil { return common.NewResponseError(err, resp) } - if cli.IsJson() { - cli.Printer.PrintJson(map[string]interface{}{ - "success": true, - "current-version": stack.GetVersion(), - "next-version": stack.NextVersion.GetVersion(), + + // New wait functionality: monitor upgrade until completion + if args.Wait { + if !cli.IsJson() { + cli.Printer.PrintLn("Waiting for upgrade to complete...") + } + + // Use OperationWaiter to poll until all configurations are upgraded + waiter := NewOperationWaiter(cli, api) + waitErr := waiter.WaitForCompletion(WaitOptions{ + StackPackName: args.TypeName, + Timeout: args.Timeout, + PollInterval: DefaultPollInterval, }) + if waitErr != nil { + // Use NewRuntimeError to avoid showing usage on operation failures + return common.NewRuntimeError(waitErr) + } + + // Re-fetch final status for display after successful completion + stackPackList, cliErr := fetchAllStackPacks(cli, api) + if cliErr != nil { + return cliErr + } + + finalStackPack, err := findStackPackByName(stackPackList, args.TypeName) + if err != nil { + return common.NewNotFoundError(err) + } + + // Display final status + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "stackpack": finalStackPack, + "status": "completed", + "current-version": finalStackPack.GetVersion(), + }) + } else { + cli.Printer.Success("StackPack upgrade completed successfully") + + // Show configurations status + data := make([][]interface{}, 0) + for _, config := range finalStackPack.GetConfigurations() { + lastUpdateTime := time.UnixMilli(config.GetLastUpdateTimestamp()) + data = append(data, []interface{}{ + config.GetId(), + finalStackPack.GetName(), + config.GetStatus(), + config.GetStackPackVersion(), + lastUpdateTime, + }) + } + + cli.Printer.Table( + printer.TableData{ + Header: []string{"id", "name", "status", "version", "last updated"}, + Data: data, + MissingTableDataMsg: printer.NotFoundMsg{Types: "configurations for " + args.TypeName}, + }, + ) + } } else { - cli.Printer.Success(fmt.Sprintf("Successfully triggered upgrade from %s to %s", stack.GetVersion(), stack.NextVersion.GetVersion())) + // Original behavior - just show the immediate response + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "success": true, + "current-version": stack.GetVersion(), + "next-version": stack.NextVersion.GetVersion(), + }) + } else { + cli.Printer.Success(fmt.Sprintf("Successfully triggered upgrade from %s to %s", stack.GetVersion(), stack.NextVersion.GetVersion())) + } } return nil diff --git a/cmd/stackpack/stackpack_upgrade_test.go b/cmd/stackpack/stackpack_upgrade_test.go index b0cb20b3..8b466293 100644 --- a/cmd/stackpack/stackpack_upgrade_test.go +++ b/cmd/stackpack/stackpack_upgrade_test.go @@ -2,6 +2,7 @@ package stackpack import ( "testing" + "time" "github.com/spf13/cobra" "github.com/stackvista/stackstate-cli/generated/stackstate_api" @@ -13,6 +14,7 @@ var ( stackPackName = "zabbix" stackPackNextVersion = "2.0.0" stackPackCurrentVersion = "1.0.0" + strategyFlag = "overwrite" ) func setupStackPackUpgradeCmd(t *testing.T) (*di.MockDeps, *cobra.Command) { @@ -27,12 +29,11 @@ func setupStackPackUpgradeCmd(t *testing.T) (*di.MockDeps, *cobra.Command) { Version: stackPackCurrentVersion, }, } - cli.MockClient.ApiMocks.StackpackApi.UpgradeStackPackResponse.Result = "successful" + cli.MockClient.ApiMocks.StackpackApi.UpgradeStackPackResponse.Result = successfulResponseResult return &cli, cmd } func TestStackpackUpgradePrintToTable(t *testing.T) { - strategyFlag := "overwrite" cli, cmd := setupStackPackUpgradeCmd(t) di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "upgrade", "--name", "zabbix", "--unlocked-strategy", strategyFlag, @@ -49,7 +50,6 @@ func TestStackpackUpgradePrintToTable(t *testing.T) { } func TestStackpackUpgradePrintToJson(t *testing.T) { - strategyFlag := "overwrite" cli, cmd := setupStackPackUpgradeCmd(t) di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "upgrade", "--name", "zabbix", "--unlocked-strategy", strategyFlag, "-o", "json", @@ -69,3 +69,82 @@ func TestStackpackUpgradePrintToJson(t *testing.T) { }} assert.Equal(t, expectedJsonCalls, *cli.MockPrinter.PrintJsonCalls) } + +func TestStackpackUpgradeHasWaitFlags(t *testing.T) { + cli := di.NewMockDeps(t) + cmd := StackpackUpgradeCommand(&cli.Deps) + + // Test that the wait and timeout flags exist + waitFlag := cmd.Flags().Lookup("wait") + assert.NotNil(t, waitFlag, "wait flag should exist") + assert.Equal(t, "false", waitFlag.DefValue, "wait flag default should be false") + + timeoutFlag := cmd.Flags().Lookup("timeout") + assert.NotNil(t, timeoutFlag, "timeout flag should exist") + assert.Equal(t, "1m0s", timeoutFlag.DefValue, "timeout flag default should be 1m0s") +} + +func TestStackpackUpgradeWithWaitError(t *testing.T) { + cli := di.NewMockDeps(t) + cmd := StackpackUpgradeCommand(&cli.Deps) + + validateWaitFlags(t, cmd) +} + +func TestStackpackUpgradeWithWaitTimeout(t *testing.T) { + cli := di.NewMockDeps(t) + cmd := StackpackUpgradeCommand(&cli.Deps) + + configID := int64(12345) + timestamp := int64(1438167001716) + + // Setup initial stackpack for validation and provisioning state for wait + provisioningStackPack := stackstate_api.FullStackPack{ + Name: stackPackName, + NextVersion: &stackstate_api.FullStackPack{ + Version: stackPackNextVersion, + }, + Version: stackPackCurrentVersion, + Configurations: []stackstate_api.StackPackConfiguration{ + { + Id: &configID, + Status: StatusProvisioning, + StackPackVersion: stackPackNextVersion, + LastUpdateTimestamp: ×tamp, + Config: map[string]interface{}{}, + }, + }, + } + + cli.MockClient.ApiMocks.StackpackApi.StackPackListResponse.Result = []stackstate_api.FullStackPack{provisioningStackPack} + cli.MockClient.ApiMocks.StackpackApi.UpgradeStackPackResponse.Result = successfulResponseResult + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "upgrade", "--name", stackPackName, + "--unlocked-strategy", strategyFlag, "--wait", "--timeout", "50ms") + + // Should return timeout error + assert.Error(t, err) + assert.Contains(t, err.Error(), "timeout waiting for stackpack") +} + +func TestStackpackUpgradeWithWaitJsonFlags(t *testing.T) { + cli := di.NewMockDeps(t) + cmd := StackpackUpgradeCommand(&cli.Deps) + + // Test that wait and timeout flags can be parsed + err := cmd.ParseFlags([]string{"--name", "test", "--wait", "--timeout", "100ms"}) + assert.NoError(t, err) + + // Verify flag values + waitValue, err := cmd.Flags().GetBool("wait") + assert.NoError(t, err) + assert.True(t, waitValue) + + timeoutValue, err := cmd.Flags().GetDuration("timeout") + assert.NoError(t, err) + assert.Equal(t, 100*time.Millisecond, timeoutValue) + + nameValue, err := cmd.Flags().GetString("name") + assert.NoError(t, err) + assert.Equal(t, "test", nameValue) +} From 5b8ffe53229a1a3c01e631e95f274dfd8a31dce6 Mon Sep 17 00:00:00 2001 From: Vladimir Iliakov Date: Thu, 4 Sep 2025 08:55:18 +0200 Subject: [PATCH 2/2] STAC-23287: Address comments --- cmd/stackpack/stackpack_install.go | 7 +++---- cmd/stackpack/stackpack_upgrade.go | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/cmd/stackpack/stackpack_install.go b/cmd/stackpack/stackpack_install.go index 4c83cac9..25837c2d 100644 --- a/cmd/stackpack/stackpack_install.go +++ b/cmd/stackpack/stackpack_install.go @@ -17,8 +17,8 @@ type InstallArgs struct { Name string UnlockedStrategy string Params map[string]string - Wait bool // New: whether to wait for installation to complete - Timeout time.Duration // New: timeout for wait operation + Wait bool + Timeout time.Duration } func StackpackInstallCommand(cli *di.Deps) *cobra.Command { @@ -61,7 +61,7 @@ func RunStackpackInstallCommand(args *InstallArgs) di.CmdWithApiFn { return common.NewResponseError(err, resp) } - // New wait functionality: monitor installation until completion + // Wait functionality: monitor installation until completion if args.Wait { if !cli.IsJson() { cli.Printer.PrintLn("Waiting for installation to complete...") @@ -121,7 +121,6 @@ func RunStackpackInstallCommand(args *InstallArgs) di.CmdWithApiFn { ) } } else { - // Original behavior - just show the immediate response if cli.IsJson() { cli.Printer.PrintJson(map[string]interface{}{ "instance": instance, diff --git a/cmd/stackpack/stackpack_upgrade.go b/cmd/stackpack/stackpack_upgrade.go index b8c3934f..14304deb 100644 --- a/cmd/stackpack/stackpack_upgrade.go +++ b/cmd/stackpack/stackpack_upgrade.go @@ -21,8 +21,8 @@ var ( type UpgradeArgs struct { TypeName string UnlockedStrategy string - Wait bool // New: whether to wait for upgrade to complete - Timeout time.Duration // New: timeout for wait operation + Wait bool + Timeout time.Duration } func StackpackUpgradeCommand(cli *di.Deps) *cobra.Command { @@ -68,7 +68,7 @@ func RunStackpackUpgradeCommand(args *UpgradeArgs) di.CmdWithApiFn { return common.NewResponseError(err, resp) } - // New wait functionality: monitor upgrade until completion + // Wait functionality: monitor upgrade until completion if args.Wait { if !cli.IsJson() { cli.Printer.PrintLn("Waiting for upgrade to complete...") @@ -129,7 +129,6 @@ func RunStackpackUpgradeCommand(args *UpgradeArgs) di.CmdWithApiFn { ) } } else { - // Original behavior - just show the immediate response if cli.IsJson() { cli.Printer.PrintJson(map[string]interface{}{ "success": true,