Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## Release (2026-MM-DD)

- `runcommand`:
- [v1.9.2](services/runcommand/CHANGELOG.md#v192)
- `v1api`: **Feature:** Add `AgentReadyWaitHandler` wait handler for waiting until the server agent has registered and submitting a command
- `v1api`: **Feature:** Add `RunCommandWaitHandler` wait handler for polling a command until it reaches a terminal state (`completed` or `failed`)
- **Dependencies:** Add `github.com/google/go-cmp v0.7.0`
- `alb`
- [v0.17.1](services/alb/CHANGELOG.md#v0171)
- `v2api`:
Expand Down
16 changes: 16 additions & 0 deletions examples/runcommand/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module github.com/stackitcloud/stackit-sdk-go/examples/runcommand

go 1.25

// 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.
replace github.com/stackitcloud/stackit-sdk-go/services/runcommand => ../../services/runcommand

require (
github.com/stackitcloud/stackit-sdk-go/core v0.26.0
github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.4.3
)

require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
)
8 changes: 8 additions & 0 deletions examples/runcommand/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/stackitcloud/stackit-sdk-go/core v0.26.0 h1:jQEb9gkehfp6VCP6TcYk7BI10cz4l0KM2L6hqYBH2QA=
github.com/stackitcloud/stackit-sdk-go/core v0.26.0/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA=
76 changes: 76 additions & 0 deletions examples/runcommand/runcommand.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package main

import (
"context"
"fmt"
"os"
"strconv"

"github.com/stackitcloud/stackit-sdk-go/core/config"
runcommand "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api"
"github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api/wait"
)

func main() {
ctx := context.Background()

projectId := "PROJECT_ID" // the uuid of your STACKIT project
serverId := "SERVER_ID" // the uuid of the server to run the command on

// Create a new API client, that uses default authentication and configuration
client, err := runcommand.NewAPIClient(
config.WithRegion("eu01"),
)
if err != nil {
fmt.Fprintf(os.Stderr, "[Run Command API] Creating API client: %v\n", err)
os.Exit(1)
}

// List available command templates
templates, err := client.DefaultAPI.ListCommandTemplates(ctx).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "[Run Command API] Error when calling `ListCommandTemplates`: %v\n", err)
os.Exit(1)
}

fmt.Printf("[Run Command API] Available command templates:\n")
for _, t := range templates.GetItems() {
fmt.Printf(" %s\n", t.GetName())
}

// Build the command payload
payload := runcommand.NewCreateCommandPayload("RunShellScript")
payload.SetParameters(map[string]string{
"script": "echo 'Hello from STACKIT Run Commands!'",
})

// AgentReadyWaitHandler submits the command and retries until the server agent
// has registered. The API returns 404 while the agent is still booting after
// server creation. The returned response already contains the command ID.
fmt.Printf("[Run Command API] Waiting for agent on server %q and submitting command...\n", serverId)

createResp, err := wait.AgentReadyWaitHandler(ctx, client.DefaultAPI, projectId, serverId, *payload).
WaitWithContext(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "[Run Command API] Error when submitting command: %v\n", err)
os.Exit(1)
}

commandId := strconv.Itoa(int(createResp.GetId()))
fmt.Printf("[Run Command API] Command submitted with ID %s.\n", commandId)

// RunCommandWaitHandler polls until the command reaches a terminal state.
// Both COMPLETED and FAILED are terminal; inspect the status to distinguish them.
fmt.Printf("[Run Command API] Waiting for command %s to finish...\n", commandId)

details, err := wait.RunCommandWaitHandler(ctx, client.DefaultAPI, projectId, serverId, commandId).
WaitWithContext(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "[Run Command API] Error when waiting for command: %v\n", err)
os.Exit(1)
}

