Skip to content

Commit 6a0728c

Browse files
committed
STAC-23287: stackpack install and upgrade commands optionally wait for operations to complete
1 parent 87afbb8 commit 6a0728c

8 files changed

Lines changed: 703 additions & 37 deletions

cmd/stackpack/common.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package stackpack
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"sort"
7+
"strings"
8+
"time"
9+
10+
"github.com/stackvista/stackstate-cli/generated/stackstate_api"
11+
"github.com/stackvista/stackstate-cli/internal/common"
12+
"github.com/stackvista/stackstate-cli/internal/di"
13+
)
14+
15+
const (
16+
StatusInstalled = "INSTALLED"
17+
StatusProvisioning = "PROVISIONING"
18+
StatusError = "ERROR"
19+
20+
DefaultPollInterval = 5 * time.Second
21+
DefaultTimeout = 1 * time.Minute
22+
)
23+
24+
type OperationWaiter struct {
25+
cli *di.Deps
26+
api *stackstate_api.APIClient
27+
}
28+
29+
type WaitOptions struct {
30+
StackPackName string
31+
Timeout time.Duration
32+
PollInterval time.Duration
33+
}
34+
35+
func NewOperationWaiter(cli *di.Deps, api *stackstate_api.APIClient) *OperationWaiter {
36+
return &OperationWaiter{
37+
cli: cli,
38+
api: api,
39+
}
40+
}
41+
42+
func (w *OperationWaiter) WaitForCompletion(options WaitOptions) error {
43+
ctx, cancel := context.WithTimeout(w.cli.Context, options.Timeout)
44+
defer cancel()
45+
46+
ticker := time.NewTicker(options.PollInterval)
47+
defer ticker.Stop()
48+
49+
for {
50+
select {
51+
case <-ctx.Done():
52+
return fmt.Errorf("timeout waiting for stackpack '%s' operation to complete after %v", options.StackPackName, options.Timeout)
53+
case <-ticker.C:
54+
stackPackList, cliErr := fetchAllStackPacks(w.cli, w.api)
55+
if cliErr != nil {
56+
return fmt.Errorf("failed to check stackpack status: %v", cliErr)
57+
}
58+
59+
stackPack, err := findStackPackByName(stackPackList, options.StackPackName)
60+
if err != nil {
61+
return fmt.Errorf("stackpack '%s' not found: %v", options.StackPackName, err)
62+
}
63+
64+
allInstalled := true
65+
hasProvisioning := false
66+
var errorMessages []string
67+
68+
for _, config := range stackPack.GetConfigurations() {
69+
status := config.GetStatus()
70+
switch status {
71+
case StatusError:
72+
errorMsg := fmt.Sprintf("Configuration %d failed", config.GetId())
73+
if config.HasError() {
74+
stackPackError := config.GetError()
75+
apiError := stackPackError.GetError()
76+
if message, ok := apiError["message"]; ok {
77+
if msgStr, ok := message.(string); ok {
78+
errorMsg = fmt.Sprintf("Configuration %d failed: %s", config.GetId(), msgStr)
79+
}
80+
}
81+
}
82+
errorMessages = append(errorMessages, errorMsg)
83+
case StatusProvisioning:
84+
hasProvisioning = true
85+
allInstalled = false
86+
case StatusInstalled:
87+
// Continue checking other configs
88+
default:
89+
// Unknown status, treat as still in progress
90+
allInstalled = false
91+
}
92+
}
93+
94+
if len(errorMessages) > 0 {
95+
return fmt.Errorf("stackpack '%s' installation failed:\n%s", options.StackPackName, strings.Join(errorMessages, "\n"))
96+
}
97+
98+
if allInstalled && !hasProvisioning {
99+
return nil // Success!
100+
}
101+
102+
// Continue polling
103+
}
104+
}
105+
}
106+
107+
func findStackPackByName(stacks []stackstate_api.FullStackPack, name string) (stackstate_api.FullStackPack, error) {
108+
for _, v := range stacks {
109+
if v.GetName() == name {
110+
return v, nil
111+
}
112+
}
113+
return stackstate_api.FullStackPack{}, fmt.Errorf("stackpack %s does not exist", name)
114+
}
115+
116+
func fetchAllStackPacks(cli *di.Deps, api *stackstate_api.APIClient) ([]stackstate_api.FullStackPack, common.CLIError) {
117+
stackPackList, resp, err := api.StackpackApi.StackPackList(cli.Context).Execute()
118+
if err != nil {
119+
return nil, common.NewResponseError(err, resp)
120+
}
121+
122+
sort.SliceStable(stackPackList, func(i, j int) bool {
123+
return stackPackList[i].Name < stackPackList[j].Name
124+
})
125+
return stackPackList, nil
126+
}

