From ea6160f2fbd70c0bf7576e1994cc5b2cf0bf7ae0 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Tue, 4 Aug 2026 16:26:35 +0200 Subject: [PATCH 1/4] 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 f8b0a91cf..dc2783edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ - `experimental`: - [v0.1.0](experimental/CHANGELOG.md#v010) - Added experimental `paginate` package for AIP compliant pagination +- `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` - `automation`: - [v0.1.0](services/automation/CHANGELOG.md#v010) - **New**: API for STACKIT Automation 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 e57c7cb07..408f42039 100644 --- a/go.work +++ b/go.work @@ -31,6 +31,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) + } + }) + }) + } +} From bdcfcf83d7297b2210e6c529b9494049694fd335 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Mon, 31 Aug 2026 09:41:52 +0200 Subject: [PATCH 2/4] review changes --- services/runcommand/v1api/wait/wait.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/runcommand/v1api/wait/wait.go b/services/runcommand/v1api/wait/wait.go index 96e00c9d1..bfe11c41d 100644 --- a/services/runcommand/v1api/wait/wait.go +++ b/services/runcommand/v1api/wait/wait.go @@ -3,6 +3,7 @@ package wait import ( "context" "errors" + "fmt" "net/http" "time" @@ -39,11 +40,11 @@ func RunCommandWaitHandler(ctx context.Context, a runcommand.DefaultAPI, project FetchInstance: a.GetCommand(ctx, projectId, serverId, commandId).Execute, GetState: func(d *runcommand.CommandDetails) (runcommand.CommandDetailsStatus, error) { if d == nil { - return "", errors.New("empty response") + return "", fmt.Errorf("failed to get command %s: empty response", commandId) } status, ok := d.GetStatusOk() if !ok { - return "", errors.New("no status in response") + return "", fmt.Errorf("command %s: status missing in response", commandId) } return *status, nil }, @@ -55,6 +56,6 @@ func RunCommandWaitHandler(ctx context.Context, a runcommand.DefaultAPI, project } handler := wait.New(waitConfig.Wait()) - handler.SetTimeout(10 * time.Minute) + handler.SetTimeout(45 * time.Minute) return handler } From c81d209115dbe49259f9224251593c22f4011696 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Tue, 1 Sep 2026 15:58:53 +0200 Subject: [PATCH 3/4] review changes --- CHANGELOG.md | 10 +- examples/runcommand/go.mod | 2 +- examples/runcommand/runcommand.go | 52 ++++-- services/runcommand/CHANGELOG.md | 5 +- services/runcommand/VERSION | 2 +- services/runcommand/v1api/wait/wait.go | 61 ------- services/runcommand/v1api/wait/wait_test.go | 180 -------------------- services/runcommand/v2api/wait/wait.go | 42 +++++ services/runcommand/v2api/wait/wait_test.go | 106 ++++++++++++ 9 files changed, 195 insertions(+), 265 deletions(-) delete mode 100644 services/runcommand/v1api/wait/wait.go delete mode 100644 services/runcommand/v1api/wait/wait_test.go create mode 100644 services/runcommand/v2api/wait/wait.go create mode 100644 services/runcommand/v2api/wait/wait_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2783edc..48a252546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,16 @@ ## Release (2026-MM-DD) +- `runcommand`: + - [v1.10.0](services/runcommand/CHANGELOG.md#v1100) + - `v2api`: **Feature:** Add `RunCommandWaitHandler` wait handler for polling a command until it reaches a terminal state. `failed` is an error state; the handler returns a non-nil error along with the `CommandDetails`. + - **Dependencies:** Add `github.com/google/go-cmp v0.7.0` - `experimental`: - [v0.1.0](experimental/CHANGELOG.md#v010) - Added experimental `paginate` package for AIP compliant pagination - `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`) + - [v1.10.0](services/runcommand/CHANGELOG.md#v1100) + - `v2api`: **Feature:** Add `AgentReadyWaitHandler` wait handler for waiting until the server agent has registered and submitting a command + - `v2api`: **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` - `automation`: - [v0.1.0](services/automation/CHANGELOG.md#v010) diff --git a/examples/runcommand/go.mod b/examples/runcommand/go.mod index d91952129..b04454e94 100644 --- a/examples/runcommand/go.mod +++ b/examples/runcommand/go.mod @@ -7,7 +7,7 @@ replace github.com/stackitcloud/stackit-sdk-go/services/runcommand => ../../serv require ( github.com/stackitcloud/stackit-sdk-go/core v0.26.0 - github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.4.3 + github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.10.0 ) require ( diff --git a/examples/runcommand/runcommand.go b/examples/runcommand/runcommand.go index a5079471c..dce71fcc2 100644 --- a/examples/runcommand/runcommand.go +++ b/examples/runcommand/runcommand.go @@ -2,13 +2,17 @@ package main import ( "context" + "errors" "fmt" + "net/http" "os" "strconv" + "time" "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" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + runcommand "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v2api" + "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v2api/wait" ) func main() { @@ -16,10 +20,11 @@ func main() { projectId := "PROJECT_ID" // the uuid of your STACKIT project serverId := "SERVER_ID" // the uuid of the server to run the command on + region := "eu01" // the region of the server // Create a new API client, that uses default authentication and configuration client, err := runcommand.NewAPIClient( - config.WithRegion("eu01"), + config.WithRegion(region), ) if err != nil { fmt.Fprintf(os.Stderr, "[Run Command API] Creating API client: %v\n", err) @@ -44,33 +49,48 @@ func main() { "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) + // Submit the command. + fmt.Printf("[Run Command API] Submitting command on server %q...\n", serverId) - createResp, err := wait.AgentReadyWaitHandler(ctx, client.DefaultAPI, projectId, serverId, *payload). - WaitWithContext(ctx) + var createResp *runcommand.NewCommandResponse + for attempt := range 60 { + createResp, err = client.DefaultAPI.CreateCommand(ctx, projectId, serverId, region).CreateCommandPayload(*payload).Execute() + if err == nil { + break + } + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + if !ok || oapiErr.StatusCode != http.StatusNotFound { + fmt.Fprintf(os.Stderr, "[Run Command API] Error when calling `CreateCommand`: %v\n", err) + os.Exit(1) + } + fmt.Printf("[Run Command API] Agent not yet ready, retrying (%d/60)...\n", attempt+1) + time.Sleep(10 * time.Second) + } if err != nil { - fmt.Fprintf(os.Stderr, "[Run Command API] Error when submitting command: %v\n", err) + fmt.Fprintf(os.Stderr, "[Run Command API] Agent did not become ready within timeout\n") 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). + details, err := wait.RunCommandWaitHandler(ctx, client.DefaultAPI, projectId, serverId, region, commandId). WaitWithContext(ctx) if err != nil { - fmt.Fprintf(os.Stderr, "[Run Command API] Error when waiting for command: %v\n", err) + exitCode := int32(0) + output := "" + if details != nil { + exitCode = details.GetExitCode() + output = details.GetOutput() + } + fmt.Fprintf(os.Stderr, "[Run Command API] Command %s failed (exit code: %d).\nOutput:\n%s\nError: %v\n", + commandId, exitCode, output, 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] Command %s completed successfully.\n", commandId) fmt.Printf("[Run Command API] Output:\n%s\n", details.GetOutput()) } diff --git a/services/runcommand/CHANGELOG.md b/services/runcommand/CHANGELOG.md index 806af0dc0..5373d4e76 100644 --- a/services/runcommand/CHANGELOG.md +++ b/services/runcommand/CHANGELOG.md @@ -1,6 +1,5 @@ -## 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`) +## v1.10.0 +- `v2api`: **Feature:** Add `RunCommandWaitHandler` wait handler for polling a command until it reaches a terminal state (`completed` or `failed`). `failed` is an error state; the handler returns a non-nil error along with the `CommandDetails` so callers can surface the exit code and output. - **Dependencies:** Add `github.com/google/go-cmp v0.7.0` ## v1.9.1 diff --git a/services/runcommand/VERSION b/services/runcommand/VERSION index ba1e8bf0b..bf7b70e00 100644 --- a/services/runcommand/VERSION +++ b/services/runcommand/VERSION @@ -1 +1 @@ -v1.9.1 +v1.10.0 diff --git a/services/runcommand/v1api/wait/wait.go b/services/runcommand/v1api/wait/wait.go deleted file mode 100644 index bfe11c41d..000000000 --- a/services/runcommand/v1api/wait/wait.go +++ /dev/null @@ -1,61 +0,0 @@ -package wait - -import ( - "context" - "errors" - "fmt" - "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 "", fmt.Errorf("failed to get command %s: empty response", commandId) - } - status, ok := d.GetStatusOk() - if !ok { - return "", fmt.Errorf("command %s: status missing in response", commandId) - } - return *status, nil - }, - ActiveState: []runcommand.CommandDetailsStatus{ - runcommand.COMMANDDETAILSSTATUS_COMPLETED, - runcommand.COMMANDDETAILSSTATUS_FAILED, - }, - ErrorState: []runcommand.CommandDetailsStatus{}, - } - - handler := wait.New(waitConfig.Wait()) - handler.SetTimeout(45 * time.Minute) - return handler -} diff --git a/services/runcommand/v1api/wait/wait_test.go b/services/runcommand/v1api/wait/wait_test.go deleted file mode 100644 index 7accd188a..000000000 --- a/services/runcommand/v1api/wait/wait_test.go +++ /dev/null @@ -1,180 +0,0 @@ -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) - } - }) - }) - } -} diff --git a/services/runcommand/v2api/wait/wait.go b/services/runcommand/v2api/wait/wait.go new file mode 100644 index 000000000..b23dd62df --- /dev/null +++ b/services/runcommand/v2api/wait/wait.go @@ -0,0 +1,42 @@ +package wait + +import ( + "context" + "fmt" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/wait" + runcommand "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v2api" +) + +// RunCommandWaitHandler will wait for a run command to reach a terminal state. +// COMPLETED is treated as success and returns the CommandDetails with no error. +// FAILED is treated as an error: the handler returns a non-nil error and the +// CommandDetails (containing exit code and output) so callers can surface +// diagnostic information without an additional API call. +func RunCommandWaitHandler(ctx context.Context, a runcommand.DefaultAPI, projectId, serverId, region, commandId string) *wait.AsyncActionHandler[runcommand.CommandDetails] { + waitConfig := wait.WaiterHelper[runcommand.CommandDetails, runcommand.CommandDetailsStatus]{ + FetchInstance: a.GetCommand(ctx, projectId, region, serverId, commandId).Execute, + GetState: func(d *runcommand.CommandDetails) (runcommand.CommandDetailsStatus, error) { + if d == nil { + return "", fmt.Errorf("failed to get command %s: empty response", commandId) + } + status, ok := d.GetStatusOk() + if !ok { + return "", fmt.Errorf("command %s: status missing in response", commandId) + } + return *status, nil + }, + ActiveState: []runcommand.CommandDetailsStatus{ + runcommand.COMMANDDETAILSSTATUS_COMPLETED, + }, + ErrorState: []runcommand.CommandDetailsStatus{ + runcommand.COMMANDDETAILSSTATUS_FAILED, + runcommand.COMMANDDETAILSSTATUS_UNKNOWN_DEFAULT_OPEN_API, + }, + } + + handler := wait.New(waitConfig.Wait()) + handler.SetTimeout(45 * time.Minute) + return handler +} diff --git a/services/runcommand/v2api/wait/wait_test.go b/services/runcommand/v2api/wait/wait_test.go new file mode 100644 index 000000000..452f7c669 --- /dev/null +++ b/services/runcommand/v2api/wait/wait_test.go @@ -0,0 +1,106 @@ +package wait + +import ( + "context" + "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/v2api" +) + +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 + }), + } +} + +func TestRunCommandWaitHandler(t *testing.T) { + tests := []struct { + desc string + getFails bool + resourceState runcommand.CommandDetailsStatus + wantErr bool + wantResp *runcommand.CommandDetails + }{ + { + desc: "command completed", + resourceState: runcommand.COMMANDDETAILSSTATUS_COMPLETED, + wantErr: false, + wantResp: &runcommand.CommandDetails{ + Id: utils.Ptr(int32(1)), + Status: utils.Ptr(runcommand.COMMANDDETAILSSTATUS_COMPLETED), + }, + }, + { + desc: "command failed returns error and details", + resourceState: runcommand.COMMANDDETAILSSTATUS_FAILED, + wantErr: true, + wantResp: &runcommand.CommandDetails{ + Id: utils.Ptr(int32(1)), + Status: utils.Ptr(runcommand.COMMANDDETAILSSTATUS_FAILED), + }, + }, + { + desc: "unknown status returns error and details", + resourceState: runcommand.COMMANDDETAILSSTATUS_UNKNOWN_DEFAULT_OPEN_API, + wantErr: true, + wantResp: &runcommand.CommandDetails{ + Id: utils.Ptr(int32(1)), + Status: utils.Ptr(runcommand.COMMANDDETAILSSTATUS_UNKNOWN_DEFAULT_OPEN_API), + }, + }, + { + desc: "get fails", + getFails: true, + wantErr: true, + wantResp: nil, + }, + { + desc: "timeout while running", + resourceState: runcommand.COMMANDDETAILSSTATUS_RUNNING, + wantErr: true, + wantResp: nil, + }, + } + 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, + }) + + handler := RunCommandWaitHandler(context.Background(), apiClient, "pid", "sid", "eu01", "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, tt.wantResp) { + t.Fatalf("handler gotRes = %v, want %v", gotRes, tt.wantResp) + } + }) + }) + } +} From a6214a44d3dac3d699acadbef31829a99dc1b14a Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Wed, 2 Sep 2026 13:48:38 +0200 Subject: [PATCH 4/4] review changes --- CHANGELOG.md | 5 ----- examples/runcommand/runcommand.go | 7 ++----- services/runcommand/v2api/wait/wait.go | 1 - services/runcommand/v2api/wait/wait_test.go | 7 ++----- 4 files changed, 4 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a252546..a36cff239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,6 @@ - `experimental`: - [v0.1.0](experimental/CHANGELOG.md#v010) - Added experimental `paginate` package for AIP compliant pagination -- `runcommand`: - - [v1.10.0](services/runcommand/CHANGELOG.md#v1100) - - `v2api`: **Feature:** Add `AgentReadyWaitHandler` wait handler for waiting until the server agent has registered and submitting a command - - `v2api`: **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` - `automation`: - [v0.1.0](services/automation/CHANGELOG.md#v010) - **New**: API for STACKIT Automation diff --git a/examples/runcommand/runcommand.go b/examples/runcommand/runcommand.go index dce71fcc2..e54865655 100644 --- a/examples/runcommand/runcommand.go +++ b/examples/runcommand/runcommand.go @@ -9,7 +9,6 @@ import ( "strconv" "time" - "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" runcommand "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v2api" "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v2api/wait" @@ -22,10 +21,8 @@ func main() { serverId := "SERVER_ID" // the uuid of the server to run the command on region := "eu01" // the region of the server - // Create a new API client, that uses default authentication and configuration - client, err := runcommand.NewAPIClient( - config.WithRegion(region), - ) + // Create a new API client, that uses default authentication and configuration. + client, err := runcommand.NewAPIClient() if err != nil { fmt.Fprintf(os.Stderr, "[Run Command API] Creating API client: %v\n", err) os.Exit(1) diff --git a/services/runcommand/v2api/wait/wait.go b/services/runcommand/v2api/wait/wait.go index b23dd62df..af4c90964 100644 --- a/services/runcommand/v2api/wait/wait.go +++ b/services/runcommand/v2api/wait/wait.go @@ -32,7 +32,6 @@ func RunCommandWaitHandler(ctx context.Context, a runcommand.DefaultAPI, project }, ErrorState: []runcommand.CommandDetailsStatus{ runcommand.COMMANDDETAILSSTATUS_FAILED, - runcommand.COMMANDDETAILSSTATUS_UNKNOWN_DEFAULT_OPEN_API, }, } diff --git a/services/runcommand/v2api/wait/wait_test.go b/services/runcommand/v2api/wait/wait_test.go index 452f7c669..fcb287071 100644 --- a/services/runcommand/v2api/wait/wait_test.go +++ b/services/runcommand/v2api/wait/wait_test.go @@ -61,13 +61,10 @@ func TestRunCommandWaitHandler(t *testing.T) { }, }, { - desc: "unknown status returns error and details", + desc: "unknown status times out", resourceState: runcommand.COMMANDDETAILSSTATUS_UNKNOWN_DEFAULT_OPEN_API, wantErr: true, - wantResp: &runcommand.CommandDetails{ - Id: utils.Ptr(int32(1)), - Status: utils.Ptr(runcommand.COMMANDDETAILSSTATUS_UNKNOWN_DEFAULT_OPEN_API), - }, + wantResp: nil, }, { desc: "get fails",