fmt.Printf("[Run Command API] Command %s finished with status %q (exit code: %d).\n",
commandId, details.GetStatus(), details.GetExitCode())
fmt.Printf("[Run Command API] Output:\n%s\n", details.GetOutput())
}
1 change: 1 addition & 0 deletions go.work
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use (
./examples/rabbitmq
./examples/redis
./examples/resourcemanager
./examples/runcommand
./examples/runtime
./examples/secretsmanager
./examples/serviceaccount
Expand Down
5 changes: 5 additions & 0 deletions services/runcommand/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## v1.9.2
- `v1api`: **Feature:** Add `AgentReadyWaitHandler` wait handler for waiting until the server agent has registered and submitting a command
- `v1api`: **Feature:** Add `RunCommandWaitHandler` wait handler for polling a command until it reaches a terminal state (`completed` or `failed`)
- **Dependencies:** Add `github.com/google/go-cmp v0.7.0`

## v1.9.1
- `v1api`:
- **Fix:** Response decoding now supports `*io.Reader` and `*[]byte` target types (previously only `string`, `*os.File`, and JSON were supported)
Expand Down
5 changes: 4 additions & 1 deletion services/runcommand/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ module github.com/stackitcloud/stackit-sdk-go/services/runcommand

go 1.25

require github.com/stackitcloud/stackit-sdk-go/core v0.26.0
require (
github.com/google/go-cmp v0.7.0
github.com/stackitcloud/stackit-sdk-go/core v0.26.0
)

require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
Expand Down
60 changes: 60 additions & 0 deletions services/runcommand/v1api/wait/wait.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package wait

import (
"context"
"errors"
"net/http"
"time"

"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/core/wait"
runcommand "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api"
)

// AgentReadyWaitHandler retries CreateCommand until the server agent registers.
// The API returns 404 while the agent is booting; any other error is terminal.
// On success, it returns the NewCommandResponse with the submitted command ID.
func AgentReadyWaitHandler(ctx context.Context, a runcommand.DefaultAPI, projectId, serverId string, payload runcommand.CreateCommandPayload) *wait.AsyncActionHandler[runcommand.NewCommandResponse] {
handler := wait.New(func() (bool, *runcommand.NewCommandResponse, error) {
resp, err := a.CreateCommand(ctx, projectId, serverId).CreateCommandPayload(payload).Execute()
if err != nil {
var oapiErr *oapierror.GenericOpenAPIError
if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound {
return false, nil, nil
}
return false, nil, err
}
return true, resp, nil
})
handler.SetThrottle(10 * time.Second)
handler.SetTimeout(10 * time.Minute)
return handler
}

// RunCommandWaitHandler will wait for a run command to reach a terminal state (completed or failed).
// Both completed and failed are treated as active states; the caller should inspect the returned
// CommandDetails.Status to distinguish success from failure.
func RunCommandWaitHandler(ctx context.Context, a runcommand.DefaultAPI, projectId, serverId, commandId string) *wait.AsyncActionHandler[runcommand.CommandDetails] {
waitConfig := wait.WaiterHelper[runcommand.CommandDetails, runcommand.CommandDetailsStatus]{
FetchInstance: a.GetCommand(ctx, projectId, serverId, commandId).Execute,
GetState: func(d *runcommand.CommandDetails) (runcommand.CommandDetailsStatus, error) {
if d == nil {
return "", errors.New("empty response")
}
status, ok := d.GetStatusOk()
if !ok {
return "", errors.New("no status in response")
}
return *status, nil
},
ActiveState: []runcommand.CommandDetailsStatus{
runcommand.COMMANDDETAILSSTATUS_COMPLETED,
runcommand.COMMANDDETAILSSTATUS_FAILED,
},
ErrorState: []runcommand.CommandDetailsStatus{},
}

handler := wait.New(waitConfig.Wait())
handler.SetTimeout(10 * time.Minute)
return handler
}
180 changes: 180 additions & 0 deletions services/runcommand/v1api/wait/wait_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package wait

import (
"context"
"sync/atomic"
"testing"
"testing/synctest"
"time"

"github.com/google/go-cmp/cmp"

"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
runcommand "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api"
)

type mockSettings struct {
getFails bool
resourceState runcommand.CommandDetailsStatus
}

func newAPIMock(settings mockSettings) runcommand.DefaultAPI {
return &runcommand.DefaultAPIServiceMock{
GetCommandExecuteMock: utils.Ptr(func(_ runcommand.ApiGetCommandRequest) (*runcommand.CommandDetails, error) {
if settings.getFails {
return nil, &oapierror.GenericOpenAPIError{
StatusCode: 500,
}
}
return &runcommand.CommandDetails{
Id: utils.Ptr(int32(1)),
Status: utils.Ptr(settings.resourceState),
}, nil
}),
}
}

var testPayload = *runcommand.NewCreateCommandPayload("RunShellScript")

func TestRunCommandWaitHandler(t *testing.T) {
tests := []struct {
desc string
getFails bool
resourceState runcommand.CommandDetailsStatus
wantErr bool
wantResp bool
}{
{
desc: "command completed",
getFails: false,
resourceState: runcommand.COMMANDDETAILSSTATUS_COMPLETED,
wantErr: false,
wantResp: true,
},
{
desc: "command failed",
getFails: false,
resourceState: runcommand.COMMANDDETAILSSTATUS_FAILED,
wantErr: false,
wantResp: true,
},
{
desc: "get fails",
getFails: true,
resourceState: runcommand.COMMANDDETAILSSTATUS_UNKNOWN_DEFAULT_OPEN_API,
wantErr: true,
wantResp: false,
},
{
desc: "timeout",
getFails: false,
resourceState: runcommand.COMMANDDETAILSSTATUS_RUNNING,
wantErr: true,
wantResp: false,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
apiClient := newAPIMock(mockSettings{
getFails: tt.getFails,
resourceState: tt.resourceState,
})

var wantRes *runcommand.CommandDetails
if tt.wantResp {
wantRes = &runcommand.CommandDetails{
Id: utils.Ptr(int32(1)),
Status: utils.Ptr(tt.resourceState),
}
}

handler := RunCommandWaitHandler(context.Background(), apiClient, "pid", "sid", "1")

gotRes, err := handler.SetTimeout(10 * time.Millisecond).WaitWithContext(context.Background())

if (err != nil) != tt.wantErr {
t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr)
}
if !cmp.Equal(gotRes, wantRes) {
t.Fatalf("handler gotRes = %v, want %v", gotRes, wantRes)
}
})
})
}
}

func TestAgentReadyWaitHandler(t *testing.T) {
tests := []struct {
desc string
createFn func(runcommand.ApiCreateCommandRequest) (*runcommand.NewCommandResponse, error)
wantErr bool
wantResp *runcommand.NewCommandResponse
}{
{
desc: "agent immediately ready",
createFn: func(_ runcommand.ApiCreateCommandRequest) (*runcommand.NewCommandResponse, error) {
return &runcommand.NewCommandResponse{Id: utils.Ptr(int32(42))}, nil
},
wantErr: false,
wantResp: &runcommand.NewCommandResponse{Id: utils.Ptr(int32(42))},
},
{
desc: "agent not ready then ready",
// atomic counter ensures the closure is safe when called from the handler goroutine
createFn: func() func(runcommand.ApiCreateCommandRequest) (*runcommand.NewCommandResponse, error) {
var calls atomic.Int32
return func(_ runcommand.ApiCreateCommandRequest) (*runcommand.NewCommandResponse, error) {
if calls.Add(1) == 1 {
return nil, &oapierror.GenericOpenAPIError{StatusCode: 404}
}
return &runcommand.NewCommandResponse{Id: utils.Ptr(int32(7))}, nil
}
}(),
wantErr: false,
wantResp: &runcommand.NewCommandResponse{Id: utils.Ptr(int32(7))},
},
{
desc: "terminal error non 404",
createFn: func(_ runcommand.ApiCreateCommandRequest) (*runcommand.NewCommandResponse, error) {
return nil, &oapierror.GenericOpenAPIError{StatusCode: 500}
},
wantErr: true,
wantResp: nil,
},
{
desc: "timeout agent never ready",
createFn: func(_ runcommand.ApiCreateCommandRequest) (*runcommand.NewCommandResponse, error) {
return nil, &oapierror.GenericOpenAPIError{StatusCode: 404}
},
wantErr: true,
wantResp: nil,
},
}

for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
apiClient := &runcommand.DefaultAPIServiceMock{
CreateCommandExecuteMock: utils.Ptr(tt.createFn),
}

handler := AgentReadyWaitHandler(context.Background(), apiClient, "pid", "sid", testPayload)

// 1 ms throttle keeps the retry case within the 10 ms fake timeout
gotRes, err := handler.
SetThrottle(time.Millisecond).
SetTimeout(10 * time.Millisecond).
WaitWithContext(context.Background())

if (err != nil) != tt.wantErr {
t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr)
}
if !cmp.Equal(gotRes, tt.wantResp) {
t.Fatalf("handler gotRes = %v, want %v", gotRes, tt.wantResp)
}
})
})
}
}
Loading