diff --git a/docs/data-sources/objectstorage_credential.md b/docs/data-sources/objectstorage_credential.md index 34447363..aa682ca6 100644 --- a/docs/data-sources/objectstorage_credential.md +++ b/docs/data-sources/objectstorage_credential.md @@ -13,7 +13,7 @@ ObjectStorage credential data source schema. Must have a `region` specified in t ## Example Usage ```terraform -data "stackit_objectstorage_credentials_group" "example" { +data "stackit_objectstorage_credential" "example" { project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" credentials_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" credential_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" @@ -35,8 +35,6 @@ data "stackit_objectstorage_credentials_group" "example" { ### 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/examples/data-sources/stackit_objectstorage_credential/data-source.tf b/examples/data-sources/stackit_objectstorage_credential/data-source.tf index 667d75e7..d61e4e47 100644 --- a/examples/data-sources/stackit_objectstorage_credential/data-source.tf +++ b/examples/data-sources/stackit_objectstorage_credential/data-source.tf @@ -1,4 +1,4 @@ -data "stackit_objectstorage_credentials_group" "example" { +data "stackit_objectstorage_credential" "example" { project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" credentials_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" credential_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" diff --git a/stackit/internal/services/objectstorage/credential/datasource.go b/stackit/internal/services/objectstorage/credential/datasource.go index 42a90da6..307176c9 100644 --- a/stackit/internal/services/objectstorage/credential/datasource.go +++ b/stackit/internal/services/objectstorage/credential/datasource.go @@ -3,14 +3,19 @@ package objectstorage import ( "context" "fmt" + "net/http" + "strings" + "time" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" ) @@ -19,6 +24,16 @@ var ( _ datasource.DataSource = &credentialDataSource{} ) +type DataSourceModel 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"` + ExpirationTimestamp types.String `tfsdk:"expiration_timestamp"` + Region types.String `tfsdk:"region"` +} + // NewCredentialDataSource is a helper function to simplify the provider implementation. func NewCredentialDataSource() datasource.DataSource { return &credentialDataSource{} @@ -104,13 +119,6 @@ func (r *credentialDataSource) Schema(_ context.Context, _ datasource.SchemaRequ "name": schema.StringAttribute{ Computed: true, }, - "access_key": schema.StringAttribute{ - Computed: true, - }, - "secret_access_key": schema.StringAttribute{ - Computed: true, - Sensitive: true, - }, "expiration_timestamp": schema.StringAttribute{ Computed: true, }, @@ -125,7 +133,7 @@ func (r *credentialDataSource) Schema(_ context.Context, _ datasource.SchemaRequ // Read refreshes the Terraform state with the latest data. func (r *credentialDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform - var model Model + var model DataSourceModel diags := req.Config.Get(ctx, &model) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -147,17 +155,33 @@ func (r *credentialDataSource) Read(ctx context.Context, req datasource.ReadRequ ctx = tflog.SetField(ctx, "credential_id", credentialId) ctx = tflog.SetField(ctx, "region", region) - found, err := readCredentials(ctx, &model, region, r.client) + credentialsGroupResp, err := r.client.ListAccessKeys(ctx, projectId, region).CredentialsGroup(credentialsGroupId).Execute() if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credential", fmt.Sprintf("Finding credential: %v", err)) + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credentials", fmt.Sprintf("Calling API: %v", err)) return } - if !found { - resp.State.RemoveResource(ctx) + if credentialsGroupResp == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credentials", fmt.Sprintf("Response is nil: %v", err)) + return + } + + credential := findCredential(*credentialsGroupResp, credentialId) + if credential == nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credential", "Credential not found") return } + err = mapDataSourceFields(credential, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credential", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set refreshed state diags = resp.State.Set(ctx, model) resp.Diagnostics.Append(diags...) @@ -166,3 +190,57 @@ func (r *credentialDataSource) Read(ctx context.Context, req datasource.ReadRequ } tflog.Info(ctx, "ObjectStorage credential read") } + +func mapDataSourceFields(credentialResp *objectstorage.AccessKey, model *DataSourceModel, region string) error { + if credentialResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var credentialId string + if model.CredentialId.ValueString() != "" { + credentialId = model.CredentialId.ValueString() + } else if credentialResp.KeyId != nil { + credentialId = *credentialResp.KeyId + } else { + return fmt.Errorf("credential id not present") + } + + 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(), + model.CredentialsGroupId.ValueString(), + credentialId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + model.CredentialId = types.StringValue(credentialId) + model.Name = types.StringPointerValue(credentialResp.DisplayName) + model.Region = types.StringValue(region) + return nil +} + +// Returns the access key if found otherwise nil +func findCredential(credentialsGroupResp objectstorage.ListAccessKeysResponse, credentialId string) *objectstorage.AccessKey { + for _, credential := range *credentialsGroupResp.AccessKeys { + if credential.KeyId == nil || *credential.KeyId != credentialId { + continue + } + return &credential + } + return nil +} diff --git a/stackit/internal/services/objectstorage/credential/datasource_test.go b/stackit/internal/services/objectstorage/credential/datasource_test.go new file mode 100644 index 00000000..d7ec8c9f --- /dev/null +++ b/stackit/internal/services/objectstorage/credential/datasource_test.go @@ -0,0 +1,122 @@ +package objectstorage + +import ( + "testing" + "time" + + "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/objectstorage" +) + +func TestMapDatasourceFields(t *testing.T) { + now := time.Now() + + tests := []struct { + description string + input *objectstorage.AccessKey + expected DataSourceModel + isValid bool + }{ + { + "default_values", + &objectstorage.AccessKey{}, + DataSourceModel{ + Id: types.StringValue("pid,cgid,cid"), + ProjectId: types.StringValue("pid"), + CredentialsGroupId: types.StringValue("cgid"), + CredentialId: types.StringValue("cid"), + Name: types.StringNull(), + ExpirationTimestamp: types.StringNull(), + Region: types.StringValue("eu01"), + }, + true, + }, + { + "simple_values", + &objectstorage.AccessKey{ + DisplayName: utils.Ptr("name"), + Expires: utils.Ptr(now.Format(time.RFC3339)), + }, + DataSourceModel{ + Id: types.StringValue("pid,cgid,cid"), + ProjectId: types.StringValue("pid"), + CredentialsGroupId: types.StringValue("cgid"), + CredentialId: types.StringValue("cid"), + Name: types.StringValue("name"), + ExpirationTimestamp: types.StringValue(now.Format(time.RFC3339)), + Region: types.StringValue("eu01"), + }, + true, + }, + { + "empty_strings", + &objectstorage.AccessKey{ + DisplayName: utils.Ptr(""), + }, + DataSourceModel{ + Id: types.StringValue("pid,cgid,cid"), + ProjectId: types.StringValue("pid"), + CredentialsGroupId: types.StringValue("cgid"), + CredentialId: types.StringValue("cid"), + Name: types.StringValue(""), + ExpirationTimestamp: types.StringNull(), + Region: types.StringValue("eu01"), + }, + true, + }, + { + "expiration_timestamp_with_fractional_seconds", + &objectstorage.AccessKey{ + Expires: utils.Ptr(now.Format(time.RFC3339Nano)), + }, + DataSourceModel{ + Id: types.StringValue("pid,cgid,cid"), + ProjectId: types.StringValue("pid"), + CredentialsGroupId: types.StringValue("cgid"), + CredentialId: types.StringValue("cid"), + Name: types.StringNull(), + ExpirationTimestamp: types.StringValue(now.Format(time.RFC3339)), + Region: types.StringValue("eu01"), + }, + true, + }, + { + "nil_response", + nil, + DataSourceModel{}, + false, + }, + { + "bad_time", + &objectstorage.AccessKey{ + Expires: utils.Ptr("foo-bar"), + }, + DataSourceModel{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + model := &DataSourceModel{ + ProjectId: tt.expected.ProjectId, + CredentialsGroupId: tt.expected.CredentialsGroupId, + CredentialId: tt.expected.CredentialId, + } + err := mapDataSourceFields(tt.input, model, "eu01") + 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) + } + } + }) + } +}