From 1f1ee3a20d11998c999ea31781b753d44501214c Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Tue, 4 Aug 2026 16:26:35 +0200 Subject: [PATCH] feat(runcommand): implement wait handler for runcommand --- CHANGELOG.md | 5 + examples/runcommand/go.mod | 16 ++ examples/runcommand/go.sum | 8 + examples/runcommand/runcommand.go | 76 +++++++++ go.work | 1 + services/runcommand/CHANGELOG.md | 5 + services/runcommand/go.mod | 5 +- services/runcommand/v1api/wait/wait.go | 60 +++++++ services/runcommand/v1api/wait/wait_test.go | 180 ++++++++++++++++++++ 9 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 examples/runcommand/go.mod create mode 100644 examples/runcommand/go.sum create mode 100644 examples/runcommand/runcommand.go create mode 100644 services/runcommand/v1api/wait/wait.go create mode 100644 services/runcommand/v1api/wait/wait_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a5e5de17d..e683c02a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`: diff --git a/examples/runcommand/go.mod b/examples/runcommand/go.mod new file mode 100644 index 000000000..d91952129 --- /dev/null +++ b/examples/runcommand/go.mod @@ -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 +) diff --git a/examples/runcommand/go.sum b/examples/runcommand/go.sum new file mode 100644 index 000000000..3712a0c87 --- /dev/null +++ b/examples/runcommand/go.sum @@ -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= diff --git a/examples/runcommand/runcommand.go b/examples/runcommand/runcommand.go new file mode 100644 index 000000000..a5079471c --- /dev/null +++ b/examples/runcommand/runcommand.go @@ -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()) +} diff --git a/go.work b/go.work index 07ad791c5..b1a7ff845 100644 --- a/go.work +++ b/go.work @@ -29,6 +29,7 @@ use ( ./examples/rabbitmq ./examples/redis ./examples/resourcemanager + ./examples/runcommand ./examples/runtime ./examples/secretsmanager ./examples/serviceaccount diff --git a/services/runcommand/CHANGELOG.md b/services/runcommand/CHANGELOG.md index 45c659e3e..806af0dc0 100644 --- a/services/runcommand/CHANGELOG.md +++ b/services/runcommand/CHANGELOG.md @@ -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) diff --git a/services/runcommand/go.mod b/services/runcommand/go.mod index 5d4f26cbf..cf8a5f5bc 100644 --- a/services/runcommand/go.mod +++ b/services/runcommand/go.mod @@ -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 diff --git a/services/runcommand/v1api/wait/wait.go b/services/runcommand/v1api/wait/wait.go new file mode 100644 index 000000000..96e00c9d1 --- /dev/null +++ b/services/runcommand/v1api/wait/wait.go @@ -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 +} diff --git a/services/runcommand/v1api/wait/wait_test.go b/services/runcommand/v1api/wait/wait_test.go new file mode 100644 index 000000000..7accd188a --- /dev/null +++ b/services/runcommand/v1api/wait/wait_test.go @@ -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) + } + }) + }) + } +}