feat: add stackit service account creation to tf provider (#708)
* feat: implement service account resource/datasource
This commit is contained in:
parent
6dc6f4129c
commit
23e9a25b4f
14 changed files with 978 additions and 8 deletions
|
|
@ -40,6 +40,7 @@ type ProviderData struct {
|
|||
ServerUpdateCustomEndpoint string
|
||||
SKECustomEndpoint string
|
||||
ServiceEnablementCustomEndpoint string
|
||||
ServiceAccountCustomEndpoint string
|
||||
EnableBetaResources bool
|
||||
Experiments []string
|
||||
}
|
||||
|
|
|
|||
176
stackit/internal/services/serviceaccount/account/datasource.go
Normal file
176
stackit/internal/services/serviceaccount/account/datasource.go
Normal 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)
|
||||
}
|
||||
363
stackit/internal/services/serviceaccount/account/resource.go
Normal file
363
stackit/internal/services/serviceaccount/account/resource.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue