-
Notifications
You must be signed in to change notification settings - Fork 40
feat(runcommand): implement wait handler for runcommand #10354
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ) |
| 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= |
| 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()) | ||
| } |
| 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`) | ||||||||||||
|
Comment on lines
+2
to
+3
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The changes from v1api can be summarized to one entry. Please update it also accordingly in the root changelog
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is already also the v2api available. I would suggest to add the wait handler only to the v2api, except there is a specific reason, why someone shouldn't use the v2api |
||||||||||||
| - **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) | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| 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() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isn't there a different endpoint, to check if the agent is ready? Personally I don't like to call create or update endpoints in the waithandler, because it could potentially create multiple resources. |
||
| 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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this state be part of the ErrorState? Because this indicates that the execution of the command failed |
||
| }, | ||
| ErrorState: []runcommand.CommandDetailsStatus{}, | ||
| } | ||
|
|
||
| handler := wait.New(waitConfig.Wait()) | ||
| handler.SetTimeout(45 * time.Minute) | ||
| return handler | ||
| } | ||
| 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) | ||
| } | ||
| }) | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would do here a minor bump (
v1.10.0), because it contains new features. Please update also the VERSION file accordingly