diff --git a/docs/resources/service_account_access_token.md b/docs/resources/service_account_access_token.md new file mode 100644 index 00000000..78d76e5e --- /dev/null +++ b/docs/resources/service_account_access_token.md @@ -0,0 +1,84 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_service_account_access_token Resource - stackit" +subcategory: "" +description: |- + Service account access token schema. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. + Example Usage + Automatically rotate access tokens + + resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "sa01" + } + + resource "time_rotating" "rotate" { + rotation_days = 80 + } + + resource "stackit_service_account_access_token" "sa_token" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + service_account_email = stackit_service_account.sa.email + ttl_days = 180 + + rotate_when_changed = { + rotation = time_rotating.rotate.id + } + } +--- + +# stackit_service_account_access_token (Resource) + +Service account access token schema. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. +## Example Usage + + +### Automatically rotate access tokens +```terraform +resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "sa01" +} + +resource "time_rotating" "rotate" { + rotation_days = 80 +} + +resource "stackit_service_account_access_token" "sa_token" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + service_account_email = stackit_service_account.sa.email + ttl_days = 180 + + rotate_when_changed = { + rotation = time_rotating.rotate.id + } +} + +``` + + + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID associated with the service account token. +- `service_account_email` (String) Email address linked to the service account. + +### Optional + +- `rotate_when_changed` (Map of String) A map of arbitrary key/value pairs that will force recreation of the token when they change, enabling token rotation based on external conditions such as a rotating timestamp. Changing this forces a new resource to be created. +- `ttl_days` (Number) Specifies the token's validity duration in days. If unspecified, defaults to 90 days. + +### Read-Only + +- `access_token_id` (String) Identifier for the access token linked to the service account. +- `active` (Boolean) Indicate whether the token is currently active or inactive +- `created_at` (String) Timestamp indicating when the access token was created. +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`service_account_email`,`access_token_id`". +- `token` (String, Sensitive) JWT access token for API authentication. Prefixed by 'Bearer' and should be stored securely as it is irretrievable once lost. +- `valid_until` (String) Estimated expiration timestamp of the access token. For precise validity, check the JWT details. diff --git a/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go b/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go index 897c1dae..266f3e7d 100644 --- a/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go +++ b/stackit/internal/services/serviceaccount/serviceaccount_acc_test.go @@ -30,6 +30,11 @@ func inputServiceAccountResourceConfig(name string) string { 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 + } `, testutil.ServiceAccountProviderConfig(), serviceAccountResource["project_id"], @@ -62,6 +67,11 @@ func TestServiceAccount(t *testing.T) { 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"), + resource.TestCheckResourceAttrSet("stackit_service_account_access_token.token", "token"), + resource.TestCheckResourceAttrSet("stackit_service_account_access_token.token", "created_at"), + resource.TestCheckResourceAttrSet("stackit_service_account_access_token.token", "valid_until"), + resource.TestCheckResourceAttrSet("stackit_service_account_access_token.token", "service_account_email"), + resource.TestCheckResourceAttrPair("stackit_service_account.sa", "email", "stackit_service_account_access_token.token", "service_account_email"), ), }, // Update @@ -71,6 +81,11 @@ func TestServiceAccount(t *testing.T) { 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"), + resource.TestCheckResourceAttrSet("stackit_service_account_access_token.token", "token"), + resource.TestCheckResourceAttrSet("stackit_service_account_access_token.token", "created_at"), + resource.TestCheckResourceAttrSet("stackit_service_account_access_token.token", "valid_until"), + resource.TestCheckResourceAttrSet("stackit_service_account_access_token.token", "service_account_email"), + resource.TestCheckResourceAttrPair("stackit_service_account.sa", "email", "stackit_service_account_access_token.token", "service_account_email"), ), }, // Data source diff --git a/stackit/internal/services/serviceaccount/token/const.go b/stackit/internal/services/serviceaccount/token/const.go new file mode 100644 index 00000000..94f9377c --- /dev/null +++ b/stackit/internal/services/serviceaccount/token/const.go @@ -0,0 +1,26 @@ +package token + +const markdownDescription = ` +## Example Usage` + "\n" + ` + +### Automatically rotate access tokens` + "\n" + + "```terraform" + ` +resource "stackit_service_account" "sa" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "sa01" +} + +resource "time_rotating" "rotate" { + rotation_days = 80 +} + +resource "stackit_service_account_access_token" "sa_token" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + service_account_email = stackit_service_account.sa.email + ttl_days = 180 + + rotate_when_changed = { + rotation = time_rotating.rotate.id + } +} +` + "\n```" diff --git a/stackit/internal/services/serviceaccount/token/resource.go b/stackit/internal/services/serviceaccount/token/resource.go new file mode 100644 index 00000000..e4f977d7 --- /dev/null +++ b/stackit/internal/services/serviceaccount/token/resource.go @@ -0,0 +1,426 @@ +package token + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + "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-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "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 = &serviceAccountTokenResource{} + _ resource.ResourceWithConfigure = &serviceAccountTokenResource{} +) + +// Model represents the schema for the service account token resource in Terraform. +type Model struct { + Id types.String `tfsdk:"id"` + AccessTokenId types.String `tfsdk:"access_token_id"` + ServiceAccountEmail types.String `tfsdk:"service_account_email"` + ProjectId types.String `tfsdk:"project_id"` + TtlDays types.Int64 `tfsdk:"ttl_days"` + RotateWhenChanged types.Map `tfsdk:"rotate_when_changed"` + Token types.String `tfsdk:"token"` + Active types.Bool `tfsdk:"active"` + CreatedAt types.String `tfsdk:"created_at"` + ValidUntil types.String `tfsdk:"valid_until"` +} + +// NewServiceAccountTokenResource is a helper function to create a new service account access token resource instance. +func NewServiceAccountTokenResource() resource.Resource { + return &serviceAccountTokenResource{} +} + +// serviceAccountResource implements the resource interface for service account access token. +type serviceAccountTokenResource struct { + client *serviceaccount.APIClient +} + +// Configure sets up the API client for the service account resource. +func (r *serviceAccountTokenResource) 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_access_token", "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 *serviceAccountTokenResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_account_access_token" +} + +// Schema defines the resource schema for the service account access token. +func (r *serviceAccountTokenResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`service_account_email`,`access_token_id`\".", + "main": "Service account access token schema.", + "project_id": "STACKIT project ID associated with the service account token.", + "service_account_email": "Email address linked to the service account.", + "ttl_days": "Specifies the token's validity duration in days. If unspecified, defaults to 90 days.", + "rotate_when_changed": "A map of arbitrary key/value pairs that will force recreation of the token when they change, enabling token rotation based on external conditions such as a rotating timestamp. Changing this forces a new resource to be created.", + "access_token_id": "Identifier for the access token linked to the service account.", + "token": "JWT access token for API authentication. Prefixed by 'Bearer' and should be stored securely as it is irretrievable once lost.", + "active": "Indicate whether the token is currently active or inactive", + "created_at": "Timestamp indicating when the access token was created.", + "valid_until": "Estimated expiration timestamp of the access token. For precise validity, check the JWT details.", + } + resp.Schema = schema.Schema{ + MarkdownDescription: fmt.Sprintf("%s%s", features.AddBetaDescription(descriptions["main"]), markdownDescription), + Description: descriptions["main"], + 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(), + }, + }, + "service_account_email": schema.StringAttribute{ + Description: descriptions["service_account_email"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "ttl_days": schema.Int64Attribute{ + Description: descriptions["ttl_days"], + Optional: true, + Computed: true, + Validators: []validator.Int64{ + int64validator.Between(1, 180), + }, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + Default: int64default.StaticInt64(90), + }, + "rotate_when_changed": schema.MapAttribute{ + Description: descriptions["rotate_when_changed"], + Optional: true, + ElementType: types.StringType, + PlanModifiers: []planmodifier.Map{ + mapplanmodifier.RequiresReplace(), + }, + }, + "access_token_id": schema.StringAttribute{ + Description: descriptions["access_token_id"], + Computed: true, + }, + "token": schema.StringAttribute{ + Description: descriptions["token"], + Computed: true, + Sensitive: true, + }, + "active": schema.BoolAttribute{ + Description: descriptions["active"], + Computed: true, + }, + "created_at": schema.StringAttribute{ + Description: descriptions["created_at"], + Computed: true, + }, + "valid_until": schema.StringAttribute{ + Description: descriptions["valid_until"], + Computed: true, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state for service accounts. +func (r *serviceAccountTokenResource) 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 email. + projectId := model.ProjectId.ValueString() + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "service_account_email", serviceAccountEmail) + + // Generate the API request payload. + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating service account access token", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Initialize the API request with the required parameters. + serviceAccountAccessTokenResp, err := r.client.CreateAccessToken(ctx, projectId, serviceAccountEmail).CreateAccessTokenPayload(*payload).Execute() + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Failed to create service account access token", fmt.Sprintf("API call error: %v", err)) + return + } + + // Map the response to the resource schema. + err = mapCreateResponse(serviceAccountAccessTokenResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating service account access token", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // 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 access token created") +} + +// Read refreshes the Terraform state with the latest service account data. +func (r *serviceAccountTokenResource) 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 and serviceAccountEmail for the service account. + projectId := model.ProjectId.ValueString() + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + + // Fetch the list of service account tokens from the API. + listSaTokensResp, err := r.client.ListAccessTokens(ctx, projectId, serviceAccountEmail).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + // due to security purposes, attempting to list access tokens for a non-existent Service Account will return 403. + if ok && oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusForbidden { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading service account tokens", fmt.Sprintf("Error calling API: %v", err)) + return + } + + // Iterate over the list of service account tokens to find the one that matches the ID from the state. + saTokens := *listSaTokensResp.Items + for i := range saTokens { + if *saTokens[i].Id != model.AccessTokenId.ValueString() { + continue + } + + if !*saTokens[i].Active { + tflog.Info(ctx, fmt.Sprintf("Service account access token with id %s is not active", model.AccessTokenId.ValueString())) + resp.State.RemoveResource(ctx) + return + } + + err = mapListResponse(&saTokens[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 access token is found, remove the resource from the state. + tflog.Info(ctx, fmt.Sprintf("Service account access token with id %s not found", model.AccessTokenId.ValueString())) + resp.State.RemoveResource(ctx) +} + +// Update attempts to update the resource. In this case, service account token cannot be updated. +// Note: This method is intentionally left without update logic because changes +// to 'project_id', 'service_account_email' or 'ttl_days' 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 *serviceAccountTokenResource) 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 access token", "Service accounts can't be updated") +} + +// Delete deletes the service account and removes it from the Terraform state on success. +func (r *serviceAccountTokenResource) 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() + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + accessTokenId := model.AccessTokenId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "service_account_email", serviceAccountEmail) + ctx = tflog.SetField(ctx, "access_token_id", accessTokenId) + + // Call API to delete the existing service account. + err := r.client.DeleteAccessToken(ctx, projectId, serviceAccountEmail, accessTokenId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting service account token", fmt.Sprintf("Calling API: %v", err)) + return + } + tflog.Info(ctx, "Service account token deleted") +} + +func toCreatePayload(model *Model) (*serviceaccount.CreateAccessTokenPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + return &serviceaccount.CreateAccessTokenPayload{ + TtlDays: conversion.Int64ValueToPointer(model.TtlDays), + }, nil +} + +func mapCreateResponse(resp *serviceaccount.AccessToken, 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.Token == nil { + return fmt.Errorf("service account token not present") + } + + if resp.Id == nil { + return fmt.Errorf("service account id not present") + } + + var createdAt basetypes.StringValue + if resp.CreatedAt != nil { + createdAtValue := *resp.CreatedAt + createdAt = types.StringValue(createdAtValue.Format(time.RFC3339)) + } + + var validUntil basetypes.StringValue + if resp.ValidUntil != nil { + validUntilValue := *resp.ValidUntil + validUntil = types.StringValue(validUntilValue.Format(time.RFC3339)) + } + + idParts := []string{model.ProjectId.ValueString(), model.ServiceAccountEmail.ValueString(), *resp.Id} + model.Id = types.StringValue(strings.Join(idParts, core.Separator)) + model.AccessTokenId = types.StringPointerValue(resp.Id) + model.Token = types.StringPointerValue(resp.Token) + model.Active = types.BoolPointerValue(resp.Active) + model.CreatedAt = createdAt + model.ValidUntil = validUntil + + return nil +} + +func mapListResponse(resp *serviceaccount.AccessTokenMetadata, 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.Id == nil { + return fmt.Errorf("service account id not present") + } + + var createdAt basetypes.StringValue + if resp.CreatedAt != nil { + createdAtValue := *resp.CreatedAt + createdAt = types.StringValue(createdAtValue.Format(time.RFC3339)) + } + + var validUntil basetypes.StringValue + if resp.ValidUntil != nil { + validUntilValue := *resp.ValidUntil + validUntil = types.StringValue(validUntilValue.Format(time.RFC3339)) + } + + idParts := []string{model.ProjectId.ValueString(), model.ServiceAccountEmail.ValueString(), *resp.Id} + model.Id = types.StringValue(strings.Join(idParts, core.Separator)) + model.AccessTokenId = types.StringPointerValue(resp.Id) + model.CreatedAt = createdAt + model.ValidUntil = validUntil + + return nil +} diff --git a/stackit/internal/services/serviceaccount/token/resource_test.go b/stackit/internal/services/serviceaccount/token/resource_test.go new file mode 100644 index 00000000..08f7d26a --- /dev/null +++ b/stackit/internal/services/serviceaccount/token/resource_test.go @@ -0,0 +1,230 @@ +package token + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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 + inputRoles []string + expected *serviceaccount.CreateAccessTokenPayload + isValid bool + }{ + { + "default_values", + &Model{ + TtlDays: types.Int64Value(20), + }, + []string{}, + &serviceaccount.CreateAccessTokenPayload{ + TtlDays: types.Int64Value(20).ValueInt64Pointer(), + }, + true, + }, + { + "nil_model", + nil, + []string{}, + 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 TestMapCreateResponse(t *testing.T) { + tests := []struct { + description string + input *serviceaccount.AccessToken + expected Model + isValid bool + }{ + { + "default_values", + &serviceaccount.AccessToken{ + Id: utils.Ptr("aid"), + Token: utils.Ptr("token"), + }, + Model{ + Id: types.StringValue("pid,email,aid"), + ProjectId: types.StringValue("pid"), + ServiceAccountEmail: types.StringValue("email"), + Token: types.StringValue("token"), + AccessTokenId: types.StringValue("aid"), + RotateWhenChanged: types.MapValueMust(types.StringType, map[string]attr.Value{}), + }, + true, + }, + { + "complete_values", + &serviceaccount.AccessToken{ + Id: utils.Ptr("aid"), + Token: utils.Ptr("token"), + CreatedAt: utils.Ptr(time.Now()), + ValidUntil: utils.Ptr(time.Now().Add(24 * time.Hour)), + Active: utils.Ptr(true), + }, + Model{ + Id: types.StringValue("pid,email,aid"), + ProjectId: types.StringValue("pid"), + ServiceAccountEmail: types.StringValue("email"), + Token: types.StringValue("token"), + AccessTokenId: types.StringValue("aid"), + Active: types.BoolValue(true), + CreatedAt: types.StringValue(time.Now().Format(time.RFC3339)), + ValidUntil: types.StringValue(time.Now().Add(24 * time.Hour).Format(time.RFC3339)), + RotateWhenChanged: types.MapValueMust(types.StringType, map[string]attr.Value{}), + }, + true, + }, + { + "nil_response", + nil, + Model{}, + false, + }, + { + "nil_response_2", + &serviceaccount.AccessToken{}, + Model{}, + false, + }, + { + "no_id", + &serviceaccount.AccessToken{ + Token: utils.Ptr("token"), + }, + Model{}, + false, + }, + { + "no_token", + &serviceaccount.AccessToken{ + Id: utils.Ptr("id"), + }, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + model := &Model{ + ProjectId: tt.expected.ProjectId, + ServiceAccountEmail: tt.expected.ServiceAccountEmail, + RotateWhenChanged: types.MapValueMust(types.StringType, map[string]attr.Value{}), + } + err := mapCreateResponse(tt.input, model) + 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(*model, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestMapListResponse(t *testing.T) { + tests := []struct { + description string + input *serviceaccount.AccessTokenMetadata + expected Model + isValid bool + }{ + { + "valid_fields", + &serviceaccount.AccessTokenMetadata{ + Id: utils.Ptr("aid"), + CreatedAt: utils.Ptr(time.Now()), + ValidUntil: utils.Ptr(time.Now().Add(24 * time.Hour)), + }, + Model{ + Id: types.StringValue("pid,email,aid"), + ProjectId: types.StringValue("pid"), + ServiceAccountEmail: types.StringValue("email"), + AccessTokenId: types.StringValue("aid"), + CreatedAt: types.StringValue(time.Now().Format(time.RFC3339)), // Adjusted for test setup time + ValidUntil: types.StringValue(time.Now().Add(24 * time.Hour).Format(time.RFC3339)), // Adjust for format + RotateWhenChanged: types.MapValueMust(types.StringType, map[string]attr.Value{}), + }, + true, + }, + { + "nil_response", + nil, + Model{}, + false, + }, + { + "nil_fields", + &serviceaccount.AccessTokenMetadata{ + Id: nil, + }, + Model{}, + false, + }, + { + "no_id", + &serviceaccount.AccessTokenMetadata{ + CreatedAt: utils.Ptr(time.Now()), + ValidUntil: utils.Ptr(time.Now().Add(24 * time.Hour)), + }, + Model{}, + false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + model := &Model{ + ProjectId: tt.expected.ProjectId, + ServiceAccountEmail: tt.expected.ServiceAccountEmail, + RotateWhenChanged: types.MapValueMust(types.StringType, map[string]attr.Value{}), + } + err := mapListResponse(tt.input, model) + if !tt.isValid && err == nil { + t.Fatalf("Expected an error but did not get one") + } + if tt.isValid && err != nil { + t.Fatalf("Did not expect an error but got one: %v", err) + } + if tt.isValid { + diff := cmp.Diff(*model, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/provider.go b/stackit/provider.go index 62d5fdcf..b2ea3a0a 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -66,6 +66,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" + serviceAccountToken "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceaccount/token" 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" @@ -564,6 +565,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { serverBackupSchedule.NewScheduleResource, serverUpdateSchedule.NewScheduleResource, serviceAccount.NewServiceAccountResource, + serviceAccountToken.NewServiceAccountTokenResource, skeProject.NewProjectResource, skeCluster.NewClusterResource, skeKubeconfig.NewKubeconfigResource,