diff --git a/docs/actions/run_command.md b/docs/actions/run_command.md new file mode 100644 index 000000000..a27d42156 --- /dev/null +++ b/docs/actions/run_command.md @@ -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 + } + } +} +``` + + +## 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. diff --git a/docs/index.md b/docs/index.md index 268b071db..eaab834ec 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/examples/actions/stackit_run_command/action.tf b/examples/actions/stackit_run_command/action.tf new file mode 100644 index 000000000..b107d598f --- /dev/null +++ b/examples/actions/stackit_run_command/action.tf @@ -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 + } + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index 5ca60f99a..d09bcd0e0 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 90ea4c0bf..302a0e770 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -67,6 +67,7 @@ type ProviderData struct { ScfCustomEndpoint string SecretsManagerCustomEndpoint string SQLServerFlexCustomEndpoint string + RunCommandCustomEndpoint string ServerBackupCustomEndpoint string ServerUpdateCustomEndpoint string SKECustomEndpoint string diff --git a/stackit/internal/services/runcommand/command/action.go b/stackit/internal/services/runcommand/command/action.go new file mode 100644 index 000000000..51a4a10fc --- /dev/null +++ b/stackit/internal/services/runcommand/command/action.go @@ -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, ¶ms, false) + if diags.HasError() { + return nil, fmt.Errorf("converting parameters: %v", diags.Errors()) + } + payload.SetParameters(params) + } + + return payload, nil +} diff --git a/stackit/internal/services/runcommand/command/action_test.go b/stackit/internal/services/runcommand/command/action_test.go new file mode 100644 index 000000000..7541a5a33 --- /dev/null +++ b/stackit/internal/services/runcommand/command/action_test.go @@ -0,0 +1,120 @@ +package runcommand + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api" +) + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *runCommandModel + expected *v1api.CreateCommandPayload + isValid bool + }{ + { + description: "nil model", + input: nil, + expected: nil, + isValid: false, + }, + { + description: "template name only no parameters", + input: &runCommandModel{ + CommandTemplateName: types.StringValue("RunShellScript"), + Parameters: types.MapNull(types.StringType), + }, + expected: func() *v1api.CreateCommandPayload { + p := v1api.NewCreateCommandPayload("RunShellScript") + return p + }(), + isValid: true, + }, + { + description: "template name with parameters", + input: &runCommandModel{ + CommandTemplateName: types.StringValue("RunShellScript"), + Parameters: types.MapValueMust(types.StringType, map[string]attr.Value{ + "script": types.StringValue("echo hello"), + }), + }, + expected: func() *v1api.CreateCommandPayload { + p := v1api.NewCreateCommandPayload("RunShellScript") + p.SetParameters(map[string]string{"script": "echo hello"}) + return p + }(), + isValid: true, + }, + { + description: "template name with multiple parameters", + input: &runCommandModel{ + CommandTemplateName: types.StringValue("RunPowerShellScript"), + Parameters: types.MapValueMust(types.StringType, map[string]attr.Value{ + "script": types.StringValue("Write-Output 'hello'"), + "timeout": types.StringValue("30"), + }), + }, + expected: func() *v1api.CreateCommandPayload { + p := v1api.NewCreateCommandPayload("RunPowerShellScript") + p.SetParameters(map[string]string{ + "script": "Write-Output 'hello'", + "timeout": "30", + }) + return p + }(), + isValid: true, + }, + { + description: "empty parameters map", + input: &runCommandModel{ + CommandTemplateName: types.StringValue("RunShellScript"), + Parameters: types.MapValueMust(types.StringType, map[string]attr.Value{}), + }, + expected: func() *v1api.CreateCommandPayload { + p := v1api.NewCreateCommandPayload("RunShellScript") + p.SetParameters(map[string]string{}) + return p + }(), + isValid: true, + }, + { + description: "empty template name", + input: &runCommandModel{ + CommandTemplateName: types.StringValue(""), + Parameters: types.MapNull(types.StringType), + }, + expected: func() *v1api.CreateCommandPayload { + p := v1api.NewCreateCommandPayload("") + return p + }(), + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + ctx := context.TODO() + output, err := toCreatePayload(ctx, tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected, + cmpopts.IgnoreUnexported(v1api.CreateCommandPayload{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/runcommand/runcommand_acc_test.go b/stackit/internal/services/runcommand/runcommand_acc_test.go new file mode 100644 index 000000000..50d48cc02 --- /dev/null +++ b/stackit/internal/services/runcommand/runcommand_acc_test.go @@ -0,0 +1,119 @@ +package runcommand_test + +import ( + "context" + _ "embed" + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api" + runcommand "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +//go:embed testdata/action.tf +var actionConfig string + +func TestAccRunCommandAction(t *testing.T) { + randSuffix := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + testConfigVars := config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "server_name": config.StringVariable(fmt.Sprintf("tf-acc-srv-%s", randSuffix)), + "network_name": config.StringVariable(fmt.Sprintf("tf-acc-net-%s", randSuffix)), + "machine_type": config.StringVariable("g2i.1"), + "region": config.StringVariable(testutil.Region), + "availability_zone": config.StringVariable("eu01-1"), + "script": config.StringVariable("echo 'acceptance test' > /root/acc-test.txt"), + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckServerDestroy, + Steps: []resource.TestStep{ + { + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + actionConfig, + ConfigVariables: testConfigVars, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("stackit_server.server", "server_id"), + resource.TestCheckResourceAttr("stackit_server.server", "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr("stackit_server.server", "availability_zone", "eu01-1"), + resource.TestCheckResourceAttr("stackit_network_interface.nic", "security", "false"), + testAccCheckRunCommandExecuted, + ), + }, + }, + }) +} + +// testAccCheckRunCommandExecuted verifies that at least one command was recorded +// for the server after the action_trigger fired. +func testAccCheckRunCommandExecuted(s *terraform.State) error { + ctx := context.Background() + + client, err := runcommand.NewAPIClient( + testutil.NewConfigBuilder().BuildClientOptions(testutil.RunCommandCustomEndpoint, true)..., + ) + if err != nil { + return fmt.Errorf("creating run command client: %w", err) + } + + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_server" { + continue + } + // Terraform ID format: "[project_id],[region],[server_id]" + parts := strings.Split(rs.Primary.ID, core.Separator) + if len(parts) < 3 { + return fmt.Errorf("unexpected server ID format: %s", rs.Primary.ID) + } + projectId, serverId := parts[0], parts[2] + + cmdsResp, err := client.DefaultAPI.ListCommands(ctx, projectId, serverId).Execute() + if err != nil { + return fmt.Errorf("listing commands for server %s: %w", serverId, err) + } + if cmdsResp == nil || len(cmdsResp.GetItems()) == 0 { + return fmt.Errorf("expected at least one command for server %s, got none", serverId) + } + return nil + } + + return fmt.Errorf("no stackit_server resource found in state") +} + +func testAccCheckServerDestroy(s *terraform.State) error { + ctx := context.Background() + + client, err := iaas.NewAPIClient( + testutil.NewConfigBuilder().BuildClientOptions(testutil.IaaSCustomEndpoint, false)..., + ) + if err != nil { + return fmt.Errorf("creating iaas client: %w", err) + } + + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_server" { + continue + } + parts := strings.Split(rs.Primary.ID, core.Separator) + if len(parts) < 3 { + continue + } + projectId, region, serverId := parts[0], parts[1], parts[2] + + _, err := client.DefaultAPI.GetServer(ctx, projectId, region, serverId).Execute() + if err == nil { + return fmt.Errorf("server %s still exists after destroy", serverId) + } + } + + return nil +} diff --git a/stackit/internal/services/runcommand/testdata/action.tf b/stackit/internal/services/runcommand/testdata/action.tf new file mode 100644 index 000000000..5ab01a2a3 --- /dev/null +++ b/stackit/internal/services/runcommand/testdata/action.tf @@ -0,0 +1,64 @@ +variable "project_id" {} +variable "server_name" {} +variable "network_name" {} +variable "machine_type" {} +variable "region" {} +variable "availability_zone" {} +variable "script" {} + +resource "stackit_network" "network" { + project_id = var.project_id + name = var.network_name +} + +resource "stackit_network_interface" "nic" { + project_id = var.project_id + network_id = stackit_network.network.network_id + security = false +} + +data "stackit_image_v2" "ubuntu" { + project_id = var.project_id + name = "Ubuntu 24.04" +} + +action "stackit_run_command" "test_action" { + config { + project_id = var.project_id + server_id = stackit_server.server.server_id + region = var.region + command_template_name = "RunShellScript" + parameters = { + script = var.script + } + } +} + +resource "stackit_server" "server" { + project_id = var.project_id + name = var.server_name + machine_type = var.machine_type + availability_zone = var.availability_zone + + boot_volume = { + source_type = "image" + source_id = data.stackit_image_v2.ubuntu.image_id + size = 32 + delete_on_termination = true + } + + network_interfaces = [ + stackit_network_interface.nic.network_interface_id + ] + + agent = { + provisioning_policy = "ALWAYS" + } + + lifecycle { + action_trigger { + events = [after_create] + actions = [action.stackit_run_command.test_action] + } + } +} diff --git a/stackit/internal/services/runcommand/utils/util.go b/stackit/internal/services/runcommand/utils/util.go new file mode 100644 index 000000000..1f6c0b45b --- /dev/null +++ b/stackit/internal/services/runcommand/utils/util.go @@ -0,0 +1,31 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + providerUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +func ConfigureClient(ctx context.Context, providerData *core.ProviderData, region string, diags *diag.Diagnostics) *v1api.APIClient { + apiClientConfigOptions := []config.ConfigurationOption{ + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(region), + providerUtils.UserAgentConfigOption(providerData.Version), + } + if providerData.RunCommandCustomEndpoint != "" { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.RunCommandCustomEndpoint)) + } + apiClient, err := v1api.NewAPIClient(apiClientConfigOptions...) + if err != nil { + core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return nil + } + + return apiClient +} diff --git a/stackit/internal/services/runcommand/utils/util_test.go b/stackit/internal/services/runcommand/utils/util_test.go new file mode 100644 index 000000000..fb4f2caee --- /dev/null +++ b/stackit/internal/services/runcommand/utils/util_test.go @@ -0,0 +1,100 @@ +package utils + +import ( + "context" + "os" + "reflect" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + sdkClients "github.com/stackitcloud/stackit-sdk-go/core/clients" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/runcommand/v1api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +const ( + testVersion = "1.2.3" + testRegion = "eu01" + testCustomEndpoint = "https://run-command-custom-endpoint.api.stackit.cloud" +) + +func TestConfigureClient(t *testing.T) { + /* mock authentication by setting service account token env variable */ + os.Clearenv() + err := os.Setenv(sdkClients.ServiceAccountToken, "mock-val") + if err != nil { + t.Errorf("error setting env variable: %v", err) + } + + type args struct { + providerData *core.ProviderData + region string + } + tests := []struct { + name string + args args + wantErr bool + expected *v1api.APIClient + }{ + { + name: "default endpoint", + args: args{ + providerData: &core.ProviderData{ + Version: testVersion, + }, + region: testRegion, + }, + expected: func() *v1api.APIClient { + apiClient, err := v1api.NewAPIClient( + utils.UserAgentConfigOption(testVersion), + config.WithRegion(testRegion), + ) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + return apiClient + }(), + wantErr: false, + }, + { + name: "custom endpoint", + args: args{ + providerData: &core.ProviderData{ + Version: testVersion, + RunCommandCustomEndpoint: testCustomEndpoint, + }, + region: testRegion, + }, + expected: func() *v1api.APIClient { + apiClient, err := v1api.NewAPIClient( + utils.UserAgentConfigOption(testVersion), + config.WithRegion(testRegion), + config.WithEndpoint(testCustomEndpoint), + ) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + return apiClient + }(), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + diags := diag.Diagnostics{} + + actual := ConfigureClient(ctx, tt.args.providerData, tt.args.region, &diags) + if diags.HasError() != tt.wantErr { + t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr) + } + + if !reflect.DeepEqual(actual, tt.expected) { + t.Errorf("ConfigureClient() = %v, want %v", actual, tt.expected) + } + }) + } +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index f2c596c53..e606a94df 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -101,6 +101,7 @@ var ( SFSCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SFS_CUSTOM_ENDPOINT", providerName: "sfs_custom_endpoint"} ServiceAccountCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SERVICE_ACCOUNT_CUSTOM_ENDPOINT", providerName: "service_account_custom_endpoint"} ServiceEnablementCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SERVICE_ENABLEMENT_CUSTOM_ENDPOINT", providerName: "service_enablement_custom_endpoint"} + RunCommandCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_RUN_COMMAND_CUSTOM_ENDPOINT", providerName: "run_command_custom_endpoint"} TokenCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_TOKEN_CUSTOM_ENDPOINT", providerName: "token_custom_endpoint"} VpnCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_VPN_CUSTOM_ENDPOINT", providerName: "vpn_custom_endpoint"} SKECustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SKE_CUSTOM_ENDPOINT", providerName: "ske_custom_endpoint"} @@ -136,6 +137,7 @@ var ( ScfCustomEndpoint, SecretsManagerCustomEndpoint, SQLServerFlexCustomEndpoint, + RunCommandCustomEndpoint, ServerBackupCustomEndpoint, ServerUpdateCustomEndpoint, SFSCustomEndpoint, diff --git a/stackit/provider.go b/stackit/provider.go index 49c1af158..dd855164a 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -108,6 +109,7 @@ import ( redisInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/redis/instance" resourceManagerFolder "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/resourcemanager/folder" resourceManagerProject "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/resourcemanager/project" + runCommandAction "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/runcommand/command" scfOrganization "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/scf/organization" scfOrganizationmanager "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/scf/organizationmanager" scfPlatform "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/scf/platform" @@ -148,6 +150,7 @@ import ( // Ensure the implementation satisfies the expected interfaces var ( _ provider.Provider = &Provider{} + _ provider.ProviderWithActions = &Provider{} _ provider.ProviderWithEphemeralResources = &Provider{} ) @@ -213,6 +216,7 @@ type providerModel struct { ResourceManagerCustomEndpoint types.String `tfsdk:"resourcemanager_custom_endpoint"` ScfCustomEndpoint types.String `tfsdk:"scf_custom_endpoint"` SecretsManagerCustomEndpoint types.String `tfsdk:"secretsmanager_custom_endpoint"` + RunCommandCustomEndpoint types.String `tfsdk:"run_command_custom_endpoint"` ServerBackupCustomEndpoint types.String `tfsdk:"server_backup_custom_endpoint"` ServerUpdateCustomEndpoint types.String `tfsdk:"server_update_custom_endpoint"` ServiceAccountCustomEndpoint types.String `tfsdk:"service_account_custom_endpoint"` @@ -272,6 +276,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "opensearch_custom_endpoint": "Custom endpoint for the OpenSearch service", "postgresflex_custom_endpoint": "Custom endpoint for the PostgresFlex service", "redis_custom_endpoint": "Custom endpoint for the Redis service", + "run_command_custom_endpoint": "Custom endpoint for the Run Command service", "server_backup_custom_endpoint": "Custom endpoint for the Server Backup service", "server_update_custom_endpoint": "Custom endpoint for the Server Update service", "service_account_custom_endpoint": "Custom endpoint for the Service Account service", @@ -477,6 +482,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["ske_custom_endpoint"], }, + "run_command_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["run_command_custom_endpoint"], + }, "server_backup_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["server_backup_custom_endpoint"], @@ -591,6 +600,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, setStringField(providerConfig.ResourceManagerCustomEndpoint, func(v string) { providerData.ResourceManagerCustomEndpoint = v }) setStringField(providerConfig.ScfCustomEndpoint, func(v string) { providerData.ScfCustomEndpoint = v }) setStringField(providerConfig.SecretsManagerCustomEndpoint, func(v string) { providerData.SecretsManagerCustomEndpoint = v }) + setStringField(providerConfig.RunCommandCustomEndpoint, func(v string) { providerData.RunCommandCustomEndpoint = v }) setStringField(providerConfig.ServerBackupCustomEndpoint, func(v string) { providerData.ServerBackupCustomEndpoint = v }) setStringField(providerConfig.ServerUpdateCustomEndpoint, func(v string) { providerData.ServerUpdateCustomEndpoint = v }) setStringField(providerConfig.ServiceAccountCustomEndpoint, func(v string) { providerData.ServiceAccountCustomEndpoint = v }) @@ -665,6 +675,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, resp.DataSourceData = providerData resp.ResourceData = providerData + resp.ActionData = providerData // Copy service account, private key credentials and custom-token endpoint to support ephemeral access token generation var ephemeralProviderData core.EphemeralProviderData @@ -912,3 +923,10 @@ func (p *Provider) EphemeralResources(_ context.Context) []func() ephemeral.Ephe access_token.NewAccessTokenEphemeralResource, } } + +// Actions defines the actions implemented in the provider. +func (p *Provider) Actions(_ context.Context) []func() action.Action { + return []func() action.Action{ + runCommandAction.NewRunCommandAction, + } +}