diff --git a/docs/data-sources/service_accounts.md b/docs/data-sources/service_accounts.md new file mode 100644 index 000000000..558aaaedb --- /dev/null +++ b/docs/data-sources/service_accounts.md @@ -0,0 +1,66 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_service_accounts Data Source - stackit" +subcategory: "" +description: |- + Service accounts plural data source schema. Returns a list of all service accounts in a project, optionally filtered. +--- + +# stackit_service_accounts (Data Source) + +Service accounts plural data source schema. Returns a list of all service accounts in a project, optionally filtered. + +## Example Usage + +```terraform +data "stackit_service_accounts" "all_sas" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +data "stackit_service_accounts" "sas_default_suffix" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email_suffix = "@sa.stackit.cloud" +} + +data "stackit_service_accounts" "sas_default_suffix_sort_asc" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email_suffix = "@sa.stackit.cloud" + sort_ascending = true +} + +data "stackit_service_accounts" "sas_ske_regex" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email_regex = ".*@ske\\.sa\\.stackit\\.cloud$" +} + +data "stackit_service_accounts" "sas_ske_suffix" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email_suffix = "@ske.sa.stackit.cloud" +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID. + +### Optional + +- `email_regex` (String) Optional regular expression to filter service accounts by email. +- `email_suffix` (String) Optional suffix to filter service accounts by email (e.g.,`@sa.stackit.cloud`, `@ske.sa.stackit.cloud`). +- `sort_ascending` (Boolean) If set to `true`, service accounts are sorted in ascending lexicographical order by email. Defaults to `false` (descending). + +### Read-Only + +- `id` (String) Terraform's internal resource ID, structured as "`project_id`". +- `items` (Attributes List) The list of service accounts matching the provided filters. (see [below for nested schema](#nestedatt--items)) + + +### Nested Schema for `items` + +Read-Only: + +- `email` (String) Email of the service account. +- `name` (String) Name of the service account. diff --git a/examples/data-sources/stackit_service_accounts/data-source.tf b/examples/data-sources/stackit_service_accounts/data-source.tf new file mode 100644 index 000000000..07b11181b --- /dev/null +++ b/examples/data-sources/stackit_service_accounts/data-source.tf @@ -0,0 +1,24 @@ +data "stackit_service_accounts" "all_sas" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +data "stackit_service_accounts" "sas_default_suffix" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email_suffix = "@sa.stackit.cloud" +} + +data "stackit_service_accounts" "sas_default_suffix_sort_asc" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email_suffix = "@sa.stackit.cloud" + sort_ascending = true +} + +data "stackit_service_accounts" "sas_ske_regex" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email_regex = ".*@ske\\.sa\\.stackit\\.cloud$" +} + +data "stackit_service_accounts" "sas_ske_suffix" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email_suffix = "@ske.sa.stackit.cloud" +} \ No newline at end of file diff --git a/stackit/internal/services/serviceaccount/account/datasource.go b/stackit/internal/services/serviceaccount/account/datasource.go index be6e0cca5..957a9687e 100644 --- a/stackit/internal/services/serviceaccount/account/datasource.go +++ b/stackit/internal/services/serviceaccount/account/datasource.go @@ -140,7 +140,7 @@ func (r *serviceAccountDataSource) Read(ctx context.Context, req datasource.Read } // Try to parse the name from the provided email address - name, err := parseNameFromEmail(model.Email.ValueString()) + name, err := serviceaccountUtils.ParseNameFromEmail(model.Email.ValueString()) if name != "" && err == nil { model.Name = types.StringValue(name) } diff --git a/stackit/internal/services/serviceaccount/account/resource.go b/stackit/internal/services/serviceaccount/account/resource.go index 1be909e01..3c1ff2708 100644 --- a/stackit/internal/services/serviceaccount/account/resource.go +++ b/stackit/internal/services/serviceaccount/account/resource.go @@ -280,7 +280,7 @@ func (r *serviceAccountResource) ImportState(ctx context.Context, req resource.I email := idParts[1] // Attempt to parse the name from the email if valid. - name, err := parseNameFromEmail(email) + name, err := serviceaccountUtils.ParseNameFromEmail(email) if name != "" && err == nil { resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), name)...) } @@ -322,19 +322,3 @@ func mapFields(resp *serviceaccount.ServiceAccount, model *Model) error { 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 index 14cbb992c..f05c13de1 100644 --- a/stackit/internal/services/serviceaccount/account/resource_test.go +++ b/stackit/internal/services/serviceaccount/account/resource_test.go @@ -123,39 +123,3 @@ func TestMapFields(t *testing.T) { }) } } - -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/accounts/datasource.go b/stackit/internal/services/serviceaccount/accounts/datasource.go new file mode 100644 index 000000000..4d44ff2ee --- /dev/null +++ b/stackit/internal/services/serviceaccount/accounts/datasource.go @@ -0,0 +1,231 @@ +package accounts + +import ( + "context" + "fmt" + "regexp" + "sort" + "strings" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + serviceaccountUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/utils" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" + "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" +) + +var ( + _ datasource.DataSource = &serviceAccountsDataSource{} +) + +// ServiceAccountItem represents a single service account inside the list. +type ServiceAccountItem struct { + Email types.String `tfsdk:"email"` + Name types.String `tfsdk:"name"` +} + +// ServiceAccountsModel represents the Model for the plural data source. +type ServiceAccountsModel struct { + Id types.String `tfsdk:"id"` + ProjectId types.String `tfsdk:"project_id"` + EmailRegex types.String `tfsdk:"email_regex"` + EmailSuffix types.String `tfsdk:"email_suffix"` + SortAscending types.Bool `tfsdk:"sort_ascending"` + Items []ServiceAccountItem `tfsdk:"items"` +} + +// NewServiceAccountsDataSource creates a new instance of the plural data source. +func NewServiceAccountsDataSource() datasource.DataSource { + return &serviceAccountsDataSource{} +} + +// serviceAccountsDataSource is the datasource implementation for querying multiple service accounts. +type serviceAccountsDataSource struct { + client *serviceaccount.APIClient +} + +func (r *serviceAccountsDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := serviceaccountUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Service Accounts (plural) client configured") +} + +func (r *serviceAccountsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_accounts" +} + +func (r *serviceAccountsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Service accounts plural data source schema. Returns a list of all service accounts in a project, optionally filtered.", + Description: "Service accounts plural data source schema.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID, structured as \"`project_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "email_regex": schema.StringAttribute{ + Description: "Optional regular expression to filter service accounts by email.", + Optional: true, + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRoot("email_suffix")), + }, + }, + "email_suffix": schema.StringAttribute{ + Description: "Optional suffix to filter service accounts by email (e.g.,`@sa.stackit.cloud`, `@ske.sa.stackit.cloud`).", + Optional: true, + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRoot("email_regex")), + }, + }, + "sort_ascending": schema.BoolAttribute{ + Description: "If set to `true`, service accounts are sorted in ascending lexicographical order by email. Defaults to `false` (descending).", + Optional: true, + }, + "items": schema.ListNestedAttribute{ + Description: "The list of service accounts matching the provided filters.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "email": schema.StringAttribute{ + Description: "Email of the service account.", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "Name of the service account.", + Computed: true, + }, + }, + }, + }, + }, + } +} + +func (r *serviceAccountsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic + var model ServiceAccountsModel + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + projectId := model.ProjectId.ValueString() + + // Compile the regex if provided + var compiledRegex *regexp.Regexp + var err error + if !model.EmailRegex.IsNull() && model.EmailRegex.ValueString() != "" { + compiledRegex, err = regexp.Compile(model.EmailRegex.ValueString()) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Invalid email_regex", err.Error()) + return + } + } + + // Fetch all service accounts + listSaResp, err := r.client.ListServiceAccounts(ctx, projectId).Execute() + if err != nil { + utils.LogError( + ctx, + &resp.Diagnostics, + err, + "Reading service accounts", + fmt.Sprintf("Forbidden access for service accounts in project %q.", projectId), + map[int]string{}, + ) + resp.State.RemoveResource(ctx) + return + } + + ctx = core.LogResponse(ctx) + + // Map the response data (filter, sort, and assign) to the model. + err = mapDataSourceFields(*listSaResp.Items, &model, compiledRegex) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading service accounts", fmt.Sprintf("Error processing API response: %v", err)) + return + } + + diags = resp.State.Set(ctx, &model) + resp.Diagnostics.Append(diags...) +} + +// mapDataSourceFields filters, sorts, and maps a list of ServiceAccount API responses to the plural model. +func mapDataSourceFields(apiItems []serviceaccount.ServiceAccount, model *ServiceAccountsModel, compiledRegex *regexp.Regexp) error { + if model == nil { + return fmt.Errorf("model input is nil") + } + + var matchedItems []ServiceAccountItem + emailSuffix := model.EmailSuffix.ValueString() + + for _, sa := range apiItems { + if sa.Email == nil { + continue + } + email := *sa.Email + + // Apply Filters (If neither is set, these checks simply pass) + if compiledRegex != nil && !compiledRegex.MatchString(email) { + continue + } + if emailSuffix != "" && !strings.HasSuffix(email, emailSuffix) { + continue + } + + // Parse name, ignore errors if the format is non-standard, just leave name empty + nameStr, _ := serviceaccountUtils.ParseNameFromEmail(email) + + matchedItems = append(matchedItems, ServiceAccountItem{ + Email: types.StringValue(email), + Name: types.StringValue(nameStr), + }) + } + + // Sorting logic + sortAsc := false + if !model.SortAscending.IsNull() && !model.SortAscending.IsUnknown() { + sortAsc = model.SortAscending.ValueBool() + } + + sort.SliceStable(matchedItems, func(i, j int) bool { + emailA := matchedItems[i].Email.ValueString() + emailB := matchedItems[j].Email.ValueString() + if sortAsc { + return emailA < emailB + } + return emailA > emailB + }) + + // Assign values to the model + model.Id = model.ProjectId // Use the project ID directly from the model as the data source ID + model.Items = matchedItems + + return nil +} diff --git a/stackit/internal/services/serviceaccount/accounts/datasource_test.go b/stackit/internal/services/serviceaccount/accounts/datasource_test.go new file mode 100644 index 000000000..5438cc041 --- /dev/null +++ b/stackit/internal/services/serviceaccount/accounts/datasource_test.go @@ -0,0 +1,181 @@ +package accounts + +import ( + "regexp" + "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 TestMapDataSourceFields(t *testing.T) { + projectId := "test-project-id" + emailA := "sa-a-1234567@sa.stackit.cloud" + emailB := "sa-b-1234567@sa.stackit.cloud" + emailC := "sa-c-1234567@ske.sa.stackit.cloud" + + nameA := "sa-a" + nameB := "sa-b" + nameC := "sa-c" + + tests := []struct { + description string + apiItems []serviceaccount.ServiceAccount + initialModel ServiceAccountsModel + regexStr string + expectedModel ServiceAccountsModel + isValid bool + }{ + { + description: "default_sort_descending", + apiItems: []serviceaccount.ServiceAccount{ + {Email: utils.Ptr(emailA)}, + {Email: utils.Ptr(emailC)}, + {Email: utils.Ptr(emailB)}, + }, + initialModel: ServiceAccountsModel{ + ProjectId: types.StringValue(projectId), + SortAscending: types.BoolNull(), // Default should trigger descending sort + }, + expectedModel: ServiceAccountsModel{ + Id: types.StringValue(projectId), + ProjectId: types.StringValue(projectId), + SortAscending: types.BoolNull(), + Items: []ServiceAccountItem{ + {Email: types.StringValue(emailC), Name: types.StringValue(nameC)}, + {Email: types.StringValue(emailB), Name: types.StringValue(nameB)}, + {Email: types.StringValue(emailA), Name: types.StringValue(nameA)}, + }, + }, + isValid: true, + }, + { + description: "sort_ascending", + apiItems: []serviceaccount.ServiceAccount{ + {Email: utils.Ptr(emailC)}, + {Email: utils.Ptr(emailA)}, + {Email: utils.Ptr(emailB)}, + }, + initialModel: ServiceAccountsModel{ + ProjectId: types.StringValue(projectId), + SortAscending: types.BoolValue(true), + }, + expectedModel: ServiceAccountsModel{ + Id: types.StringValue(projectId), + ProjectId: types.StringValue(projectId), + SortAscending: types.BoolValue(true), + Items: []ServiceAccountItem{ + {Email: types.StringValue(emailA), Name: types.StringValue(nameA)}, + {Email: types.StringValue(emailB), Name: types.StringValue(nameB)}, + {Email: types.StringValue(emailC), Name: types.StringValue(nameC)}, + }, + }, + isValid: true, + }, + { + description: "regex_filter_match", + apiItems: []serviceaccount.ServiceAccount{ + {Email: utils.Ptr(emailA)}, + {Email: utils.Ptr(emailB)}, + {Email: utils.Ptr(emailC)}, + }, + initialModel: ServiceAccountsModel{ + ProjectId: types.StringValue(projectId), + EmailRegex: types.StringValue(`.*-b-.*`), + SortAscending: types.BoolValue(true), + }, + regexStr: `.*-b-.*`, + expectedModel: ServiceAccountsModel{ + Id: types.StringValue(projectId), + ProjectId: types.StringValue(projectId), + EmailRegex: types.StringValue(`.*-b-.*`), + SortAscending: types.BoolValue(true), + Items: []ServiceAccountItem{ + {Email: types.StringValue(emailB), Name: types.StringValue(nameB)}, + }, + }, + isValid: true, + }, + { + description: "suffix_filter_match", + apiItems: []serviceaccount.ServiceAccount{ + {Email: utils.Ptr(emailA)}, + {Email: utils.Ptr(emailB)}, + {Email: utils.Ptr(emailC)}, + }, + initialModel: ServiceAccountsModel{ + ProjectId: types.StringValue(projectId), + EmailSuffix: types.StringValue(`@ske.sa.stackit.cloud`), + }, + expectedModel: ServiceAccountsModel{ + Id: types.StringValue(projectId), + ProjectId: types.StringValue(projectId), + EmailSuffix: types.StringValue(`@ske.sa.stackit.cloud`), + Items: []ServiceAccountItem{ + {Email: types.StringValue(emailC), Name: types.StringValue(nameC)}, + }, + }, + isValid: true, + }, + { + description: "skip_nil_email", + apiItems: []serviceaccount.ServiceAccount{ + {Email: utils.Ptr(emailA)}, + {Email: nil}, // Should be skipped + }, + initialModel: ServiceAccountsModel{ + ProjectId: types.StringValue(projectId), + SortAscending: types.BoolValue(true), + }, + expectedModel: ServiceAccountsModel{ + Id: types.StringValue(projectId), + ProjectId: types.StringValue(projectId), + SortAscending: types.BoolValue(true), + Items: []ServiceAccountItem{ + {Email: types.StringValue(emailA), Name: types.StringValue(nameA)}, + }, + }, + isValid: true, + }, + { + description: "nil_model", + apiItems: []serviceaccount.ServiceAccount{}, + initialModel: ServiceAccountsModel{}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + var compiledRegex *regexp.Regexp + if tt.regexStr != "" { + compiledRegex = regexp.MustCompile(tt.regexStr) + } + + // Handle nil model scenario + var modelPtr *ServiceAccountsModel + if tt.description != "nil_model" { + modelCopy := tt.initialModel + modelPtr = &modelCopy + } + + err := mapDataSourceFields(tt.apiItems, modelPtr, compiledRegex) + + 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(*modelPtr, tt.expectedModel, cmp.AllowUnexported(types.String{}, types.Bool{})) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go b/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go index 032dae785..0d9f61b0f 100644 --- a/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go +++ b/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go @@ -2,63 +2,68 @@ package serviceaccount import ( "context" + _ "embed" "fmt" + "regexp" "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/config" + stackitSdkConfig "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", +var ( + //go:embed testdata/resource-service-account.tf + resourceServiceAccount string + + //go:embed testdata/datasource-service-account.tf + datasourceServiceAccount string + + //go:embed testdata/datasource-service-accounts.tf + datasourceServiceAccounts string + + //go:embed testdata/datasource-service-accounts-regex.tf + datasourceServiceAccountsRegex string + + //go:embed testdata/datasource-service-accounts-suffix.tf + datasourceServiceAccountsSuffix string + + //go:embed testdata/datasource-service-account-exact-not-found.tf + datasourceServiceAccountExactNotFound string +) + +var testConfigVars = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable("satest01"), } -func inputServiceAccountResourceConfig(name string) string { - return fmt.Sprintf(` - %s - - resource "stackit_service_account" "sa" { - project_id = "%s" - name = "%s" - } - - resource "stackit_service_account_access_token" "token" { - project_id = stackit_service_account.sa.project_id - service_account_email = stackit_service_account.sa.email - } - - resource "stackit_service_account_key" "key" { - project_id = stackit_service_account.sa.project_id - service_account_email = stackit_service_account.sa.email - ttl_days = 90 - } - `, - testutil.ServiceAccountProviderConfig(), - serviceAccountResource["project_id"], - name, - ) +var testConfigVarsUpdate = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable("satest02"), } -func inputServiceAccountDataSourceConfig() string { - return fmt.Sprintf(` - %s +var testConfigVarsPluralRegex = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable("satest02"), + "email_regex": config.StringVariable(`^satest02-.*@(?:ske\.)?sa\.stackit\.cloud$`), +} - data "stackit_service_account" "sa" { - project_id = stackit_service_account.sa.project_id - email = stackit_service_account.sa.email - } - `, - inputServiceAccountResourceConfig(serviceAccountResource["name01"]), - ) +var testConfigVarsPluralSuffix = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable("satest02"), + "email_suffix": config.StringVariable(`@sa.stackit.cloud`), +} + +var testConfigVarsExactNotFound = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable("satest02"), + "not_found_email": config.StringVariable("does-not-exist-123@sa.stackit.cloud"), } func TestServiceAccount(t *testing.T) { @@ -68,10 +73,11 @@ func TestServiceAccount(t *testing.T) { Steps: []resource.TestStep{ // Creation { - Config: inputServiceAccountResourceConfig(serviceAccountResource["name01"]), + ConfigVariables: testConfigVars, + Config: testutil.ServiceAccountProviderConfig() + "\n" + resourceServiceAccount, Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("stackit_service_account.sa", "project_id", serviceAccountResource["project_id"]), - resource.TestCheckResourceAttr("stackit_service_account.sa", "name", serviceAccountResource["name01"]), + resource.TestCheckResourceAttr("stackit_service_account.sa", "project_id", testutil.ConvertConfigVariable(testConfigVars["project_id"])), + resource.TestCheckResourceAttr("stackit_service_account.sa", "name", testutil.ConvertConfigVariable(testConfigVars["name"])), resource.TestCheckResourceAttrSet("stackit_service_account.sa", "email"), resource.TestCheckResourceAttrSet("stackit_service_account_access_token.token", "token"), resource.TestCheckResourceAttrSet("stackit_service_account_access_token.token", "created_at"), @@ -86,10 +92,11 @@ func TestServiceAccount(t *testing.T) { }, // Update { - Config: inputServiceAccountResourceConfig(serviceAccountResource["name02"]), + ConfigVariables: testConfigVarsUpdate, + Config: testutil.ServiceAccountProviderConfig() + "\n" + resourceServiceAccount, Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("stackit_service_account.sa", "project_id", serviceAccountResource["project_id"]), - resource.TestCheckResourceAttr("stackit_service_account.sa", "name", serviceAccountResource["name02"]), + resource.TestCheckResourceAttr("stackit_service_account.sa", "project_id", testutil.ConvertConfigVariable(testConfigVarsUpdate["project_id"])), + resource.TestCheckResourceAttr("stackit_service_account.sa", "name", testutil.ConvertConfigVariable(testConfigVarsUpdate["name"])), resource.TestCheckResourceAttrSet("stackit_service_account.sa", "email"), resource.TestCheckResourceAttrSet("stackit_service_account_access_token.token", "token"), resource.TestCheckResourceAttrSet("stackit_service_account_access_token.token", "created_at"), @@ -102,12 +109,12 @@ func TestServiceAccount(t *testing.T) { resource.TestCheckResourceAttrPair("stackit_service_account.sa", "email", "stackit_service_account_key.key", "service_account_email"), ), }, - // Data source + // Data source (Using exact email) { - Config: inputServiceAccountDataSourceConfig(), + ConfigVariables: testConfigVarsUpdate, + Config: testutil.ServiceAccountProviderConfig() + "\n" + resourceServiceAccount + "\n" + datasourceServiceAccount, Check: resource.ComposeAggregateTestCheckFunc( - // Instance - resource.TestCheckResourceAttr("data.stackit_service_account.sa", "project_id", serviceAccountResource["project_id"]), + resource.TestCheckResourceAttr("data.stackit_service_account.sa", "project_id", testutil.ConvertConfigVariable(testConfigVarsUpdate["project_id"])), resource.TestCheckResourceAttrPair( "stackit_service_account.sa", "project_id", "data.stackit_service_account.sa", "project_id", @@ -122,9 +129,45 @@ func TestServiceAccount(t *testing.T) { ), ), }, + // Data source (Singular Exact Email - Not Found Expectation) + { + ConfigVariables: testConfigVarsExactNotFound, + Config: testutil.ServiceAccountProviderConfig() + "\n" + resourceServiceAccount + "\n" + datasourceServiceAccountExactNotFound, + ExpectError: regexp.MustCompile(`Service account not found`), + }, + // Data source (Plural / List of Service Accounts - No filter) + { + ConfigVariables: testConfigVarsUpdate, + Config: testutil.ServiceAccountProviderConfig() + "\n" + resourceServiceAccount + "\n" + datasourceServiceAccounts, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_service_accounts.list", "project_id", testutil.ConvertConfigVariable(testConfigVarsUpdate["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_service_accounts.list", "items.0.email"), + resource.TestCheckResourceAttrSet("data.stackit_service_accounts.list", "items.0.name"), + ), + }, + // Data source (Plural - Filtered by Regex) + { + ConfigVariables: testConfigVarsPluralRegex, + Config: testutil.ServiceAccountProviderConfig() + "\n" + resourceServiceAccount + "\n" + datasourceServiceAccountsRegex, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_service_accounts.list_regex", "project_id", testutil.ConvertConfigVariable(testConfigVarsPluralRegex["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_service_accounts.list_regex", "items.0.email"), + ), + }, + // Data source (Plural - Filtered by Suffix) + { + ConfigVariables: testConfigVarsPluralSuffix, + Config: testutil.ServiceAccountProviderConfig() + "\n" + resourceServiceAccount + "\n" + datasourceServiceAccountsSuffix, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_service_accounts.list_suffix", "project_id", testutil.ConvertConfigVariable(testConfigVarsPluralSuffix["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_service_accounts.list_suffix", "items.0.email"), + ), + }, // Import { - ResourceName: "stackit_service_account.sa", + ConfigVariables: testConfigVarsUpdate, + Config: testutil.ServiceAccountProviderConfig() + "\n" + resourceServiceAccount, + ResourceName: "stackit_service_account.sa", ImportStateIdFunc: func(s *terraform.State) (string, error) { r, ok := s.RootModule().Resources["stackit_service_account.sa"] if !ok { @@ -153,7 +196,7 @@ func testAccCheckServiceAccountDestroy(s *terraform.State) error { client, err = serviceaccount.NewAPIClient() } else { client, err = serviceaccount.NewAPIClient( - config.WithEndpoint(testutil.ServiceAccountCustomEndpoint), + stackitSdkConfig.WithEndpoint(testutil.ServiceAccountCustomEndpoint), ) } diff --git a/stackit/internal/services/serviceaccount/testdata/datasource-service-account-exact-not-found.tf b/stackit/internal/services/serviceaccount/testdata/datasource-service-account-exact-not-found.tf new file mode 100644 index 000000000..f625ee038 --- /dev/null +++ b/stackit/internal/services/serviceaccount/testdata/datasource-service-account-exact-not-found.tf @@ -0,0 +1,8 @@ +variable "not_found_email" { + type = string +} + +data "stackit_service_account" "sa_not_found" { + project_id = stackit_service_account.sa.project_id + email = var.not_found_email +} \ No newline at end of file diff --git a/stackit/internal/services/serviceaccount/testdata/datasource-service-account.tf b/stackit/internal/services/serviceaccount/testdata/datasource-service-account.tf new file mode 100644 index 000000000..8062ea3a1 --- /dev/null +++ b/stackit/internal/services/serviceaccount/testdata/datasource-service-account.tf @@ -0,0 +1,4 @@ +data "stackit_service_account" "sa" { + project_id = stackit_service_account.sa.project_id + email = stackit_service_account.sa.email +} \ No newline at end of file diff --git a/stackit/internal/services/serviceaccount/testdata/datasource-service-accounts-regex.tf b/stackit/internal/services/serviceaccount/testdata/datasource-service-accounts-regex.tf new file mode 100644 index 000000000..0ffca09c2 --- /dev/null +++ b/stackit/internal/services/serviceaccount/testdata/datasource-service-accounts-regex.tf @@ -0,0 +1,8 @@ +variable "email_regex" { + type = string +} + +data "stackit_service_accounts" "list_regex" { + project_id = stackit_service_account.sa.project_id + email_regex = var.email_regex +} \ No newline at end of file diff --git a/stackit/internal/services/serviceaccount/testdata/datasource-service-accounts-suffix.tf b/stackit/internal/services/serviceaccount/testdata/datasource-service-accounts-suffix.tf new file mode 100644 index 000000000..c1cf9d5ad --- /dev/null +++ b/stackit/internal/services/serviceaccount/testdata/datasource-service-accounts-suffix.tf @@ -0,0 +1,8 @@ +variable "email_suffix" { + type = string +} + +data "stackit_service_accounts" "list_suffix" { + project_id = stackit_service_account.sa.project_id + email_suffix = var.email_suffix +} \ No newline at end of file diff --git a/stackit/internal/services/serviceaccount/testdata/datasource-service-accounts.tf b/stackit/internal/services/serviceaccount/testdata/datasource-service-accounts.tf new file mode 100644 index 000000000..ee0e06349 --- /dev/null +++ b/stackit/internal/services/serviceaccount/testdata/datasource-service-accounts.tf @@ -0,0 +1,3 @@ +data "stackit_service_accounts" "list" { + project_id = stackit_service_account.sa.project_id +} \ No newline at end of file diff --git a/stackit/internal/services/serviceaccount/testdata/resource-service-account.tf b/stackit/internal/services/serviceaccount/testdata/resource-service-account.tf new file mode 100644 index 000000000..981e282fe --- /dev/null +++ b/stackit/internal/services/serviceaccount/testdata/resource-service-account.tf @@ -0,0 +1,23 @@ +variable "project_id" { + type = string +} + +variable "name" { + type = string +} + +resource "stackit_service_account" "sa" { + project_id = var.project_id + name = var.name +} + +resource "stackit_service_account_access_token" "token" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email +} + +resource "stackit_service_account_key" "key" { + project_id = stackit_service_account.sa.project_id + service_account_email = stackit_service_account.sa.email + ttl_days = 90 +} \ No newline at end of file diff --git a/stackit/internal/services/serviceaccount/utils/util.go b/stackit/internal/services/serviceaccount/utils/util.go index 5fd45eb0b..c2aff4ff4 100644 --- a/stackit/internal/services/serviceaccount/utils/util.go +++ b/stackit/internal/services/serviceaccount/utils/util.go @@ -3,6 +3,7 @@ package utils import ( "context" "fmt" + "regexp" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/stackitcloud/stackit-sdk-go/core/config" @@ -27,3 +28,20 @@ func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags return apiClient } + +// ParseNameFromEmail extracts the name component from a service account email address. +// The expected email format is `name-@sa.stackit.cloud` +// or `name-@ske.sa.stackit.cloud`. +func ParseNameFromEmail(email string) (string, error) { + namePattern := `^([a-z][a-z0-9]*(?:-[a-z0-9]+)*)-\w{7,10}@(?:ske\.)?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/utils/util_test.go b/stackit/internal/services/serviceaccount/utils/util_test.go index e18942f7c..b0f044472 100644 --- a/stackit/internal/services/serviceaccount/utils/util_test.go +++ b/stackit/internal/services/serviceaccount/utils/util_test.go @@ -91,3 +91,58 @@ func TestConfigureClient(t *testing.T) { }) } } + +func TestParseNameFromEmail(t *testing.T) { + testCases := []struct { + email string + expected string + shouldError bool + }{ + // Standard SA domain (Positive: 7 to 10 random characters) + {"foo-vshp191@sa.stackit.cloud", "foo", false}, // 7 chars + {"bar-8565oq12@sa.stackit.cloud", "bar", false}, // 8 chars + {"foo-bar-acfj2s123@sa.stackit.cloud", "foo-bar", false}, // 9 chars + {"baz-abcdefghij@sa.stackit.cloud", "baz", false}, // 10 chars + + // Standard SA domain (Negative: 6 and 11 random characters) + {"foo-vshp19@sa.stackit.cloud", "", true}, // 6 chars (Too short) + {"bar-8565oq12345@sa.stackit.cloud", "", true}, // 11 chars (Too long) + + // SKE SA domain (Positive: 7 to 10 random characters) + {"foo-qnmbwo1@ske.sa.stackit.cloud", "foo", false}, // 7 chars + {"bar-qnmbwo12@ske.sa.stackit.cloud", "bar", false}, // 8 chars + {"foo-bar-qnmbwo123@ske.sa.stackit.cloud", "foo-bar", false}, // 9 chars + {"baz-abcdefghij@ske.sa.stackit.cloud", "baz", false}, // 10 chars + + // SKE SA domain (Negative: 6 and 11 random characters) + {"foo-qnmbwo@ske.sa.stackit.cloud", "", true}, // 6 chars (Too short) + {"bar-qnmbwo12345@ske.sa.stackit.cloud", "", true}, // 11 chars (Too long) + + // Invalid cases (Formatting & Unknown Domains) + {"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}, // Missing .sa. and ends in .com + {"someotherformat@sa.stackit.cloud", "", true}, + {"invalid-format@ske.sa.stackit.cloud", "", true}, // SKE domain but missing the character suffix completely + } + + 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/provider.go b/stackit/provider.go index 85abf49ba..59e8608c7 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -99,6 +99,7 @@ import ( 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" + serviceAccounts "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/accounts" serviceAccountKey "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/key" serviceAccountToken "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/token" exportpolicy "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sfs/export-policy" @@ -654,6 +655,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource serverUpdateSchedule.NewScheduleDataSource, serverUpdateSchedule.NewSchedulesDataSource, serviceAccount.NewServiceAccountDataSource, + serviceAccounts.NewServiceAccountsDataSource, skeCluster.NewClusterDataSource, skeKubernetesVersion.NewKubernetesVersionsDataSource, skeMachineImages.NewKubernetesMachineImageVersionDataSource,