diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 267a95238..4c9cfce7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,7 +83,7 @@ If you want to onboard resources of a STACKIT service `foo` that was not yet in ```go setStringField(providerConfig.FooCustomEndpoint, func(v string) { providerData.FooCustomEndpoint = v }) ``` -4. Create a utils package, for service `foo` it would be `stackit/internal/foo/utils`. Add a `ConfigureClient()` func and use it in your resource and datasource implementations. +4. Create a utils package, for service `foo` it would be `stackit/internal/services/foo/utils`. Add a `ConfigureClient()` func and use it in your resource and datasource implementations. https://github.com/stackitcloud/terraform-provider-stackit/blob/main/.github/docs/contribution-guide/utils/util.go diff --git a/examples/data-sources/stackit_dremio_instance/data-source.tf b/examples/data-sources/stackit_dremio_instance/data-source.tf new file mode 100644 index 000000000..e39553403 --- /dev/null +++ b/examples/data-sources/stackit_dremio_instance/data-source.tf @@ -0,0 +1,5 @@ +data "stackit_dremio_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + region = "eu01" + instance_id = "example-instance-id" +} \ No newline at end of file diff --git a/examples/resources/stackit_dremio_instance/resource.tf b/examples/resources/stackit_dremio_instance/resource.tf new file mode 100644 index 000000000..7f8042576 --- /dev/null +++ b/examples/resources/stackit_dremio_instance/resource.tf @@ -0,0 +1,34 @@ +resource "stackit_dremio_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + region = "eu01" + display_name = "exampleName" + description = "Example description" + authentication = { + type = "local-only" // "oauth" or "azuread" for IDP config + + oauth = { // only needed if "oauth" is given as type + authority_url = "authority" + client_id = "client-id" + client_secret = "client-secret" + jwt_claims = { + user_name = "example" + } + scope = "idp-scope" + parameters = [ + { "name" : "example", "value" : "example-value" } + ] + } + + azuread = { // only needed if "azuread" is given as type + authority_url = "authority" + client_id = "client-id" + client_secret = "client-secret" + } + } +} + +# Only use the import statement, if you want to import an existing dns zone +import { + to = stackit_dremio_instance.import_example + id = "${var.project_id},${var.region},${var.instance_id}" +} \ No newline at end of file diff --git a/go.mod b/go.mod index f0dd76e59..5ff027b45 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/cdn v1.16.0 github.com/stackitcloud/stackit-sdk-go/services/certificates v1.7.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.20.2 + github.com/stackitcloud/stackit-sdk-go/services/dremio v0.1.0 github.com/stackitcloud/stackit-sdk-go/services/edge v0.11.0 github.com/stackitcloud/stackit-sdk-go/services/git v0.13.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.12.0 diff --git a/go.sum b/go.sum index c52202b3b..3e6e6ae71 100644 --- a/go.sum +++ b/go.sum @@ -680,6 +680,8 @@ github.com/stackitcloud/stackit-sdk-go/services/certificates v1.7.0 h1:J7BVVHjRT github.com/stackitcloud/stackit-sdk-go/services/certificates v1.7.0/go.mod h1:eJpB3/pukz+KzVPVHQ4g3DVtQkxGga18VbFBhq9ugdY= github.com/stackitcloud/stackit-sdk-go/services/dns v0.20.2 h1:nMJRg1dKioOlMwXJnZZgIRwfTWYCksVA9GyfAVmib1g= github.com/stackitcloud/stackit-sdk-go/services/dns v0.20.2/go.mod h1:FiYSv3D9rzgEVzi8Mpq5oYZBosrasa5uUYqVdEIbM1U= +github.com/stackitcloud/stackit-sdk-go/services/dremio v0.1.0 h1:yNFIU1+1dA2uK8ERdBb1Ut74Kt2szn4qgelBbM93JXA= +github.com/stackitcloud/stackit-sdk-go/services/dremio v0.1.0/go.mod h1:iMoiM8fM1mXC1Nz8FBiiQ08Yh+0C3yN0GPCdAbOlRXo= github.com/stackitcloud/stackit-sdk-go/services/edge v0.11.0 h1:/JUxaJSGmg+PRj90e4fngWkXNQkRKHOYpVykJ3zoy7w= github.com/stackitcloud/stackit-sdk-go/services/edge v0.11.0/go.mod h1:Ylse6gqGJtsd5TVmvha+hoLd1QQHLKvhY5dO15+q5kg= github.com/stackitcloud/stackit-sdk-go/services/git v0.13.0 h1:BdamSnGYhDkDqUWQQcJ8Kqik90laTK1IlG5CQqyLVgA= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index fec9b4b91..856b05b5f 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -45,6 +45,7 @@ type ProviderData struct { AuthorizationCustomEndpoint string CdnCustomEndpoint string DnsCustomEndpoint string + DremioCustomEndpoint string EdgeCloudCustomEndpoint string GitCustomEndpoint string IaaSCustomEndpoint string diff --git a/stackit/internal/services/dremio/dremio_acc_test.go b/stackit/internal/services/dremio/dremio_acc_test.go new file mode 100644 index 000000000..8550bde99 --- /dev/null +++ b/stackit/internal/services/dremio/dremio_acc_test.go @@ -0,0 +1,374 @@ +package dremio + +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" + + "github.com/stackitcloud/stackit-sdk-go/core/utils" + dremioSdk "github.com/stackitcloud/stackit-sdk-go/services/dremio/v1alphaapi" + dremioWaiter "github.com/stackitcloud/stackit-sdk-go/services/dremio/v1alphaapi/wait/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 resourceDremioInstanceMin string + +//go:embed testdata/resource-max.tf +var resourceDremioInstanceMax string + +const dremioInstanceResource = "stackit_dremio_instance.example" +const dremioInstanceDataResource = "data.stackit_dremio_instance.example" + +var testDremioInstanceConfigVarsMin = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "region": config.StringVariable(testutil.Region), + "display_name": config.StringVariable("dremioMinInstance"), + "authentication_type": config.StringVariable("local-only"), +} + +var testDremioInstanceConfigVarsMax = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "region": config.StringVariable("eu01"), + "display_name": config.StringVariable("dremioMaxInstance"), + "description": config.StringVariable("description"), + + "authentication_type": config.StringVariable("oauth"), + + "authentication_oauth_authority_url": config.StringVariable("oauth-authority-url"), + "authentication_oauth_client_id": config.StringVariable("oauth-client-id"), + "authentication_oauth_client_secret": config.StringVariable("oauth-client-secret"), + "authentication_oauth_client_jwt_claims_user_name": config.StringVariable("oauth-jwt-claim-user"), + "authentication_oauth_scope": config.StringVariable("oauth-scope"), + "authentication_oauth_parameter_name": config.StringVariable("oauth-parameter-name"), + "authentication_oauth_parameter_value": config.StringVariable("oauth-parameter-value"), +} + +func testDremioInstanceConfigVarsMinUpdated() config.Variables { + tempConfig := make(config.Variables, len(testDremioInstanceConfigVarsMin)) + maps.Copy(tempConfig, testDremioInstanceConfigVarsMin) + tempConfig["display_name"] = config.StringVariable("dremioMinInstanceUpd") + return tempConfig +} + +func testDremioInstanceConfigVarsMaxUpdated() config.Variables { + tempConfig := make(config.Variables, len(testDremioInstanceConfigVarsMax)) + maps.Copy(tempConfig, testDremioInstanceConfigVarsMax) + tempConfig["display_name"] = config.StringVariable("dremioMaxInstanceUpd") + tempConfig["description"] = config.StringVariable("description-upd") + + // switching idp to azuread + tempConfig["authentication_type"] = config.StringVariable("azuread") + + tempConfig["authentication_azuread_authority_url"] = config.StringVariable("azuread-authority-url-upd") + tempConfig["authentication_azuread_client_id"] = config.StringVariable("azuread-client-id-upd") + tempConfig["authentication_azuread_client_secret"] = config.StringVariable("azuread-client-secret-upd") + + return tempConfig +} + +func TestDremioInstanceMin(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccDremioInstanceDestroy, + Steps: []resource.TestStep{ + // 1) Creation + { + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + resourceDremioInstanceMin, + ConfigVariables: testDremioInstanceConfigVarsMin, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(dremioInstanceResource, "project_id", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMin["project_id"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "region", testutil.Region), + resource.TestCheckResourceAttr(dremioInstanceResource, "display_name", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMin["display_name"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.type", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMin["authentication_type"])), + + resource.TestCheckResourceAttrSet(dremioInstanceResource, "instance_id"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "id"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "state"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "endpoints.ui"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "endpoints.arrow_flight"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "endpoints.catalog"), + ), + }, + // 2) Data Source + { + Config: testutil.NewConfigBuilder().BuildProviderConfig() + resourceDremioInstanceMin, + ConfigVariables: testDremioInstanceConfigVarsMin, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "project_id", + dremioInstanceDataResource, "project_id", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "region", + dremioInstanceDataResource, "region", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "instance_id", + dremioInstanceDataResource, "instance_id", + ), + + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "display_name", + dremioInstanceDataResource, "display_name", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "authentication.type", + dremioInstanceDataResource, "authentication.type", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "endpoints.arrow_flight", + dremioInstanceDataResource, "endpoints.arrow_flight", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "catalog", + dremioInstanceDataResource, "catalog", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "endpoints.ui", + dremioInstanceDataResource, "endpoints.ui", + ), + ), + }, + // 3) Import + { + ConfigVariables: testDremioInstanceConfigVarsMin, + ResourceName: dremioInstanceResource, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources[dremioInstanceResource] + if !ok { + return "", fmt.Errorf("couldn't find resource %s", dremioInstanceResource) + } + instanceId, ok := r.Primary.Attributes["instance_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute instanceId") + } + + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId), nil + }, + }, + // 4) Update + { + Config: testutil.NewConfigBuilder().BuildProviderConfig() + resourceDremioInstanceMin, + ConfigVariables: testDremioInstanceConfigVarsMinUpdated(), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(dremioInstanceResource, "project_id", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMin["project_id"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "region", testutil.Region), + resource.TestCheckResourceAttr(dremioInstanceResource, "display_name", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMin["display_name"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.type", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMin["authentication_type"])), + + resource.TestCheckResourceAttrSet(dremioInstanceResource, "instance_id"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "id"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "state"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "endpoints.ui"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "endpoints.arrow_flight"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "endpoints.catalog"), + ), + }, + }, + }) +} + +func TestDremioInstanceMax(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccDremioInstanceDestroy, + Steps: []resource.TestStep{ + // 1) Creation + { + ConfigVariables: testDremioInstanceConfigVarsMax, + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + resourceDremioInstanceMax, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(dremioInstanceResource, "project_id", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMax["project_id"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "region", testutil.Region), + resource.TestCheckResourceAttr(dremioInstanceResource, "display_name", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMax["display_name"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "description", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMax["description"])), + + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.type", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMax["authentication_type"])), + + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.oauth.authority_url", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMax["authentication_oauth_authority_url"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.oauth.client_id", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMax["authentication_oauth_client_id"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.oauth.client_secret", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMax["authentication_oauth_client_secret"])), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "authentication.oauth.redirect_url"), + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.oauth.jwt_claims.user_name", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMax["authentication_oauth_client_jwt_claims_user_name"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.oauth.scope", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMax["authentication_oauth_scope"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.oauth.parameters.0.name", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMax["authentication_oauth_parameter_name"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.oauth.parameters.0.value", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMax["authentication_oauth_parameter_value"])), + + resource.TestCheckResourceAttrSet(dremioInstanceResource, "instance_id"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "id"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "state"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "endpoints.ui"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "endpoints.arrow_flight"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "endpoints.catalog"), + ), + }, + // 2) Data Source + { + Config: testutil.NewConfigBuilder().BuildProviderConfig() + resourceDremioInstanceMax, + ConfigVariables: testDremioInstanceConfigVarsMax, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "project_id", + dremioInstanceDataResource, "project_id", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "region", + dremioInstanceDataResource, "region", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "instance_id", + dremioInstanceDataResource, "instance_id", + ), + + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "display_name", + dremioInstanceDataResource, "display_name", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "description", + dremioInstanceDataResource, "description", + ), + + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "authentication.type", + dremioInstanceDataResource, "authentication.type", + ), + // Authentication on the data source only shows the currently set IDP config, + // which is oauth for the config here. Hence why we test for the oauth value here. + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "authentication.oauth.authority_url", + dremioInstanceDataResource, "authentication.authority_url", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "authentication.oauth.client_id", + dremioInstanceDataResource, "authentication.client_id", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "authentication.oauth.scope", + dremioInstanceDataResource, "authentication.scope", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "authentication.oauth.parameters", + dremioInstanceDataResource, "authentication.parameters", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "authentication.oauth.redirect_url", + dremioInstanceDataResource, "authentication.redirect_url", + ), + + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "endpoints.arrow_flight", + dremioInstanceDataResource, "endpoints.arrow_flight", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "catalog", + dremioInstanceDataResource, "catalog", + ), + resource.TestCheckResourceAttrPair( + dremioInstanceResource, "endpoints.ui", + dremioInstanceDataResource, "endpoints.ui", + ), + ), + }, + // 3) Import + { + ConfigVariables: testDremioInstanceConfigVarsMax, + ResourceName: dremioInstanceResource, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources[dremioInstanceResource] + if !ok { + return "", fmt.Errorf("couldn't find resource %s", dremioInstanceResource) + } + instanceId, ok := r.Primary.Attributes["instance_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute instanceId") + } + + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId), nil + }}, + // 4) Update + { + Config: testutil.NewConfigBuilder().BuildProviderConfig() + resourceDremioInstanceMax, + ConfigVariables: testDremioInstanceConfigVarsMaxUpdated(), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(dremioInstanceResource, "project_id", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMaxUpdated()["project_id"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "region", testutil.Region), + resource.TestCheckResourceAttr(dremioInstanceResource, "display_name", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMaxUpdated()["display_name"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "description", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMaxUpdated()["description"])), + + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.type", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMaxUpdated()["authentication_type"])), + + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.azuread.authority_url", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMaxUpdated()["authentication_azuread_authority_url"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.azuread.client_id", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMaxUpdated()["authentication_azuread_client_id"])), + resource.TestCheckResourceAttr(dremioInstanceResource, "authentication.azuread.client_secret", testutil.ConvertConfigVariable(testDremioInstanceConfigVarsMaxUpdated()["authentication_azuread_client_secret"])), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "authentication.azuread.redirect_url"), + + resource.TestCheckResourceAttrSet(dremioInstanceResource, "instance_id"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "id"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "state"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "endpoints.ui"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "endpoints.arrow_flight"), + resource.TestCheckResourceAttrSet(dremioInstanceResource, "endpoints.catalog"), + ), + }, + }, + }) +} + +func testAccDremioInstanceDestroy(s *terraform.State) error { + ctx := context.Background() + client, err := dremioSdk.NewAPIClient( + testutil.NewConfigBuilder().BuildClientOptions(testutil.DremioCustomEndpoint, true)...) + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + instancesToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_dremio_instance" { + continue + } + // Dremio internal ID: "[project_id],[region],[instance_id]" + instanceId := strings.Split(rs.Primary.ID, core.Separator)[2] + instancesToDestroy = append(instancesToDestroy, instanceId) + } + + // List all resources in the project/region to see what's left + instancesResp, err := client.DefaultAPI.ListDremioInstances(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.Dremios + for i := range items { + // 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.DefaultAPI.DeleteDremioInstance(ctx, testutil.ProjectId, testutil.Region, items[i].Id).Execute() + if err != nil { + return fmt.Errorf("deleting Dremio instance %s during CheckDestroy: %w", items[i].Id, err) + } + + // Using the wait handler for destruction verification + _, err = dremioWaiter.DeleteDremioWaitHandler(ctx, client.DefaultAPI, testutil.ProjectId, testutil.Region, items[i].Id).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("deleting Dremio instance %s during CheckDestroy: waiting for deletion %w", items[i].Id, err) + } + } + } + return nil +} diff --git a/stackit/internal/services/dremio/instance/datasource.go b/stackit/internal/services/dremio/instance/datasource.go new file mode 100644 index 000000000..410776b08 --- /dev/null +++ b/stackit/internal/services/dremio/instance/datasource.go @@ -0,0 +1,336 @@ +package dremio + +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" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + dremioSdk "github.com/stackitcloud/stackit-sdk-go/services/dremio/v1alphaapi" + + dremioUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dremio/utils" +) + +var ( + _ datasource.DataSource = &instanceDataSource{} + _ datasource.DataSourceWithConfigure = &instanceDataSource{} +) + +type InstanceDataSourceModel struct { + Model + + // Required Fields + Authentication *DataSourceAuthenticationModel `tfsdk:"authentication"` +} + +type DataSourceAuthenticationModel struct { + Type types.String `tfsdk:"type"` + AuthorityUrl types.String `tfsdk:"authority_url"` + ClientId types.String `tfsdk:"client_id"` + JwtClaims *JwtClaimsModel `tfsdk:"jwt_claims"` + Scope types.String `tfsdk:"scope"` + Parameters []AuthParameterModel `tfsdk:"parameters"` + RedirectUrl types.String `tfsdk:"redirect_url"` +} + +type instanceDataSource struct { + client *dremioSdk.APIClient +} + +func NewInstanceDataSource() datasource.DataSource { + return &instanceDataSource{} +} + +// Metadata should return the full name of the data source, such as +// examplecloud_thing. +func (d *instanceDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_dremio_instance" +} + +// Configure enables provider-level data or clients to be set in the +// provider-defined DataSource type. It is separately executed for each +// ReadDataSource RPC. +func (d *instanceDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := dremioUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + d.client = apiClient + tflog.Info(ctx, "Dremio instance client configured for data source") +} + +// Schema should return the schema for this data source. +func (d *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + descriptions := map[string]string{ + "main": "Manages a STACKIT Dremio instance.", + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`region`,`dremio_id`\".", + "project_id": "STACKIT Project ID to which the resource is associated.", + "instance_id": "The Dremio instance ID.", + "region": "The STACKIT region name the resource is located in. If not defined, the provider region is used.", + "display_name": "The display name is a short name chosen by the user to identify the resource.", + "description": "The description is a longer text chosen by the user to provide more context for the resource.", + "state": "The current state of the resource.", + "error_message": "A message describing an actionable error the user can resolve. This field is empty if no such error exists.", + "endpoints": "The available endpoints of the Dremio instance.", + "endpoints_arrow_flight": "The arrow flight endpoint of the Dremio instance.", + "endpoints_catalog": "The Apache Iceberg endpoint of the Dremio instance.", + "endpoints_ui": "The UI endpoint of the Dremio instance.", + "authentication": "Dremio instance authentication settings. A change here triggers a Dremio restart and will incur downtime.", + "authentication_type": "Type of authentication (local-only, azuread, oauth).", + "authentication_authority_url": "The Issuer location URI, where the OIDC provider configuration can be found.", + "authentication_client_id": "The client ID assigned by the Identity Provider.", + "authentication_scope": "A list of space-separated scopes. The `openid` scope is always required; other scopes can vary by provider.", + "authentication_redirect_url": "The URL where the Dremio instance is hosted. The URL must match the redirect URL set in the Identity Provider.", + "authentication_jwt_claims": "Maps fields from the JWT token to fields Dremio requires.", + "authentication_jwt_claims_user_name": "Mapped user name claim (e.g. email).", + "authentication_parameters": "Any additional parameters the Identity Provider requires.", + "authentication_parameters_name": "Parameter name.", + "authentication_parameters_value": "Parameter value.", + } + + 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(), + }, + }, + "instance_id": schema.StringAttribute{ + Description: descriptions["instance_id"], + Required: true, + }, + "region": schema.StringAttribute{ + Description: descriptions["region"], + Required: true, + }, + "display_name": schema.StringAttribute{ + Description: descriptions["display_name"], + Computed: true, + }, + "description": schema.StringAttribute{ + Description: descriptions["description"], + Computed: true, + Optional: true, + }, + "state": schema.StringAttribute{ + Description: descriptions["state"], + Computed: true, + }, + "error_message": schema.StringAttribute{ + Description: descriptions["error_message"], + Computed: true, + Optional: true, + }, + "endpoints": schema.SingleNestedAttribute{ + Description: descriptions["endpoints"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "arrow_flight": schema.StringAttribute{ + Description: descriptions["endpoints_arrow_flight"], + Computed: true, + }, + "catalog": schema.StringAttribute{ + Description: descriptions["endpoints_catalog"], + Computed: true, + }, + "ui": schema.StringAttribute{ + Description: descriptions["endpoints_ui"], + Computed: true, + }, + }, + }, + "authentication": schema.SingleNestedAttribute{ + Description: descriptions["authentication"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + Description: descriptions["authentication_type"], + Computed: true, + }, + "authority_url": schema.StringAttribute{ + Description: descriptions["oauth_authority_url"], + Computed: true, + Optional: true, + }, + "client_id": schema.StringAttribute{ + Description: descriptions["oauth_client_id"], + Computed: true, + Optional: true, + }, + "scope": schema.StringAttribute{ + Description: descriptions["oauth_scope"], + Computed: true, + Optional: true, + }, + "redirect_url": schema.StringAttribute{ + Description: descriptions["oauth_redirect_url"], + Computed: true, + Optional: true, + }, + "jwt_claims": schema.SingleNestedAttribute{ + Description: descriptions["oauth_jwt_claims"], + Computed: true, + Optional: true, + Attributes: map[string]schema.Attribute{ + "user_name": schema.StringAttribute{ + Description: descriptions["oauth_jwt_claims_user_name"], + Computed: true, + }, + }, + }, + "parameters": schema.ListNestedAttribute{ + Description: descriptions["oauth_parameters"], + Computed: true, + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["oauth_parameters_name"], + Computed: true, + }, + "value": schema.StringAttribute{ + Description: descriptions["oauth_parameters_value"], + Computed: true, + }, + }, + }, + }, + }, + }, + }, + } +} + +// Read is called when the provider must read data source values in +// order to update state. Config values should be read from the +// ReadRequest and new state values set on the ReadResponse. +func (d *instanceDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + // nolint:gocritic // function signature required by Terraform + var model InstanceDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() + instanceId := model.InstanceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + + instanceResp, err := d.client.DefaultAPI.GetDremioInstance(ctx, projectId, region, instanceId).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("Dremio instance with ID %s not found in project %s and region %s", instanceId, projectId, region)) + return + } + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Dremio instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + err = mapDataSourceFields(instanceResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Dremio instance", 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, "Dremio instance read") +} + +func mapDataSourceFields(instanceResp *dremioSdk.DremioResponse, model *InstanceDataSourceModel) error { + if instanceResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + err := mapModelFields(instanceResp, &model.Model) + if err != nil { + return fmt.Errorf("failed to map Model fields") + } + err = mapDataSourceAuthentication(instanceResp, model) + if err != nil { + return fmt.Errorf("failed to map Authentication fields") + } + + return nil +} + +func mapDataSourceAuthentication(instanceResp *dremioSdk.DremioResponse, model *InstanceDataSourceModel) error { + authResp := instanceResp.Authentication + + authModel := DataSourceAuthenticationModel{} + + authModel.Type = types.StringValue(authResp.Type) + + if authResp.Type == "azuread" { + azureADResp := authResp.Azuread + authModel.AuthorityUrl = types.StringValue(azureADResp.AuthorityUrl) + authModel.ClientId = types.StringValue(azureADResp.ClientId) + authModel.RedirectUrl = types.StringPointerValue(azureADResp.RedirectUrl) + } + + if authResp.Type == "oauth" { + oauthResp := authResp.Oauth + authModel.AuthorityUrl = types.StringValue(oauthResp.AuthorityUrl) + authModel.ClientId = types.StringValue(oauthResp.ClientId) + authModel.Scope = types.StringPointerValue(oauthResp.Scope) + authModel.RedirectUrl = types.StringPointerValue(oauthResp.RedirectUrl) + authModel.JwtClaims = &JwtClaimsModel{ + UserName: types.StringValue(oauthResp.JwtClaims.UserName), + } + + if len(oauthResp.Parameters) > 0 { + var params []AuthParameterModel + for _, p := range oauthResp.Parameters { + params = append(params, AuthParameterModel{ + Name: types.StringValue(p.Name), + Value: types.StringValue(p.Value), + }) + } + authModel.Parameters = params + } + } + + model.Authentication = &authModel + + return nil +} diff --git a/stackit/internal/services/dremio/instance/resource.go b/stackit/internal/services/dremio/instance/resource.go new file mode 100644 index 000000000..8d870defd --- /dev/null +++ b/stackit/internal/services/dremio/instance/resource.go @@ -0,0 +1,865 @@ +package dremio + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "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/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "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" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + dremioSdk "github.com/stackitcloud/stackit-sdk-go/services/dremio/v1alphaapi" + dremioWaiter "github.com/stackitcloud/stackit-sdk-go/services/dremio/v1alphaapi/wait/wait" + + dremioUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dremio/utils" +) + +var ( + _ resource.Resource = &instanceResource{} + _ resource.ResourceWithConfigure = &instanceResource{} + _ resource.ResourceWithImportState = &instanceResource{} + _ resource.ResourceWithModifyPlan = &instanceResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` + + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + InstanceId types.String `tfsdk:"instance_id"` + + // Required Fields + DisplayName types.String `tfsdk:"display_name"` + + // Optional Fields + Description types.String `tfsdk:"description"` + + // Read-only Fields + State types.String `tfsdk:"state"` + ErrorMessage types.String `tfsdk:"error_message"` + Endpoints types.Object `tfsdk:"endpoints"` // see EdnpointsModel +} + +// InstanceModel maps the resource schema data. +type InstanceModel struct { + Model + + // Required Fields + Authentication *AuthenticationModel `tfsdk:"authentication"` + + Timeouts timeouts.Value `tfsdk:"timeouts"` +} + +// AuthenticationModel maps the nested authentication block. +type AuthenticationModel struct { + // Required Fields + Type types.String `tfsdk:"type"` + + // Optional Fields + AzureAD *AzureADModel `tfsdk:"azuread"` + OAuth *OAuthModel `tfsdk:"oauth"` +} + +type AzureADModel struct { + // Required Fields + AuthorityUrl types.String `tfsdk:"authority_url"` + ClientId types.String `tfsdk:"client_id"` + ClientSecret types.String `tfsdk:"client_secret"` + + RedirectUrl types.String `tfsdk:"redirect_url"` +} + +type OAuthModel struct { + // Required Fields + AuthorityUrl types.String `tfsdk:"authority_url"` + ClientId types.String `tfsdk:"client_id"` + ClientSecret types.String `tfsdk:"client_secret"` + JwtClaims *JwtClaimsModel `tfsdk:"jwt_claims"` + + // Optional Fields + Scope types.String `tfsdk:"scope"` + Parameters []AuthParameterModel `tfsdk:"parameters"` + + // Read-only Fields + RedirectUrl types.String `tfsdk:"redirect_url"` +} + +type JwtClaimsModel struct { + // Required Fields + UserName types.String `tfsdk:"user_name"` +} + +type AuthParameterModel struct { + // Required Fields + Name types.String `tfsdk:"name"` + Value types.String `tfsdk:"value"` +} + +type EndpointsModel struct { + ArrowFlight types.String `tfsdk:"arrow_flight"` + Catalog types.String `tfsdk:"catalog"` + Ui types.String `tfsdk:"ui"` +} + +var endpointsAttrTypes = map[string]attr.Type{ + "arrow_flight": types.StringType, + "catalog": types.StringType, + "ui": types.StringType, +} + +func NewInstanceResource() resource.Resource { + return &instanceResource{} +} + +type instanceResource struct { + client *dremioSdk.APIClient + providerData core.ProviderData +} + +func (r *instanceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_dremio_instance" +} + +func (r *instanceResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel InstanceModel + // 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 InstanceModel + 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 + } +} + +func (r *instanceResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := dremioUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Dremio instance client configured") +} + +func (r *instanceResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ //nolint:gosec // no hardcoded credentials in here + "main": "Manages a STACKIT Dremio instance.", + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`region`,`instance_id`\".", + "project_id": "STACKIT Project ID to which the resource is associated.", + "instance_id": "The Dremio instance ID.", + "region": "The STACKIT region name the resource is located in. If not defined, the provider region is used.", + "display_name": "The display name is a short name chosen by the user to identify the resource.", + "description": "The description is a longer text chosen by the user to provide more context for the resource.", + "state": "The current state of the resource.", + "error_message": "A message describing an actionable error the user can resolve. This field is empty if no such error exists.", + "endpoints": "The available endpoints of the Dremio instance.", + "endpoints_arrow_flight": "The arrow flight endpoint of the Dremio instance.", + "endpoints_catalog": "The Apache Iceberg endpoint of the Dremio instance.", + "endpoints_ui": "The UI endpoint of the Dremio instance.", + "authentication": "Dremio instance authentication settings. A change here triggers a Dremio restart and will incur downtime.", + "authentication_type": "Type of authentication (local-only, azuread, oauth).", + "azuread": "Azure Active Directory authentication configuration.", + "azuread_authority_url": "The Azure AD authority URL.", + "azuread_client_id": "The Azure AD client ID.", + "azuread_client_secret": "The Azure AD client secret.", + "azuread_redirect_url": "The Azure AD redirect URL.", + "oauth": "OIDC authentication configuration.", + "oauth_authority_url": "The Issuer location URI, where the OIDC provider configuration can be found.", + "oauth_client_id": "The client ID assigned by the Identity Provider.", + "oauth_client_secret": "The client secret generated by the Identity Provider.", + "oauth_scope": "A list of space-separated scopes. The `openid` scope is always required; other scopes can vary by provider.", + "oauth_redirect_url": "The URL where the Dremio instance is hosted. The URL must match the redirect URL set in the Identity Provider.", + "oauth_jwt_claims": "Maps fields from the JWT token to fields Dremio requires.", + "oauth_jwt_claims_user_name": "Mapped user name claim (e.g. email).", + "oauth_parameters": "Any additional parameters the Identity Provider requires.", + "oauth_parameters_name": "Parameter name.", + "oauth_parameters_value": "Parameter value.", + } + + 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(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "instance_id": schema.StringAttribute{ + Description: descriptions["instance_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "region": schema.StringAttribute{ + Required: true, + Description: descriptions["region"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "display_name": schema.StringAttribute{ + Description: descriptions["display_name"], + Required: true, + }, + "description": schema.StringAttribute{ + Description: descriptions["description"], + Optional: true, + Computed: true, // Must be computed if a default is applied + Default: stringdefault.StaticString(""), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "state": schema.StringAttribute{ + Description: descriptions["state"], + Computed: true, + }, + "error_message": schema.StringAttribute{ + Description: descriptions["error_message"], + Optional: true, + Computed: true, + }, + "endpoints": schema.SingleNestedAttribute{ + Description: descriptions["endpoints"], + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "arrow_flight": schema.StringAttribute{ + Description: descriptions["endpoints_arrow_flight"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "catalog": schema.StringAttribute{ + Description: descriptions["endpoints_catalog"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "ui": schema.StringAttribute{ + Description: descriptions["endpoints_ui"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + }, + "authentication": schema.SingleNestedAttribute{ + Description: descriptions["authentication"], + Required: true, + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + Description: descriptions["authentication_type"], + Required: true, + }, + "azuread": schema.SingleNestedAttribute{ + Description: descriptions["azuread"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "authority_url": schema.StringAttribute{ + Description: descriptions["azuread_authority_url"], + Required: true, + }, + "client_id": schema.StringAttribute{ + Description: descriptions["azuread_client_id"], + Required: true, + }, + "client_secret": schema.StringAttribute{ + Description: descriptions["azuread_client_secret"], + Required: true, + // Sensitive: true, + }, + "redirect_url": schema.StringAttribute{ + Description: descriptions["azuread_redirect_url"], + Computed: true, + }, + }, + }, + "oauth": schema.SingleNestedAttribute{ + Description: descriptions["oauth"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "authority_url": schema.StringAttribute{ + Description: descriptions["oauth_authority_url"], + Required: true, + }, + "client_id": schema.StringAttribute{ + Description: descriptions["oauth_client_id"], + Required: true, + }, + "client_secret": schema.StringAttribute{ + Description: descriptions["oauth_client_secret"], + Required: true, + // Sensitive: true, + }, + "scope": schema.StringAttribute{ + Description: descriptions["oauth_scope"], + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "redirect_url": schema.StringAttribute{ + Description: descriptions["oauth_redirect_url"], + Computed: true, + }, + "jwt_claims": schema.SingleNestedAttribute{ + Description: descriptions["oauth_jwt_claims"], + Required: true, + Attributes: map[string]schema.Attribute{ + "user_name": schema.StringAttribute{ + Description: descriptions["oauth_jwt_claims_user_name"], + Required: true, + }, + }, + }, + "parameters": schema.ListNestedAttribute{ + Description: descriptions["oauth_parameters"], + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: descriptions["oauth_parameters_name"], + Required: true, + }, + "value": schema.StringAttribute{ + Description: descriptions["oauth_parameters_value"], + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + "timeouts": timeouts.AttributesAll(ctx), + }, + } +} + +func (r *instanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + var model InstanceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + waiterTimeout := dremioWaiter.CreateDremioWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() + createTimeout, diags := model.Timeouts.Create(ctx, waiterTimeout+core.DefaultTimeoutMargin) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + ctx, cancel := context.WithTimeout(ctx, createTimeout) + defer cancel() + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() // not needed for global APIs + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + // prepare the payload struct for the create instance request + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new Dremio instance + instanceResp, err := r.client.DefaultAPI.CreateDremioInstance(ctx, projectId, region).CreateDremioInstancePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{ + "project_id": projectId, + "region": region, + "instance_id": instanceResp.Id, + }) + if resp.Diagnostics.HasError() { + return + } + + _, err = dremioWaiter.CreateDremioWaitHandler(ctx, r.client.DefaultAPI, projectId, region, instanceResp.Id).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Dremio instance", fmt.Sprintf("Dremio instance creation waiting: %v", err)) + return + } + + err = mapFields(instanceResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Dremio instance", fmt.Sprintf("Processing API payload: %v", err)) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Dremio instance created") +} + +func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model InstanceModel + resp.Diagnostics.Append(req.State.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + readTimeout, diags := model.Timeouts.Read(ctx, core.DefaultOperationTimeout) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + ctx, cancel := context.WithTimeout(ctx, readTimeout) + defer cancel() + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + instanceId := model.InstanceId.ValueString() + if instanceId == "" { + // Resource not yet created; ID is unknown. + resp.State.RemoveResource(ctx) + return + } + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + + instanceResp, err := r.client.DefaultAPI.GetDremioInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading dremio instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(instanceResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading dremio instance", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Dremio instance read") +} + +func (r *instanceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + var model, state InstanceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + waiterTimeout := dremioWaiter.UpdateDremioWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() + updateTimeout, diags := model.Timeouts.Update(ctx, waiterTimeout+core.DefaultTimeoutMargin) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + ctx, cancel := context.WithTimeout(ctx, updateTimeout) + defer cancel() + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() // not needed for global APIs + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + payload, err := toUpdatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + instanceId := state.InstanceId.ValueString() + instanceResp, err := r.client.DefaultAPI.UpdateDremioInstance(ctx, projectId, region, instanceId).UpdateDremioInstancePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{ + "project_id": projectId, + "region": region, + "instance_id": instanceResp.Id, + }) + if resp.Diagnostics.HasError() { + return + } + + _, err = dremioWaiter.UpdateDremioWaitHandler(ctx, r.client.DefaultAPI, projectId, region, instanceResp.Id).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Dremio instance", fmt.Sprintf("Dremio instance updating waiting: %v", err)) + return + } + + err = mapFields(instanceResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Dremio instance", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Dremio instance updated") +} + +func (r *instanceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + var model InstanceModel + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + waiterTimeout := dremioWaiter.DeleteDremioWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() + deleteTimeout, diags := model.Timeouts.Delete(ctx, waiterTimeout+core.DefaultTimeoutMargin) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + ctx, cancel := context.WithTimeout(ctx, deleteTimeout) + defer cancel() + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() + instanceId := model.InstanceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + + err := r.client.DefaultAPI.DeleteDremioInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting Dremio instance", fmt.Sprintf("Calling API: %v", err)) + } + + ctx = core.LogResponse(ctx) + + _, err = dremioWaiter.DeleteDremioWaitHandler(ctx, r.client.DefaultAPI, projectId, region, instanceId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting Dremio instance", fmt.Sprintf("Dremio instance deletion waiting: %v", err)) + return + } + + tflog.Info(ctx, "Dremio instance deleted") +} + +func (r *instanceResource) 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 dremio instance", + fmt.Sprintf("Expected import identifier with format [project_id],[region],[instance_id], got %q", req.ID), + ) + return + } + + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": idParts[0], + "region": idParts[1], + "instance_id": idParts[2], + }) + + tflog.Info(ctx, "Dremio instance state imported") +} + +func mapFields(instanceResp *dremioSdk.DremioResponse, model *InstanceModel) error { + if instanceResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + err := mapModelFields(instanceResp, &model.Model) + if err != nil { + return fmt.Errorf("failed to map Model fields") + } + err = mapAuthentication(instanceResp, model) + if err != nil { + return fmt.Errorf("failed to map Authentication fields") + } + + return nil +} + +// Maps instance fields to the provider's internal model +func mapModelFields(instanceResp *dremioSdk.DremioResponse, model *Model) error { + if model == nil { + return fmt.Errorf("model input is nil") + } + + model.InstanceId = types.StringValue(instanceResp.Id) + + model.Id = utils.BuildInternalTerraformId( + model.ProjectId.ValueString(), + model.Region.ValueString(), + model.InstanceId.ValueString(), + ) + + model.DisplayName = types.StringValue(instanceResp.DisplayName) + model.State = types.StringValue(instanceResp.State) + + model.Description = types.StringPointerValue(instanceResp.Description) + model.ErrorMessage = types.StringPointerValue(instanceResp.ErrorMessage) + + endpoints := &EndpointsModel{ + ArrowFlight: types.StringValue(instanceResp.Endpoints.ArrowFlight), + Catalog: types.StringValue(instanceResp.Endpoints.Catalog), + Ui: types.StringValue(instanceResp.Endpoints.Ui), + } + endpointsObj, diags := types.ObjectValueFrom(context.Background(), endpointsAttrTypes, endpoints) + if diags.HasError() { + return fmt.Errorf("failed to parse endpoints") + } + model.Endpoints = endpointsObj + + return nil +} + +func mapAuthentication(instanceResp *dremioSdk.DremioResponse, model *InstanceModel) error { + if model == nil { + return fmt.Errorf("model input is nil") + } + + authModel := AuthenticationModel{ + Type: types.StringValue(instanceResp.Authentication.Type), + } + + if instanceResp.Authentication.Azuread != nil { + azureADResp := instanceResp.Authentication.Azuread + authModel.AzureAD = &AzureADModel{ + AuthorityUrl: types.StringValue(azureADResp.AuthorityUrl), + ClientId: types.StringValue(azureADResp.ClientId), + ClientSecret: types.StringValue(azureADResp.ClientSecret), + RedirectUrl: types.StringPointerValue(azureADResp.RedirectUrl), + } + } + + if instanceResp.Authentication.Oauth != nil { + oauthResp := instanceResp.Authentication.Oauth + oauthModel := &OAuthModel{ + AuthorityUrl: types.StringValue(oauthResp.AuthorityUrl), + ClientId: types.StringValue(oauthResp.ClientId), + ClientSecret: types.StringValue(oauthResp.ClientSecret), + Scope: types.StringPointerValue(oauthResp.Scope), + RedirectUrl: types.StringPointerValue(oauthResp.RedirectUrl), + JwtClaims: &JwtClaimsModel{ + UserName: types.StringValue(oauthResp.JwtClaims.UserName), + }, + } + + if len(oauthResp.Parameters) > 0 { + var params []AuthParameterModel + for _, p := range oauthResp.Parameters { + params = append(params, AuthParameterModel{ + Name: types.StringValue(p.Name), + Value: types.StringValue(p.Value), + }) + } + oauthModel.Parameters = params + } + + authModel.OAuth = oauthModel + } + + model.Authentication = &authModel + + return nil +} + +// Build UpdateDremioInstancePayload from provider's model +func toUpdatePayload(model *InstanceModel) (*dremioSdk.UpdateDremioInstancePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + authentication, err := parseAuthentication(model) + if err != nil { + return nil, fmt.Errorf("failed to parse authentication: %v", err) + } + + return &dremioSdk.UpdateDremioInstancePayload{ + Authentication: authentication, + Description: model.Description.ValueStringPointer(), + DisplayName: model.DisplayName.ValueStringPointer(), + }, nil +} + +// Build CreateDremioInstancePayload from provider's model +func toCreatePayload(model *InstanceModel) (*dremioSdk.CreateDremioInstancePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + authentication, err := parseAuthentication(model) + if err != nil { + return nil, fmt.Errorf("failed to parse authentication: %v", err) + } + + return &dremioSdk.CreateDremioInstancePayload{ + Authentication: authentication, + Description: model.Description.ValueStringPointer(), + DisplayName: model.DisplayName.ValueString(), + }, nil +} + +func parseAuthentication(model *InstanceModel) (*dremioSdk.Authentication, error) { + // API only saves the block of the stated type. The other one is omitted. + // Keeping the block in TF leads to inconsistent state. Therefore we have + // make sure the type matches the existing block. + + switch model.Authentication.Type.ValueString() { + case "local-only": + if !(model.Authentication.OAuth == nil) || !(model.Authentication.AzureAD == nil) { + return nil, fmt.Errorf("can't state idp config if auth type is local-only") + } + return &dremioSdk.Authentication{ + Azuread: nil, + Oauth: nil, + Type: model.Authentication.Type.ValueString(), + }, nil + case "oauth": + if !(model.Authentication.AzureAD == nil) { + return nil, fmt.Errorf("can't state azure idp config if auth type is oauth") + } + if model.Authentication.OAuth == nil { + return nil, fmt.Errorf("missing oauth idp config") + } + + oAuthParams := []dremioSdk.AuthParameters{} + if len(model.Authentication.OAuth.Parameters) > 0 { + parameters := model.Authentication.OAuth.Parameters + for _, param := range parameters { + oAuthParams = append(oAuthParams, dremioSdk.AuthParameters{ + Name: param.Name.ValueString(), + Value: param.Value.ValueString(), + }) + } + } + + oAuthPayload := &dremioSdk.Oauth{ + AuthorityUrl: model.Authentication.OAuth.AuthorityUrl.ValueString(), + ClientId: model.Authentication.OAuth.ClientId.ValueString(), + ClientSecret: model.Authentication.OAuth.ClientSecret.ValueString(), + JwtClaims: dremioSdk.OauthJwtClaims{ + UserName: model.Authentication.OAuth.JwtClaims.UserName.ValueString(), + }, + RedirectUrl: model.Authentication.OAuth.RedirectUrl.ValueStringPointer(), + Scope: model.Authentication.OAuth.Scope.ValueStringPointer(), + Parameters: oAuthParams, + } + + return &dremioSdk.Authentication{ + Azuread: nil, + Oauth: oAuthPayload, + Type: model.Authentication.Type.ValueString(), + }, nil + case "azuread": + if !(model.Authentication.OAuth == nil) { + return nil, fmt.Errorf("can't state oauth idp config if auth type is azuread") + } + if model.Authentication.AzureAD == nil { + return nil, fmt.Errorf("missing azuread config") + } + + azureAdPayload := &dremioSdk.Azuread{ + AuthorityUrl: model.Authentication.AzureAD.AuthorityUrl.ValueString(), + ClientId: model.Authentication.AzureAD.ClientId.ValueString(), + ClientSecret: model.Authentication.AzureAD.ClientSecret.ValueString(), + RedirectUrl: model.Authentication.AzureAD.RedirectUrl.ValueStringPointer(), + } + return &dremioSdk.Authentication{ + Azuread: azureAdPayload, + Oauth: nil, + Type: model.Authentication.Type.ValueString(), + }, nil + default: + return nil, fmt.Errorf("unknown authentication type: %s", model.Authentication.Type) + } +} diff --git a/stackit/internal/services/dremio/instance/resource_test.go b/stackit/internal/services/dremio/instance/resource_test.go new file mode 100644 index 000000000..1bd1845e1 --- /dev/null +++ b/stackit/internal/services/dremio/instance/resource_test.go @@ -0,0 +1,529 @@ +package dremio + +import ( + "testing" + "time" + + "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" + dremioSdk "github.com/stackitcloud/stackit-sdk-go/services/dremio/v1alphaapi" +) + +func TestMapFields(t *testing.T) { + instanceId := uuid.New().String() + tests := []struct { + description string + state *InstanceModel + input *dremioSdk.DremioResponse + expected *InstanceModel + wantErr bool + }{ + { + "all_fields_filled", + &InstanceModel{ + Model: Model{ + Region: types.StringValue("rid"), + ProjectId: types.StringValue("pid"), + }, + }, + &dremioSdk.DremioResponse{ + Id: instanceId, + CreateTime: time.Now(), + Description: utils.Ptr("minimal-required-values"), + DisplayName: "greatName", + Authentication: dremioSdk.Authentication{ + Azuread: &dremioSdk.Azuread{ + AuthorityUrl: "azure-authority", + ClientId: "azure-client", + ClientSecret: "azure-secret", + RedirectUrl: utils.Ptr("azure-redirect"), + }, + Oauth: &dremioSdk.Oauth{ + AuthorityUrl: "oauth-authority", + ClientId: "oauth-client", + ClientSecret: "oauth-secret", + JwtClaims: dremioSdk.OauthJwtClaims{ + UserName: "oauth-username", + }, + Parameters: []dremioSdk.AuthParameters{ + { + Name: "oauth-parameter", + Value: "oauth-value", + }, + }, + RedirectUrl: utils.Ptr("oauth-redirect"), + Scope: utils.Ptr("oauth-scope"), + }, + Type: "local-only", + }, + Endpoints: dremioSdk.Endpoints{ + ArrowFlight: "flight", + Catalog: "catalog", + Ui: "ui", + }, + State: "active", + }, + &InstanceModel{ + Model: Model{ + Id: types.StringValue("pid,rid," + instanceId), + + ProjectId: types.StringValue("pid"), + Region: types.StringValue("rid"), + InstanceId: types.StringValue(instanceId), + + DisplayName: types.StringValue("greatName"), + Description: types.StringValue("minimal-required-values"), + + State: types.StringValue("active"), + ErrorMessage: types.StringNull(), + Endpoints: types.ObjectValueMust( + map[string]attr.Type{ + "arrow_flight": types.StringType, + "catalog": types.StringType, + "ui": types.StringType, + }, + map[string]attr.Value{ + "arrow_flight": types.StringValue("flight"), + "catalog": types.StringValue("catalog"), + "ui": types.StringValue("ui"), + }, + ), + }, + Authentication: &AuthenticationModel{ + AzureAD: &AzureADModel{ + AuthorityUrl: types.StringValue("azure-authority"), + ClientId: types.StringValue("azure-client"), + ClientSecret: types.StringValue("azure-secret"), + RedirectUrl: types.StringValue("azure-redirect"), + }, + OAuth: &OAuthModel{ + AuthorityUrl: types.StringValue("oauth-authority"), + ClientId: types.StringValue("oauth-client"), + ClientSecret: types.StringValue("oauth-secret"), + JwtClaims: &JwtClaimsModel{ + UserName: types.StringValue("oauth-username"), + }, + Parameters: []AuthParameterModel{ + { + Name: types.StringValue("oauth-parameter"), + Value: types.StringValue("oauth-value"), + }, + }, + RedirectUrl: types.StringValue("oauth-redirect"), + Scope: types.StringValue("oauth-scope"), + }, + Type: types.StringValue("local-only"), + }, + }, + false, + }, + { + "nil response", + &InstanceModel{ + Model: Model{ + Region: types.StringValue("rid"), + ProjectId: types.StringValue("pid"), + }, + }, + nil, + &InstanceModel{ + Model: Model{ + Id: types.StringValue("pid,rid,"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue("rid"), + }, + }, + true, + }, + { + "nil state", + nil, + &dremioSdk.DremioResponse{}, + nil, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(tt.input, tt.state) + 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.state); diff != "" { + t.Errorf("mapping mismatch (-want +got):\n%s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + state *InstanceModel + expected *dremioSdk.CreateDremioInstancePayload + wantErr bool + }{ + { + "success-local", + &InstanceModel{ + Model: Model{ + Description: types.StringValue("test description"), + DisplayName: types.StringValue("displayName"), + }, + Authentication: &AuthenticationModel{ + Type: types.StringValue("local-only"), + }, + }, + &dremioSdk.CreateDremioInstancePayload{ + Authentication: &dremioSdk.Authentication{ + Type: "local-only", + }, + Description: utils.Ptr("test description"), + DisplayName: "displayName", + }, + false, + }, + { + "success-oauth", + &InstanceModel{ + Model: Model{ + Description: types.StringValue("test description"), + DisplayName: types.StringValue("displayName"), + }, + Authentication: &AuthenticationModel{ + OAuth: &OAuthModel{ + AuthorityUrl: types.StringValue("oauth-authority"), + ClientId: types.StringValue("oauth-client"), + ClientSecret: types.StringValue("oauth-secret"), + JwtClaims: &JwtClaimsModel{ + UserName: types.StringValue("oauth-username"), + }, + Parameters: []AuthParameterModel{ + { + Name: types.StringValue("oauth-parameter"), + Value: types.StringValue("oauth-value"), + }, + }, + RedirectUrl: types.StringValue("oauth-redirect"), + Scope: types.StringValue("oauth-scope"), + }, + Type: types.StringValue("oauth"), + }, + }, + &dremioSdk.CreateDremioInstancePayload{ + Authentication: &dremioSdk.Authentication{ + Oauth: &dremioSdk.Oauth{ + AuthorityUrl: "oauth-authority", + ClientId: "oauth-client", + ClientSecret: "oauth-secret", + JwtClaims: dremioSdk.OauthJwtClaims{ + UserName: "oauth-username", + }, + Parameters: []dremioSdk.AuthParameters{ + { + Name: "oauth-parameter", + Value: "oauth-value", + }, + }, + RedirectUrl: utils.Ptr("oauth-redirect"), + Scope: utils.Ptr("oauth-scope"), + }, + Type: "oauth", + }, + Description: utils.Ptr("test description"), + DisplayName: "displayName", + }, + false, + }, + { + "success-azuread", + &InstanceModel{ + Model: Model{ + Description: types.StringValue("test description"), + DisplayName: types.StringValue("displayName"), + }, + Authentication: &AuthenticationModel{ + AzureAD: &AzureADModel{ + AuthorityUrl: types.StringValue("azure-authority"), + ClientId: types.StringValue("azure-client"), + ClientSecret: types.StringValue("azure-secret"), + RedirectUrl: types.StringValue("azure-redirect"), + }, + Type: types.StringValue("azuread"), + }, + }, + &dremioSdk.CreateDremioInstancePayload{ + Authentication: &dremioSdk.Authentication{ + Azuread: &dremioSdk.Azuread{ + AuthorityUrl: "azure-authority", + ClientId: "azure-client", + ClientSecret: "azure-secret", + RedirectUrl: utils.Ptr("azure-redirect"), + }, + Type: "azuread", + }, + Description: utils.Ptr("test description"), + DisplayName: "displayName", + }, + false, + }, + { + "idp-config-mismatch-local", + &InstanceModel{ + Model: Model{ + Description: types.StringValue("test description"), + DisplayName: types.StringValue("displayName"), + }, + Authentication: &AuthenticationModel{ + AzureAD: &AzureADModel{ + AuthorityUrl: types.StringValue("azure-authority"), + ClientId: types.StringValue("azure-client"), + ClientSecret: types.StringValue("azure-secret"), + RedirectUrl: types.StringValue("azure-redirect"), + }, + Type: types.StringValue("local-only"), + }, + }, + nil, + true, + }, + { + "missing-idp-config-oauth", + &InstanceModel{ + Model: Model{ + Description: types.StringValue("test description"), + DisplayName: types.StringValue("displayName"), + }, + Authentication: &AuthenticationModel{ + Type: types.StringValue("oauth"), + }, + }, + nil, + true, + }, + { + "missing-idp-config-azuread", + &InstanceModel{ + Model: Model{ + Description: types.StringValue("test description"), + DisplayName: types.StringValue("displayName"), + }, + Authentication: &AuthenticationModel{ + Type: types.StringValue("azuread"), + }, + }, + nil, + true, + }, + { + "nil model", + nil, + nil, + true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toCreatePayload(tt.state) + 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 + state *InstanceModel + expected *dremioSdk.UpdateDremioInstancePayload + wantErr bool + }{ + { + "success", + &InstanceModel{ + Model: Model{ + Description: types.StringValue("test description"), + DisplayName: types.StringValue("displayName"), + }, + Authentication: &AuthenticationModel{ + Type: types.StringValue("local-only"), + }, + }, + &dremioSdk.UpdateDremioInstancePayload{ + Authentication: &dremioSdk.Authentication{ + Type: "local-only", + }, + Description: utils.Ptr("test description"), + DisplayName: utils.Ptr("displayName"), + }, + false, + }, + { + "success-oauth", + &InstanceModel{ + Model: Model{ + Description: types.StringValue("test description"), + DisplayName: types.StringValue("displayName"), + }, + Authentication: &AuthenticationModel{ + OAuth: &OAuthModel{ + AuthorityUrl: types.StringValue("oauth-authority"), + ClientId: types.StringValue("oauth-client"), + ClientSecret: types.StringValue("oauth-secret"), + JwtClaims: &JwtClaimsModel{ + UserName: types.StringValue("oauth-username"), + }, + Parameters: []AuthParameterModel{ + { + Name: types.StringValue("oauth-parameter"), + Value: types.StringValue("oauth-value"), + }, + }, + RedirectUrl: types.StringValue("oauth-redirect"), + Scope: types.StringValue("oauth-scope"), + }, + Type: types.StringValue("oauth"), + }, + }, + &dremioSdk.UpdateDremioInstancePayload{ + Authentication: &dremioSdk.Authentication{ + Oauth: &dremioSdk.Oauth{ + AuthorityUrl: "oauth-authority", + ClientId: "oauth-client", + ClientSecret: "oauth-secret", + JwtClaims: dremioSdk.OauthJwtClaims{ + UserName: "oauth-username", + }, + Parameters: []dremioSdk.AuthParameters{ + { + Name: "oauth-parameter", + Value: "oauth-value", + }, + }, + RedirectUrl: utils.Ptr("oauth-redirect"), + Scope: utils.Ptr("oauth-scope"), + }, + Type: "oauth", + }, + Description: utils.Ptr("test description"), + DisplayName: utils.Ptr("displayName"), + }, + false, + }, + { + "success-azuread", + &InstanceModel{ + Model: Model{ + Description: types.StringValue("test description"), + DisplayName: types.StringValue("displayName"), + }, + Authentication: &AuthenticationModel{ + AzureAD: &AzureADModel{ + AuthorityUrl: types.StringValue("azure-authority"), + ClientId: types.StringValue("azure-client"), + ClientSecret: types.StringValue("azure-secret"), + RedirectUrl: types.StringValue("azure-redirect"), + }, + Type: types.StringValue("azuread"), + }, + }, + &dremioSdk.UpdateDremioInstancePayload{ + Authentication: &dremioSdk.Authentication{ + Azuread: &dremioSdk.Azuread{ + AuthorityUrl: "azure-authority", + ClientId: "azure-client", + ClientSecret: "azure-secret", + RedirectUrl: utils.Ptr("azure-redirect"), + }, + Type: "azuread", + }, + Description: utils.Ptr("test description"), + DisplayName: utils.Ptr("displayName"), + }, + false, + }, + { + "idp-config-mismatch-local", + &InstanceModel{ + Model: Model{ + Description: types.StringValue("test description"), + DisplayName: types.StringValue("displayName"), + }, + Authentication: &AuthenticationModel{ + AzureAD: &AzureADModel{ + AuthorityUrl: types.StringValue("azure-authority"), + ClientId: types.StringValue("azure-client"), + ClientSecret: types.StringValue("azure-secret"), + RedirectUrl: types.StringValue("azure-redirect"), + }, + Type: types.StringValue("local-only"), + }, + }, + nil, + true, + }, + { + "missing-idp-config-oauth", + &InstanceModel{ + Model: Model{ + Description: types.StringValue("test description"), + DisplayName: types.StringValue("displayName"), + }, + Authentication: &AuthenticationModel{ + Type: types.StringValue("oauth"), + }, + }, + nil, + true, + }, + { + "missing-idp-config-azuread", + &InstanceModel{ + Model: Model{ + Description: types.StringValue("test description"), + DisplayName: types.StringValue("displayName"), + }, + Authentication: &AuthenticationModel{ + Type: types.StringValue("azuread"), + }, + }, + nil, + true, + }, + { + "nil model", + nil, + nil, + true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := toUpdatePayload(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/dremio/testdata/resource-max.tf b/stackit/internal/services/dremio/testdata/resource-max.tf new file mode 100644 index 000000000..57943490b --- /dev/null +++ b/stackit/internal/services/dremio/testdata/resource-max.tf @@ -0,0 +1,60 @@ + +variable "project_id"{} +variable "region" {} +variable "display_name" {} +variable "description" {} + +// authentication +variable "authentication_type" {} + +// oauth +variable "authentication_oauth_authority_url" {} +variable "authentication_oauth_client_id" {} +variable "authentication_oauth_client_secret" {} +variable "authentication_oauth_client_jwt_claims_user_name" {} +variable "authentication_oauth_scope" {} +variable "authentication_oauth_parameter_name" {} +variable "authentication_oauth_parameter_value" {} + +// azuread +variable "authentication_type_azuread" {default=null} +variable "authentication_azuread_authority_url" {default=null} +variable "authentication_azuread_client_id" {default=null} +variable "authentication_azuread_client_secret" {default=null} + +resource "stackit_dremio_instance" "example" { + project_id = var.project_id + region = var.region + display_name = var.display_name + description = var.description + authentication = { + type = var.authentication_type + + oauth = var.authentication_type == "oauth" ? { + authority_url = var.authentication_oauth_authority_url + client_id = var.authentication_oauth_client_id + client_secret = var.authentication_oauth_client_secret + jwt_claims = { + user_name = var.authentication_oauth_client_jwt_claims_user_name + } + scope = var.authentication_oauth_scope + parameters = [ + { + "name": var.authentication_oauth_parameter_name, + "value": var.authentication_oauth_parameter_value + } + ] + } : null + azuread = var.authentication_type == "azuread" ? { + authority_url = var.authentication_azuread_authority_url + client_id = var.authentication_azuread_client_id + client_secret = var.authentication_azuread_client_secret + } : null + } +} + +data "stackit_dremio_instance" "example" { + project_id = var.project_id + region = var.region + instance_id = stackit_dremio_instance.example.instance_id +} \ No newline at end of file diff --git a/stackit/internal/services/dremio/testdata/resource-min.tf b/stackit/internal/services/dremio/testdata/resource-min.tf new file mode 100644 index 000000000..5a3bf4fb3 --- /dev/null +++ b/stackit/internal/services/dremio/testdata/resource-min.tf @@ -0,0 +1,20 @@ + +variable "project_id"{} +variable "region" {} +variable "display_name" {} +variable "authentication_type" {} + +resource "stackit_dremio_instance" "example" { + project_id = var.project_id + region = var.region + display_name = var.display_name + authentication = { + type = var.authentication_type + } +} + +data "stackit_dremio_instance" "example" { + project_id = var.project_id + region = var.region + instance_id = stackit_dremio_instance.example.instance_id +} \ No newline at end of file diff --git a/stackit/internal/services/dremio/user/resource.go b/stackit/internal/services/dremio/user/resource.go new file mode 100644 index 000000000..528733a64 --- /dev/null +++ b/stackit/internal/services/dremio/user/resource.go @@ -0,0 +1,450 @@ +package dremio + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "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/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + dremioSdk "github.com/stackitcloud/stackit-sdk-go/services/dremio/v1alphaapi" + dremioUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dremio/utils" + + dremioWaiter "github.com/stackitcloud/stackit-sdk-go/services/dremio/v1alphaapi/wait/wait" +) + +var ( + _ resource.Resource = &userResource{} + _ resource.ResourceWithConfigure = &userResource{} + _ resource.ResourceWithImportState = &userResource{} + _ resource.ResourceWithModifyPlan = &userResource{} +) + +type Model struct { + ID types.String `tfsdk:"id"` + + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + InstanceId types.String `tfsdk:"instance_id"` + UserId types.String `tfsdk:"user_id"` + + Description types.String `tfsdk:"description"` + Email types.String `tfsdk:"email"` + FirstName types.String `tfsdk:"first_name"` + LastName types.String `tfsdk:"last_name"` + Name types.String `tfsdk:"name"` + Password types.String `tfsdk:"password"` +} + +type UserModel struct { + Model + + Timeouts timeouts.Value `tfsdk:"timeouts"` +} + +type userResource struct { + client *dremioSdk.APIClient + providerData core.ProviderData +} + +func NewUserResource() resource.Resource { + return &userResource{} +} + +func (r *userResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_dremio_user" +} + +func (r *userResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel UserModel + // 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 + } +} + +func (r *userResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := dremioUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Dremio user client configured") +} + +func (r *userResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ + "main": "Manages a STACKIT Dremio instances user.", + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`region`,`instance_id`,`user_id`\".", + "project_id": "STACKIT Project ID to which the resource is associated.", + "instance_id": "The Dremio instance ID.", + "region": "The STACKIT region name the resource is located in. If not defined, the provider region is used.", + "user_id": "The Dremio user ID.", + "description": "The description of the user.", + "email": "The email address of the user.", + "first_name": "The first name of the user.", + "last_name": "The last name of the user.", + "name": "The username of the user.", + "password": "The password of the user. Only used for creation and updates. Must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number and one special character.", + } + + 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(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "instance_id": schema.StringAttribute{ + Description: descriptions["instance_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "region": schema.StringAttribute{ + Description: descriptions["region"], + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "user_id": schema.StringAttribute{ + Description: descriptions["user_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "description": schema.StringAttribute{ + Description: descriptions["description"], + Optional: true, + }, + "email": schema.StringAttribute{ + Description: descriptions["email"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "first_name": schema.StringAttribute{ + Description: descriptions["first_name"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "last_name": schema.StringAttribute{ + Description: descriptions["last_name"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "password": schema.StringAttribute{ + Description: descriptions["password"], + Optional: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *userResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + var model UserModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + waiterTimeout := dremioWaiter.CreateDremioUserWaitHandler(ctx, r.client.DefaultAPI, "", "", "", "").GetTimeout() + createTimeout, diags := model.Timeouts.Create(ctx, waiterTimeout+core.DefaultTimeoutMargin) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + ctx, cancel := context.WithTimeout(ctx, createTimeout) + defer cancel() + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() // not needed for global APIs + instanceId := model.InstanceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + + // prepare the payload struct for the create user request + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new Dremio user + userResp, err := r.client.DefaultAPI.CreateDremioUser(ctx, projectId, region, instanceId).CreateDremioUserPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{ + "project_id": projectId, + "region": region, + "user_id": userResp.Id, + }) + if resp.Diagnostics.HasError() { + return + } + + _, err = dremioWaiter.CreateDremioUserWaitHandler(ctx, r.client.DefaultAPI, projectId, region, instanceId, userResp.Id).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Dremio user", fmt.Sprintf("Dremio user creation waiting: %v", err)) + return + } + + err = mapFields(userResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Dremio user", fmt.Sprintf("Processing API payload: %v", err)) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Dremio user created") +} + +func (r *userResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model UserModel + resp.Diagnostics.Append(req.State.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + readTimeout, diags := model.Timeouts.Read(ctx, core.DefaultOperationTimeout) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + ctx, cancel := context.WithTimeout(ctx, readTimeout) + defer cancel() + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + instanceId := model.InstanceId.ValueString() + userId := model.UserId.ValueString() + if userId == "" { + // Resource not yet created; ID is unknown. + resp.State.RemoveResource(ctx) + return + } + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "user_id", userId) + + userResp, err := r.client.DefaultAPI.GetDremioUser(ctx, projectId, region, instanceId, userId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading dremio user", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(userResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading dremio user", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Dremio user read") +} + +func (r *userResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // We don't allow updates on Dremio users. +} + +func (r *userResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + var model UserModel + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + waiterTimeout := dremioWaiter.DeleteDremioUserWaitHandler(ctx, r.client.DefaultAPI, "", "", "", "").GetTimeout() + deleteTimeout, diags := model.Timeouts.Delete(ctx, waiterTimeout+core.DefaultTimeoutMargin) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + ctx, cancel := context.WithTimeout(ctx, deleteTimeout) + defer cancel() + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := model.Region.ValueString() + instanceId := model.InstanceId.ValueString() + userId := model.UserId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "user_id", userId) + + err := r.client.DefaultAPI.DeleteDremioUser(ctx, projectId, region, instanceId, userId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting Dremio user", fmt.Sprintf("Calling API: %v", err)) + } + + ctx = core.LogResponse(ctx) + + _, err = dremioWaiter.DeleteDremioUserWaitHandler(ctx, r.client.DefaultAPI, projectId, region, instanceId, userId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting Dremio user", fmt.Sprintf("Dremio user deletion waiting: %v", err)) + return + } + + tflog.Info(ctx, "Dremio user deleted") +} + +func (r *userResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing dremio user", + fmt.Sprintf("Expected import identifier with format [project_id],[region],[instance_id],[user_id] got %q", req.ID), + ) + return + } + + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": idParts[0], + "region": idParts[1], + "instance_id": idParts[2], + "user_id": idParts[3], + }) + + tflog.Info(ctx, "Dremio user state imported") +} + +func mapFields(userResp *dremioSdk.DremioUserResponse, model *UserModel) error { + if userResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + model.UserId = types.StringValue(userResp.Id) + model.Description = types.StringPointerValue(userResp.Description) + model.Email = types.StringValue(userResp.Email) + model.FirstName = types.StringValue(userResp.FirstName) + model.LastName = types.StringValue(userResp.LastName) + model.Name = types.StringValue(userResp.Name) + + return nil +} + +func toCreatePayload(model *UserModel) (*dremioSdk.CreateDremioUserPayload, error) { + if model == nil { + return nil, fmt.Errorf("model input is nil") + } + + payload := &dremioSdk.CreateDremioUserPayload{ + Description: model.Description.ValueStringPointer(), + Email: model.Email.ValueString(), + FirstName: model.FirstName.ValueString(), + LastName: model.LastName.ValueString(), + Name: model.Name.ValueString(), + Password: model.Password.ValueString(), + } + + return payload, nil +} + +// func toUpdatePayload(model *UserModel) (*dremioSdk.UpdateDremioUserPayload, error) { +// return nil, nil +// } diff --git a/stackit/internal/services/dremio/utils/util.go b/stackit/internal/services/dremio/utils/util.go new file mode 100644 index 000000000..912af6e39 --- /dev/null +++ b/stackit/internal/services/dremio/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" + dremio "github.com/stackitcloud/stackit-sdk-go/services/dremio/v1alphaapi" + + "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) *dremio.APIClient { + apiClientConfigOptions := []config.ConfigurationOption{ + config.WithCustomAuth(providerData.RoundTripper), + utils.UserAgentConfigOption(providerData.Version), + config.WithRegion(providerData.DefaultRegion), + } + if providerData.DremioCustomEndpoint != "" { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.DremioCustomEndpoint)) + } + apiClient, err := dremio.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 9e057574e..d47fe925b 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -71,6 +71,7 @@ var ( ALBCertCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_ALB_CERT_CUSTOM_ENDPOINT", providerName: "alb_certificates_custom_endpoint"} CdnCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_CDN_CUSTOM_ENDPOINT", providerName: "cdn_custom_endpoint"} DnsCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_DNS_CUSTOM_ENDPOINT", providerName: "dns_custom_endpoint"} + DremioCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_DREMIO_CUSTOM_ENDPOINT", providerName: "dremio_custom_endpoint"} EdgeCloudCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_EDGECLOUD_CUSTOM_ENDPOINT", providerName: "edgecloud_custom_endpoint"} GitCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_GIT_CUSTOM_ENDPOINT", providerName: "git_custom_endpoint"} IaaSCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_IAAS_CUSTOM_ENDPOINT", providerName: "iaas_custom_endpoint"} diff --git a/stackit/provider.go b/stackit/provider.go index 645b4925b..a181cbbdf 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -30,6 +30,7 @@ import ( cdn "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/distribution" dnsRecordSet "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/recordset" dnsZone "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/zone" + dremioInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dremio/instance" edgeCloudInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/edgecloud/instance" edgeCloudInstances "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/edgecloud/instances" edgeCloudKubeconfig "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/edgecloud/kubeconfig" @@ -175,6 +176,7 @@ type providerModel struct { CdnCustomEndpoint types.String `tfsdk:"cdn_custom_endpoint"` ALBCertificatesCustomEndpoint types.String `tfsdk:"alb_certificates_custom_endpoint"` DnsCustomEndpoint types.String `tfsdk:"dns_custom_endpoint"` + DremioCustomEndpoint types.String `tfsdk:"dremio_custom_endpoint"` EdgeCloudCustomEndpoint types.String `tfsdk:"edgecloud_custom_endpoint"` GitCustomEndpoint types.String `tfsdk:"git_custom_endpoint"` IaaSCustomEndpoint types.String `tfsdk:"iaas_custom_endpoint"` @@ -232,6 +234,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "alb_custom_endpoint": "Custom endpoint for the Application Load Balancer service", "cdn_custom_endpoint": "Custom endpoint for the CDN service", "dns_custom_endpoint": "Custom endpoint for the DNS service", + "dremio_custom_endpoint": "Custom endpoint for the Dremio service", "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", @@ -360,6 +363,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["dns_custom_endpoint"], }, + "dremio_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["dremio_custom_endpoint"], + }, "edgecloud_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["edgecloud_custom_endpoint"], @@ -536,6 +543,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, setStringField(providerConfig.AuthorizationCustomEndpoint, func(v string) { providerData.AuthorizationCustomEndpoint = v }) setStringField(providerConfig.CdnCustomEndpoint, func(v string) { providerData.CdnCustomEndpoint = v }) setStringField(providerConfig.DnsCustomEndpoint, func(v string) { providerData.DnsCustomEndpoint = v }) + setStringField(providerConfig.DremioCustomEndpoint, func(v string) { providerData.DremioCustomEndpoint = v }) 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 }) @@ -645,6 +653,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource cdnCustomDomain.NewCustomDomainDataSource, dnsZone.NewZoneDataSource, dnsRecordSet.NewRecordSetDataSource, + dremioInstance.NewInstanceDataSource, edgeCloudInstances.NewInstancesDataSource, edgeCloudPlans.NewPlansDataSource, gitInstance.NewGitDataSource, @@ -745,6 +754,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { cdnCustomDomain.NewCustomDomainResource, dnsZone.NewZoneResource, dnsRecordSet.NewRecordSetResource, + dremioInstance.NewInstanceResource, edgeCloudInstance.NewInstanceResource, edgeCloudKubeconfig.NewKubeconfigResource, edgeCloudToken.NewTokenResource,