Skip to content

Commit 6b56536

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

14 files changed

Lines changed: 855 additions & 1 deletion

File tree

docs/actions/run_command.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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 "time_rotating" "rotate" {
17+
rotation_days = 30
18+
}
19+
20+
resource "stackit_server" "example" {
21+
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
22+
name = "example"
23+
machine_type = "g2i.4"
24+
availability_zone = "eu01-1"
25+
26+
boot_volume = {
27+
source_type = "image"
28+
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
29+
size = 32
30+
delete_on_termination = true
31+
}
32+
33+
agent = {
34+
provisioning_policy = "ALWAYS"
35+
}
36+
37+
# Changing this label triggers after_update -> cert is regenerated.
38+
labels = {
39+
cert_rotation_id = substr(sha256(time_rotating.rotate.id), 0, 63)
40+
}
41+
42+
lifecycle {
43+
action_trigger {
44+
events = [after_update]
45+
actions = [action.stackit_run_command.renew_cert]
46+
}
47+
}
48+
}
49+
50+
action "stackit_run_command" "renew_cert" {
51+
config {
52+
project_id = var.stackit_project_id
53+
server_id = stackit_server.example.server_id
54+
region = "eu01"
55+
command_template_name = "RunShellScript"
56+
parameters = {
57+
script = <<-EOT
58+
#!/bin/bash
59+
set -euo pipefail
60+
openssl req -x509 -nodes -newkey rsa:2048 -days 90 \
61+
-subj "/CN=action-server" \
62+
-keyout /root/server.key \
63+
-out /root/server.crt
64+
echo "renewed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> /root/cert.log
65+
openssl x509 -in /root/server.crt -noout -dates >> /root/cert.log
66+
EOT
67+
}
68+
}
69+
}
70+
```
71+
72+
<!-- action schema generated by tfplugindocs -->
73+
## Schema
74+
75+
### Required
76+
77+
- `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`
78+
- `project_id` (String) STACKIT Project ID to which the server belongs.
79+
- `server_id` (String) The ID of the server on which to execute the command.
80+
81+
### Optional
82+
83+
- `parameters` (Map of String) Optional parameters passed to the command template as key-value pairs.
84+
- `region` (String) The region of the server. If not defined, the provider default_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
- `rabbitmq_custom_endpoint` (String) Custom endpoint for the RabbitMQ service
206206
- `redis_custom_endpoint` (String) Custom endpoint for the Redis service
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: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
resource "time_rotating" "rotate" {
2+
rotation_days = 30
3+
}
4+
5+
resource "stackit_server" "example" {
6+
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
7+
name = "example"
8+
machine_type = "g2i.4"
9+
availability_zone = "eu01-1"
10+
11+
boot_volume = {
12+
source_type = "image"
13+
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
14+
size = 32
15+
delete_on_termination = true
16+
}
17+
18+
agent = {
19+
provisioning_policy = "ALWAYS"
20+
}
21+
22+
# Changing this label triggers after_update -> cert is regenerated.
23+
labels = {
24+
cert_rotation_id = substr(sha256(time_rotating.rotate.id), 0, 63)
25+
}
26+
27+
lifecycle {
28+
action_trigger {
29+
events = [after_update]
30+
actions = [action.stackit_run_command.renew_cert]
31+
}
32+
}
33+
}
34+
35+
action "stackit_run_command" "renew_cert" {
36+
config {
37+
project_id = var.stackit_project_id
38+
server_id = stackit_server.example.server_id
39+
region = "eu01"
40+
command_template_name = "RunShellScript"
41+
parameters = {
42+
script = <<-EOT
43+
#!/bin/bash
44+
set -euo pipefail
45+
openssl req -x509 -nodes -newkey rsa:2048 -days 90 \
46+
-subj "/CN=action-server" \
47+
-keyout /root/server.key \
48+
-out /root/server.crt
49+
echo "renewed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> /root/cert.log
50+
openssl x509 -in /root/server.crt -noout -dates >> /root/cert.log
51+
EOT
52+
}
53+
}
54+
}

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ require (
3838
github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v1.3.0
3939
github.com/stackitcloud/stackit-sdk-go/services/redis v1.4.0
4040
github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.2
41+
github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.10.0
4142
github.com/stackitcloud/stackit-sdk-go/services/scf v0.10.1
4243
github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.18.2
4344
github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.7.1
@@ -83,7 +84,7 @@ require (
8384
github.com/hashicorp/hcl/v2 v2.24.0 // indirect
8485
github.com/hashicorp/logutils v1.0.0 // indirect
8586
github.com/hashicorp/terraform-exec v0.25.1 // indirect
86-
github.com/hashicorp/terraform-json v0.27.2 // indirect
87+
github.com/hashicorp/terraform-json v0.27.2
8788
github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 // indirect
8889
github.com/hashicorp/terraform-registry-address v0.4.0 // indirect
8990
github.com/hashicorp/terraform-svchost v0.2.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ github.com/stackitcloud/stackit-sdk-go/services/redis v1.4.0 h1:/f1sItmKRplpM8Ka
207207
github.com/stackitcloud/stackit-sdk-go/services/redis v1.4.0/go.mod h1:yjej6QfYoYdRIyKXlmbVz8fZYxbuUdl+QBkvLDPgA4k=
208208
github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.2 h1:4UxxJmCSCwV8q4bT4G+D1JH8F9Gm6BKaLGixX5DVcvI=
209209
github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.2/go.mod h1:NEz3f+GV5G++BE9/MmZCsXJyCih7jtg0pZuSyG2sLEs=
210+
github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.10.0 h1:I8fMdzOB49VQIZ9VoWWzAeVQm/kCnJhc6623KFxoxww=
211+
github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.10.0/go.mod h1:kvv2I3BO4woruWMeRpmKrnnBx5G42opTxP9eeqnneK8=
210212
github.com/stackitcloud/stackit-sdk-go/services/scf v0.10.1 h1:KCmsMjiKdTcbw7zQH4hVGjzSL8sZQHENGspyJYB+EFM=
211213
github.com/stackitcloud/stackit-sdk-go/services/scf v0.10.1/go.mod h1:w3rXz3Klz5XxpI7eXpvZTYlu8LQkZJiFxKg6DNBgenQ=
212214
github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.18.2 h1:1MvnXjo5C73HPErKY7pWIg7j2QXrH9z/axCVihEIvNo=

stackit/internal/core/core.go

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

0 commit comments

Comments
 (0)