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,