Skip to content

Commit 34605fd

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

14 files changed

Lines changed: 856 additions & 0 deletions

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
@@ -206,6 +206,7 @@ See this [example](https://professional-service.git.onstackit.cloud/professional
206206
- `rabbitmq_custom_endpoint` (String) Custom endpoint for the RabbitMQ service
207207
- `redis_custom_endpoint` (String) Custom endpoint for the Redis service
208208
- `resourcemanager_custom_endpoint` (String) Custom endpoint for the Resource Manager service
209+
- `run_command_custom_endpoint` (String) Custom endpoint for the Run Command service
209210
- `scf_custom_endpoint` (String) Custom endpoint for the Cloud Foundry (SCF) service
210211
- `secretsmanager_custom_endpoint` (String) Custom endpoint for the Secrets Manager service
211212
- `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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ require (
3939
github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v1.3.0
4040
github.com/stackitcloud/stackit-sdk-go/services/redis v1.4.0
4141
github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.2
42+
github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.10.0
4243
github.com/stackitcloud/stackit-sdk-go/services/scf v0.10.1
4344
github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.19.0
4445
github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.7.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ github.com/stackitcloud/stackit-sdk-go/services/redis v1.4.0 h1:/f1sItmKRplpM8Ka
211211
github.com/stackitcloud/stackit-sdk-go/services/redis v1.4.0/go.mod h1:yjej6QfYoYdRIyKXlmbVz8fZYxbuUdl+QBkvLDPgA4k=
212212
github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.2 h1:4UxxJmCSCwV8q4bT4G+D1JH8F9Gm6BKaLGixX5DVcvI=
213213
github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.2/go.mod h1:NEz3f+GV5G++BE9/MmZCsXJyCih7jtg0pZuSyG2sLEs=
214+
github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.10.0 h1:I8fMdzOB49VQIZ9VoWWzAeVQm/kCnJhc6623KFxoxww=
215+
github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.10.0/go.mod h1:kvv2I3BO4woruWMeRpmKrnnBx5G42opTxP9eeqnneK8=
214216
github.com/stackitcloud/stackit-sdk-go/services/scf v0.10.1 h1:KCmsMjiKdTcbw7zQH4hVGjzSL8sZQHENGspyJYB+EFM=
215217
github.com/stackitcloud/stackit-sdk-go/services/scf v0.10.1/go.mod h1:w3rXz3Klz5XxpI7eXpvZTYlu8LQkZJiFxKg6DNBgenQ=
216218
github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.19.0 h1:ezEJd42YyjKvPNZEsXJodyTyGzRs+vJNLEL9SSeeQzA=

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: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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+
// Retry on 404: the Run Command agent returns 404 until it is installed and ready on the server.
146+
// There is no dedicated readiness endpoint, so we poll CreateCommand until it succeeds.
147+
createResp, err := providerUtils.RetryRequest(ctx,
148+
a.client.DefaultAPI.CreateCommand(ctx, projectId, serverId, region).CreateCommandPayload(*payload).Execute,
149+
providerUtils.RetryConfig{
150+
Attempts: 60,
151+
Delay: 10 * time.Second,
152+
RetryStatusCodes: []int{http.StatusNotFound},
153+
},
154+
)
155+
if err != nil {
156+
core.LogAndAddError(ctx, &resp.Diagnostics, "Error invoking run command", fmt.Sprintf("Waiting for agent / calling API: %v", err))
157+
return
158+
}
159+
if createResp == nil || createResp.Id == nil {
160+
core.LogAndAddError(ctx, &resp.Diagnostics, "Error invoking run command", "API returned empty response or missing command ID")
161+
return
162+
}
163+
164+
commandId := createResp.GetId()
165+
commandIdStr := strconv.Itoa(int(commandId))
166+
ctx = tflog.SetField(ctx, "command_id", commandIdStr)
167+
168+
resp.SendProgress(action.InvokeProgressEvent{
169+
Message: fmt.Sprintf("Command %q submitted (ID: %s). Waiting for completion...", model.CommandTemplateName.ValueString(), commandIdStr),
170+
})
171+
172+
details, err := wait.RunCommandWaitHandler(ctx, a.client.DefaultAPI, projectId, serverId, region, commandIdStr).WaitWithContext(ctx)
173+
if err != nil {
174+
errDetail := fmt.Sprintf("Polling API: %v", err)
175+
if details != nil {
176+
errDetail = fmt.Sprintf("Command %s finished with status %q (exit code: %d).\nOutput:\n%s",
177+
commandIdStr, details.GetStatus(), details.GetExitCode(), details.GetOutput())
178+
}
179+
core.LogAndAddError(ctx, &resp.Diagnostics, "Error waiting for run command", errDetail)
180+
return
181+
}
182+
183+
tflog.Info(ctx, fmt.Sprintf("Run command %s completed successfully", commandIdStr))
184+
}
185+
186+
func toCreatePayload(ctx context.Context, model *runCommandModel) (*v2api.CreateCommandPayload, error) {
187+
if model == nil {
188+
return nil, fmt.Errorf("nil model")
189+
}
190+
191+
payload := v2api.NewCreateCommandPayload(model.CommandTemplateName.ValueString())
192+
193+
if !model.Parameters.IsNull() && !model.Parameters.IsUnknown() {
194+
params := map[string]string{}
195+
diags := model.Parameters.ElementsAs(ctx, &params, false)
196+
if diags.HasError() {
197+
return nil, fmt.Errorf("converting parameters: %v", diags.Errors())
198+
}
199+
payload.SetParameters(params)
200+
}
201+
202+
return payload, nil
203+
}

0 commit comments

Comments
 (0)