Skip to content
Draft
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
84 changes: 84 additions & 0 deletions docs/actions/run_command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_run_command Action - stackit"
subcategory: ""
description: |-
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.
---

# stackit_run_command (Action)

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.

## Example Usage

```terraform
resource "time_rotating" "rotate" {
rotation_days = 30
}

resource "stackit_server" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example"
machine_type = "g2i.4"
availability_zone = "eu01-1"

boot_volume = {
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
size = 32
delete_on_termination = true
}

agent = {
provisioning_policy = "ALWAYS"
}

# Changing this label triggers after_update -> cert is regenerated.
labels = {
cert_rotation_id = substr(sha256(time_rotating.rotate.id), 0, 63)
}

lifecycle {
action_trigger {
events = [after_update]
actions = [action.stackit_run_command.renew_cert]
}
}
}

action "stackit_run_command" "renew_cert" {
config {
project_id = var.stackit_project_id
server_id = stackit_server.example.server_id
region = "eu01"
command_template_name = "RunShellScript"
parameters = {
script = <<-EOT
#!/bin/bash
set -euo pipefail
openssl req -x509 -nodes -newkey rsa:2048 -days 90 \
-subj "/CN=action-server" \
-keyout /root/server.key \
-out /root/server.crt
echo "renewed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> /root/cert.log
openssl x509 -in /root/server.crt -noout -dates >> /root/cert.log
EOT
}
}
}
```

<!-- action schema generated by tfplugindocs -->
## Schema

### Required

- `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`
- `project_id` (String) STACKIT Project ID to which the server belongs.
- `server_id` (String) The ID of the server on which to execute the command.

### Optional

- `parameters` (Map of String) Optional parameters passed to the command template as key-value pairs.
- `region` (String) The region of the server. If not defined, the provider default_region is used.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ See this [example](https://professional-service.git.onstackit.cloud/professional
- `rabbitmq_custom_endpoint` (String) Custom endpoint for the RabbitMQ service
- `redis_custom_endpoint` (String) Custom endpoint for the Redis service
- `resourcemanager_custom_endpoint` (String) Custom endpoint for the Resource Manager service
- `run_command_custom_endpoint` (String) Custom endpoint for the Run Command service
- `scf_custom_endpoint` (String) Custom endpoint for the Cloud Foundry (SCF) service
- `secretsmanager_custom_endpoint` (String) Custom endpoint for the Secrets Manager service
- `server_backup_custom_endpoint` (String) Custom endpoint for the Server Backup service
Expand Down
54 changes: 54 additions & 0 deletions examples/actions/stackit_run_command/action.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
resource "time_rotating" "rotate" {
rotation_days = 30
}

resource "stackit_server" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example"
machine_type = "g2i.4"
availability_zone = "eu01-1"

boot_volume = {
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
size = 32
delete_on_termination = true
}

agent = {
provisioning_policy = "ALWAYS"
}

# Changing this label triggers after_update -> cert is regenerated.
labels = {
cert_rotation_id = substr(sha256(time_rotating.rotate.id), 0, 63)
}

lifecycle {
action_trigger {
events = [after_update]
actions = [action.stackit_run_command.renew_cert]
}
}
}