cmd/stackpack/common_test.go

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
package stackpack
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
"time"
7+
8+
"github.com/spf13/cobra"
9+
"github.com/stackvista/stackstate-cli/generated/stackstate_api"
10+
"github.com/stackvista/stackstate-cli/internal/di"
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
const (
15+
testStackPackName = "test-stackpack"
16+
successfulResponseResult = "successful"
17+
)
18+
19+
//nolint:funlen
20+
func TestOperationWaiter_WaitForCompletion_Success(t *testing.T) {
21+
cli := di.NewMockDeps(t)
22+
api, _, _ := cli.MockClient.Connect()
23+
24+
configID := int64(12345)
25+
timestamp := int64(1438167001716)
26+
27+
// Mock successful completion
28+
cli.MockClient.ApiMocks.StackpackApi.StackPackListResponse.Result = []stackstate_api.FullStackPack{
29+
{
30+
Name: testStackPackName,
31+
Configurations: []stackstate_api.StackPackConfiguration{
32+
{
33+
Id: &configID,
34+
Status: StatusInstalled,
35+
StackPackVersion: "1.0.0",
36+
LastUpdateTimestamp: &timestamp,
37+
Config: map[string]interface{}{},
38+
},
39+
},
40+
},
41+
}
42+
43+
waiter := NewOperationWaiter(&cli.Deps, api)
44+
options := WaitOptions{
45+
StackPackName: testStackPackName,
46+
Timeout: 5 * time.Second,
47+
PollInterval: 10 * time.Millisecond,
48+
}
49+
50+
err := waiter.WaitForCompletion(options)
51+
52+
assert.NoError(t, err)
53+
assert.True(t, len(*cli.MockClient.ApiMocks.StackpackApi.StackPackListCalls) >= 1)
54+
}
55+
56+
//nolint:funlen
57+
func TestOperationWaiter_WaitForCompletion_Error(t *testing.T) {
58+
cli := di.NewMockDeps(t)
59+
api, _, _ := cli.MockClient.Connect()
60+
61+
configID := int64(12345)
62+
timestamp := int64(1438167001716)
63+
errorMessage := "Object is missing required member 'function'"
64+
65+
// Mock error response
66+
stackPackError := stackstate_api.StackPackError{
67+
Retryable: false,
68+
Error: map[string]interface{}{
69+
"message": errorMessage,
70+
},
71+
}
72+
73+
cli.MockClient.ApiMocks.StackpackApi.StackPackListResponse.Result = []stackstate_api.FullStackPack{
74+
{
75+
Name: testStackPackName,
76+
Configurations: []stackstate_api.StackPackConfiguration{
77+
{
78+
Id: &configID,
79+
Status: StatusError,
80+
StackPackVersion: "1.0.0",
81+
LastUpdateTimestamp: &timestamp,
82+
Config: map[string]interface{}{},
83+
Error: &stackPackError,
84+
},
85+
},
86+
},
87+
}
88+
89+
waiter := NewOperationWaiter(&cli.Deps, api)
90+
options := WaitOptions{
91+
StackPackName: testStackPackName,
92+
Timeout: 5 * time.Second,
93+
PollInterval: 10 * time.Millisecond,
94+
}
95+
96+
err := waiter.WaitForCompletion(options)
97+
98+
assert.Error(t, err)
99+
assert.Contains(t, err.Error(), fmt.Sprintf("Configuration %d failed: %s", configID, errorMessage))
100+
assert.Contains(t, err.Error(), testStackPackName)
101+
}
102+
103+
//nolint:funlen
104+
func TestOperationWaiter_WaitForCompletion_Timeout(t *testing.T) {
105+
cli := di.NewMockDeps(t)
106+
api, _, _ := cli.MockClient.Connect()
107+
108+
configID := int64(12345)
109+
timestamp := int64(1438167001716)
110+
111+
// Mock provisioning state that never completes
112+
cli.MockClient.ApiMocks.StackpackApi.StackPackListResponse.Result = []stackstate_api.FullStackPack{
113+
{
114+
Name: testStackPackName,
115+
Configurations: []stackstate_api.StackPackConfiguration{
116+
{
117+
Id: &configID,
118+
Status: StatusProvisioning,
119+
StackPackVersion: "1.0.0",
120+
LastUpdateTimestamp: &timestamp,
121+
Config: map[string]interface{}{},
122+
},
123+
},
124+
},
125+
}
126+
127+
waiter := NewOperationWaiter(&cli.Deps, api)
128+
options := WaitOptions{
129+
StackPackName: testStackPackName,
130+
Timeout: 50 * time.Millisecond,
131+
PollInterval: 10 * time.Millisecond,
132+
}
133+
134+
err := waiter.WaitForCompletion(options)
135+
136+
assert.Error(t, err)
137+
assert.Contains(t, err.Error(), "timeout waiting for stackpack")
138+
assert.Contains(t, err.Error(), testStackPackName)
139+
}
140+
141+
func TestOperationWaiter_WaitForCompletion_StackpackNotFound(t *testing.T) {
142+
cli := di.NewMockDeps(t)
143+
api, _, _ := cli.MockClient.Connect()
144+
145+
stackpackName := "nonexistent-stackpack"
146+
147+
// Mock empty stackpack list
148+
cli.MockClient.ApiMocks.StackpackApi.StackPackListResponse.Result = []stackstate_api.FullStackPack{}
149+
150+
waiter := NewOperationWaiter(&cli.Deps, api)
151+
options := WaitOptions{
152+
StackPackName: stackpackName,
153+
Timeout: 5 * time.Second,
154+
PollInterval: 10 * time.Millisecond,
155+
}
156+
157+
err := waiter.WaitForCompletion(options)
158+
159+
assert.Error(t, err)
160+
assert.Contains(t, err.Error(), "stackpack 'nonexistent-stackpack' not found")
161+
}
162+
163+
//nolint:funlen
164+
func TestOperationWaiter_WaitForCompletion_MultipleConfigurations(t *testing.T) {
165+
cli := di.NewMockDeps(t)
166+
api, _, _ := cli.MockClient.Connect()
167+
168+
configID1 := int64(12345)
169+
configID2 := int64(67890)
170+
timestamp := int64(1438167001716)
171+
172+
// Mock multiple configurations - one installed, one provisioning
173+
cli.MockClient.ApiMocks.StackpackApi.StackPackListResponse.Result = []stackstate_api.FullStackPack{
174+
{
175+
Name: testStackPackName,
176+
Configurations: []stackstate_api.StackPackConfiguration{
177+
{
178+
Id: &configID1,
179+
Status: StatusInstalled,
180+
StackPackVersion: "1.0.0",
181+
LastUpdateTimestamp: &timestamp,
182+
Config: map[string]interface{}{},
183+
},
184+
{
185+
Id: &configID2,
186+
Status: StatusProvisioning,
187+
StackPackVersion: "1.0.0",
188+
LastUpdateTimestamp: &timestamp,
189+
Config: map[string]interface{}{},
190+
},
191+
},
192+
},
193+
}
194+
195+
waiter := NewOperationWaiter(&cli.Deps, api)
196+
options := WaitOptions{
197+
StackPackName: testStackPackName,
198+
Timeout: 50 * time.Millisecond,
199+
PollInterval: 10 * time.Millisecond,
200+
}
201+
202+
err := waiter.WaitForCompletion(options)
203+
204+
// Should timeout because one config is still provisioning
205+
assert.Error(t, err)
206+
assert.Contains(t, err.Error(), "timeout waiting for stackpack")
207+
}
208+
209+
func TestFindStackPackByName_Found(t *testing.T) {
210+
stackpackName := "zabbix"
211+
stacks := []stackstate_api.FullStackPack{
212+
{Name: "mysql"},
213+
{Name: stackpackName},
214+
{Name: "redis"},
215+
}
216+
217+
result, err := findStackPackByName(stacks, stackpackName)
218+
219+
assert.NoError(t, err)
220+
assert.Equal(t, stackpackName, result.GetName())
221+
}
222+
223+
func TestFindStackPackByName_NotFound(t *testing.T) {
224+
stacks := []stackstate_api.FullStackPack{
225+
{Name: "mysql"},
226+
{Name: "redis"},
227+
}
228+
229+
_, err := findStackPackByName(stacks, "nonexistent")
230+
231+
assert.Error(t, err)
232+
assert.Contains(t, err.Error(), "stackpack nonexistent does not exist")
233+
}
234+
235+
func TestNewOperationWaiter(t *testing.T) {
236+
cli := di.NewMockDeps(t)
237+
api, _, _ := cli.MockClient.Connect()
238+
239+
waiter := NewOperationWaiter(&cli.Deps, api)
240+
241+
assert.NotNil(t, waiter)
242+
assert.Equal(t, &cli.Deps, waiter.cli)
243+
assert.Equal(t, api, waiter.api)
244+
}
245+
246+
// Helper function to validate wait flags for commands
247+
func validateWaitFlags(t *testing.T, cmd *cobra.Command) {
248+
// Test that the wait flag exists and has correct defaults
249+
waitFlag := cmd.Flags().Lookup("wait")
250+
assert.NotNil(t, waitFlag, "wait flag should exist")
251+
assert.Equal(t, "false", waitFlag.DefValue, "wait flag default should be false")
252+
253+
timeoutFlag := cmd.Flags().Lookup("timeout")
254+
assert.NotNil(t, timeoutFlag, "timeout flag should exist")
255+
assert.Equal(t, "1m0s", timeoutFlag.DefValue, "timeout flag default should be 1m0s")
256+
257+
// Verify the flags can be set
258+
err := cmd.ParseFlags([]string{"--wait", "--timeout", "30s"})
259+
assert.NoError(t, err)
260+
261+
waitValue, err := cmd.Flags().GetBool("wait")
262+
assert.NoError(t, err)
263+
assert.True(t, waitValue)
264+
265+
timeoutValue, err := cmd.Flags().GetDuration("timeout")
266+
assert.NoError(t, err)
267+
assert.Equal(t, 30*time.Second, timeoutValue)
268+
}

0 commit comments

Comments
 (0)