From d7a7c2c30c2bb6fbd0bc304f2b828014487e4108 Mon Sep 17 00:00:00 2001 From: Brian Kane Date: Fri, 20 Jun 2025 09:13:27 +0100 Subject: [PATCH] feature: Adds Service Scorecard Resource Signed-off-by: Brian Kane --- datadog/fwprovider/framework_provider.go | 7 + ...resource_datadog_service_scorecard_rule.go | 320 ++++++++++++++++++ .../internal/utils/api_instances_helper.go | 9 + .../TestAccScorecardRuleBasic.freeze | 1 + .../cassettes/TestAccScorecardRuleBasic.yaml | 205 +++++++++++ datadog/tests/framework_provider_test.go | 6 + datadog/tests/provider_test.go | 1 + ..._datadog_on_call_escalation_policy_test.go | 1 + ...datadog_on_call_team_routing_rules_test.go | 1 + ...rce_datadog_service_scorecard_rule_test.go | 139 ++++++++ docs/resources/service_scorecard_rule.md | 55 +++ .../datadog_service_scorecard_rules/import.sh | 2 + .../resource.tf | 9 + 13 files changed, 756 insertions(+) create mode 100644 datadog/fwprovider/resource_datadog_service_scorecard_rule.go create mode 100644 datadog/tests/cassettes/TestAccScorecardRuleBasic.freeze create mode 100644 datadog/tests/cassettes/TestAccScorecardRuleBasic.yaml create mode 100644 datadog/tests/resource_datadog_service_scorecard_rule_test.go create mode 100644 docs/resources/service_scorecard_rule.md create mode 100644 examples/resources/datadog_service_scorecard_rules/import.sh create mode 100644 examples/resources/datadog_service_scorecard_rules/resource.tf diff --git a/datadog/fwprovider/framework_provider.go b/datadog/fwprovider/framework_provider.go index 6a58237e9..aa31b3517 100644 --- a/datadog/fwprovider/framework_provider.go +++ b/datadog/fwprovider/framework_provider.go @@ -92,6 +92,7 @@ var Resources = []func() resource.Resource{ NewCostBudgetResource, NewCSMThreatsAgentRuleResource, NewCSMThreatsPolicyResource, + NewScorecardRuleResource, } var Datasources = []func() datasource.DataSource{ @@ -455,6 +456,12 @@ func defaultConfigureFunc(p *FrameworkProvider, request *provider.ConfigureReque ddClientConfig.SetUnstableOperationEnabled("v2.DeleteMonitorNotificationRule", true) ddClientConfig.SetUnstableOperationEnabled("v2.UpdateMonitorNotificationRule", true) + // Enable ServiceScorecards + ddClientConfig.SetUnstableOperationEnabled("v2.CreateScorecardRule", true) + ddClientConfig.SetUnstableOperationEnabled("v2.UpdateScorecardRule", true) + ddClientConfig.SetUnstableOperationEnabled("v2.ListScorecardRules", true) + ddClientConfig.SetUnstableOperationEnabled("v2.DeleteScorecardRule", true) + if !config.ApiUrl.IsNull() && config.ApiUrl.ValueString() != "" { parsedAPIURL, parseErr := url.Parse(config.ApiUrl.ValueString()) if parseErr != nil { diff --git a/datadog/fwprovider/resource_datadog_service_scorecard_rule.go b/datadog/fwprovider/resource_datadog_service_scorecard_rule.go new file mode 100644 index 000000000..4434e4c4a --- /dev/null +++ b/datadog/fwprovider/resource_datadog_service_scorecard_rule.go @@ -0,0 +1,320 @@ +package fwprovider + +import ( + "context" + "fmt" + "regexp" + "time" + + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + frameworkPath "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/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int32default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int32planmodifier" + "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/terraform-providers/terraform-provider-datadog/datadog/internal/utils" +) + +var ( + _ resource.ResourceWithConfigure = &scorecardRuleResource{} + _ resource.ResourceWithImportState = &scorecardRuleResource{} +) + +type scorecardRuleResource struct { + Api *datadogV2.ServiceScorecardsApi + Auth context.Context +} + +type scorecardRuleModel struct { + CreatedAt types.String `tfsdk:"created_at"` + ModifiedAt types.String `tfsdk:"modified_at"` + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Custom types.Bool `tfsdk:"custom"` + Enabled types.Bool `tfsdk:"enabled"` + Owner types.String `tfsdk:"owner"` + ScorecardName types.String `tfsdk:"scorecard_name"` + Level types.Int32 `tfsdk:"level"` + ScopeQuery types.String `tfsdk:"scope_query"` +} + +func NewScorecardRuleResource() resource.Resource { + return &scorecardRuleResource{} +} + +func (r *scorecardRuleResource) Configure(_ context.Context, request resource.ConfigureRequest, _ *resource.ConfigureResponse) { + providerData := request.ProviderData.(*FrameworkProvider) + r.Api = providerData.DatadogApiInstances.GetServiceScorecardsApiV2() + r.Auth = providerData.Auth +} + +func (r *scorecardRuleResource) Metadata(_ context.Context, _ resource.MetadataRequest, response *resource.MetadataResponse) { + response.TypeName = "service_scorecard_rule" +} + +func (r *scorecardRuleResource) Schema(_ context.Context, _ resource.SchemaRequest, response *resource.SchemaResponse) { + response.Schema = schema.Schema{ + Description: "Provides a Datadog Service Scorecard Rule resource. This can be used to create and manage Datadog Service Scorecard Rules.", + Attributes: map[string]schema.Attribute{ + "id": utils.ResourceIDAttribute(), + "name": schema.StringAttribute{ + Description: "Name of the rule.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 140), + }, + }, + "description": schema.StringAttribute{ + Description: "Explanation of the rule.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "enabled": schema.BoolAttribute{ + Description: "If enabled, the rule is calculated as part of the score", + Optional: true, + Default: booldefault.StaticBool(true), + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "custom": schema.BoolAttribute{ + Description: "Defines if the rule is a custom rule", + Computed: true, + }, + "owner": schema.StringAttribute{ + Description: "Owner of the rule.", + Optional: true, + }, + "scorecard_name": schema.StringAttribute{ + Description: "The scorecard name to which this rule must belong", + Required: true, + }, + "level": schema.Int32Attribute{ + Description: "The criticality level of the rule", + Optional: true, + Default: int32default.StaticInt32(3), + Computed: true, + Validators: []validator.Int32{ + int32validator.OneOf(int32(1), int32(2), int32(3)), + }, + PlanModifiers: []planmodifier.Int32{ + int32planmodifier.UseStateForUnknown(), + }, + }, + "scope_query": schema.StringAttribute{ + Description: "The scope query to apply to the rule", + Optional: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^$|^([a-zA-Z0-9_-]+:\S+)(\s[a-zA-Z0-9_-]+:\S+)*$`), + "Scope query must be a valid Datadog query (e.g. tier:critical team:team-a)", + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "created_at": schema.StringAttribute{ + Description: "Creation time of the rule", + Computed: true, + }, + "modified_at": schema.StringAttribute{ + Description: "Last modification time of the rule", + Computed: true, + }, + }, + } +} + +func (r *scorecardRuleResource) ImportState(ctx context.Context, request resource.ImportStateRequest, response *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, frameworkPath.Root("id"), request, response) +} + +func (r *scorecardRuleResource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) { + var state scorecardRuleModel + response.Diagnostics.Append(request.Plan.Get(ctx, &state)...) + if response.Diagnostics.HasError() { + return + } + attrs, diags := r.buildServiceScorecardRule(ctx, &state) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + ruleReq := datadogV2.NewCreateRuleRequest() + ruleReq.SetData(*datadogV2.NewCreateRuleRequestData()) + ruleReq.Data.SetAttributes(*attrs) + + res, _, err := r.Api.CreateScorecardRule(r.Auth, *ruleReq) + if err != nil { + response.Diagnostics.Append(utils.FrameworkErrorDiag(err, "error creating Scorecard Rule")) + return + } + if err := utils.CheckForUnparsed(res); err != nil { + response.Diagnostics.AddError("response contains unparsedObject", err.Error()) + return + } + + state.ID = types.StringValue(res.Data.GetId()) + response.Diagnostics.Append(r.updateState(ctx, &state, res.Data.Attributes)...) + response.Diagnostics.Append(response.State.Set(ctx, &state)...) +} + +func (r *scorecardRuleResource) Read(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) { + var state scorecardRuleModel + response.Diagnostics.Append(request.State.Get(ctx, &state)...) + if response.Diagnostics.HasError() { + return + } + + optParams := datadogV2.NewListScorecardRulesOptionalParameters().WithFilterRuleId(state.ID.ValueString()) + res, _, err := r.Api.ListScorecardRules(r.Auth, *optParams) + if err != nil { + response.Diagnostics.Append(utils.FrameworkErrorDiag(err, "error retrieving Scorecard Rule")) + return + } + if err := utils.CheckForUnparsed(res); err != nil { + response.Diagnostics.AddError("response contains unparsedObject", err.Error()) + return + } + + if err := utils.CheckForUnparsed(res); err != nil { + response.Diagnostics.AddError("response contains unparsedObject", err.Error()) + return + } + if len(res.Data) < 1 { + response.Diagnostics.AddError( + fmt.Sprintf("Scorecard Rule with ID %s not found", state.ID.ValueString()), + "No rule was returned by the API.", + ) + return + } + response.Diagnostics.Append(r.updateState(ctx, &state, res.Data[0].Attributes)...) + response.Diagnostics.Append(response.State.Set(ctx, &state)...) +} + +func (r *scorecardRuleResource) Update(ctx context.Context, request resource.UpdateRequest, response *resource.UpdateResponse) { + var state scorecardRuleModel + response.Diagnostics.Append(request.Plan.Get(ctx, &state)...) + if response.Diagnostics.HasError() { + return + } + + attrs, diags := r.buildServiceScorecardRule(ctx, &state) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + ruleReq := datadogV2.NewUpdateRuleRequest() + ruleReq.SetData(*datadogV2.NewUpdateRuleRequestData()) + ruleReq.Data.SetAttributes(*attrs) + + res, _, err := r.Api.UpdateScorecardRule(r.Auth, state.ID.ValueString(), *ruleReq) + if err != nil { + response.Diagnostics.Append(utils.FrameworkErrorDiag(err, "error updating Scorecard Rule")) + return + } + if err := utils.CheckForUnparsed(res); err != nil { + response.Diagnostics.AddError("response contains unparsedObject", err.Error()) + return + } + + response.Diagnostics.Append(r.updateState(ctx, &state, res.Data.Attributes)...) + response.Diagnostics.Append(response.State.Set(ctx, &state)...) +} + +func (r *scorecardRuleResource) Delete(ctx context.Context, request resource.DeleteRequest, response *resource.DeleteResponse) { + var state scorecardRuleModel + response.Diagnostics.Append(request.State.Get(ctx, &state)...) + if response.Diagnostics.HasError() { + return + } + + httpResp, err := r.Api.DeleteScorecardRule(r.Auth, state.ID.ValueString()) + if err != nil { + response.Diagnostics.Append(utils.FrameworkErrorDiag(err, "error deleting Scorecard Rule")) + return + } + if httpResp.StatusCode == 404 { + return // already deleted + } +} + +func (r *scorecardRuleResource) buildServiceScorecardRule(ctx context.Context, state *scorecardRuleModel) (*datadogV2.RuleAttributes, diag.Diagnostics) { + diags := diag.Diagnostics{} + + attrs := datadogV2.RuleAttributes{} + if attrs.AdditionalProperties == nil { + attrs.AdditionalProperties = make(map[string]interface{}) + } + + attrs.SetName(state.Name.ValueString()) + attrs.SetEnabled(state.Enabled.ValueBool()) + attrs.SetOwner(state.Owner.ValueString()) + attrs.SetScorecardName(state.ScorecardName.ValueString()) + attrs.AdditionalProperties["level"] = state.Level.ValueInt32() + + if !state.Description.IsNull() { + attrs.SetDescription(state.Description.ValueString()) + } + + if !state.ScopeQuery.IsNull() { + attrs.AdditionalProperties["scope_query"] = state.ScopeQuery.ValueString() + } + + return &attrs, diags +} + +func (r *scorecardRuleResource) updateState(ctx context.Context, state *scorecardRuleModel, attrs *datadogV2.RuleAttributes) diag.Diagnostics { + diags := diag.Diagnostics{} + + if createdAt, ok := attrs.GetCreatedAtOk(); ok { + state.CreatedAt = types.StringValue(createdAt.Format(time.RFC3339Nano)) + } + + if modifiedAt, ok := attrs.GetModifiedAtOk(); ok { + state.ModifiedAt = types.StringValue(modifiedAt.Format(time.RFC3339Nano)) + } + + state.Name = types.StringPointerValue(attrs.Name) + state.ScorecardName = types.StringPointerValue(attrs.ScorecardName) + state.Enabled = types.BoolPointerValue(attrs.Enabled) + + if owner, ok := attrs.GetOwnerOk(); ok { + state.Owner = types.StringPointerValue(owner) + } + + if desc, ok := attrs.GetDescriptionOk(); ok { + state.Description = types.StringPointerValue(desc) + } + + if custom, ok := attrs.GetCustomOk(); ok { + state.Custom = types.BoolPointerValue(custom) + } + + if sq, ok := attrs.AdditionalProperties["scope_query"].(string); ok { + state.ScopeQuery = types.StringValue(sq) + } + + if level, ok := attrs.AdditionalProperties["level"].(int32); ok { + state.Level = types.Int32Value(level) + } + + return diags +} diff --git a/datadog/internal/utils/api_instances_helper.go b/datadog/internal/utils/api_instances_helper.go index 94f0a9dcb..d260cdb75 100644 --- a/datadog/internal/utils/api_instances_helper.go +++ b/datadog/internal/utils/api_instances_helper.go @@ -88,6 +88,7 @@ type ApiInstances struct { securityMonitoringApiV2 *datadogV2.SecurityMonitoringApi sensitiveDataScannerApiV2 *datadogV2.SensitiveDataScannerApi serviceAccountsApiV2 *datadogV2.ServiceAccountsApi + serviceScorecardsApiV2 *datadogV2.ServiceScorecardsApi softwareCatalogApiV2 *datadogV2.SoftwareCatalogApi spansMetricsApiV2 *datadogV2.SpansMetricsApi syntheticsApiV2 *datadogV2.SyntheticsApi @@ -616,6 +617,14 @@ func (i *ApiInstances) GetServiceAccountsApiV2() *datadogV2.ServiceAccountsApi { return i.serviceAccountsApiV2 } +// GetServiceScorecardsApiV2 get instance of ServiceScorecardsApi +func (i *ApiInstances) GetServiceScorecardsApiV2() *datadogV2.ServiceScorecardsApi { + if i.serviceScorecardsApiV2 == nil { + i.serviceScorecardsApiV2 = datadogV2.NewServiceScorecardsApi(i.HttpClient) + } + return i.serviceScorecardsApiV2 +} + // GetSoftwareCatalogApiV2 get instance of SoftwareCatalogApi func (i *ApiInstances) GetSoftwareCatalogApiV2() *datadogV2.SoftwareCatalogApi { if i.softwareCatalogApiV2 == nil { diff --git a/datadog/tests/cassettes/TestAccScorecardRuleBasic.freeze b/datadog/tests/cassettes/TestAccScorecardRuleBasic.freeze new file mode 100644 index 000000000..29a141eca --- /dev/null +++ b/datadog/tests/cassettes/TestAccScorecardRuleBasic.freeze @@ -0,0 +1 @@ +2025-07-01T08:46:03.603664+01:00 \ No newline at end of file diff --git a/datadog/tests/cassettes/TestAccScorecardRuleBasic.yaml b/datadog/tests/cassettes/TestAccScorecardRuleBasic.yaml new file mode 100644 index 000000000..7847d9719 --- /dev/null +++ b/datadog/tests/cassettes/TestAccScorecardRuleBasic.yaml @@ -0,0 +1,205 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 255 + transfer_encoding: [] + trailer: {} + host: api.datadoghq.com + remote_addr: "" + request_uri: "" + body: | + {"data":{"attributes":{"description":"Acceptance test rule","enabled":true,"level":2,"name":"tf-testaccscorecardrulebasic-local-1751355963","owner":"terraform","scope_query":"env:prod team:platform","scorecard_name":"terraform_scorecard"},"type":"rule"}} + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + url: https://api.datadoghq.com/api/v2/scorecard/rules + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 581 + uncompressed: false + body: '{"data":{"id":"pm0e2g8p1wsmgflh","type":"rule","attributes":{"category":"terraform_scorecard","created_at":"2025-07-01T07:46:06.30930645Z","custom":true,"description":"Acceptance test rule","enabled":true,"level":2,"modified_at":"2025-07-01T07:46:06.30930645Z","name":"tf-testaccscorecardrulebasic-local-1751355963","owner":"terraform","scope_query":"env:prod team:platform","scorecard_name":"terraform_scorecard"},"relationships":{"scope":{"data":{"id":"5600279c-60f3-5784-8aca-d2f2a25ba022","type":"entity-scope"}},"scorecard":{"data":{"id":"QUEQlS7ULLJv","type":"scorecard"}}}}}' + headers: + Content-Type: + - application/vnd.api+json + status: 201 Created + code: 201 + duration: 505.900375ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: api.datadoghq.com + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept: + - application/json + url: https://api.datadoghq.com/api/v2/scorecard/rules?filter%5Brule%5D%5Bid%5D=pm0e2g8p1wsmgflh + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 715 + uncompressed: false + body: '{"data":[{"id":"pm0e2g8p1wsmgflh","type":"rule","attributes":{"category":"terraform_scorecard","created_at":"2025-07-01T07:46:06.309306Z","custom":true,"description":"Acceptance test rule","enabled":true,"level":2,"modified_at":"2025-07-01T07:46:06.309306Z","name":"tf-testaccscorecardrulebasic-local-1751355963","owner":"terraform","scope_query":"env:prod team:platform","scorecard_name":"terraform_scorecard"},"relationships":{"scope":{"data":{"id":"5600279c-60f3-5784-8aca-d2f2a25ba022","type":"entity-scope"}},"scorecard":{"data":{"id":"QUEQlS7ULLJv","type":"scorecard"}}}}],"links":{"next":"/api/v2/scorecard/rules?filter%5Brule%5D%5Bid%5D=pm0e2g8p1wsmgflh\u0026page%5Blimit%5D=100\u0026page%5Boffset%5D=100"}}' + headers: + Content-Type: + - application/vnd.api+json + status: 200 OK + code: 200 + duration: 116.884666ms + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: api.datadoghq.com + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept: + - application/json + url: https://api.datadoghq.com/api/v2/scorecard/rules?filter%5Brule%5D%5Bid%5D=pm0e2g8p1wsmgflh + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 715 + uncompressed: false + body: '{"data":[{"id":"pm0e2g8p1wsmgflh","type":"rule","attributes":{"category":"terraform_scorecard","created_at":"2025-07-01T07:46:06.309306Z","custom":true,"description":"Acceptance test rule","enabled":true,"level":2,"modified_at":"2025-07-01T07:46:06.309306Z","name":"tf-testaccscorecardrulebasic-local-1751355963","owner":"terraform","scope_query":"env:prod team:platform","scorecard_name":"terraform_scorecard"},"relationships":{"scope":{"data":{"id":"5600279c-60f3-5784-8aca-d2f2a25ba022","type":"entity-scope"}},"scorecard":{"data":{"id":"QUEQlS7ULLJv","type":"scorecard"}}}}],"links":{"next":"/api/v2/scorecard/rules?filter%5Brule%5D%5Bid%5D=pm0e2g8p1wsmgflh\u0026page%5Blimit%5D=100\u0026page%5Boffset%5D=100"}}' + headers: + Content-Type: + - application/vnd.api+json + status: 200 OK + code: 200 + duration: 112.091042ms + - id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 260 + transfer_encoding: [] + trailer: {} + host: api.datadoghq.com + remote_addr: "" + request_uri: "" + body: | + {"data":{"attributes":{"description":"Updated description","enabled":true,"level":1,"name":"tf-testaccscorecardrulebasic-local-1751355963","owner":"terraform","scope_query":"env:staging team:development","scorecard_name":"terraform_scorecard"},"type":"rule"}} + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + url: https://api.datadoghq.com/api/v2/scorecard/rules/pm0e2g8p1wsmgflh + method: PUT + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 582 + uncompressed: false + body: '{"data":{"id":"pm0e2g8p1wsmgflh","type":"rule","attributes":{"category":"terraform_scorecard","created_at":"2025-07-01T07:46:06.309306Z","custom":true,"description":"Updated description","enabled":true,"level":1,"modified_at":"2025-07-01T07:46:08.572996Z","name":"tf-testaccscorecardrulebasic-local-1751355963","owner":"terraform","scope_query":"env:staging team:development","scorecard_name":"terraform_scorecard"},"relationships":{"scope":{"data":{"id":"4d304ff6-f64f-553f-b60c-e53aa0c84677","type":"entity-scope"}},"scorecard":{"data":{"id":"QUEQlS7ULLJv","type":"scorecard"}}}}}' + headers: + Content-Type: + - application/vnd.api+json + status: 200 OK + code: 200 + duration: 138.517958ms + - id: 4 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: api.datadoghq.com + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept: + - application/json + url: https://api.datadoghq.com/api/v2/scorecard/rules?filter%5Brule%5D%5Bid%5D=pm0e2g8p1wsmgflh + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 720 + uncompressed: false + body: '{"data":[{"id":"pm0e2g8p1wsmgflh","type":"rule","attributes":{"category":"terraform_scorecard","created_at":"2025-07-01T07:46:06.309306Z","custom":true,"description":"Updated description","enabled":true,"level":1,"modified_at":"2025-07-01T07:46:08.572996Z","name":"tf-testaccscorecardrulebasic-local-1751355963","owner":"terraform","scope_query":"env:staging team:development","scorecard_name":"terraform_scorecard"},"relationships":{"scope":{"data":{"id":"4d304ff6-f64f-553f-b60c-e53aa0c84677","type":"entity-scope"}},"scorecard":{"data":{"id":"QUEQlS7ULLJv","type":"scorecard"}}}}],"links":{"next":"/api/v2/scorecard/rules?filter%5Brule%5D%5Bid%5D=pm0e2g8p1wsmgflh\u0026page%5Blimit%5D=100\u0026page%5Boffset%5D=100"}}' + headers: + Content-Type: + - application/vnd.api+json + status: 200 OK + code: 200 + duration: 112.100333ms + - id: 5 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: api.datadoghq.com + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept: + - '*/*' + url: https://api.datadoghq.com/api/v2/scorecard/rules/pm0e2g8p1wsmgflh + method: DELETE + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 0 + uncompressed: false + body: "" + headers: {} + status: 204 No Content + code: 204 + duration: 489.7135ms diff --git a/datadog/tests/framework_provider_test.go b/datadog/tests/framework_provider_test.go index 9e44d01c9..cb8e62147 100644 --- a/datadog/tests/framework_provider_test.go +++ b/datadog/tests/framework_provider_test.go @@ -57,6 +57,12 @@ func buildFrameworkDatadogClient(ctx context.Context, httpClient *http.Client) * config.SetUnstableOperationEnabled("v2.DeleteMonitorNotificationRule", true) config.SetUnstableOperationEnabled("v2.UpdateMonitorNotificationRule", true) + // Enable ServiceScorecards + config.SetUnstableOperationEnabled("v2.CreateScorecardRule", true) + config.SetUnstableOperationEnabled("v2.UpdateScorecardRule", true) + config.SetUnstableOperationEnabled("v2.ListScorecardRules", true) + config.SetUnstableOperationEnabled("v2.DeleteScorecardRule", true) + if ctx.Value("http_retry_enable") == true { config.RetryConfiguration.EnableRetry = true } diff --git a/datadog/tests/provider_test.go b/datadog/tests/provider_test.go index 55580b3de..74f5605f8 100644 --- a/datadog/tests/provider_test.go +++ b/datadog/tests/provider_test.go @@ -277,6 +277,7 @@ var testFiles2EndpointTags = map[string]string{ "tests/resource_datadog_compliance_resource_evaluation_filter_test": "resource_filters", "tests/resource_datadog_compliance_custom_framework_test": "compliance_custom_framework", "tests/resource_datadog_cost_budget_test": "cost-budget", + "tests/resource_datadog_service_scorecard_rule_test": "scorecard", } // getEndpointTagValue traverses callstack frames to find the test function that invoked this call; diff --git a/datadog/tests/resource_datadog_on_call_escalation_policy_test.go b/datadog/tests/resource_datadog_on_call_escalation_policy_test.go index ac32a00ff..eb2ffd9a4 100644 --- a/datadog/tests/resource_datadog_on_call_escalation_policy_test.go +++ b/datadog/tests/resource_datadog_on_call_escalation_policy_test.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/terraform-providers/terraform-provider-datadog/datadog/fwprovider" "github.com/terraform-providers/terraform-provider-datadog/datadog/internal/utils" ) diff --git a/datadog/tests/resource_datadog_on_call_team_routing_rules_test.go b/datadog/tests/resource_datadog_on_call_team_routing_rules_test.go index 8575778b7..ed9b76e9a 100644 --- a/datadog/tests/resource_datadog_on_call_team_routing_rules_test.go +++ b/datadog/tests/resource_datadog_on_call_team_routing_rules_test.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/terraform-providers/terraform-provider-datadog/datadog/fwprovider" "github.com/terraform-providers/terraform-provider-datadog/datadog/internal/utils" ) diff --git a/datadog/tests/resource_datadog_service_scorecard_rule_test.go b/datadog/tests/resource_datadog_service_scorecard_rule_test.go new file mode 100644 index 000000000..1700c2749 --- /dev/null +++ b/datadog/tests/resource_datadog_service_scorecard_rule_test.go @@ -0,0 +1,139 @@ +package test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + "github.com/terraform-providers/terraform-provider-datadog/datadog/fwprovider" + "github.com/terraform-providers/terraform-provider-datadog/datadog/internal/utils" +) + +func TestAccScorecardRuleBasic(t *testing.T) { + t.Parallel() + ctx, providers, accProviders := testAccFrameworkMuxProviders(context.Background(), t) + uniqName := strings.ToLower(uniqueEntityName(ctx, t)) + + resource.Test(t, resource.TestCase{ + ProtoV5ProviderFactories: accProviders, + CheckDestroy: testAccCheckDatadogScorecardRuleDestroy(providers.frameworkProvider), + Steps: []resource.TestStep{ + { + Config: testAccScorecardRule(uniqName), + Check: resource.ComposeTestCheckFunc( + testAccCheckDatadogScorecardRuleExists(providers.frameworkProvider), + resource.TestCheckResourceAttr("datadog_service_scorecard_rule.foo", "name", uniqName), + resource.TestCheckResourceAttr("datadog_service_scorecard_rule.foo", "owner", "terraform"), + resource.TestCheckResourceAttr("datadog_service_scorecard_rule.foo", "scorecard_name", "terraform_scorecard"), + resource.TestCheckResourceAttr("datadog_service_scorecard_rule.foo", "enabled", "true"), + resource.TestCheckResourceAttr("datadog_service_scorecard_rule.foo", "custom", "true"), + resource.TestCheckResourceAttr("datadog_service_scorecard_rule.foo", "scope_query", "env:prod team:platform"), + resource.TestCheckResourceAttrSet("datadog_service_scorecard_rule.foo", "created_at"), + resource.TestCheckResourceAttrSet("datadog_service_scorecard_rule.foo", "modified_at"), + ), + }, + { + Config: testAccScorecardRuleUpdated(uniqName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("datadog_service_scorecard_rule.foo", "description", "Updated description"), + resource.TestCheckResourceAttr("datadog_service_scorecard_rule.foo", "level", "1"), + resource.TestCheckResourceAttr("datadog_service_scorecard_rule.foo", "custom", "true"), + resource.TestCheckResourceAttr("datadog_service_scorecard_rule.foo", "scope_query", "env:staging team:development"), + resource.TestCheckResourceAttrSet("datadog_service_scorecard_rule.foo", "created_at"), + resource.TestCheckResourceAttrSet("datadog_service_scorecard_rule.foo", "modified_at"), + ), + }, + }, + }) +} + +func testAccScorecardRule(uniq string) string { + return fmt.Sprintf(` +resource "datadog_service_scorecard_rule" "foo" { + name = "%s" + description = "Acceptance test rule" + enabled = true + owner = "terraform" + scorecard_name = "terraform_scorecard" + level = 2 + scope_query = "env:prod team:platform" +} +`, uniq) +} + +func testAccScorecardRuleUpdated(uniq string) string { + return fmt.Sprintf(` +resource "datadog_service_scorecard_rule" "foo" { + name = "%s" + description = "Updated description" + enabled = true + owner = "terraform" + scorecard_name = "terraform_scorecard" + level = 1 + scope_query = "env:staging team:development" +} +`, uniq) +} + +func testAccCheckDatadogScorecardRuleExists(accProvider *fwprovider.FrameworkProvider) resource.TestCheckFunc { + return func(s *terraform.State) error { + apiInstances := accProvider.DatadogApiInstances + auth := accProvider.Auth + + for _, r := range s.RootModule().Resources { + if r.Type != "datadog_scorecard_rule" { + continue + } + id := r.Primary.ID + opt := datadogV2.NewListScorecardRulesOptionalParameters().WithFilterRuleId(id) + resp, httpResp, err := apiInstances.GetServiceScorecardsApiV2().ListScorecardRules(auth, *opt) + if err != nil { + return utils.TranslateClientError(err, httpResp, "error retrieving Scorecard Rule") + } + if len(resp.Data) == 0 { + return fmt.Errorf("scorecard rule not found") + } + } + return nil + } +} + +func testAccCheckDatadogScorecardRuleDestroy(accProvider *fwprovider.FrameworkProvider) func(*terraform.State) error { + return func(s *terraform.State) error { + apiInstances := accProvider.DatadogApiInstances + auth := accProvider.Auth + + for _, r := range s.RootModule().Resources { + if r.Type != "datadog_scorecard_rule" { + continue + } + id := r.Primary.ID + + err := utils.Retry(2, 10, func() error { + opt := datadogV2.NewListScorecardRulesOptionalParameters().WithFilterRuleId(id) + resp, httpResp, err := apiInstances.GetServiceScorecardsApiV2().ListScorecardRules(auth, *opt) + if err != nil { + if httpResp != nil && httpResp.StatusCode == 404 { + return nil + } + return &utils.RetryableError{Prob: fmt.Sprintf("error retrieving Scorecard Rule: %s", err)} + } + if len(resp.Data) == 0 { + return nil + } + return &utils.RetryableError{Prob: "Scorecard Rule still exists"} + }) + + if err != nil { + return err + } + } + return nil + } +} diff --git a/docs/resources/service_scorecard_rule.md b/docs/resources/service_scorecard_rule.md new file mode 100644 index 000000000..0b9887665 --- /dev/null +++ b/docs/resources/service_scorecard_rule.md @@ -0,0 +1,55 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "datadog_service_scorecard_rule Resource - terraform-provider-datadog" +subcategory: "" +description: |- + Provides a Datadog Service Scorecard Rule resource. This can be used to create and manage Datadog Service Scorecard Rules. +--- + +# datadog_service_scorecard_rule (Resource) + +Provides a Datadog Service Scorecard Rule resource. This can be used to create and manage Datadog Service Scorecard Rules. + +## Example Usage + +```terraform +resource "datadog_service_scorecard_rule" "foo" { + name = "my-scorecard-rule" + description = "My Custom Scorecard Rule" + owner = "platform" + scorecard_name = "my_scorecard" + level = 2 + scope_query = "env:prod team:platform" +} +``` + + +## Schema + +### Required + +- `name` (String) Name of the rule. +- `scorecard_name` (String) The scorecard name to which this rule must belong. + +### Optional + +- `description` (String) Explanation of the rule. +- `owner` (String) Owner of the rule. +- `enabled` (Boolean) If enabled, the rule is calculated as part of the score. Defaults to `true`. +- `level` (Number) The criticality level of the rule. Defaults to `3`. +- `scope_query` (String) The scope query to apply to the rule. Scope query must be a valid Datadog query format. + +### Read-Only + +- `id` (String) The ID of this resource. +- `custom` (Boolean) Defines if the rule is a custom rule. +- `created_at` (String) Creation time of the rule. +- `modified_at` (String) Last modification time of the rule. + +## Import + +Import is supported using the following syntax: + +```shell +terraform import datadog_service_scorecard_rule.foo "{rule_id}" +``` \ No newline at end of file diff --git a/examples/resources/datadog_service_scorecard_rules/import.sh b/examples/resources/datadog_service_scorecard_rules/import.sh new file mode 100644 index 000000000..65fb73d52 --- /dev/null +++ b/examples/resources/datadog_service_scorecard_rules/import.sh @@ -0,0 +1,2 @@ +# Service Level Objectives can be imported using their string ID, e.g. +terraform import datadog_service_scorecard_rule.foo a1B2c3D4e5F6g7H8 diff --git a/examples/resources/datadog_service_scorecard_rules/resource.tf b/examples/resources/datadog_service_scorecard_rules/resource.tf new file mode 100644 index 000000000..ba6b33e0c --- /dev/null +++ b/examples/resources/datadog_service_scorecard_rules/resource.tf @@ -0,0 +1,9 @@ +resource "datadog_service_scorecard_rule" "foo" { + name = "my-scorecard-rule" + description = "My Custom Scorecard Rule" + enabled = true + owner = "platform" + scorecard_name = "my_scorecard" + level = 2 + scope_query = "env:prod team:platform" +} \ No newline at end of file