diff --git a/docs/data-sources/service_account.md b/docs/data-sources/service_account.md new file mode 100644 index 00000000..884696b9 --- /dev/null +++ b/docs/data-sources/service_account.md @@ -0,0 +1,36 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_service_account Data Source - stackit" +subcategory: "" +description: |- + Service account data source schema. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_service_account (Data Source) + +Service account data source schema. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +data "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email = "sa01-8565oq1@sa.stackit.cloud" +} +``` + + +## Schema + +### Required + +- `email` (String) Email of the service account. +- `project_id` (String) STACKIT project ID to which the service account is associated. + +### Read-Only + +- `id` (String) Terraform's internal resource ID, structured as "`project_id`,`email`". +- `name` (String) Name of the service account. diff --git a/docs/index.md b/docs/index.md index e45cbb63..ae1998cd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -176,6 +176,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de - `secretsmanager_custom_endpoint` (String) Custom endpoint for the Secrets Manager service - `server_backup_custom_endpoint` (String) Custom endpoint for the Server Backup service - `server_update_custom_endpoint` (String) Custom endpoint for the Server Update service +- `service_account_custom_endpoint` (String) Custom endpoint for the Service Account service - `service_account_email` (String, Deprecated) Service account email. It can also be set using the environment variable STACKIT_SERVICE_ACCOUNT_EMAIL. It is required if you want to use the resource manager project resource. - `service_account_key` (String) Service account key used for authentication. If set, the key flow will be used to authenticate all operations. - `service_account_key_path` (String) Path for the service account key used for authentication. If set, the key flow will be used to authenticate all operations. diff --git a/docs/resources/service_account.md b/docs/resources/service_account.md new file mode 100644 index 00000000..c6f5e9b1 --- /dev/null +++ b/docs/resources/service_account.md @@ -0,0 +1,36 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_service_account Resource - stackit" +subcategory: "" +description: |- + Service account resource schema. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_service_account (Resource) + +Service account resource schema. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "sa01" +} +``` + + +## Schema + +### Required + +- `name` (String) Name of the service account. +- `project_id` (String) STACKIT project ID to which the service account is associated. + +### Read-Only + +- `email` (String) Email of the service account. +- `id` (String) Terraform's internal resource ID, structured as "`project_id`,`email`". diff --git a/examples/data-sources/stackit_service_account/data-source.tf b/examples/data-sources/stackit_service_account/data-source.tf new file mode 100644 index 00000000..bb658f11 --- /dev/null +++ b/examples/data-sources/stackit_service_account/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email = "sa01-8565oq1@sa.stackit.cloud" +} diff --git a/examples/resources/stackit_service_account/resource.tf b/examples/resources/stackit_service_account/resource.tf new file mode 100644 index 00000000..633e4de1 --- /dev/null +++ b/examples/resources/stackit_service_account/resource.tf @@ -0,0 +1,4 @@ +resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "sa01" +} diff --git a/go.mod b/go.mod index 8056faab..975bccf0 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.11.0 github.com/stackitcloud/stackit-sdk-go/services/serverbackup v0.6.0 github.com/stackitcloud/stackit-sdk-go/services/serverupdate v0.5.0 + github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.6.0 github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v0.5.0 github.com/stackitcloud/stackit-sdk-go/services/ske v0.22.0 github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.0.0 diff --git a/go.sum b/go.sum index c8568d5b..905f7612 100644 --- a/go.sum +++ b/go.sum @@ -189,6 +189,8 @@ github.com/stackitcloud/stackit-sdk-go/services/serverbackup v0.6.0 h1:cESGAkm0f github.com/stackitcloud/stackit-sdk-go/services/serverbackup v0.6.0/go.mod h1:aYPLsiImzWaYXEfYIZ0wJnV56PwcR+buy8Xu9jjbfGA= github.com/stackitcloud/stackit-sdk-go/services/serverupdate v0.5.0 h1:TMUxDh8XGgWUpnWo7GsawVq2ICDsy/r8dMlfC26MR5g= github.com/stackitcloud/stackit-sdk-go/services/serverupdate v0.5.0/go.mod h1:giHnHz3kHeLY8Av9MZLsyJlaTXYz+BuGqdP/SKB5Vo0= +github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.6.0 h1:y+XzJcntHJ7M+IWWvAUkiVFA8op+jZxwHs3ktW2aLoA= +github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.6.0/go.mod h1:J/Wa67cbDI1wAyxib9PiEbNqGfIoFUH+DSLueVazQx8= github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v0.5.0 h1:QG+rGBHsyXOlJ3ZIeOgExGqu9PoTlGY1rltW/VpG6lw= github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v0.5.0/go.mod h1:16dOVT052cMuHhUJ3NIcPuY7TrpCr9QlxmvvfjLZubA= github.com/stackitcloud/stackit-sdk-go/services/ske v0.22.0 h1:3KUVls8zXsbT2tOYRSHyp3/l0Kpjl4f3INmQKYTe65Y= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index d27e49a4..d1fbe1b9 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -40,6 +40,7 @@ type ProviderData struct { ServerUpdateCustomEndpoint string SKECustomEndpoint string ServiceEnablementCustomEndpoint string + ServiceAccountCustomEndpoint string EnableBetaResources bool Experiments []string } diff --git a/stackit/internal/services/serviceaccount/account/datasource.go b/stackit/internal/services/serviceaccount/account/datasource.go new file mode 100644 index 00000000..4cde8481 --- /dev/null +++ b/stackit/internal/services/serviceaccount/account/datasource.go @@ -0,0 +1,176 @@ +package account + +import ( + "context" + "fmt" + + "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/config" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// dataSourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var dataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &serviceAccountDataSource{} +) + +// NewServiceAccountDataSource creates a new instance of the serviceAccountDataSource. +func NewServiceAccountDataSource() datasource.DataSource { + return &serviceAccountDataSource{} +} + +// serviceAccountDataSource is the datasource implementation for service accounts. +type serviceAccountDataSource struct { + client *serviceaccount.APIClient +} + +// Configure initializes the serviceAccountDataSource with the provided provider data. +func (r *serviceAccountDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured correctly. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !dataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_service_account", "datasource") + if resp.Diagnostics.HasError() { + return + } + dataSourceBetaCheckDone = true + } + + var apiClient *serviceaccount.APIClient + var err error + if providerData.ServiceAccountCustomEndpoint != "" { + apiClient, err = serviceaccount.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.ServiceAccountCustomEndpoint), + ) + } else { + apiClient, err = serviceaccount.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "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 + } + + r.client = apiClient + tflog.Info(ctx, "Service Account client configured") +} + +// Metadata provides metadata for the service account datasource. +func (r *serviceAccountDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_account" +} + +// Schema defines the schema for the service account data source. +func (r *serviceAccountDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + descriptions := map[string]string{ + "id": "Terraform's internal resource ID, structured as \"`project_id`,`email`\".", + "project_id": "STACKIT project ID to which the service account is associated.", + "name": "Name of the service account.", + "email": "Email of the service account.", + } + + // Define the schema with validation rules and descriptions for each attribute. + // The datasource schema differs slightly from the resource schema. + // In this case, the email attribute is required to read the service account data from the API. + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Service account data source schema."), + Description: "Service account data source schema.", + 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(), + }, + }, + "email": schema.StringAttribute{ + Description: descriptions["email"], + Required: true, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Computed: true, + }, + }, + } +} + +// Read reads all service accounts from the API and updates the state with the latest information. +func (r *serviceAccountDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Extract the project ID from the model configuration + projectId := model.ProjectId.ValueString() + + // Call the API to list service accounts in the specified project + listSaResp, err := r.client.ListServiceAccounts(ctx, projectId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading service account", fmt.Sprintf("Error calling API: %v", err)) + return + } + + // Iterate over the service accounts returned by the API to find the one matching the email + serviceAccounts := *listSaResp.Items + for i := range serviceAccounts { + // Skip if the service account email does not match + if *serviceAccounts[i].Email != model.Email.ValueString() { + continue + } + + // Map the API response to the model, updating its fields with the service account data + err = mapFields(&serviceAccounts[i], &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading service account", fmt.Sprintf("Error processing API response: %v", err)) + return + } + + // Try to parse the name from the provided email address + name, err := parseNameFromEmail(model.Email.ValueString()) + if name != "" && err == nil { + model.Name = types.StringValue(name) + } + + // Update the state with the service account model + diags = resp.State.Set(ctx, &model) + resp.Diagnostics.Append(diags...) + return + } + + // If no matching service account is found, remove the resource from the state + core.LogAndAddError(ctx, &resp.Diagnostics, "Service account not found", "") + resp.State.RemoveResource(ctx) +} diff --git a/stackit/internal/services/serviceaccount/account/resource.go b/stackit/internal/services/serviceaccount/account/resource.go new file mode 100644 index 00000000..68d51854 --- /dev/null +++ b/stackit/internal/services/serviceaccount/account/resource.go @@ -0,0 +1,363 @@ +package account + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "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/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" + "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/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// resourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var resourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &serviceAccountResource{} + _ resource.ResourceWithConfigure = &serviceAccountResource{} + _ resource.ResourceWithImportState = &serviceAccountResource{} +) + +// Model represents the schema for the service account resource. +type Model struct { + Id types.String `tfsdk:"id"` // Required by Terraform + ProjectId types.String `tfsdk:"project_id"` // ProjectId associated with the service account + Name types.String `tfsdk:"name"` // Name of the service account + Email types.String `tfsdk:"email"` // Email linked to the service account +} + +// NewServiceAccountResource is a helper function to create a new service account resource instance. +func NewServiceAccountResource() resource.Resource { + return &serviceAccountResource{} +} + +// serviceAccountResource implements the resource interface for service accounts. +type serviceAccountResource struct { + client *serviceaccount.APIClient +} + +// Configure sets up the API client for the service account resource. +func (r *serviceAccountResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent potential panics if the provider is not properly configured. + if req.ProviderData == nil { + return + } + + // Validate provider data type before proceeding. + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !resourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_service_account", "resource") + if resp.Diagnostics.HasError() { + return + } + resourceBetaCheckDone = true + } + + // Initialize the API client with the appropriate authentication and endpoint settings. + var apiClient *serviceaccount.APIClient + var err error + if providerData.ServiceAccountCustomEndpoint != "" { + apiClient, err = serviceaccount.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.ServiceAccountCustomEndpoint), + ) + } else { + apiClient, err = serviceaccount.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + ) + } + + // Handle API client initialization errors. + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "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 + } + + // Store the initialized client. + r.client = apiClient + tflog.Info(ctx, "Service Account client configured") +} + +// Metadata sets the resource type name for the service account resource. +func (r *serviceAccountResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_account" +} + +// Schema defines the schema for the resource. +func (r *serviceAccountResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ + "id": "Terraform's internal resource ID, structured as \"`project_id`,`email`\".", + "project_id": "STACKIT project ID to which the service account is associated.", + "name": "Name of the service account.", + "email": "Email of the service account.", + } + + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Service account resource schema."), + Description: "Service account resource schema.", + 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(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtMost(20), + stringvalidator.RegexMatches(regexp.MustCompile(`^[a-z](?:-?[a-z0-9]+)*$`), "must start with a lowercase letter, can contain lowercase letters, numbers, and dashes, but cannot start or end with a dash, and dashes cannot be consecutive"), + }, + }, + "email": schema.StringAttribute{ + Description: descriptions["email"], + Computed: true, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state for service accounts. +func (r *serviceAccountResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve the planned values for the resource. + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Set logging context with the project ID and service account name. + projectId := model.ProjectId.ValueString() + serviceAccountName := model.Name.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "service_account_name", serviceAccountName) + + // Generate the API request payload. + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating service account", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create the new service account via the API client. + serviceAccountResp, err := r.client.CreateServiceAccount(ctx, projectId).CreateServiceAccountPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating service account", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Set the service account name and map the response to the resource schema. + model.Name = types.StringValue(serviceAccountName) + err = mapFields(serviceAccountResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating service account", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // This sleep is currently needed due to the IAM Cache. + time.Sleep(5 * time.Second) + + // Set the state with fully populated data. + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Service account created") +} + +// Read refreshes the Terraform state with the latest service account data. +func (r *serviceAccountResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve the current state of the resource. + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Extract the project ID for the service account. + projectId := model.ProjectId.ValueString() + + // Fetch the list of service accounts from the API. + listSaResp, err := r.client.ListServiceAccounts(ctx, projectId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading service account", fmt.Sprintf("Error calling API: %v", err)) + return + } + + // Iterate over the list of service accounts to find the one that matches the email from the state. + serviceAccounts := *listSaResp.Items + for i := range serviceAccounts { + if *serviceAccounts[i].Email != model.Email.ValueString() { + continue + } + + // Map the response data to the resource schema and update the state. + err = mapFields(&serviceAccounts[i], &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading service account", fmt.Sprintf("Error processing API response: %v", err)) + return + } + + // Set the updated state. + diags = resp.State.Set(ctx, &model) + resp.Diagnostics.Append(diags...) + return + } + + // If no matching service account is found, remove the resource from the state. + resp.State.RemoveResource(ctx) +} + +// Update attempts to update the resource. In this case, service accounts cannot be updated. +// Note: This method is intentionally left without update logic because changes +// to 'project_id' or 'name' require the resource to be entirely replaced. +// As a result, the Update function is redundant since any modifications will +// automatically trigger a resource recreation through Terraform's built-in +// lifecycle management. +func (r *serviceAccountResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Service accounts cannot be updated, so we log an error. + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating service account", "Service accounts can't be updated") +} + +// Delete deletes the service account and removes it from the Terraform state on success. +func (r *serviceAccountResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve current state of the resource. + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + serviceAccountName := model.Name.ValueString() + serviceAccountEmail := model.Email.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "service_account_name", serviceAccountName) + + // Call API to delete the existing service account. + err := r.client.DeleteServiceAccount(ctx, projectId, serviceAccountEmail).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting service account", fmt.Sprintf("Calling API: %v", err)) + return + } + tflog.Info(ctx, "Service account deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,email +func (r *serviceAccountResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Split the import identifier to extract project ID and email. + idParts := strings.Split(req.ID, core.Separator) + + // Ensure the import identifier format is correct. + if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing service account", + fmt.Sprintf("Expected import identifier with format: [project_id],[email] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + email := idParts[1] + + // Attempt to parse the name from the email if valid. + name, err := parseNameFromEmail(email) + if name != "" && err == nil { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), name)...) + } + + // Set the project ID and email attributes in the state. + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("email"), email)...) + tflog.Info(ctx, "Service account state imported") +} + +// toCreatePayload generates the payload to create a new service account. +func toCreatePayload(model *Model) (*serviceaccount.CreateServiceAccountPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + return &serviceaccount.CreateServiceAccountPayload{ + Name: conversion.StringValueToPointer(model.Name), + }, nil +} + +// mapFields maps a ServiceAccount response to the model. +func mapFields(resp *serviceaccount.ServiceAccount, model *Model) error { + if resp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + if resp.Email == nil { + return fmt.Errorf("service account email not present") + } + + // Build the ID by combining the project ID and email and assign the model's fields. + idParts := []string{model.ProjectId.ValueString(), *resp.Email} + model.Id = types.StringValue(strings.Join(idParts, core.Separator)) + model.Email = types.StringPointerValue(resp.Email) + model.ProjectId = types.StringPointerValue(resp.ProjectId) + + return nil +} + +// parseNameFromEmail extracts the name component from an email address. +// The email format must be `name-@sa.stackit.cloud`. +func parseNameFromEmail(email string) (string, error) { + namePattern := `^([a-z][a-z0-9]*(?:-[a-z0-9]+)*)-\w{7}@sa\.stackit\.cloud$` + re := regexp.MustCompile(namePattern) + match := re.FindStringSubmatch(email) + + // If a match is found, return the name component + if len(match) > 1 { + return match[1], nil + } + + // If no match is found, return an error + return "", fmt.Errorf("unable to parse name from email") +} diff --git a/stackit/internal/services/serviceaccount/account/resource_test.go b/stackit/internal/services/serviceaccount/account/resource_test.go new file mode 100644 index 00000000..14cbb992 --- /dev/null +++ b/stackit/internal/services/serviceaccount/account/resource_test.go @@ -0,0 +1,161 @@ +package account + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *serviceaccount.CreateServiceAccountPayload + isValid bool + }{ + { + "default_values", + &Model{}, + &serviceaccount.CreateServiceAccountPayload{ + Name: nil, + }, + true, + }, + { + "default_values", + &Model{ + Name: types.StringValue("example-name1"), + }, + &serviceaccount.CreateServiceAccountPayload{ + Name: utils.Ptr("example-name1"), + }, + true, + }, + { + "nil_model", + nil, + nil, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + input *serviceaccount.ServiceAccount + expected Model + isValid bool + }{ + { + "default_values", + &serviceaccount.ServiceAccount{ + ProjectId: utils.Ptr("pid"), + Email: utils.Ptr("mail"), + }, + Model{ + Id: types.StringValue("pid,mail"), + ProjectId: types.StringValue("pid"), + Email: types.StringValue("mail"), + }, + true, + }, + { + "nil_response", + nil, + Model{}, + false, + }, + { + "nil_response_2", + &serviceaccount.ServiceAccount{}, + Model{}, + false, + }, + { + "no_id", + &serviceaccount.ServiceAccount{ + ProjectId: utils.Ptr("pid"), + Internal: utils.Ptr(true), + }, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{ + ProjectId: tt.expected.ProjectId, + } + err := mapFields(tt.input, state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(state, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestParseNameFromEmail(t *testing.T) { + testCases := []struct { + email string + expected string + shouldError bool + }{ + {"test03-8565oq1@sa.stackit.cloud", "test03", false}, + {"import-test-vshp191@sa.stackit.cloud", "import-test", false}, + {"sa-test-01-acfj2s1@sa.stackit.cloud", "sa-test-01", false}, + {"invalid-email@sa.stackit.cloud", "", true}, + {"missingcode-@sa.stackit.cloud", "", true}, + {"nohyphen8565oq1@sa.stackit.cloud", "", true}, + {"eu01-qnmbwo1@unknown.stackit.cloud", "", true}, + {"eu01-qnmbwo1@ske.stackit.com", "", true}, + {"someotherformat@sa.stackit.cloud", "", true}, + } + + for _, tc := range testCases { + t.Run(tc.email, func(t *testing.T) { + name, err := parseNameFromEmail(tc.email) + if tc.shouldError { + if err == nil { + t.Errorf("expected an error for email: %s, but got none", tc.email) + } + } else { + if err != nil { + t.Errorf("did not expect an error for email: %s, but got: %v", tc.email, err) + } + if name != tc.expected { + t.Errorf("expected name: %s, got: %s for email: %s", tc.expected, name, tc.email) + } + } + }) + } +} diff --git a/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go b/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go new file mode 100644 index 00000000..897c1dae --- /dev/null +++ b/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go @@ -0,0 +1,162 @@ +package serviceaccount + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +// Service Account resource data +var serviceAccountResource = map[string]string{ + "project_id": testutil.ProjectId, + "name01": "sa-test-01", + "name02": "sa-test-02", +} + +func inputServiceAccountResourceConfig(name string) string { + return fmt.Sprintf(` + %s + + resource "stackit_service_account" "sa" { + project_id = "%s" + name = "%s" + } + `, + testutil.ServiceAccountProviderConfig(), + serviceAccountResource["project_id"], + name, + ) +} + +func inputServiceAccountDataSourceConfig() string { + return fmt.Sprintf(` + %s + + data "stackit_service_account" "sa" { + project_id = stackit_service_account.sa.project_id + email = stackit_service_account.sa.email + } + `, + inputServiceAccountResourceConfig(serviceAccountResource["name01"]), + ) +} + +func TestServiceAccount(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckServiceAccountDestroy, + Steps: []resource.TestStep{ + // Creation + { + Config: inputServiceAccountResourceConfig(serviceAccountResource["name01"]), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_service_account.sa", "project_id", serviceAccountResource["project_id"]), + resource.TestCheckResourceAttr("stackit_service_account.sa", "name", serviceAccountResource["name01"]), + resource.TestCheckResourceAttrSet("stackit_service_account.sa", "email"), + ), + }, + // Update + { + Config: inputServiceAccountResourceConfig(serviceAccountResource["name02"]), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_service_account.sa", "project_id", serviceAccountResource["project_id"]), + resource.TestCheckResourceAttr("stackit_service_account.sa", "name", serviceAccountResource["name02"]), + resource.TestCheckResourceAttrSet("stackit_service_account.sa", "email"), + ), + }, + // Data source + { + Config: inputServiceAccountDataSourceConfig(), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("data.stackit_service_account.sa", "project_id", serviceAccountResource["project_id"]), + resource.TestCheckResourceAttrPair( + "stackit_service_account.sa", "project_id", + "data.stackit_service_account.sa", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_service_account.sa", "name", + "data.stackit_service_account.sa", "name", + ), + resource.TestCheckResourceAttrPair( + "stackit_service_account.sa", "email", + "data.stackit_service_account.sa", "email", + ), + ), + }, + // Import + { + ResourceName: "stackit_service_account.sa", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_service_account.sa"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_service_account.sa") + } + email, ok := r.Primary.Attributes["email"] + if !ok { + return "", fmt.Errorf("couldn't find attribute email") + } + return fmt.Sprintf("%s,%s", testutil.ProjectId, email), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func testAccCheckServiceAccountDestroy(s *terraform.State) error { + ctx := context.Background() + var client *serviceaccount.APIClient + var err error + + if testutil.ServiceAccountCustomEndpoint == "" { + client, err = serviceaccount.NewAPIClient() + } else { + client, err = serviceaccount.NewAPIClient( + config.WithEndpoint(testutil.ServiceAccountCustomEndpoint), + ) + } + + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + var instancesToDestroy []string + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_service_account" { + continue + } + serviceAccountEmail := strings.Split(rs.Primary.ID, core.Separator)[1] + instancesToDestroy = append(instancesToDestroy, serviceAccountEmail) + } + + instancesResp, err := client.ListServiceAccounts(ctx, testutil.ProjectId).Execute() + if err != nil { + return fmt.Errorf("getting service accounts: %w", err) + } + + serviceAccounts := *instancesResp.Items + for i := range serviceAccounts { + if serviceAccounts[i].Email == nil { + continue + } + if utils.Contains(instancesToDestroy, *serviceAccounts[i].Email) { + err := client.DeleteServiceAccount(ctx, testutil.ProjectId, *serviceAccounts[i].Email).Execute() + if err != nil { + return fmt.Errorf("destroying instance %s during CheckDestroy: %w", *serviceAccounts[i].Email, err) + } + } + } + return nil +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index d22ad865..1eb55f12 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -40,7 +40,7 @@ var ( IaaSImageId = getenv("TF_ACC_IMAGE_ID", "59838a89-51b1-4892-b57f-b3caf598ee2f") // TestProjectParentContainerID is the container id of the parent resource under which projects are created as part of the resource-manager acceptance tests TestProjectParentContainerID = os.Getenv("TF_ACC_TEST_PROJECT_PARENT_CONTAINER_ID") - // TestProjectParentContainerID is the uuid of the parent resource under which projects are created as part of the resource-manager acceptance tests + // TestProjectParentUUID is the uuid of the parent resource under which projects are created as part of the resource-manager acceptance tests TestProjectParentUUID = os.Getenv("TF_ACC_TEST_PROJECT_PARENT_UUID") // TestProjectServiceAccountEmail is the e-mail of a service account with admin permissions on the organization under which projects are created as part of the resource-manager acceptance tests TestProjectServiceAccountEmail = os.Getenv("TF_ACC_TEST_PROJECT_SERVICE_ACCOUNT_EMAIL") @@ -69,14 +69,8 @@ var ( SQLServerFlexCustomEndpoint = os.Getenv("TF_ACC_SQLSERVERFLEX_CUSTOM_ENDPOINT") ServerBackupCustomEndpoint = os.Getenv("TF_ACC_SERVER_BACKUP_CUSTOM_ENDPOINT") ServerUpdateCustomEndpoint = os.Getenv("TF_ACC_SERVER_UPDATE_CUSTOM_ENDPOINT") + ServiceAccountCustomEndpoint = os.Getenv("TF_ACC_SERVICE_ACCOUNT_CUSTOM_ENDPOINT") SKECustomEndpoint = os.Getenv("TF_ACC_SKE_CUSTOM_ENDPOINT") - - // OpenStack user domain name - OSUserDomainName = os.Getenv("TF_ACC_OS_USER_DOMAIN_NAME") - // OpenStack user name - OSUserName = os.Getenv("TF_ACC_OS_USER_NAME") - // OpenStack password - OSPassword = os.Getenv("TF_ACC_OS_PASSWORD") ) // Provider config helper functions @@ -393,6 +387,23 @@ func AuthorizationProviderConfig() string { ) } +func ServiceAccountProviderConfig() string { + if ServiceAccountCustomEndpoint == "" { + return ` + provider "stackit" { + region = "eu01" + enable_beta_resources = true + }` + } + return fmt.Sprintf(` + provider "stackit" { + service_account_custom_endpoint = "%s" + enable_beta_resources = true + }`, + ServiceAccountCustomEndpoint, + ) +} + func ResourceNameWithDateTime(name string) string { dateTime := time.Now().Format(time.RFC3339) // Remove timezone to have a smaller datetime diff --git a/stackit/provider.go b/stackit/provider.go index f7dbafd9..62d5fdcf 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -65,6 +65,7 @@ import ( secretsManagerUser "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/secretsmanager/user" serverBackupSchedule "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serverbackup/schedule" serverUpdateSchedule "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serverupdate/schedule" + serviceAccount "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/account" skeCluster "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/cluster" skeKubeconfig "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/kubeconfig" skeProject "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/ske/project" @@ -131,6 +132,7 @@ type providerModel struct { SKECustomEndpoint types.String `tfsdk:"ske_custom_endpoint"` ServerBackupCustomEndpoint types.String `tfsdk:"server_backup_custom_endpoint"` ServerUpdateCustomEndpoint types.String `tfsdk:"server_update_custom_endpoint"` + ServiceAccountCustomEndpoint types.String `tfsdk:"service_account_custom_endpoint"` ResourceManagerCustomEndpoint types.String `tfsdk:"resourcemanager_custom_endpoint"` TokenCustomEndpoint types.String `tfsdk:"token_custom_endpoint"` EnableBetaResources types.Bool `tfsdk:"enable_beta_resources"` @@ -166,6 +168,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "redis_custom_endpoint": "Custom endpoint for the Redis service", "server_backup_custom_endpoint": "Custom endpoint for the Server Backup service", "server_update_custom_endpoint": "Custom endpoint for the Server Update service", + "service_account_custom_endpoint": "Custom endpoint for the Service Account service", "resourcemanager_custom_endpoint": "Custom endpoint for the Resource Manager service", "secretsmanager_custom_endpoint": "Custom endpoint for the Secrets Manager service", "sqlserverflex_custom_endpoint": "Custom endpoint for the SQL Server Flex service", @@ -303,6 +306,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["server_update_custom_endpoint"], }, + "service_account_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["service_account_custom_endpoint"], + }, "service_enablement_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["service_enablement_custom_endpoint"], @@ -408,6 +415,9 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, if !(providerConfig.SQLServerFlexCustomEndpoint.IsUnknown() || providerConfig.SQLServerFlexCustomEndpoint.IsNull()) { providerData.SQLServerFlexCustomEndpoint = providerConfig.SQLServerFlexCustomEndpoint.ValueString() } + if !(providerConfig.ServiceAccountCustomEndpoint.IsUnknown() || providerConfig.ServiceAccountCustomEndpoint.IsNull()) { + providerData.ServiceAccountCustomEndpoint = providerConfig.ServiceAccountCustomEndpoint.ValueString() + } if !(providerConfig.SKECustomEndpoint.IsUnknown() || providerConfig.SKECustomEndpoint.IsNull()) { providerData.SKECustomEndpoint = providerConfig.SKECustomEndpoint.ValueString() } @@ -492,6 +502,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource serverBackupSchedule.NewSchedulesDataSource, serverUpdateSchedule.NewScheduleDataSource, serverUpdateSchedule.NewSchedulesDataSource, + serviceAccount.NewServiceAccountDataSource, skeProject.NewProjectDataSource, skeCluster.NewClusterDataSource, } @@ -552,6 +563,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { sqlServerFlexUser.NewUserResource, serverBackupSchedule.NewScheduleResource, serverUpdateSchedule.NewScheduleResource, + serviceAccount.NewServiceAccountResource, skeProject.NewProjectResource, skeCluster.NewClusterResource, skeKubeconfig.NewKubeconfigResource,