Skip to content

Commit e8f40c6

Browse files
committed
feat: add action for server run commands
Signed-off-by: Mauritz Uphoff <mauritz.uphoff@stackit.cloud>
1 parent 06ea7d7 commit e8f40c6

13 files changed

Lines changed: 753 additions & 0 deletions

File tree

docs/actions/run_command.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "stackit_run_command Action - stackit"
4+
subcategory: ""
5+
description: |-
6+
Executes a command on an IaaS server using the STACKIT Run Commands API. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on resource level.
7+
---
8+
9+
# stackit_run_command (Action)
10+
11+
Executes a command on an IaaS server using the STACKIT Run Commands API. Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on resource level.
12+
13+
## Example Usage
14+
15+
```terraform
16+
resource "stackit_server" "server" {
17+
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
18+
name = "example-server"
19+
machine_type = "g2i.1"
20+
21+
boot_volume = {
22+
source_type = "image"
23+
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
24+
size = 32
25+
delete_on_termination = true
26+
}
27+
28+
agent = {
29+
provisioning_policy = "ALWAYS"
30+
}
31+
32+
lifecycle {
33+
action_trigger {
34+
events = [after_create]
35+
actions = [action.stackit_run_command.hello]
36+
}
37+
}
38+
}
39+
40+
action "stackit_run_command" "hello" {
41+
config {
42+
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
43+
server_id = stackit_server.server.server_id
44+
command_template_name = "RunShellScript"
45+
parameters = {
46+
script = "echo 'Hello from Terraform!'"
47+
}
48+
}
49+
}
50+
```
51+
52+
<!-- action schema generated by tfplugindocs -->
53+
## Schema
54+
55+
### Required
56+
57+
- `command_template_name` (String) The name of the command template to execute (e.g. RunShellScript). Available templates can be listed with: `stackit server command template list`
58+
- `project_id` (String) STACKIT Project ID to which the server belongs.
59+
- `server_id` (String) The ID of the server on which to execute the command.
60+
61+
### Optional
62+
63+
- `parameters` (Map of String) Optional parameters passed to the command template as key-value pairs.
64+
- `region` (String) The region of the server. If not defined, the provider region is used.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ See this [example](https://professional-service.git.onstackit.cloud/professional
205205
- `redis_custom_endpoint` (String) Custom endpoint for the Redis service
206206
- `region` (String, Deprecated) Region will be used as the default location for regional services. Not all services require a region, some are global
207207
- `resourcemanager_custom_endpoint` (String) Custom endpoint for the Resource Manager service
208+
- `run_command_custom_endpoint` (String) Custom endpoint for the Run Command service
208209
- `scf_custom_endpoint` (String) Custom endpoint for the Cloud Foundry (SCF) service
209210
- `secretsmanager_custom_endpoint` (String) Custom endpoint for the Secrets Manager service
210211
- `server_backup_custom_endpoint` (String) Custom endpoint for the Server Backup service
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
resource "stackit_server" "server" {
2+
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
3+
name = "example-server"
4+
machine_type = "g2i.1"
5+
6+
boot_volume = {
7+
source_type = "image"
8+
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
9+
size = 32
10+
delete_on_termination = true
11+
}
12+
13+
agent = {
14+
provisioning_policy = "ALWAYS"
15+
}
16+
17+
lifecycle {
18+
action_trigger {
19+
events = [after_create]
20+
actions = [action.stackit_run_command.hello]
21+
}
22+
}
23+
}
24+
25+
action "stackit_run_command" "hello" {
26+
config {
27+
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
28+
server_id = stackit_server.server.server_id
29+
region = "eu01"
30+
command_template_name = "RunShellScript"
31+
parameters = {
32+
script = "echo 'Hello from Terraform!'"
33+
}
34+
}
35+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ require (
216216
github.com/spf13/pflag v1.0.10 // indirect
217217
github.com/spf13/viper v1.12.0 // indirect
218218
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
219+
github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.4.3
219220
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
220221
github.com/stretchr/objx v0.5.2 // indirect
221222
github.com/stretchr/testify v1.11.1 // indirect
@@ -307,3 +308,5 @@ tool (
307308
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
308309
golang.org/x/tools/cmd/goimports
309310
)
311+
312+
replace github.com/stackitcloud/stackit-sdk-go/services/runcommand => /Users/uphoffm/GolandProjects/stackit-sdk-go/services/runcommand

stackit/internal/core/core.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ type ProviderData struct {
6868
ScfCustomEndpoint string
6969
SecretsManagerCustomEndpoint string
7070
SQLServerFlexCustomEndpoint string
71+
RunCommandCustomEndpoint string
7172
ServerBackupCustomEndpoint string
7273
ServerUpdateCustomEndpoint string
7374
SKECustomEndpoint string
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package runcommand
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strconv"
7+
8+
"github.com/hashicorp/terraform-plugin-framework/action"
9+
"github.com/hashicorp/terraform-plugin-framework/action/schema"
10+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
11+
"github.com/hashicorp/terraform-plugin-framework/types"
12+
"github.com/hashicorp/terraform-plugin-log/tflog"
13+
"github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api"
14+
"github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api/wait"
15+
16+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
17+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
18+
runCommandUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/runcommand/utils"
19+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
20+
)
21+
22+
// Ensure the implementation satisfies the expected interfaces.
23+
var (
24+
_ action.Action = &runCommandAction{}
25+
_ action.ActionWithConfigure = &runCommandAction{}
26+
)
27+
28+
type runCommandModel struct {
29+
ProjectId types.String `tfsdk:"project_id"`
30+
ServerId types.String `tfsdk:"server_id"`
31+
Region types.String `tfsdk:"region"`
32+
CommandTemplateName types.String `tfsdk:"command_template_name"`
33+
Parameters types.Map `tfsdk:"parameters"`
34+
}
35+
36+
// NewRunCommandAction is a helper function to simplify the provider implementation.
37+
func NewRunCommandAction() action.Action {
38+
return &runCommandAction{}
39+
}
40+
41+
// runCommandAction is the action implementation.
42+
type runCommandAction struct {
43+
client *v1api.APIClient
44+
providerData core.ProviderData
45+
}
46+
47+
// Metadata returns the action type name.
48+
func (a *runCommandAction) Metadata(_ context.Context, req action.MetadataRequest, resp *action.MetadataResponse) {
49+
resp.TypeName = req.ProviderTypeName + "_run_command"
50+
}
51+
52+
// Configure adds the provider configured client to the action.
53+
func (a *runCommandAction) Configure(ctx context.Context, req action.ConfigureRequest, resp *action.ConfigureResponse) {
54+
var ok bool
55+
a.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
56+
if !ok {
57+
return
58+
}
59+
a.client = runCommandUtils.ConfigureClient(ctx, &a.providerData, a.providerData.DefaultRegion, &resp.Diagnostics)
60+
if resp.Diagnostics.HasError() {
61+
return
62+
}
63+
tflog.Info(ctx, "Run command client configured")
64+
}
65+
66+
// Schema defines the schema for the action.
67+
func (a *runCommandAction) Schema(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) {
68+
descriptions := map[string]string{
69+
"main": "Executes a command on an IaaS server using the STACKIT Run Commands API. " + core.ResourceRegionFallbackDocstring,
70+
"project_id": "STACKIT Project ID to which the server belongs.",
71+
"server_id": "The ID of the server on which to execute the command.",
72+
"region": "The region of the server. If not defined, the provider region is used.",
73+
"command_template_name": "The name of the command template to execute (e.g. RunShellScript). Available templates can be listed with: `stackit server command template list`",
74+
"parameters": "Optional parameters passed to the command template as key-value pairs.",
75+
}
76+
77+
resp.Schema = schema.Schema{
78+
Description: descriptions["main"],
79+
Attributes: map[string]schema.Attribute{
80+
"project_id": schema.StringAttribute{
81+
Description: descriptions["project_id"],
82+
Required: true,
83+
Validators: []validator.String{
84+
validate.UUID(),
85+
validate.NoSeparator(),
86+
},
87+
},
88+
"server_id": schema.StringAttribute{
89+
Description: descriptions["server_id"],
90+
Required: true,
91+
Validators: []validator.String{
92+
validate.UUID(),
93+
validate.NoSeparator(),
94+
},
95+
},
96+
"region": schema.StringAttribute{
97+
Description: descriptions["region"],
98+
Optional: true,
99+
},
100+
"command_template_name": schema.StringAttribute{
101+
Description: descriptions["command_template_name"],
102+
Required: true,
103+
},
104+
"parameters": schema.MapAttribute{
105+
Description: descriptions["parameters"],
106+
Optional: true,
107+
ElementType: types.StringType,
108+
},
109+
},
110+
}
111+
}
112+
113+
// Invoke executes the run command action.
114+
func (a *runCommandAction) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) {
115+
var model runCommandModel
116+
resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
117+
if resp.Diagnostics.HasError() {
118+
return
119+
}
120+
121+
ctx = core.InitProviderContext(ctx)
122+
123+
projectId := model.ProjectId.ValueString()
124+
serverId := model.ServerId.ValueString()
125+
region := a.providerData.GetRegionWithOverride(model.Region)
126+
127+
ctx = tflog.SetField(ctx, "project_id", projectId)
128+
ctx = tflog.SetField(ctx, "server_id", serverId)
129+
ctx = tflog.SetField(ctx, "region", region)
130+
ctx = tflog.SetField(ctx, "command_template_name", model.CommandTemplateName.ValueString())
131+
132+
payload, err := toCreatePayload(ctx, &model)
133+
if err != nil {
134+
core.LogAndAddError(ctx, &resp.Diagnostics, "Error invoking run command", fmt.Sprintf("Building API payload: %v", err))
135+
return
136+
}
137+
138+
resp.SendProgress(action.InvokeProgressEvent{
139+
Message: fmt.Sprintf("Waiting for agent on server %s to be ready...", serverId),
140+
})
141+
142+
// waits for the agent to register (404 while booting) and submits the command in one step
143+
createResp, err := wait.AgentReadyWaitHandler(ctx, a.client.DefaultAPI, projectId, serverId, *payload).WaitWithContext(ctx)
144+
if err != nil {
145+
core.LogAndAddError(ctx, &resp.Diagnostics, "Error invoking run command", fmt.Sprintf("Waiting for agent / calling API: %v", err))
146+
return
147+
}
148+
if createResp == nil || createResp.Id == nil {
149+
core.LogAndAddError(ctx, &resp.Diagnostics, "Error invoking run command", "API returned empty response or missing command ID")
150+
return
151+
}
152+
153+
commandId := createResp.GetId()
154+
commandIdStr := strconv.Itoa(int(commandId))
155+
ctx = tflog.SetField(ctx, "command_id", commandIdStr)
156+
157+
resp.SendProgress(action.InvokeProgressEvent{
158+
Message: fmt.Sprintf("Command %q submitted (ID: %s). Waiting for completion...", model.CommandTemplateName.ValueString(), commandIdStr),
159+
})
160+
161+
details, err := wait.RunCommandWaitHandler(ctx, a.client.DefaultAPI, projectId, serverId, commandIdStr).WaitWithContext(ctx)
162+
if err != nil {
163+
core.LogAndAddError(ctx, &resp.Diagnostics, "Error waiting for run command", fmt.Sprintf("Polling API: %v", err))
164+
return
165+
}
166+
167+
if details.GetStatus() == v1api.COMMANDDETAILSSTATUS_FAILED {
168+
resp.Diagnostics.AddError(
169+
"Run command failed",
170+
fmt.Sprintf("Command %s finished with status %q (exit code: %d).\nOutput:\n%s", commandIdStr, details.GetStatus(), details.GetExitCode(), details.GetOutput()),
171+
)
172+
return
173+
}
174+
175+
tflog.Info(ctx, fmt.Sprintf("Run command %s completed successfully", commandIdStr))
176+
}
177+
178+
func toCreatePayload(ctx context.Context, model *runCommandModel) (*v1api.CreateCommandPayload, error) {
179+
if model == nil {
180+
return nil, fmt.Errorf("nil model")
181+
}
182+
183+
payload := v1api.NewCreateCommandPayload(model.CommandTemplateName.ValueString())
184+
185+
if !model.Parameters.IsNull() && !model.Parameters.IsUnknown() {
186+
params := map[string]string{}
187+
diags := model.Parameters.ElementsAs(ctx, &params, false)
188+
if diags.HasError() {
189+
return nil, fmt.Errorf("converting parameters: %v", diags.Errors())
190+
}
191+
payload.SetParameters(params)
192+
}
193+
194+
return payload, nil
195+
}

0 commit comments

Comments
 (0)