Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
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.10.0
)

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=
93 changes: 93 additions & 0 deletions examples/runcommand/runcommand.go
Original file line number Diff line number Diff line change
@@ -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())
}
1 change: 1 addition & 0 deletions go.work
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ use (
./examples/rabbitmq
./examples/redis
./examples/resourcemanager
./examples/runcommand
./examples/runtime
./examples/secretsmanager
./examples/serviceaccount
Expand Down
4 changes: 4 additions & 0 deletions services/runcommand/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion services/runcommand/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v1.9.1
v1.10.0
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
41 changes: 41 additions & 0 deletions services/runcommand/v2api/wait/wait.go
Original file line number Diff line number Diff line change
@@ -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
}
103 changes: 103 additions & 0 deletions services/runcommand/v2api/wait/wait_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
})
}
}
Loading