diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b0a91cf..a36cff239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## 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 diff --git a/examples/runcommand/go.mod b/examples/runcommand/go.mod new file mode 100644 index 000000000..b04454e94 --- /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.10.0 +) + +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..e54865655 --- /dev/null +++ b/examples/runcommand/runcommand.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "strconv" + "time" + + "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() { + 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 + region := "eu01" // the region of the server + + // 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) + } + + // 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!'", + }) + + // Submit the command. + fmt.Printf("[Run Command API] Submitting command on server %q...\n", serverId) + + 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] 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) + + fmt.Printf("[Run Command API] Waiting for command %s to finish...\n", commandId) + + details, err := wait.RunCommandWaitHandler(ctx, client.DefaultAPI, projectId, serverId, region, commandId). + WaitWithContext(ctx) + if err != nil { + 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 completed successfully.\n", commandId) + 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..5373d4e76 100644 --- a/services/runcommand/CHANGELOG.md +++ b/services/runcommand/CHANGELOG.md @@ -1,3 +1,7 @@ +## 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 - `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/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/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/v2api/wait/wait.go b/services/runcommand/v2api/wait/wait.go new file mode 100644 index 000000000..af4c90964 --- /dev/null +++ b/services/runcommand/v2api/wait/wait.go @@ -0,0 +1,41 @@ +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, + }, + } + + 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..fcb287071 --- /dev/null +++ b/services/runcommand/v2api/wait/wait_test.go @@ -0,0 +1,103 @@ +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 times out", + resourceState: runcommand.COMMANDDETAILSSTATUS_UNKNOWN_DEFAULT_OPEN_API, + wantErr: true, + wantResp: nil, + }, + { + 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) + } + }) + }) + } +}