Skip to content

Commit 72b2e00

Browse files
committed
feat(runcommand): implement wait handler for runcommand
1 parent f477ebe commit 72b2e00

9 files changed

Lines changed: 354 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
## Release (2026-MM-DD)
22

3+
- `runcommand`:
4+
- [v1.9.2](services/runcommand/CHANGELOG.md#v192)
5+
- `v1api`: **Feature:** Add `AgentReadyWaitHandler` wait handler for waiting until the server agent has registered and submitting a command
6+
- `v1api`: **Feature:** Add `RunCommandWaitHandler` wait handler for polling a command until it reaches a terminal state (`completed` or `failed`)
7+
- **Dependencies:** Add `github.com/google/go-cmp v0.7.0`
38
- `alb`
49
- [v0.17.1](services/alb/CHANGELOG.md#v0171)
510
- `v2api`:

examples/runcommand/go.mod

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module github.com/stackitcloud/stackit-sdk-go/examples/runcommand
2+
3+
go 1.25
4+
5+
// This is not needed in production. This is only here to point the golangci linter to the local version instead of the last release on GitHub.
6+
replace github.com/stackitcloud/stackit-sdk-go/services/runcommand => ../../services/runcommand
7+
8+
require (
9+
github.com/stackitcloud/stackit-sdk-go/core v0.26.0
10+
github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.4.3
11+
)
12+
13+
require (
14+
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
15+
github.com/google/uuid v1.6.0 // indirect
16+
)

examples/runcommand/go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
2+
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
3+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
4+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
5+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
6+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7+
github.com/stackitcloud/stackit-sdk-go/core v0.26.0 h1:jQEb9gkehfp6VCP6TcYk7BI10cz4l0KM2L6hqYBH2QA=
8+
github.com/stackitcloud/stackit-sdk-go/core v0.26.0/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA=

examples/runcommand/runcommand.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"strconv"
8+
9+
"github.com/stackitcloud/stackit-sdk-go/core/config"
10+
runcommand "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api"
11+
"github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api/wait"
12+
)
13+
14+
func main() {
15+
ctx := context.Background()
16+
17+
projectId := "PROJECT_ID" // the uuid of your STACKIT project
18+
serverId := "SERVER_ID" // the uuid of the server to run the command on
19+
20+
// Create a new API client, that uses default authentication and configuration
21+
client, err := runcommand.NewAPIClient(
22+
config.WithRegion("eu01"),
23+
)
24+
if err != nil {
25+
fmt.Fprintf(os.Stderr, "[Run Command API] Creating API client: %v\n", err)
26+
os.Exit(1)
27+
}
28+
29+
// List available command templates
30+
templates, err := client.DefaultAPI.ListCommandTemplates(ctx).Execute()
31+
if err != nil {
32+
fmt.Fprintf(os.Stderr, "[Run Command API] Error when calling `ListCommandTemplates`: %v\n", err)
33+
os.Exit(1)
34+
}
35+
36+
fmt.Printf("[Run Command API] Available command templates:\n")
37+
for _, t := range templates.GetItems() {
38+
fmt.Printf(" %s\n", t.GetName())
39+
}
40+
41+
// Build the command payload
42+
payload := runcommand.NewCreateCommandPayload("RunShellScript")
43+
payload.SetParameters(map[string]string{
44+
"script": "echo 'Hello from STACKIT Run Commands!'",
45+
})
46+
47+
// AgentReadyWaitHandler submits the command and retries until the server agent
48+
// has registered. The API returns 404 while the agent is still booting after
49+
// server creation. The returned response already contains the command ID.
50+
fmt.Printf("[Run Command API] Waiting for agent on server %q and submitting command...\n", serverId)
51+
52+
createResp, err := wait.AgentReadyWaitHandler(ctx, client.DefaultAPI, projectId, serverId, *payload).
53+
WaitWithContext(ctx)
54+
if err != nil {
55+
fmt.Fprintf(os.Stderr, "[Run Command API] Error when submitting command: %v\n", err)
56+
os.Exit(1)
57+
}
58+
59+
commandId := strconv.Itoa(int(createResp.GetId()))
60+
fmt.Printf("[Run Command API] Command submitted with ID %s.\n", commandId)
61+
62+
// RunCommandWaitHandler polls until the command reaches a terminal state.
63+
// Both COMPLETED and FAILED are terminal; inspect the status to distinguish them.
64+
fmt.Printf("[Run Command API] Waiting for command %s to finish...\n", commandId)
65+
66+
details, err := wait.RunCommandWaitHandler(ctx, client.DefaultAPI, projectId, serverId, commandId).
67+
WaitWithContext(ctx)
68+
if err != nil {
69+
fmt.Fprintf(os.Stderr, "[Run Command API] Error when waiting for command: %v\n", err)
70+
os.Exit(1)
71+
}
72+
73+
fmt.Printf("[Run Command API] Command %s finished with status %q (exit code: %d).\n",
74+
commandId, details.GetStatus(), details.GetExitCode())
75+
fmt.Printf("[Run Command API] Output:\n%s\n", details.GetOutput())
76+
}

go.work

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use (
2929
./examples/rabbitmq
3030
./examples/redis
3131
./examples/resourcemanager
32+
./examples/runcommand
3233
./examples/runtime
3334
./examples/secretsmanager
3435
./examples/serviceaccount

services/runcommand/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## v1.9.2
2+
- `v1api`: **Feature:** Add `AgentReadyWaitHandler` wait handler for waiting until the server agent has registered and submitting a command
3+
- `v1api`: **Feature:** Add `RunCommandWaitHandler` wait handler for polling a command until it reaches a terminal state (`completed` or `failed`)
4+
- **Dependencies:** Add `github.com/google/go-cmp v0.7.0`
5+
16
## v1.9.1
27
- `v1api`:
38
- **Fix:** Response decoding now supports `*io.Reader` and `*[]byte` target types (previously only `string`, `*os.File`, and JSON were supported)

services/runcommand/go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ module github.com/stackitcloud/stackit-sdk-go/services/runcommand
22

33
go 1.25
44

5-
require github.com/stackitcloud/stackit-sdk-go/core v0.26.0
5+
require (
6+
github.com/google/go-cmp v0.7.0
7+
github.com/stackitcloud/stackit-sdk-go/core v0.26.0
8+
)
69

710
require (
811
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package wait
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"time"
8+
9+
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
10+
"github.com/stackitcloud/stackit-sdk-go/core/wait"
11+
runcommand "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api"
12+
)
13+
14+
// AgentReadyWaitHandler retries CreateCommand until the server agent registers.
15+
// The API returns 404 while the agent is booting; any other error is terminal.
16+
// On success, it returns the NewCommandResponse with the submitted command ID.
17+
func AgentReadyWaitHandler(ctx context.Context, a runcommand.DefaultAPI, projectId, serverId string, payload runcommand.CreateCommandPayload) *wait.AsyncActionHandler[runcommand.NewCommandResponse] {
18+
handler := wait.New(func() (bool, *runcommand.NewCommandResponse, error) {
19+
resp, err := a.CreateCommand(ctx, projectId, serverId).CreateCommandPayload(payload).Execute()
20+
if err != nil {
21+
var oapiErr *oapierror.GenericOpenAPIError
22+
if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound {
23+
return false, nil, nil
24+
}
25+
return false, nil, err
26+
}
27+
return true, resp, nil
28+
})
29+
handler.SetThrottle(10 * time.Second)
30+
handler.SetTimeout(10 * time.Minute)
31+
return handler
32+
}
33+
34+
// RunCommandWaitHandler will wait for a run command to reach a terminal state (completed or failed).
35+
// Both completed and failed are treated as active states; the caller should inspect the returned
36+
// CommandDetails.Status to distinguish success from failure.
37+
func RunCommandWaitHandler(ctx context.Context, a runcommand.DefaultAPI, projectId, serverId, commandId string) *wait.AsyncActionHandler[runcommand.CommandDetails] {
38+
waitConfig := wait.WaiterHelper[runcommand.CommandDetails, runcommand.CommandDetailsStatus]{
39+
FetchInstance: a.GetCommand(ctx, projectId, serverId, commandId).Execute,
40+
GetState: func(d *runcommand.CommandDetails) (runcommand.CommandDetailsStatus, error) {
41+
if d == nil {
42+
return "", errors.New("empty response")
43+
}
44+
status, ok := d.GetStatusOk()
45+
if !ok {
46+
return "", errors.New("no status in response")
47+
}
48+
return *status, nil
49+
},
50+
ActiveState: []runcommand.CommandDetailsStatus{
51+
runcommand.COMMANDDETAILSSTATUS_COMPLETED,
52+
runcommand.COMMANDDETAILSSTATUS_FAILED,
53+
},
54+
ErrorState: []runcommand.CommandDetailsStatus{},
55+
}
56+
57+
handler := wait.New(waitConfig.Wait())
58+
handler.SetTimeout(10 * time.Minute)
59+
return handler
60+
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package wait
2+
3+
import (
4+
"context"
5+
"sync/atomic"
6+
"testing"
7+
"testing/synctest"
8+
"time"
9+
10+
"github.com/google/go-cmp/cmp"
11+
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
12+
"github.com/stackitcloud/stackit-sdk-go/core/utils"
13+
runcommand "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api"
14+
)
15+
16+
type mockSettings struct {
17+
getFails bool
18+
resourceState runcommand.CommandDetailsStatus
19+
}
20+
21+
func newAPIMock(settings mockSettings) runcommand.DefaultAPI {
22+
return &runcommand.DefaultAPIServiceMock{
23+
GetCommandExecuteMock: utils.Ptr(func(_ runcommand.ApiGetCommandRequest) (*runcommand.CommandDetails, error) {
24+
if settings.getFails {
25+
return nil, &oapierror.GenericOpenAPIError{
26+
StatusCode: 500,
27+
}
28+
}
29+
return &runcommand.CommandDetails{
30+
Id: utils.Ptr(int32(1)),
31+
Status: utils.Ptr(settings.resourceState),
32+
}, nil
33+
}),
34+
}
35+
}
36+
37+
var testPayload = *runcommand.NewCreateCommandPayload("RunShellScript")
38+
39+
func TestRunCommandWaitHandler(t *testing.T) {
40+
tests := []struct {
41+
desc string
42+
getFails bool
43+
resourceState runcommand.CommandDetailsStatus
44+
wantErr bool
45+
wantResp bool
46+
}{
47+
{
48+
desc: "command completed",
49+
getFails: false,
50+
resourceState: runcommand.COMMANDDETAILSSTATUS_COMPLETED,
51+
wantErr: false,
52+
wantResp: true,
53+
},
54+
{
55+
desc: "command failed",
56+
getFails: false,
57+
resourceState: runcommand.COMMANDDETAILSSTATUS_FAILED,
58+
wantErr: false,
59+
wantResp: true,
60+
},
61+
{
62+
desc: "get fails",
63+
getFails: true,
64+
resourceState: runcommand.COMMANDDETAILSSTATUS_UNKNOWN_DEFAULT_OPEN_API,
65+
wantErr: true,
66+
wantResp: false,
67+
},
68+
{
69+
desc: "timeout",
70+
getFails: false,
71+
resourceState: runcommand.COMMANDDETAILSSTATUS_RUNNING,
72+
wantErr: true,
73+
wantResp: false,
74+
},
75+
}
76+
for _, tt := range tests {
77+
t.Run(tt.desc, func(t *testing.T) {
78+
synctest.Test(t, func(t *testing.T) {
79+
apiClient := newAPIMock(mockSettings{
80+
getFails: tt.getFails,
81+
resourceState: tt.resourceState,
82+
})
83+
84+
var wantRes *runcommand.CommandDetails
85+
if tt.wantResp {
86+
wantRes = &runcommand.CommandDetails{
87+
Id: utils.Ptr(int32(1)),
88+
Status: utils.Ptr(tt.resourceState),
89+
}
90+
}
91+
92+
handler := RunCommandWaitHandler(context.Background(), apiClient, "pid", "sid", "1")
93+
94+
gotRes, err := handler.SetTimeout(10 * time.Millisecond).WaitWithContext(context.Background())
95+
96+
if (err != nil) != tt.wantErr {
97+
t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr)
98+
}
99+
if !cmp.Equal(gotRes, wantRes) {
100+
t.Fatalf("handler gotRes = %v, want %v", gotRes, wantRes)
101+
}
102+
})
103+
})
104+
}
105+
}
106+
107+
func TestAgentReadyWaitHandler(t *testing.T) {
108+
tests := []struct {
109+
desc string
110+
createFn func(runcommand.ApiCreateCommandRequest) (*runcommand.NewCommandResponse, error)
111+
wantErr bool
112+
wantResp *runcommand.NewCommandResponse
113+
}{
114+
{
115+
desc: "agent immediately ready",
116+
createFn: func(_ runcommand.ApiCreateCommandRequest) (*runcommand.NewCommandResponse, error) {
117+
return &runcommand.NewCommandResponse{Id: utils.Ptr(int32(42))}, nil
118+
},
119+
wantErr: false,
120+
wantResp: &runcommand.NewCommandResponse{Id: utils.Ptr(int32(42))},
121+
},
122+
{
123+
desc: "agent not ready then ready",
124+
// atomic counter ensures the closure is safe when called from the handler goroutine
125+
createFn: func() func(runcommand.ApiCreateCommandRequest) (*runcommand.NewCommandResponse, error) {
126+
var calls atomic.Int32
127+
return func(_ runcommand.ApiCreateCommandRequest) (*runcommand.NewCommandResponse, error) {
128+
if calls.Add(1) == 1 {
129+
return nil, &oapierror.GenericOpenAPIError{StatusCode: 404}
130+
}
131+
return &runcommand.NewCommandResponse{Id: utils.Ptr(int32(7))}, nil
132+
}
133+
}(),
134+
wantErr: false,
135+
wantResp: &runcommand.NewCommandResponse{Id: utils.Ptr(int32(7))},
136+
},
137+
{
138+
desc: "terminal error non 404",
139+
createFn: func(_ runcommand.ApiCreateCommandRequest) (*runcommand.NewCommandResponse, error) {
140+
return nil, &oapierror.GenericOpenAPIError{StatusCode: 500}
141+
},
142+
wantErr: true,
143+
wantResp: nil,
144+
},
145+
{
146+
desc: "timeout agent never ready",
147+
createFn: func(_ runcommand.ApiCreateCommandRequest) (*runcommand.NewCommandResponse, error) {
148+
return nil, &oapierror.GenericOpenAPIError{StatusCode: 404}
149+
},
150+
wantErr: true,
151+
wantResp: nil,
152+
},
153+
}
154+
155+
for _, tt := range tests {
156+
t.Run(tt.desc, func(t *testing.T) {
157+
synctest.Test(t, func(t *testing.T) {
158+
apiClient := &runcommand.DefaultAPIServiceMock{
159+
CreateCommandExecuteMock: utils.Ptr(tt.createFn),
160+
}
161+
162+
handler := AgentReadyWaitHandler(context.Background(), apiClient, "pid", "sid", testPayload)
163+
164+
// 1 ms throttle keeps the retry case within the 10 ms fake timeout
165+
gotRes, err := handler.
166+
SetThrottle(time.Millisecond).
167+
SetTimeout(10 * time.Millisecond).
168+
WaitWithContext(context.Background())
169+
170+
if (err != nil) != tt.wantErr {
171+
t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr)
172+
}
173+
if !cmp.Equal(gotRes, tt.wantResp) {
174+
t.Fatalf("handler gotRes = %v, want %v", gotRes, tt.wantResp)
175+
}
176+
})
177+
})
178+
}
179+
}

0 commit comments

Comments
 (0)