action "stackit_run_command" "renew_cert" {
config {
project_id = var.stackit_project_id
server_id = stackit_server.example.server_id
region = "eu01"
command_template_name = "RunShellScript"
parameters = {
script = <<-EOT
#!/bin/bash
set -euo pipefail
openssl req -x509 -nodes -newkey rsa:2048 -days 90 \
-subj "/CN=action-server" \
-keyout /root/server.key \
-out /root/server.crt
echo "renewed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> /root/cert.log
openssl x509 -in /root/server.crt -noout -dates >> /root/cert.log
EOT
}
}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ require (
github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v1.1.1
github.com/stackitcloud/stackit-sdk-go/services/redis v1.1.1
github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.0
github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.9.2
github.com/stackitcloud/stackit-sdk-go/services/scf v0.10.0
github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.18.1
github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.7.0
Expand Down Expand Up @@ -111,3 +112,5 @@ require (
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

replace github.com/stackitcloud/stackit-sdk-go/services/runcommand => ../stackit-sdk-go/services/runcommand
1 change: 1 addition & 0 deletions stackit/internal/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ type ProviderData struct {
ScfCustomEndpoint string
SecretsManagerCustomEndpoint string
SQLServerFlexCustomEndpoint string
RunCommandCustomEndpoint string
ServerBackupCustomEndpoint string
ServerUpdateCustomEndpoint string
SKECustomEndpoint string
Expand Down
195 changes: 195 additions & 0 deletions stackit/internal/services/runcommand/command/action.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package runcommand

import (
"context"
"fmt"
"strconv"

"github.com/hashicorp/terraform-plugin-framework/action"
"github.com/hashicorp/terraform-plugin-framework/action/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api"
"github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api/wait"

"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
runCommandUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/runcommand/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)

// Ensure the implementation satisfies the expected interfaces.
var (
_ action.Action = &runCommandAction{}
_ action.ActionWithConfigure = &runCommandAction{}
)

type runCommandModel struct {
ProjectId types.String `tfsdk:"project_id"`
ServerId types.String `tfsdk:"server_id"`
Region types.String `tfsdk:"region"`
CommandTemplateName types.String `tfsdk:"command_template_name"`
Parameters types.Map `tfsdk:"parameters"`
}

// NewRunCommandAction is a helper function to simplify the provider implementation.
func NewRunCommandAction() action.Action {
return &runCommandAction{}
}

// runCommandAction is the action implementation.
type runCommandAction struct {
client *v1api.APIClient
providerData core.ProviderData
}

// Metadata returns the action type name.
func (a *runCommandAction) Metadata(_ context.Context, req action.MetadataRequest, resp *action.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_run_command"
}

// Configure adds the provider configured client to the action.
func (a *runCommandAction) Configure(ctx context.Context, req action.ConfigureRequest, resp *action.ConfigureResponse) {
var ok bool
a.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
a.client = runCommandUtils.ConfigureClient(ctx, &a.providerData, a.providerData.DefaultRegion, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Run command client configured")
}

// Schema defines the schema for the action.
func (a *runCommandAction) Schema(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) {
descriptions := map[string]string{
"main": "Executes a command on an IaaS server using the STACKIT Run Commands API. " + core.ResourceRegionFallbackDocstring,
"project_id": "STACKIT Project ID to which the server belongs.",
"server_id": "The ID of the server on which to execute the command.",
"region": "The region of the server. If not defined, the provider default_region is used.",
"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`",
"parameters": "Optional parameters passed to the command template as key-value pairs.",
}

resp.Schema = schema.Schema{
Description: descriptions["main"],
Attributes: map[string]schema.Attribute{
"project_id": schema.StringAttribute{
Description: descriptions["project_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"server_id": schema.StringAttribute{
Description: descriptions["server_id"],
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"region": schema.StringAttribute{
Description: descriptions["region"],
Optional: true,
},
"command_template_name": schema.StringAttribute{
Description: descriptions["command_template_name"],
Required: true,
},
"parameters": schema.MapAttribute{
Description: descriptions["parameters"],
Optional: true,
ElementType: types.StringType,
},
},
}
}

// Invoke executes the run command action.
func (a *runCommandAction) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) {
var model runCommandModel
resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}

ctx = core.InitProviderContext(ctx)

projectId := model.ProjectId.ValueString()
serverId := model.ServerId.ValueString()
region := a.providerData.GetRegionWithOverride(model.Region)

ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "server_id", serverId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "command_template_name", model.CommandTemplateName.ValueString())

payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error invoking run command", fmt.Sprintf("Building API payload: %v", err))
return
}

resp.SendProgress(action.InvokeProgressEvent{
Message: fmt.Sprintf("Waiting for agent on server %s to be ready...", serverId),
})

// waits for the agent to register (404 while booting) and submits the command in one step
createResp, err := wait.AgentReadyWaitHandler(ctx, a.client.DefaultAPI, projectId, serverId, *payload).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error invoking run command", fmt.Sprintf("Waiting for agent / calling API: %v", err))
return
}
if createResp == nil || createResp.Id == nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error invoking run command", "API returned empty response or missing command ID")
return
}

commandId := createResp.GetId()
commandIdStr := strconv.Itoa(int(commandId))
ctx = tflog.SetField(ctx, "command_id", commandIdStr)

resp.SendProgress(action.InvokeProgressEvent{
Message: fmt.Sprintf("Command %q submitted (ID: %s). Waiting for completion...", model.CommandTemplateName.ValueString(), commandIdStr),
})

details, err := wait.RunCommandWaitHandler(ctx, a.client.DefaultAPI, projectId, serverId, commandIdStr).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error waiting for run command", fmt.Sprintf("Polling API: %v", err))
return
}

if details.GetStatus() == v1api.COMMANDDETAILSSTATUS_FAILED {
resp.Diagnostics.AddError(
"Run command failed",
fmt.Sprintf("Command %s finished with status %q (exit code: %d).\nOutput:\n%s", commandIdStr, details.GetStatus(), details.GetExitCode(), details.GetOutput()),
)
return
}

tflog.Info(ctx, fmt.Sprintf("Run command %s completed successfully", commandIdStr))
}

func toCreatePayload(ctx context.Context, model *runCommandModel) (*v1api.CreateCommandPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}

payload := v1api.NewCreateCommandPayload(model.CommandTemplateName.ValueString())

if !model.Parameters.IsNull() && !model.Parameters.IsUnknown() {
params := map[string]string{}
diags := model.Parameters.ElementsAs(ctx, &params, false)
if diags.HasError() {
return nil, fmt.Errorf("converting parameters: %v", diags.Errors())
}
payload.SetParameters(params)
}

return payload, nil
}
Loading
Loading