feat: add stackit service account creation to tf provider (#708)

* feat: implement service account resource/datasource
This commit is contained in:
Mauritz Uphoff 2025-03-19 16:51:56 +01:00 committed by GitHub
parent 6dc6f4129c
commit 23e9a25b4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 978 additions and 8 deletions

View file

@ -40,6 +40,7 @@ type ProviderData struct {
ServerUpdateCustomEndpoint string
SKECustomEndpoint string
ServiceEnablementCustomEndpoint string
ServiceAccountCustomEndpoint string
EnableBetaResources bool
Experiments []string
}

View file

@ -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)
}

View file

@ -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-<random7characters>@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")
}

View file

@ -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)
}
}
})
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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,