diff --git a/docs/data-sources/objectstorage_credential.md b/docs/data-sources/objectstorage_credential.md index eced3b84..1acc5d84 100644 --- a/docs/data-sources/objectstorage_credential.md +++ b/docs/data-sources/objectstorage_credential.md @@ -19,12 +19,12 @@ ObjectStorage credential data source schema. - `credential_id` (String) The credential ID. - `credentials_group_id` (String) The credential group ID. -- `expiration_timestamp` (String) - `project_id` (String) STACKIT Project ID to which the credential group is associated. ### Read-Only - `access_key` (String) +- `expiration_timestamp` (String) - `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`credentials_group_id`,`credential_id`". - `name` (String) - `secret_access_key` (String, Sensitive) diff --git a/docs/resources/objectstorage_credential.md b/docs/resources/objectstorage_credential.md index a0f790fb..f99ca0ec 100644 --- a/docs/resources/objectstorage_credential.md +++ b/docs/resources/objectstorage_credential.md @@ -17,17 +17,17 @@ ObjectStorage credential resource schema. ### Required +- `credentials_group_id` (String) The credential group ID. - `project_id` (String) STACKIT Project ID to which the credential group is associated. ### Optional -- `expiration_timestamp` (String) +- `expiration_timestamp` (String) Expiration timestamp, in RFC339 format without fractional seconds. Example: "2025-01-01T00:00:00Z". If not set, the credential never expires. ### Read-Only - `access_key` (String) - `credential_id` (String) The credential ID. -- `credentials_group_id` (String) The credential group ID. - `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`credentials_group_id`,`credential_id`". - `name` (String) - `secret_access_key` (String, Sensitive) diff --git a/go.mod b/go.mod index 31d795b8..f9fdb597 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.3.1 github.com/hashicorp/terraform-plugin-framework v1.4.1 - github.com/hashicorp/terraform-plugin-framework-timetypes v0.3.0 github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 github.com/hashicorp/terraform-plugin-go v0.19.0 github.com/hashicorp/terraform-plugin-log v0.9.0 diff --git a/go.sum b/go.sum index 100a964a..c2509b6a 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,6 @@ github.com/hashicorp/terraform-json v0.17.1 h1:eMfvh/uWggKmY7Pmb3T85u86E2EQg6EQH github.com/hashicorp/terraform-json v0.17.1/go.mod h1:Huy6zt6euxaY9knPAFKjUITn8QxUFIe9VuSzb4zn/0o= github.com/hashicorp/terraform-plugin-framework v1.4.1 h1:ZC29MoB3Nbov6axHdgPbMz7799pT5H8kIrM8YAsaVrs= github.com/hashicorp/terraform-plugin-framework v1.4.1/go.mod h1:XC0hPcQbBvlbxwmjxuV/8sn8SbZRg4XwGMs22f+kqV0= -github.com/hashicorp/terraform-plugin-framework-timetypes v0.3.0 h1:egR4InfakWkgepZNUATWGwkrPhaAYOTEybPfEol+G/I= -github.com/hashicorp/terraform-plugin-framework-timetypes v0.3.0/go.mod h1:9vjvl36aY1p6KltaA5QCvGC5hdE/9t4YuhGftw6WOgE= github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 h1:HOjBuMbOEzl7snOdOoUfE2Jgeto6JOjLVQ39Ls2nksc= github.com/hashicorp/terraform-plugin-framework-validators v0.12.0/go.mod h1:jfHGE/gzjxYz6XoUwi/aYiiKrJDeutQNUtGQXkaHklg= github.com/hashicorp/terraform-plugin-go v0.19.0 h1:BuZx/6Cp+lkmiG0cOBk6Zps0Cb2tmqQpDM3iAtnhDQU= diff --git a/stackit/internal/services/objectstorage/credential/datasource.go b/stackit/internal/services/objectstorage/credential/datasource.go index f4c2ee9c..23eb6381 100644 --- a/stackit/internal/services/objectstorage/credential/datasource.go +++ b/stackit/internal/services/objectstorage/credential/datasource.go @@ -8,7 +8,6 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" - "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" @@ -110,8 +109,7 @@ func (r *credentialDataSource) Schema(_ context.Context, _ datasource.SchemaRequ Sensitive: true, }, "expiration_timestamp": schema.StringAttribute{ - CustomType: timetypes.RFC3339Type{}, - Required: true, + Computed: true, }, }, } diff --git a/stackit/internal/services/objectstorage/credential/resource.go b/stackit/internal/services/objectstorage/credential/resource.go index fca0a9be..122c3d05 100644 --- a/stackit/internal/services/objectstorage/credential/resource.go +++ b/stackit/internal/services/objectstorage/credential/resource.go @@ -4,14 +4,13 @@ import ( "context" "fmt" "strings" + "time" - "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" - "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -30,14 +29,14 @@ var ( ) type Model struct { - Id types.String `tfsdk:"id"` // needed by TF - CredentialId types.String `tfsdk:"credential_id"` - CredentialsGroupId types.String `tfsdk:"credentials_group_id"` - ProjectId types.String `tfsdk:"project_id"` - Name types.String `tfsdk:"name"` - AccessKey types.String `tfsdk:"access_key"` - SecretAccessKey types.String `tfsdk:"secret_access_key"` - ExpirationTimestamp timetypes.RFC3339 `tfsdk:"expiration_timestamp"` + Id types.String `tfsdk:"id"` // needed by TF + CredentialId types.String `tfsdk:"credential_id"` + CredentialsGroupId types.String `tfsdk:"credentials_group_id"` + ProjectId types.String `tfsdk:"project_id"` + Name types.String `tfsdk:"name"` + AccessKey types.String `tfsdk:"access_key"` + SecretAccessKey types.String `tfsdk:"secret_access_key"` + ExpirationTimestamp types.String `tfsdk:"expiration_timestamp"` } // NewCredentialResource is a helper function to simplify the provider implementation. @@ -99,6 +98,7 @@ func (r *credentialResource) Schema(_ context.Context, _ resource.SchemaRequest, "credential_id": "The credential ID.", "credentials_group_id": "The credential group ID.", "project_id": "STACKIT Project ID to which the credential group is associated.", + "expiration_timestamp": "Expiration timestamp, in RFC339 format without fractional seconds. Example: \"2025-01-01T00:00:00Z\". If not set, the credential never expires.", } resp.Schema = schema.Schema{ @@ -124,7 +124,7 @@ func (r *credentialResource) Schema(_ context.Context, _ resource.SchemaRequest, }, "credentials_group_id": schema.StringAttribute{ Description: descriptions["credentials_group_id"], - Computed: true, + Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, @@ -156,9 +156,12 @@ func (r *credentialResource) Schema(_ context.Context, _ resource.SchemaRequest, Sensitive: true, }, "expiration_timestamp": schema.StringAttribute{ - CustomType: timetypes.RFC3339Type{}, - Optional: true, - Computed: true, + Description: descriptions["expiration_timestamp"], + Optional: true, + Computed: true, + Validators: []validator.String{ + validate.RFC3339SecondsOnly(), + }, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, @@ -325,9 +328,13 @@ func toCreatePayload(model *Model) (*objectstorage.CreateAccessKeyPayload, error return &objectstorage.CreateAccessKeyPayload{}, nil } - expirationTimestamp, diags := model.ExpirationTimestamp.ValueRFC3339Time() - if diags.HasError() { - return nil, fmt.Errorf("unable to fecth expiration timestamp: %w", core.DiagsToError(diags)) + expirationTimestampValue := model.ExpirationTimestamp.ValueStringPointer() + if expirationTimestampValue == nil { + return &objectstorage.CreateAccessKeyPayload{}, nil + } + expirationTimestamp, err := time.Parse(time.RFC3339, *expirationTimestampValue) + if err != nil { + return nil, fmt.Errorf("unable to parse expiration timestamp '%v': %w", *expirationTimestampValue, err) } return &objectstorage.CreateAccessKeyPayload{ Expires: &expirationTimestamp, @@ -351,7 +358,17 @@ func mapFields(credentialResp *objectstorage.CreateAccessKeyResponse, model *Mod return fmt.Errorf("credential id not present") } - var diags diag.Diagnostics + if credentialResp.Expires == nil { + model.ExpirationTimestamp = types.StringNull() + } else { + // Harmonize the timestamp format + // Eg. "2027-01-02T03:04:05.000Z" = "2027-01-02T03:04:05Z" + expirationTimestamp, err := time.Parse(time.RFC3339, *credentialResp.Expires) + if err != nil { + return fmt.Errorf("unable to parse payload expiration timestamp '%v': %w", *credentialResp.Expires, err) + } + model.ExpirationTimestamp = types.StringValue(expirationTimestamp.Format(time.RFC3339)) + } idParts := []string{ model.ProjectId.ValueString(), @@ -365,10 +382,6 @@ func mapFields(credentialResp *objectstorage.CreateAccessKeyResponse, model *Mod model.Name = types.StringPointerValue(credentialResp.DisplayName) model.AccessKey = types.StringPointerValue(credentialResp.AccessKey) model.SecretAccessKey = types.StringPointerValue(credentialResp.SecretAccessKey) - model.ExpirationTimestamp, diags = timetypes.NewRFC3339PointerValue(credentialResp.Expires) - if diags.HasError() { - return fmt.Errorf("parsing expiration timestamp: %w", core.DiagsToError(diags)) - } return nil } @@ -396,8 +409,6 @@ func readCredentials(ctx context.Context, model *Model, client *objectstorage.AP foundCredential = true - var diags diag.Diagnostics - idParts := []string{ projectId, credentialsGroupId, @@ -407,9 +418,17 @@ func readCredentials(ctx context.Context, model *Model, client *objectstorage.AP strings.Join(idParts, core.Separator), ) model.Name = types.StringPointerValue(credential.DisplayName) - model.ExpirationTimestamp, diags = timetypes.NewRFC3339PointerValue(credential.Expires) - if diags.HasError() { - return fmt.Errorf("parsing expiration timestamp: %w", core.DiagsToError(diags)) + + if credential.Expires == nil { + model.ExpirationTimestamp = types.StringNull() + } else { + // Harmonize the timestamp format + // Eg. "2027-01-02T03:04:05.000Z" = "2027-01-02T03:04:05Z" + expirationTimestamp, err := time.Parse(time.RFC3339, *credential.Expires) + if err != nil { + return fmt.Errorf("unable to parse payload expiration timestamp '%v': %w", *credential.Expires, err) + } + model.ExpirationTimestamp = types.StringValue(expirationTimestamp.Format(time.RFC3339)) } break } diff --git a/stackit/internal/services/objectstorage/credential/resource_test.go b/stackit/internal/services/objectstorage/credential/resource_test.go index 46f0e69d..5529b0f5 100644 --- a/stackit/internal/services/objectstorage/credential/resource_test.go +++ b/stackit/internal/services/objectstorage/credential/resource_test.go @@ -10,7 +10,6 @@ import ( "time" "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/utils" @@ -32,7 +31,7 @@ func (c *objectStorageClientMocked) CreateProjectExecute(_ context.Context, proj } func TestMapFields(t *testing.T) { - timeValue := time.Now() + now := time.Now() tests := []struct { description string @@ -51,7 +50,7 @@ func TestMapFields(t *testing.T) { Name: types.StringNull(), AccessKey: types.StringNull(), SecretAccessKey: types.StringNull(), - ExpirationTimestamp: timetypes.NewRFC3339Null(), + ExpirationTimestamp: types.StringNull(), }, true, }, @@ -60,7 +59,7 @@ func TestMapFields(t *testing.T) { &objectstorage.CreateAccessKeyResponse{ AccessKey: utils.Ptr("key"), DisplayName: utils.Ptr("name"), - Expires: utils.Ptr(timeValue.Format(time.RFC3339)), + Expires: utils.Ptr(now.Format(time.RFC3339)), SecretAccessKey: utils.Ptr("secret-key"), }, Model{ @@ -71,7 +70,7 @@ func TestMapFields(t *testing.T) { Name: types.StringValue("name"), AccessKey: types.StringValue("key"), SecretAccessKey: types.StringValue("secret-key"), - ExpirationTimestamp: timetypes.NewRFC3339TimeValue(timeValue), + ExpirationTimestamp: types.StringValue(now.Format(time.RFC3339)), }, true, }, @@ -90,7 +89,23 @@ func TestMapFields(t *testing.T) { Name: types.StringValue(""), AccessKey: types.StringValue(""), SecretAccessKey: types.StringValue(""), - ExpirationTimestamp: timetypes.NewRFC3339Null(), + ExpirationTimestamp: types.StringNull(), + }, + true, + }, + { + "expiration_timestamp_with_fractional_seconds", + &objectstorage.CreateAccessKeyResponse{ + Expires: utils.Ptr(now.Format(time.RFC3339Nano)), + }, + Model{ + Id: types.StringValue("pid,cgid,cid"), + ProjectId: types.StringValue("pid"), + CredentialsGroupId: types.StringValue("cgid"), + CredentialId: types.StringValue("cid"), + Name: types.StringNull(), + AccessKey: types.StringNull(), + ExpirationTimestamp: types.StringValue(now.Format(time.RFC3339)), }, true, }, @@ -150,7 +165,7 @@ func TestEnableProject(t *testing.T) { Name: types.StringNull(), AccessKey: types.StringNull(), SecretAccessKey: types.StringNull(), - ExpirationTimestamp: timetypes.NewRFC3339Null(), + ExpirationTimestamp: types.StringNull(), }, false, true, @@ -165,7 +180,7 @@ func TestEnableProject(t *testing.T) { Name: types.StringNull(), AccessKey: types.StringNull(), SecretAccessKey: types.StringNull(), - ExpirationTimestamp: timetypes.NewRFC3339Null(), + ExpirationTimestamp: types.StringNull(), }, true, false, @@ -193,7 +208,7 @@ func TestEnableProject(t *testing.T) { } func TestReadCredentials(t *testing.T) { - timeValue := time.Now() + now := time.Now() tests := []struct { description string @@ -225,7 +240,7 @@ func TestReadCredentials(t *testing.T) { Name: types.StringNull(), AccessKey: types.StringNull(), SecretAccessKey: types.StringNull(), - ExpirationTimestamp: timetypes.NewRFC3339Null(), + ExpirationTimestamp: types.StringNull(), }, false, true, @@ -237,17 +252,17 @@ func TestReadCredentials(t *testing.T) { { KeyId: utils.Ptr("foo-cid"), DisplayName: utils.Ptr("foo-name"), - Expires: utils.Ptr(timeValue.Add(time.Hour).Format(time.RFC3339)), + Expires: utils.Ptr(now.Add(time.Hour).Format(time.RFC3339)), }, { KeyId: utils.Ptr("bar-cid"), DisplayName: utils.Ptr("bar-name"), - Expires: utils.Ptr(timeValue.Add(time.Minute).Format(time.RFC3339)), + Expires: utils.Ptr(now.Add(time.Minute).Format(time.RFC3339)), }, { KeyId: utils.Ptr("cid"), DisplayName: utils.Ptr("name"), - Expires: utils.Ptr(timeValue.Format(time.RFC3339)), + Expires: utils.Ptr(now.Format(time.RFC3339)), }, }, }, @@ -259,7 +274,41 @@ func TestReadCredentials(t *testing.T) { Name: types.StringValue("name"), AccessKey: types.StringNull(), SecretAccessKey: types.StringNull(), - ExpirationTimestamp: timetypes.NewRFC3339TimeValue(timeValue), + ExpirationTimestamp: types.StringValue(now.Format(time.RFC3339)), + }, + false, + true, + }, + { + "expiration_timestamp_with_fractional_seconds", + &objectstorage.GetAccessKeysResponse{ + AccessKeys: &[]objectstorage.AccessKey{ + { + KeyId: utils.Ptr("foo-cid"), + DisplayName: utils.Ptr("foo-name"), + Expires: utils.Ptr(now.Add(time.Hour).Format(time.RFC3339Nano)), + }, + { + KeyId: utils.Ptr("bar-cid"), + DisplayName: utils.Ptr("bar-name"), + Expires: utils.Ptr(now.Add(time.Minute).Format(time.RFC3339Nano)), + }, + { + KeyId: utils.Ptr("cid"), + DisplayName: utils.Ptr("name"), + Expires: utils.Ptr(now.Format(time.RFC3339Nano)), + }, + }, + }, + Model{ + Id: types.StringValue("pid,cgid,cid"), + ProjectId: types.StringValue("pid"), + CredentialsGroupId: types.StringValue("cgid"), + CredentialId: types.StringValue("cid"), + Name: types.StringValue("name"), + AccessKey: types.StringNull(), + SecretAccessKey: types.StringNull(), + ExpirationTimestamp: types.StringValue(now.Format(time.RFC3339)), }, false, true, @@ -287,12 +336,12 @@ func TestReadCredentials(t *testing.T) { { KeyId: utils.Ptr("foo-cid"), DisplayName: utils.Ptr("foo-name"), - Expires: utils.Ptr(timeValue.Add(time.Hour).Format(time.RFC3339)), + Expires: utils.Ptr(now.Add(time.Hour).Format(time.RFC3339)), }, { KeyId: utils.Ptr("bar-cid"), DisplayName: utils.Ptr("bar-name"), - Expires: utils.Ptr(timeValue.Add(time.Minute).Format(time.RFC3339)), + Expires: utils.Ptr(now.Add(time.Minute).Format(time.RFC3339)), }, }, }, @@ -307,7 +356,7 @@ func TestReadCredentials(t *testing.T) { { KeyId: utils.Ptr("cid"), DisplayName: utils.Ptr("name"), - Expires: utils.Ptr(timeValue.Format(time.RFC3339)), + Expires: utils.Ptr(now.Format(time.RFC3339)), }, }, }, @@ -344,8 +393,9 @@ func TestReadCredentials(t *testing.T) { mockedServer := httptest.NewServer(handler) defer mockedServer.Close() client, err := objectstorage.NewAPIClient( - config.WithoutAuthentication(), config.WithEndpoint(mockedServer.URL), + config.WithoutAuthentication(), + config.WithRetryTimeout(time.Millisecond), ) if err != nil { t.Fatalf("Failed to initialize client: %v", err) diff --git a/stackit/internal/services/objectstorage/credentialsgroup/resource.go b/stackit/internal/services/objectstorage/credentialsgroup/resource.go index 7fdb21f3..fde90f27 100644 --- a/stackit/internal/services/objectstorage/credentialsgroup/resource.go +++ b/stackit/internal/services/objectstorage/credentialsgroup/resource.go @@ -270,21 +270,34 @@ func mapFields(credentialsGroupResp *objectstorage.CreateCredentialsGroupRespons } credentialsGroup := credentialsGroupResp.CredentialsGroup - mapCredentialsGroup(*credentialsGroup, model) + err := mapCredentialsGroup(*credentialsGroup, model) + if err != nil { + return err + } return nil } -func mapCredentialsGroup(credentialsGroup objectstorage.CredentialsGroup, model *Model) { - model.URN = types.StringPointerValue(credentialsGroup.Urn) - model.Name = types.StringPointerValue(credentialsGroup.DisplayName) +func mapCredentialsGroup(credentialsGroup objectstorage.CredentialsGroup, model *Model) error { + var credentialsGroupId string + if model.CredentialsGroupId.ValueString() != "" { + credentialsGroupId = model.CredentialsGroupId.ValueString() + } else if credentialsGroup.CredentialsGroupId != nil { + credentialsGroupId = *credentialsGroup.CredentialsGroupId + } else { + return fmt.Errorf("credential id not present") + } idParts := []string{ model.ProjectId.ValueString(), - model.CredentialsGroupId.ValueString(), + credentialsGroupId, } model.Id = types.StringValue( strings.Join(idParts, core.Separator), ) + model.CredentialsGroupId = types.StringValue(credentialsGroupId) + model.URN = types.StringPointerValue(credentialsGroup.Urn) + model.Name = types.StringPointerValue(credentialsGroup.DisplayName) + return nil } type objectStorageClient interface { @@ -327,7 +340,10 @@ func readCredentialsGroups(ctx context.Context, model *Model, client objectStora continue } found = true - mapCredentialsGroup(credentialsGroup, model) + err = mapCredentialsGroup(credentialsGroup, model) + if err != nil { + return err + } break } diff --git a/stackit/internal/services/objectstorage/objectstorage_acc_test.go b/stackit/internal/services/objectstorage/objectstorage_acc_test.go index 2e79be90..6651edab 100644 --- a/stackit/internal/services/objectstorage/objectstorage_acc_test.go +++ b/stackit/internal/services/objectstorage/objectstorage_acc_test.go @@ -32,7 +32,7 @@ var credentialsGroupResource = map[string]string{ // Credential resource data var credentialResource = map[string]string{ - "expiration_timestamp": "2345-06-07T08:09:10.110Z", + "expiration_timestamp": "2027-01-02T03:04:05Z", } func resourceConfig() string { @@ -50,8 +50,8 @@ func resourceConfig() string { } resource "stackit_objectstorage_credential" "credential" { - project_id = stackit_objectstorage_credential.credentials_group.project_id - credentials_group_id = stackit_objectstorage_credential.credentials_group.credentials_group_id + project_id = stackit_objectstorage_credentials_group.credentials_group.project_id + credentials_group_id = stackit_objectstorage_credentials_group.credentials_group.credentials_group_id expiration_timestamp = "%s" } `, @@ -168,14 +168,6 @@ func TestAccObjectStorageResource(t *testing.T) { "stackit_objectstorage_credential.credential", "credential_id", "data.stackit_objectstorage_credential.credential", "credential_id", ), - resource.TestCheckResourceAttrPair( - "stackit_objectstorage_credential.credential", "access_key", - "data.stackit_objectstorage_credential.credential", "access_key", - ), - resource.TestCheckResourceAttrPair( - "stackit_objectstorage_credential.credential", "secret_access_key", - "data.stackit_objectstorage_credential.credential", "secret_access_key", - ), resource.TestCheckResourceAttrPair( "stackit_objectstorage_credential.credential", "name", "data.stackit_objectstorage_credential.credential", "name", @@ -221,8 +213,9 @@ func TestAccObjectStorageResource(t *testing.T) { } return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, credentialsGroupId, credentialId), nil }, - ImportState: true, - ImportStateVerify: true, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"access_key", "secret_access_key"}, }, // Deletion is done by the framework implicitly }, diff --git a/stackit/internal/validate/validate.go b/stackit/internal/validate/validate.go index 0d61348e..8ebd6652 100644 --- a/stackit/internal/validate/validate.go +++ b/stackit/internal/validate/validate.go @@ -6,6 +6,7 @@ import ( "net" "regexp" "strings" + "time" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" @@ -108,3 +109,31 @@ func MinorVersionNumber() *Validator { }, } } + +func RFC3339SecondsOnly() *Validator { + description := "value must be in RFC339 format (seconds only)" + + return &Validator{ + description: description, + validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + t, err := time.Parse(time.RFC3339, req.ConfigValue.ValueString()) + if err != nil { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + description, + req.ConfigValue.ValueString(), + )) + return + } + + // Check if it failed because it has nanoseconds + if t.Nanosecond() != 0 { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + "value can't have fractional seconds", + req.ConfigValue.ValueString(), + )) + } + }, + } +} diff --git a/stackit/internal/validate/validate_test.go b/stackit/internal/validate/validate_test.go index 9ea438b3..d4f12212 100644 --- a/stackit/internal/validate/validate_test.go +++ b/stackit/internal/validate/validate_test.go @@ -208,3 +208,57 @@ func TestMinorVersionNumber(t *testing.T) { }) } } + +func TestRFC3339SecondsOnly(t *testing.T) { + tests := []struct { + description string + input string + isValid bool + }{ + { + "ok", + "9999-01-02T03:04:05Z", + true, + }, + { + "ok_2", + "9999-01-02T03:04:05+06:00", + true, + }, + { + "empty", + "", + false, + }, + { + "not_ok", + "foo-bar", + false, + }, + { + "with_sub_seconds", + "9999-01-02T03:04:05.678Z", + false, + }, + { + "with_sub_seconds_2", + "9999-01-02T03:04:05.678+06:00", + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + r := validator.StringResponse{} + RFC3339SecondsOnly().ValidateString(context.Background(), validator.StringRequest{ + ConfigValue: types.StringValue(tt.input), + }, &r) + + if !tt.isValid && !r.Diagnostics.HasError() { + t.Fatalf("Should have failed") + } + if tt.isValid && r.Diagnostics.HasError() { + t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors()) + } + }) + } +}