diff --git a/docs/data-sources/intake_runner.md b/docs/data-sources/intake_runner.md new file mode 100644 index 000000000..bb995f4a7 --- /dev/null +++ b/docs/data-sources/intake_runner.md @@ -0,0 +1,34 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_intake_runner Data Source - stackit" +subcategory: "" +description: |- + Datasource for STACKIT Intake Runner. +--- + +# stackit_intake_runner (Data Source) + +Datasource for STACKIT Intake Runner. + + + + +## Schema + +### Required + +- `project_id` (String) STACKIT Project ID to which the runner is associated. +- `runner_id` (String) The runner ID. + +### Optional + +- `region` (String) The resource region. If not defined, the provider region is used. + +### Read-Only + +- `description` (String) The description of the runner. +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`region`,`runner_id`". +- `labels` (Map of String) User-defined labels. +- `max_message_size_kib` (Number) The maximum message size in KiB. +- `max_messages_per_hour` (Number) The maximum number of messages per hour. +- `name` (String) The name of the runner. diff --git a/docs/index.md b/docs/index.md index 8c93ec933..108fb183d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -163,6 +163,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de - `experiments` (List of String) Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: iam, routing-tables, network - `git_custom_endpoint` (String) Custom endpoint for the Git service - `iaas_custom_endpoint` (String) Custom endpoint for the IaaS service +- `intake_custom_endpoint` (String) Custom endpoint for the Intake service - `kms_custom_endpoint` (String) Custom endpoint for the KMS service - `loadbalancer_custom_endpoint` (String) Custom endpoint for the Load Balancer service - `logme_custom_endpoint` (String) Custom endpoint for the LogMe service diff --git a/docs/resources/intake_runner.md b/docs/resources/intake_runner.md new file mode 100644 index 000000000..215b5316f --- /dev/null +++ b/docs/resources/intake_runner.md @@ -0,0 +1,54 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_intake_runner Resource - stackit" +subcategory: "" +description: |- + Manages STACKIT Intake Runner. +--- + +# stackit_intake_runner (Resource) + +Manages STACKIT Intake Runner. + +## Example Usage + +```terraform +resource "stackit_intake_runner" "example" { + project_id = var.project_id + name = "example-runner-full" + description = "An example runner for STACKIT Intake" + max_message_size_kib = 2048 + max_messages_per_hour = 1500 + labels = { + "created_by" = "terraform-example" + "env" = "production" + } + region = var.region +} + +import { + to = stackit_intake_runner.example + id = "${var.project_id},${var.region},${var.runner_id}" +} +``` + + +## Schema + +### Required + +- `max_message_size_kib` (Number) The maximum message size in KiB. +- `max_messages_per_hour` (Number) The maximum number of messages per hour. +- `name` (String) The name of the runner. +- `project_id` (String) STACKIT Project ID to which the runner is associated. + +### Optional + +- `description` (String) The description of the runner. +- `labels` (Map of String) User-defined labels. +- `region` (String) The resource region. If not defined, the provider region is used. + +### Read-Only + +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`region`,`runner_id`". +- `runner_id` (String) The runner ID. diff --git a/examples/resources/stackit_intake_runner/resource.tf b/examples/resources/stackit_intake_runner/resource.tf new file mode 100644 index 000000000..311a9f41f --- /dev/null +++ b/examples/resources/stackit_intake_runner/resource.tf @@ -0,0 +1,17 @@ +resource "stackit_intake_runner" "example" { + project_id = var.project_id + name = "example-runner-full" + description = "An example runner for STACKIT Intake" + max_message_size_kib = 2048 + max_messages_per_hour = 1500 + labels = { + "created_by" = "terraform-example" + "env" = "production" + } + region = var.region +} + +import { + to = stackit_intake_runner.example + id = "${var.project_id},${var.region},${var.runner_id}" +} \ No newline at end of file diff --git a/go.mod b/go.mod index e700f0849..34c56cb8e 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/edge v0.4.1 github.com/stackitcloud/stackit-sdk-go/services/git v0.10.1 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.2 + github.com/stackitcloud/stackit-sdk-go/services/intake v0.4.2 github.com/stackitcloud/stackit-sdk-go/services/kms v1.3.0 github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.7.1 github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.4 @@ -26,7 +27,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.5.5 github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.4.2 github.com/stackitcloud/stackit-sdk-go/services/observability v0.15.1 - github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.4 + github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.3 github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.3.2 github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.25.3 github.com/stackitcloud/stackit-sdk-go/services/redis v0.25.4 @@ -38,7 +39,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.11.3 github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.2.4 github.com/stackitcloud/stackit-sdk-go/services/sfs v0.2.0 - github.com/stackitcloud/stackit-sdk-go/services/ske v1.6.1 + github.com/stackitcloud/stackit-sdk-go/services/ske v1.6.0 github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.4.1 github.com/teambition/rrule-go v1.8.2 golang.org/x/mod v0.32.0 diff --git a/go.sum b/go.sum index e75fed2bb..0d88055dc 100644 --- a/go.sum +++ b/go.sum @@ -163,6 +163,8 @@ github.com/stackitcloud/stackit-sdk-go/services/git v0.10.1 h1:3JKXfI5hdcXcRVBjU github.com/stackitcloud/stackit-sdk-go/services/git v0.10.1/go.mod h1:3nTaj8IGjNNGYUD2CpuXkXwc5c4giTUmoPggFhjVFxo= github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.2 h1:bdjhr77dlp02OWqlzaju4QG8nNraT4bWsxKtGWcQ2EI= github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.2/go.mod h1:VlDVK/MW8Obgacl5UPrfB8MSIfu4rS/fSYPI1i/fDac= +github.com/stackitcloud/stackit-sdk-go/services/intake v0.4.2 h1:xywfPSTBV6lqcnueI++KsyWvnZTKCfoCVp8/kzT/RXE= +github.com/stackitcloud/stackit-sdk-go/services/intake v0.4.2/go.mod h1:Ki7BldvSi1f5Lhy7iDeBkAhUvgXPCSAsaqFuxrkPDpg= github.com/stackitcloud/stackit-sdk-go/services/kms v1.3.0 h1:CMwegmCMidg1yrNy8NtEdMQ/9+28GwI/gOCT3isynls= github.com/stackitcloud/stackit-sdk-go/services/kms v1.3.0/go.mod h1:Kuem2KiTGZL1i6kL9LLOlvA9M+SatKaJbNWQ7o87Px4= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.7.1 h1:2UKKtBg+E5IFUcZAkRyKz1Km+tsA//zGjFQjVWwaXss= @@ -181,6 +183,8 @@ github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.4.2 h1:nsC6oA1w github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.4.2/go.mod h1:WA6QlAAQ8aaw81W0VSVoDrxOfchGkdtmn2jQL/ub/50= github.com/stackitcloud/stackit-sdk-go/services/observability v0.15.1 h1:zk+47GhutK2ajO4Yiek0laGm2PdXvY8BvFZc8yHFnSE= github.com/stackitcloud/stackit-sdk-go/services/observability v0.15.1/go.mod h1:vapb/sJqbHlf+c7pZWdE9GqrbyI8wesGvUc9o7oJ1Xk= +github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.3 h1:CAgu3Wsmo8pA1/VWqnqLftMn7X26uDs5zctTci4WG7A= +github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.3/go.mod h1:VC3vqIQIDN+8SAzhlMdrK4eXeiSaNE1JtjIGFzpgiRI= github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.4 h1:ppmezCAc8XBW6sdS5lvV6Edx0FOPwC/fosvH3FEIdps= github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.4/go.mod h1:c509eL4M/Qdg4FGT6qTh7r4E07FtB2Ui3sbbLERUBqk= github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.3.2 h1:uQIpj0phDRGrV78/vhtULwxaO2cBdHwqZcFKYUrH1Hs= @@ -205,6 +209,8 @@ github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.2.4 h1:h4aS github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.2.4/go.mod h1:Iv+svIxk5baXnvrEdvVl5JZri6a3H/2OrQDlRWmUFMI= github.com/stackitcloud/stackit-sdk-go/services/sfs v0.2.0 h1:DRp1p0Gb1YZSnFXgkiKTHQD9bFfqn6OC3PcsDjqGJiw= github.com/stackitcloud/stackit-sdk-go/services/sfs v0.2.0/go.mod h1:XHOtGgBwwCqPSoQt2ojIRb/BeOd4kICwb9RuMXXFGt8= +github.com/stackitcloud/stackit-sdk-go/services/ske v1.6.0 h1:Dab1jzN0u9c67lvELoWf1RuagjO3eUBRytoX8SYL8Zs= +github.com/stackitcloud/stackit-sdk-go/services/ske v1.6.0/go.mod h1:NzcTU5GGlUF6Lys3Ra7ylRj4ZKxJr3f/29/yoE5tjPI= github.com/stackitcloud/stackit-sdk-go/services/ske v1.6.1 h1:gWC/4rCroldoHtlSu1WMhYOZqXsNSoVCiLr/uQcIN1I= github.com/stackitcloud/stackit-sdk-go/services/ske v1.6.1/go.mod h1:gMb+Jx6OmkZ12GxVXf8UffSVQbkc+OO/Ml6AXz9ifhg= github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.4.1 h1:6MJdy1xmdE+uOo/F8mR5HSldjPSHpdhwuqS3u9m2EWQ= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 9817e6fd5..51f001251 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -49,6 +49,7 @@ type ProviderData struct { EdgeCloudCustomEndpoint string GitCustomEndpoint string IaaSCustomEndpoint string + IntakeCustomEndpoint string KMSCustomEndpoint string LoadBalancerCustomEndpoint string LogMeCustomEndpoint string diff --git a/stackit/internal/services/intake/resource_acc_test.go b/stackit/internal/services/intake/resource_acc_test.go new file mode 100644 index 000000000..5adb68761 --- /dev/null +++ b/stackit/internal/services/intake/resource_acc_test.go @@ -0,0 +1,239 @@ +package intake_test + +import ( + "context" + _ "embed" + "fmt" + "maps" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +//go:embed testdata/resource-min.tf +var resourceMin string + +//go:embed testdata/resource-max.tf +var resourceMax string + +const intakeRunnerResource = "stackit_intake_runner.example" + +const ( + intakeRunnerMinName = "intake-min-runner" + intakeRunnerMinNameUpdated = "intake-min-runner-upd" + intakeRunnerMaxName = "intake-max-runner" + intakeRunnerMaxNameUpdated = "intake-max-runner-upd" +) + +var testConfigVarsMin = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable(intakeRunnerMinName), +} + +var testConfigVarsMax = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable(intakeRunnerMaxName), +} + +func testConfigVarsMinUpdated() config.Variables { + tempConfig := make(config.Variables, len(testConfigVarsMin)) + maps.Copy(tempConfig, testConfigVarsMin) + tempConfig["name"] = config.StringVariable(intakeRunnerMinNameUpdated) + return tempConfig +} + +func testConfigVarsMaxUpdated() config.Variables { + tempConfig := make(config.Variables, len(testConfigVarsMax)) + maps.Copy(tempConfig, testConfigVarsMax) + tempConfig["name"] = config.StringVariable(intakeRunnerMaxNameUpdated) + return tempConfig +} + +func TestAccIntakeRunnerMin(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckIntakeRunnerDestroy, + Steps: []resource.TestStep{ + // Create the minimum runner from the HCL file + { + ConfigVariables: testConfigVarsMin, + Config: testutil.IntakeProviderConfig() + "\n" + resourceMin, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(intakeRunnerResource, "project_id", testutil.ProjectId), + resource.TestCheckResourceAttr(intakeRunnerResource, "name", intakeRunnerMinName), + resource.TestCheckResourceAttrSet(intakeRunnerResource, "runner_id"), + resource.TestCheckResourceAttr(intakeRunnerResource, "max_message_size_kib", "1024"), + resource.TestCheckResourceAttr(intakeRunnerResource, "max_messages_per_hour", "1000"), + resource.TestCheckResourceAttrSet(intakeRunnerResource, "id"), + resource.TestCheckResourceAttrSet(intakeRunnerResource, "region"), + ), + }, + // Data source check: creates config that includes resource and data source + { + ConfigVariables: testConfigVarsMin, + Config: fmt.Sprintf(` + %s + data "stackit_intake_runner" "example" { + project_id = %s.project_id + runner_id = %s.runner_id + region = %s.region + }`, testutil.IntakeProviderConfig()+"\n"+resourceMin, intakeRunnerResource, intakeRunnerResource, intakeRunnerResource), + Check: resource.ComposeAggregateTestCheckFunc( + // Make sure it's correctly found resource by comparing runner_id attribute + resource.TestCheckResourceAttrPair(intakeRunnerResource, "project_id", "data.stackit_intake_runner.example", "project_id"), + resource.TestCheckResourceAttrPair(intakeRunnerResource, "runner_id", "data.stackit_intake_runner.example", "runner_id"), + resource.TestCheckResourceAttrPair(intakeRunnerResource, "name", "data.stackit_intake_runner.example", "name"), + resource.TestCheckResourceAttrPair(intakeRunnerResource, "region", "data.stackit_intake_runner.example", "region"), + resource.TestCheckResourceAttrPair(intakeRunnerResource, "max_messages_per_hour", "data.stackit_intake_runner.example", "max_messages_per_hour"), + ), + }, + // Simulate terraform import + { + ConfigVariables: testConfigVarsMin, + Config: testutil.IntakeProviderConfig() + "\n" + resourceMin, + ResourceName: intakeRunnerResource, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + // Construct ID string + r, ok := s.RootModule().Resources[intakeRunnerResource] + if !ok { + return "", fmt.Errorf("couldn't find resource %s", intakeRunnerResource) + } + return fmt.Sprintf("%s,%s,%s", r.Primary.Attributes["project_id"], r.Primary.Attributes["region"], r.Primary.Attributes["runner_id"]), nil + }, + }, + // Update check: verifies API updated resource name without crashing + { + ConfigVariables: testConfigVarsMinUpdated(), + Config: testutil.IntakeProviderConfig() + "\n" + resourceMin, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(intakeRunnerResource, "name", intakeRunnerMinNameUpdated), + ), + }, + }, + }) +} + +func TestAccIntakeRunnerMax(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckIntakeRunnerDestroy, + Steps: []resource.TestStep{ + // Create the max intake runner from HCL files and verify comparison + { + ConfigVariables: testConfigVarsMax, + Config: testutil.IntakeProviderConfig() + "\n" + resourceMax, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(intakeRunnerResource, "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), + resource.TestCheckResourceAttr(intakeRunnerResource, "name", testutil.ConvertConfigVariable(testConfigVarsMax["name"])), + resource.TestCheckResourceAttr(intakeRunnerResource, "description", "An example runner for Intake"), + resource.TestCheckResourceAttr(intakeRunnerResource, "max_message_size_kib", "1024"), + resource.TestCheckResourceAttr(intakeRunnerResource, "max_messages_per_hour", "1100"), + resource.TestCheckResourceAttr(intakeRunnerResource, "labels.%", "2"), + resource.TestCheckResourceAttr(intakeRunnerResource, "labels.env", "development"), + resource.TestCheckResourceAttr(intakeRunnerResource, "labels.created_by", "terraform-provider-stackit"), + resource.TestCheckResourceAttrSet(intakeRunnerResource, "runner_id"), + resource.TestCheckResourceAttrSet(intakeRunnerResource, "id"), + resource.TestCheckResourceAttrSet(intakeRunnerResource, "region"), + ), + }, + { + ConfigVariables: testConfigVarsMax, + Config: fmt.Sprintf(` + %s + data "stackit_intake_runner" "example" { + project_id = %s.project_id + runner_id = %s.runner_id + }`, testutil.IntakeProviderConfig()+"\n"+resourceMax, intakeRunnerResource, intakeRunnerResource), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair(intakeRunnerResource, "project_id", "data.stackit_intake_runner.example", "project_id"), + resource.TestCheckResourceAttrPair(intakeRunnerResource, "runner_id", "data.stackit_intake_runner.example", "runner_id"), + resource.TestCheckResourceAttrPair(intakeRunnerResource, "name", "data.stackit_intake_runner.example", "name"), + resource.TestCheckResourceAttrPair(intakeRunnerResource, "description", "data.stackit_intake_runner.example", "description"), + resource.TestCheckResourceAttrPair(intakeRunnerResource, "region", "data.stackit_intake_runner.example", "region"), + resource.TestCheckResourceAttrPair(intakeRunnerResource, "labels.env", "data.stackit_intake_runner.example", "labels.env"), + resource.TestCheckResourceAttrPair(intakeRunnerResource, "max_messages_per_hour", "data.stackit_intake_runner.example", "max_messages_per_hour"), + ), + }, + // Update and verify changes are reflected + { + ConfigVariables: testConfigVarsMaxUpdated(), + Config: testutil.IntakeProviderConfig() + "\n" + resourceMax, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(intakeRunnerResource, "name", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["name"])), + // Ensure optional fields survived the update (didn't get wiped by a bad Update payload) + resource.TestCheckResourceAttr(intakeRunnerResource, "description", "An example runner for Intake"), + resource.TestCheckResourceAttr(intakeRunnerResource, "labels.env", "development"), + ), + }, + }, + }) +} + +// testAccCheckIntakeRunnerDestroy act as independent auditor to verify destroy operation +func testAccCheckIntakeRunnerDestroy(s *terraform.State) error { + ctx := context.Background() + var client *intake.APIClient + var err error + + if testutil.IntakeCustomEndpoint == "" { + client, err = intake.NewAPIClient() + } else { + client, err = intake.NewAPIClient( + sdkConfig.WithEndpoint(testutil.IntakeCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + instancesToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_intake_runner" { + continue + } + // Intake internal ID: "[project_id],[region],[runner_id]" + runnerId := strings.Split(rs.Primary.ID, core.Separator)[2] + instancesToDestroy = append(instancesToDestroy, runnerId) + } + + // List all resources in the project/region to see what's left + instancesResp, err := client.ListIntakeRunners(ctx, testutil.ProjectId, testutil.Region).Execute() + if err != nil { + return fmt.Errorf("getting instancesResp: %w", err) + } + + // If the API returns a list of runners, check if our deleted ones are still there + items := *instancesResp.IntakeRunners + for i := range items { + if items[i].Id == nil { + continue + } + + // If a runner we thought we deleted is found in the list + if utils.Contains(instancesToDestroy, *items[i].Id) { + // Attempt a final delete and wait, just like Postgres + err := client.DeleteIntakeRunner(ctx, testutil.ProjectId, testutil.Region, *items[i].Id).Execute() + if err != nil { + return fmt.Errorf("deleting runner %s during CheckDestroy: %w", *items[i].Id, err) + } + + // Using the wait handler for destruction verification + _, err = wait.DeleteIntakeRunnerWaitHandler(ctx, client, testutil.ProjectId, testutil.Region, *items[i].Id).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("deleting runner %s during CheckDestroy: waiting for deletion %w", *items[i].Id, err) + } + } + } + return nil +} diff --git a/stackit/internal/services/intake/runner/data_source.go b/stackit/internal/services/intake/runner/data_source.go new file mode 100644 index 000000000..f44dfaf69 --- /dev/null +++ b/stackit/internal/services/intake/runner/data_source.go @@ -0,0 +1,169 @@ +package runner + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/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/core/oapierror" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + intakeUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/intake/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +// Ensure the implementation satisfies the expected interfaces +var ( + _ datasource.DataSource = &runnerDataSource{} +) + +// NewRunnerDataSource is a helper function to simplify the provider implementation +func NewRunnerDataSource() datasource.DataSource { + return &runnerDataSource{} +} + +type runnerDataSource struct { + client *intake.APIClient + providerData core.ProviderData +} + +func (r *runnerDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_intake_runner" +} + +// Configure adds the provider configured client to the data source +func (r *runnerDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + var ok bool + r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := intakeUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Intake runner client configured for data source") +} + +// Schema defines the schema for the data source +func (r *runnerDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + descriptions := map[string]string{ + "main": "Datasource for STACKIT Intake Runner.", + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`region`,`runner_id`\".", + "project_id": "STACKIT Project ID to which the runner is associated.", + "runner_id": "The runner ID.", + "name": "The name of the runner.", + "description": "The description of the runner.", + "labels": "User-defined labels.", + "max_message_size_kib": "The maximum message size in KiB.", + "max_messages_per_hour": "The maximum number of messages per hour.", + "region": "The resource region. If not defined, the provider region is used.", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "runner_id": schema.StringAttribute{ + Description: descriptions["runner_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Computed: true, + }, + "description": schema.StringAttribute{ + Description: descriptions["description"], + Computed: true, + }, + "labels": schema.MapAttribute{ + Description: descriptions["labels"], + ElementType: types.StringType, + Computed: true, + }, + "max_message_size_kib": schema.Int64Attribute{ + Description: descriptions["max_message_size_kib"], + Computed: true, + }, + "max_messages_per_hour": schema.Int64Attribute{ + Description: descriptions["max_messages_per_hour"], + Computed: true, + }, + "region": schema.StringAttribute{ + Optional: true, + Description: descriptions["region"], + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (r *runnerDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + resp.Diagnostics.Append(req.Config.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + runnerId := model.RunnerId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "runner_id", runnerId) + + runnerResp, err := r.client.GetIntakeRunner(ctx, projectId, region, runnerId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading runner", fmt.Sprintf("Runner with ID %s not found in project %s and region %s", runnerId, projectId, region)) + return + } + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading runner", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(runnerResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading runner", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set refreshed state + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Intake runner read") +} diff --git a/stackit/internal/services/intake/runner/resource.go b/stackit/internal/services/intake/runner/resource.go new file mode 100644 index 000000000..c73818bcf --- /dev/null +++ b/stackit/internal/services/intake/runner/resource.go @@ -0,0 +1,507 @@ +package runner + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "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/core/oapierror" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + intakeUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/intake/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &runnerResource{} + _ resource.ResourceWithConfigure = &runnerResource{} + _ resource.ResourceWithImportState = &runnerResource{} + _ resource.ResourceWithModifyPlan = &runnerResource{} +) + +// Model is the internal model of the terraform resource +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + RunnerId types.String `tfsdk:"runner_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Labels types.Map `tfsdk:"labels"` + MaxMessageSizeKiB types.Int64 `tfsdk:"max_message_size_kib"` + MaxMessagesPerHour types.Int64 `tfsdk:"max_messages_per_hour"` + Region types.String `tfsdk:"region"` +} + +// NewRunnerResource is a helper function to simplify the provider implementation. +func NewRunnerResource() resource.Resource { + return &runnerResource{} +} + +// runnerResource is the resource implementation. +type runnerResource struct { + client *intake.APIClient + providerData core.ProviderData +} + +// Metadata returns the resource type name. +func (r *runnerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_intake_runner" +} + +// Configure adds the provider configured client to the resource. +func (r *runnerResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := intakeUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Intake runner client configured") +} + +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *runnerResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + // skip initial empty configuration to avoid follow-up errors + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + +// Schema defines the schema for the resource. +func (r *runnerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ + "main": "Manages STACKIT Intake Runner.", + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`region`,`runner_id`\".", + "project_id": "STACKIT Project ID to which the runner is associated.", + "runner_id": "The runner ID.", + "name": "The name of the runner.", + "region": "The resource region. If not defined, the provider region is used.", + "description": "The description of the runner.", + "labels": "User-defined labels.", + "max_message_size_kib": "The maximum message size in KiB.", + "max_messages_per_hour": "The maximum number of messages per hour.", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "runner_id": schema.StringAttribute{ + Description: descriptions["runner_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Required: true, + }, + "description": schema.StringAttribute{ + Description: descriptions["description"], + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "labels": schema.MapAttribute{ + Description: descriptions["labels"], + ElementType: types.StringType, + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Map{ + mapplanmodifier.UseStateForUnknown(), + }, + }, + "max_message_size_kib": schema.Int64Attribute{ + Description: descriptions["max_message_size_kib"], + Required: true, + }, + "max_messages_per_hour": schema.Int64Attribute{ + Description: descriptions["max_messages_per_hour"], + Required: true, + }, + "region": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: descriptions["region"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *runnerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + // prepare the payload struct for the create bar request + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new runner + runnerResp, err := r.client.CreateIntakeRunner(ctx, projectId, region).CreateIntakeRunnerPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating runner", fmt.Sprintf("Calling API: %v", err)) + return + } + ctx = core.LogResponse(ctx) + + // Wait for creation of intake runner + _, err = wait.CreateOrUpdateIntakeRunnerWaitHandler(ctx, r.client, projectId, region, runnerResp.GetId()).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating runner", fmt.Sprintf("Intake runner creation waiting: %v", err)) + return + } + + err = mapFields(runnerResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating runner", fmt.Sprintf("Processing API payload: %v", err)) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Intake runner created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *runnerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + resp.Diagnostics.Append(req.State.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + runnerId := model.RunnerId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "runner_id", runnerId) + + runnerResp, err := r.client.GetIntakeRunner(ctx, projectId, region, runnerId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading runner", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + // Map response body to schema + err = mapFields(runnerResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading runner", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set refreshed state + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Intake runner read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *runnerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + var model, state Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + runnerId := model.RunnerId.ValueString() + region := model.Region.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "runner_id", runnerId) + ctx = tflog.SetField(ctx, "region", region) + + payload, err := toUpdatePayload(&model, &state) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating runner", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Update runner + runnerResp, err := r.client.UpdateIntakeRunner(ctx, projectId, region, runnerId).UpdateIntakeRunnerPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating runner", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Wait for update + _, err = wait.CreateOrUpdateIntakeRunnerWaitHandler(ctx, r.client, projectId, region, runnerId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating runner", fmt.Sprintf("Runner update waiting: %v", err)) + return + } + + // Map response body to schema + err = mapFields(runnerResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating runner", fmt.Sprintf("Processing API response: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Intake runner updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *runnerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() + runnerId := model.RunnerId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "runner_id", runnerId) + + // Delete existing runner + err := r.client.DeleteIntakeRunner(ctx, projectId, region, runnerId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Intake runner already deleted") + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting runner", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + // Wait for the delete operation to complete + _, err = wait.DeleteIntakeRunnerWaitHandler(ctx, r.client, projectId, region, runnerId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting runner", fmt.Sprintf("Runner deletion waiting: %v", err)) + return + } + + tflog.Info(ctx, "Intake runner deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the Intake runner resource import identifier is: [project_id],[region],[runner_id] +func (r *runnerResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing intake runner", + fmt.Sprintf("Expected import identifier with format [project_id],[region],[runner_id], got %q", req.ID), + ) + return + } + + utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": idParts[0], + "region": idParts[1], + "runner_id": idParts[2], + }) + + tflog.Info(ctx, "Intake runner state imported") +} + +// Maps runner fields to the provider internal model +func mapFields(runnerResp *intake.IntakeRunnerResponse, model *Model, region string) error { + if runnerResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var runnerId string + if runnerResp.Id != nil { + runnerId = *runnerResp.Id + } + + model.Id = utils.BuildInternalTerraformId( + model.ProjectId.ValueString(), + region, + runnerId, + ) + + if runnerResp.Id == nil || *runnerResp.Id == "" { + model.RunnerId = types.StringNull() + } else { + model.RunnerId = types.StringPointerValue(runnerResp.Id) + } + + if runnerResp.Labels == nil { + model.Labels = types.MapValueMust(types.StringType, map[string]attr.Value{}) + } else { + labels, diags := types.MapValueFrom(context.Background(), types.StringType, runnerResp.Labels) + if diags.HasError() { + return fmt.Errorf("converting labels: %w", core.DiagsToError(diags)) + } + model.Labels = labels + } + + model.Name = types.StringPointerValue(runnerResp.DisplayName) + model.Description = types.StringPointerValue(runnerResp.Description) + model.Region = types.StringValue(region) + model.MaxMessageSizeKiB = types.Int64PointerValue(runnerResp.MaxMessageSizeKiB) + model.MaxMessagesPerHour = types.Int64PointerValue(runnerResp.MaxMessagesPerHour) + return nil +} + +// Build CreateIntakeRunnerPayload from provider's model +func toCreatePayload(model *Model) (*intake.CreateIntakeRunnerPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + var labels map[string]string + if !model.Labels.IsNull() && !model.Labels.IsUnknown() { + diags := model.Labels.ElementsAs(context.Background(), &labels, false) + if diags.HasError() { + return nil, fmt.Errorf("converting labels: %w", core.DiagsToError(diags)) + } + } + + var labelsPtr *map[string]string + if len(labels) > 0 { + labelsPtr = &labels + } + + return &intake.CreateIntakeRunnerPayload{ + Description: conversion.StringValueToPointer(model.Description), + DisplayName: conversion.StringValueToPointer(model.Name), + Labels: labelsPtr, + MaxMessageSizeKiB: conversion.Int64ValueToPointer(model.MaxMessageSizeKiB), + MaxMessagesPerHour: conversion.Int64ValueToPointer(model.MaxMessagesPerHour), + }, nil +} + +// Build UpdateIntakeRunnerPayload from provider's model +func toUpdatePayload(model, state *Model) (*intake.UpdateIntakeRunnerPayload, error) { + if model == nil { + return nil, fmt.Errorf("model is nil") + } + if state == nil { + return nil, fmt.Errorf("state is nil") + } + + payload := &intake.UpdateIntakeRunnerPayload{} + payload.MaxMessageSizeKiB = conversion.Int64ValueToPointer(model.MaxMessageSizeKiB) + payload.MaxMessagesPerHour = conversion.Int64ValueToPointer(model.MaxMessagesPerHour) + + // Optional fields + payload.DisplayName = conversion.StringValueToPointer(model.Name) + payload.Description = conversion.StringValueToPointer(model.Description) + + var labels map[string]string + if !model.Labels.IsNull() && !model.Labels.IsUnknown() { + diags := model.Labels.ElementsAs(context.Background(), &labels, false) + if diags.HasError() { + return nil, fmt.Errorf("failed to convert labels: %w", core.DiagsToError(diags)) + } + payload.Labels = &labels + } + + return payload, nil +} diff --git a/stackit/internal/services/intake/runner/resource_test.go b/stackit/internal/services/intake/runner/resource_test.go new file mode 100644 index 000000000..b6d3594a2 --- /dev/null +++ b/stackit/internal/services/intake/runner/resource_test.go @@ -0,0 +1,245 @@ +package runner + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" +) + +func TestMapFields(t *testing.T) { + runnerId := uuid.New().String() + tests := []struct { + description string + input *intake.IntakeRunnerResponse + model *Model + region string + expected *Model + wantErr bool + }{ + { + "success", + &intake.IntakeRunnerResponse{ + Id: utils.Ptr(runnerId), + DisplayName: utils.Ptr("name"), + Description: utils.Ptr("description"), + Labels: &map[string]string{"key": "value"}, + MaxMessageSizeKiB: utils.Ptr(int64(1024)), + MaxMessagesPerHour: utils.Ptr(int64(100)), + }, + &Model{ + ProjectId: types.StringValue("pid"), + }, + "eu01", + &Model{ + Id: types.StringValue(fmt.Sprintf("pid,eu01,%s", runnerId)), + ProjectId: types.StringValue("pid"), + Region: types.StringValue("eu01"), + RunnerId: types.StringValue(runnerId), + Name: types.StringValue("name"), + Description: types.StringValue("description"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + MaxMessageSizeKiB: types.Int64Value(1024), + MaxMessagesPerHour: types.Int64Value(100), + }, + false, + }, + { + "nil input", + nil, + &Model{}, + "eu01", + nil, + true, + }, + { + "nil model", + &intake.IntakeRunnerResponse{}, + nil, + "eu01", + nil, + true, + }, + { + "empty response", + &intake.IntakeRunnerResponse{ + Id: utils.Ptr(""), + Labels: &map[string]string{}, + }, + &Model{ + ProjectId: types.StringValue("pid"), + }, + "eu01", + &Model{ + Id: types.StringValue("pid,eu01,"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue("eu01"), + RunnerId: types.StringNull(), + Name: types.StringNull(), + Description: types.StringNull(), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + MaxMessageSizeKiB: types.Int64Null(), + MaxMessagesPerHour: types.Int64Null(), + }, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(tt.input, tt.model, tt.region) + if (err != nil) != tt.wantErr { + t.Errorf("mapFields error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if diff := cmp.Diff(tt.expected, tt.model); diff != "" { + t.Errorf("mapFields mismatch (-want +got):\n%s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + model *Model + expected *intake.CreateIntakeRunnerPayload + wantErr bool + }{ + { + "success", + &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("description"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + MaxMessageSizeKiB: types.Int64Value(1024), + MaxMessagesPerHour: types.Int64Value(100), + }, + &intake.CreateIntakeRunnerPayload{ + DisplayName: utils.Ptr("name"), + Description: utils.Ptr("description"), + Labels: utils.Ptr(map[string]string{"key": "value"}), + MaxMessageSizeKiB: utils.Ptr(int64(1024)), + MaxMessagesPerHour: utils.Ptr(int64(100)), + }, + false, + }, + { + "nil model", + nil, + nil, + true, + }, + { + "empty model", + &Model{}, + &intake.CreateIntakeRunnerPayload{ + DisplayName: nil, + Description: nil, + Labels: nil, + MaxMessageSizeKiB: nil, + MaxMessagesPerHour: nil, + }, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toCreatePayload(tt.model) + if (err != nil) != tt.wantErr { + t.Errorf("toCreatePayload error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if diff := cmp.Diff(tt.expected, payload); diff != "" { + t.Errorf("toCreatePayload mismatch (-want +got):\n%s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + tests := []struct { + description string + model *Model + state *Model + expected *intake.UpdateIntakeRunnerPayload + wantErr bool + }{ + { + "success", + &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("description"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + MaxMessageSizeKiB: types.Int64Value(1024), + MaxMessagesPerHour: types.Int64Value(100), + }, + &Model{}, + &intake.UpdateIntakeRunnerPayload{ + DisplayName: conversion.StringValueToPointer(types.StringValue("name")), + Description: conversion.StringValueToPointer(types.StringValue("description")), + Labels: utils.Ptr(map[string]string{"key": "value"}), + MaxMessageSizeKiB: conversion.Int64ValueToPointer(types.Int64Value(1024)), + MaxMessagesPerHour: conversion.Int64ValueToPointer(types.Int64Value(100)), + }, + false, + }, + { + "nil model", + nil, + &Model{}, + nil, + true, + }, + { + "nil state", + &Model{}, + nil, + nil, + true, + }, + { + "empty model", + &Model{}, + &Model{}, + &intake.UpdateIntakeRunnerPayload{}, + false, + }, + { + "unknown values", + &Model{ + Name: types.StringUnknown(), + Description: types.StringUnknown(), + Labels: types.MapUnknown(types.StringType), + MaxMessageSizeKiB: types.Int64Unknown(), + MaxMessagesPerHour: types.Int64Unknown(), + }, + &Model{}, + &intake.UpdateIntakeRunnerPayload{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toUpdatePayload(tt.model, tt.state) + if (err != nil) != tt.wantErr { + t.Errorf("toUpdatePayload error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if diff := cmp.Diff(tt.expected, payload); diff != "" { + t.Errorf("toUpdatePayload mismatch (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/intake/testdata/resource-max.tf b/stackit/internal/services/intake/testdata/resource-max.tf new file mode 100644 index 000000000..5614426bb --- /dev/null +++ b/stackit/internal/services/intake/testdata/resource-max.tf @@ -0,0 +1,15 @@ + +variable "project_id" {} +variable "name" {} + +resource "stackit_intake_runner" "example" { + project_id = var.project_id + name = var.name + description = "An example runner for Intake" + max_message_size_kib = 1024 + max_messages_per_hour = 1100 + labels = { + "created_by" = "terraform-provider-stackit" + "env" = "development" + } +} \ No newline at end of file diff --git a/stackit/internal/services/intake/testdata/resource-min.tf b/stackit/internal/services/intake/testdata/resource-min.tf new file mode 100644 index 000000000..e7c8d77fa --- /dev/null +++ b/stackit/internal/services/intake/testdata/resource-min.tf @@ -0,0 +1,10 @@ + +variable "project_id" {} +variable "name" {} + +resource "stackit_intake_runner" "example" { + project_id = var.project_id + name = var.name + max_message_size_kib = 1024 + max_messages_per_hour = 1000 +} \ No newline at end of file diff --git a/stackit/internal/services/intake/utils/utils.go b/stackit/internal/services/intake/utils/utils.go new file mode 100644 index 000000000..b6357b496 --- /dev/null +++ b/stackit/internal/services/intake/utils/utils.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/intake" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *intake.APIClient { + apiClientConfigOptions := []config.ConfigurationOption{ + config.WithCustomAuth(providerData.RoundTripper), + utils.UserAgentConfigOption(providerData.Version), + } + if providerData.IntakeCustomEndpoint != "" { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.IntakeCustomEndpoint)) + } else { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithRegion(providerData.GetRegion())) + } + apiClient, err := intake.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/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 38cd9cd36..e20247a69 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -91,8 +91,9 @@ var ( ServerUpdateCustomEndpoint = os.Getenv("TF_ACC_SERVER_UPDATE_CUSTOM_ENDPOINT") SFSCustomEndpoint = os.Getenv("TF_ACC_SFS_CUSTOM_ENDPOINT") ServiceAccountCustomEndpoint = os.Getenv("TF_ACC_SERVICE_ACCOUNT_CUSTOM_ENDPOINT") - TokenCustomEndpoint = os.Getenv("TF_ACC_TOKEN_CUSTOM_ENDPOINT") SKECustomEndpoint = os.Getenv("TF_ACC_SKE_CUSTOM_ENDPOINT") + IntakeCustomEndpoint = os.Getenv("TF_ACC_INTAKE_CUSTOM_ENDPOINT") + TokenCustomEndpoint = os.Getenv("TF_ACC_TOKEN_CUSTOM_ENDPOINT") ) // Provider config helper functions @@ -208,6 +209,20 @@ func IaaSProviderConfigWithExperiments() string { ) } +func IntakeProviderConfig() string { + if IntakeCustomEndpoint == "" { + return `provider "stackit" { + default_region = "eu01" + }` + } + return fmt.Sprintf(` + provider "stackit" { + intake_custom_endpoint = "%s" + }`, + IntakeCustomEndpoint, + ) +} + func KMSProviderConfig() string { if KMSCustomEndpoint == "" { return ` diff --git a/stackit/provider.go b/stackit/provider.go index 98f155c20..a2d5bd89a 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -57,6 +57,7 @@ import ( iaasServiceAccountAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/serviceaccountattach" iaasVolume "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volume" iaasVolumeAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volumeattach" + intakeRunner "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/intake/runner" kmsKey "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/kms/key" kmsKeyRing "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/kms/keyring" kmsWrappingKey "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/kms/wrapping-key" @@ -155,6 +156,7 @@ type providerModel struct { EdgeCloudCustomEndpoint types.String `tfsdk:"edgecloud_custom_endpoint"` GitCustomEndpoint types.String `tfsdk:"git_custom_endpoint"` IaaSCustomEndpoint types.String `tfsdk:"iaas_custom_endpoint"` + IntakeCustomEndpoint types.String `tfsdk:"intake_custom_endpoint"` KmsCustomEndpoint types.String `tfsdk:"kms_custom_endpoint"` LoadBalancerCustomEndpoint types.String `tfsdk:"loadbalancer_custom_endpoint"` LogMeCustomEndpoint types.String `tfsdk:"logme_custom_endpoint"` @@ -201,6 +203,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "edgecloud_custom_endpoint": "Custom endpoint for the Edge Cloud service", "git_custom_endpoint": "Custom endpoint for the Git service", "iaas_custom_endpoint": "Custom endpoint for the IaaS service", + "intake_custom_endpoint": "Custom endpoint for the Intake service", "kms_custom_endpoint": "Custom endpoint for the KMS service", "mongodbflex_custom_endpoint": "Custom endpoint for the MongoDB Flex service", "modelserving_custom_endpoint": "Custom endpoint for the AI Model Serving service", @@ -309,6 +312,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["iaas_custom_endpoint"], }, + "intake_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["intake_custom_endpoint"], + }, "kms_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["kms_custom_endpoint"], @@ -464,6 +471,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, setStringField(providerConfig.EdgeCloudCustomEndpoint, func(v string) { providerData.EdgeCloudCustomEndpoint = v }) setStringField(providerConfig.GitCustomEndpoint, func(v string) { providerData.GitCustomEndpoint = v }) setStringField(providerConfig.IaaSCustomEndpoint, func(v string) { providerData.IaaSCustomEndpoint = v }) + setStringField(providerConfig.IntakeCustomEndpoint, func(v string) { providerData.IntakeCustomEndpoint = v }) setStringField(providerConfig.KmsCustomEndpoint, func(v string) { providerData.KMSCustomEndpoint = v }) setStringField(providerConfig.LoadBalancerCustomEndpoint, func(v string) { providerData.LoadBalancerCustomEndpoint = v }) setStringField(providerConfig.LogMeCustomEndpoint, func(v string) { providerData.LogMeCustomEndpoint = v }) @@ -554,6 +562,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource iaasRoutingTables.NewRoutingTablesDataSource, iaasRoutingTableRoutes.NewRoutingTableRoutesDataSource, iaasSecurityGroupRule.NewSecurityGroupRuleDataSource, + intakeRunner.NewRunnerDataSource, kmsKey.NewKeyDataSource, kmsKeyRing.NewKeyRingDataSource, kmsWrappingKey.NewWrappingKeyDataSource, @@ -639,6 +648,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { iaasSecurityGroupRule.NewSecurityGroupRuleResource, iaasRoutingTable.NewRoutingTableResource, iaasRoutingTableRoute.NewRoutingTableRouteResource, + intakeRunner.NewRunnerResource, kmsKey.NewKeyResource, kmsKeyRing.NewKeyRingResource, kmsWrappingKey.NewWrappingKeyResource,