diff --git a/docs/resources/port_scorecard.md b/docs/resources/port_scorecard.md index bc13c874..da0f2093 100644 --- a/docs/resources/port_scorecard.md +++ b/docs/resources/port_scorecard.md @@ -7,7 +7,7 @@ description: |- This resource allows you to manage a scorecard. See the Port documentation https://docs.getport.io/promote-scorecards/ for more information about scorecards. Example Usage - Create a parent blueprint with a child blueprint and an aggregation property to count the parent kids: + This will create a blueprint with a Scorecard measuring the readiness of a microservice. ```hcl resource "portblueprint" "microservice" { title = "microservice" @@ -97,6 +97,111 @@ description: |- portblueprint.microservice ] } + Example Usage with Levels + This will override the default levels (Basic, Bronze, Silver, Gold) with the provided levels: Not Ready, Partially Ready, Ready. + ```hcl + resource "portblueprint" "microservice" { + title = "microservice" + icon = "Terraform" + identifier = "microservice" + properties = { + stringprops = { + "author" = { + title = "Author" + } + "url" = { + title = "URL" + } + } + booleanprops = { + "required" = { + type = "boolean" + } + } + numberprops = { + "sum" = { + type = "number" + } + } + } + } + resource "portscorecard" "readiness" { + identifier = "Readiness" + title = "Readiness" + blueprint = portblueprint.microservice.identifier + levels = [ + { + color = "red" + title = "No Ready" + }, + { + color = "yellow" + title = "Partially Ready" + }, + { + color = "green" + title = "Ready" + } + ] + rules = [ + { + identifier = "hasOwner" + title = "Has Owner" + level = "Ready" + query = { + combinator = "and" + conditions = [ + jsonencode({ + property = "$team" + operator = "isNotEmpty" + }), + jsonencode({ + property = "author", + operator : "=", + value : "myValue" + }) + ] + } + }, + { + identifier = "hasUrl" + title = "Has URL" + level = "Partially Ready" + query = { + combinator = "and" + conditions = [ + jsonencode({ + property = "url" + operator = "isNotEmpty" + }) + ] + } + }, + { + identifier = "checkSumIfRequired" + title = "Check Sum If Required" + level = "Partially Ready" + query = { + combinator = "or" + conditions = [ + jsonencode({ + property = "required" + operator : "=" + value : false + }), + jsonencode({ + property = "sum" + operator : ">" + value : 2 + }) + ] + } + } + ] + dependson = [ + portblueprint.microservice + ] + } ``` --- @@ -110,7 +215,7 @@ See the [Port documentation](https://docs.getport.io/promote-scorecards/) for mo ## Example Usage -Create a parent blueprint with a child blueprint and an aggregation property to count the parent kids: +This will create a blueprint with a Scorecard measuring the readiness of a microservice. ```hcl @@ -204,6 +309,119 @@ resource "port_scorecard" "readiness" { ] } + + +## Example Usage with Levels + +This will override the default levels (Basic, Bronze, Silver, Gold) with the provided levels: Not Ready, Partially Ready, Ready. + + +```hcl + +resource "port_blueprint" "microservice" { + title = "microservice" + icon = "Terraform" + identifier = "microservice" + properties = { + string_props = { + "author" = { + title = "Author" + } + "url" = { + title = "URL" + } + } + boolean_props = { + "required" = { + type = "boolean" + } + } + number_props = { + "sum" = { + type = "number" + } + } + } +} + +resource "port_scorecard" "readiness" { + identifier = "Readiness" + title = "Readiness" + blueprint = port_blueprint.microservice.identifier + levels = [ + { + color = "red" + title = "No Ready" + }, + { + color = "yellow" + title = "Partially Ready" + }, + { + color = "green" + title = "Ready" + } + ] + rules = [ + { + identifier = "hasOwner" + title = "Has Owner" + level = "Ready" + query = { + combinator = "and" + conditions = [ + jsonencode({ + property = "$team" + operator = "isNotEmpty" + }), + jsonencode({ + property = "author", + operator : "=", + value : "myValue" + }) + ] + } + }, + { + identifier = "hasUrl" + title = "Has URL" + level = "Partially Ready" + query = { + combinator = "and" + conditions = [ + jsonencode({ + property = "url" + operator = "isNotEmpty" + }) + ] + } + }, + { + identifier = "checkSumIfRequired" + title = "Check Sum If Required" + level = "Partially Ready" + query = { + combinator = "or" + conditions = [ + jsonencode({ + property = "required" + operator : "=" + value : false + }), + jsonencode({ + property = "sum" + operator : ">" + value : 2 + }) + ] + } + } + ] + depends_on = [ + port_blueprint.microservice + ] +} + ``` @@ -218,6 +436,10 @@ resource "port_scorecard" "readiness" { - `rules` (Attributes List) The rules of the scorecard (see [below for nested schema](#nestedatt--rules)) - `title` (String) The title of the scorecard +### Optional + +- `levels` (Attributes List) The levels of the scorecard. This overrides the default levels (Basic, Bronze, Silver, Gold) if provided (see [below for nested schema](#nestedatt--levels)) + ### Read-Only - `created_at` (String) The creation date of the scorecard @@ -243,3 +465,13 @@ Required: - `combinator` (String) The combinator of the query - `conditions` (List of String) The conditions of the query. Each condition object should be encoded to a string + + + + +### Nested Schema for `levels` + +Required: + +- `color` (String) The color of the level +- `title` (String) The title of the level diff --git a/internal/cli/models.go b/internal/cli/models.go index 202442dc..1e9c618b 100644 --- a/internal/cli/models.go +++ b/internal/cli/models.go @@ -21,14 +21,14 @@ type ( } ScorecardRulesModel struct { - Identifier string `tfsdk:"identifier"` - Status string `tfsdk:"status"` - Level string `tfsdk:"level"` + Identifier string `json:"identifier"` + Status string `json:"status"` + Level string `json:"level"` } ScorecardModel struct { - Rules []ScorecardRulesModel `tfsdk:"rules"` - Level string `tfsdk:"level"` + Rules []ScorecardRulesModel `json:"rules"` + Level string `json:"level"` } Entity struct { @@ -326,10 +326,11 @@ type ( Scorecard struct { Meta - Identifier string `json:"identifier,omitempty"` - Title string `json:"title,omitempty"` - Blueprint string `json:"blueprint,omitempty"` - Rules []Rule `json:"rules,omitempty"` + Identifier string `json:"identifier,omitempty"` + Title string `json:"title,omitempty"` + Blueprint string `json:"blueprint,omitempty"` + Levels []Level `json:"levels,omitempty"` + Rules []Rule `json:"rules,omitempty"` } Rule struct { @@ -339,6 +340,10 @@ type ( Query Query `json:"query,omitempty"` } + Level struct { + Title string `json:"title,omitempty"` + Color string `json:"color,omitempty"` + } Query struct { Combinator string `json:"combinator,omitempty"` Conditions []any `json:"conditions,omitempty"` diff --git a/port/scorecard/model.go b/port/scorecard/model.go index e518ae20..fbf860ce 100644 --- a/port/scorecard/model.go +++ b/port/scorecard/model.go @@ -16,11 +16,17 @@ type Rule struct { Query *Query `tfsdk:"query"` } +type Level struct { + Title types.String `tfsdk:"title"` + Color types.String `tfsdk:"color"` +} + type ScorecardModel struct { ID types.String `tfsdk:"id"` Identifier types.String `tfsdk:"identifier"` Blueprint types.String `tfsdk:"blueprint"` Title types.String `tfsdk:"title"` + Levels []Level `tfsdk:"levels"` Rules []Rule `tfsdk:"rules"` CreatedAt types.String `tfsdk:"created_at"` CreatedBy types.String `tfsdk:"created_by"` diff --git a/port/scorecard/refreshScorecardState.go b/port/scorecard/refreshScorecardState.go index a263ce15..79ded6da 100644 --- a/port/scorecard/refreshScorecardState.go +++ b/port/scorecard/refreshScorecardState.go @@ -7,8 +7,63 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/port-labs/terraform-provider-port-labs/v2/internal/cli" + "reflect" ) +func shouldRefreshLevels(stateLevels []Level, cliLevels []cli.Level) bool { + // When you create a scorecard in Port, the scorecard gets created with default levels. + // If your scorecard doesn't have the "levels" attribute, it means the scorecard is created with default levels behind the scenes. + // + // If the TF state has no levels and the Port existing levels are the default levels, This means both are considered + // to have default levels, And so we don't need to update them. + if len(stateLevels) == 0 && reflect.DeepEqual(cliLevels, DefaultCliLevels()) { + return false + } + // If the TF state has defined levels, we have to make sure that Port's existing levels are the same as the TF state levels. + // also, + // If TF state doesn't have levels and the Port existing levels are not the default ones, + // this means we have to make sure that Port's defined levels are the default levels, + // as the state without levels is considered to have default levels. + if len(stateLevels) > 0 || (len(stateLevels) == 0 && !reflect.DeepEqual(cliLevels, DefaultCliLevels())) { + return true + } + + return false +} + +func fromCliLevelsToTerraformLevels(cliLevels []cli.Level) []Level { + terraformLevels := []Level{} + for _, cliLevel := range cliLevels { + level := &Level{ + Color: types.StringValue(cliLevel.Color), + Title: types.StringValue(cliLevel.Title), + } + terraformLevels = append(terraformLevels, *level) + } + return terraformLevels +} + +func DefaultCliLevels() []cli.Level { + return []cli.Level{ + { + Color: "paleBlue", + Title: "Basic", + }, + { + Color: "bronze", + Title: "Bronze", + }, + { + Color: "silver", + Title: "Silver", + }, + { + Color: "gold", + Title: "Gold", + }, + } +} + func refreshScorecardState(ctx context.Context, state *ScorecardModel, s *cli.Scorecard, blueprintIdentifier string) { state.ID = types.StringValue(fmt.Sprintf("%s:%s", blueprintIdentifier, s.Identifier)) state.Identifier = types.StringValue(s.Identifier) @@ -42,5 +97,7 @@ func refreshScorecardState(ctx context.Context, state *ScorecardModel, s *cli.Sc } state.Rules = stateRules - + if shouldRefreshLevels(state.Levels, s.Levels) { + state.Levels = fromCliLevelsToTerraformLevels(s.Levels) + } } diff --git a/port/scorecard/resource.go b/port/scorecard/resource.go index cbad0c9b..fe24e12b 100644 --- a/port/scorecard/resource.go +++ b/port/scorecard/resource.go @@ -2,12 +2,10 @@ package scorecard import ( "context" - "fmt" "strings" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/port-labs/terraform-provider-port-labs/v2/internal/cli" ) @@ -55,7 +53,6 @@ func (r *ScorecardResource) Read(ctx context.Context, req resource.ReadRequest, resp.Diagnostics.AddError("failed to read scorecard", err.Error()) return } - refreshScorecardState(ctx, state, s, blueprintIdentifier) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) @@ -81,20 +78,11 @@ func (r *ScorecardResource) Create(ctx context.Context, req resource.CreateReque return } - writeScorecardComputedFieldsToState(state, sp) + refreshScorecardState(ctx, state, sp, state.Blueprint.ValueString()) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } -func writeScorecardComputedFieldsToState(state *ScorecardModel, wp *cli.Scorecard) { - state.ID = types.StringValue(fmt.Sprintf("%s:%s", wp.Blueprint, wp.Identifier)) - state.Identifier = types.StringValue(wp.Identifier) - state.CreatedAt = types.StringValue(wp.CreatedAt.String()) - state.CreatedBy = types.StringValue(wp.CreatedBy) - state.UpdatedAt = types.StringValue(wp.UpdatedAt.String()) - state.UpdatedBy = types.StringValue(wp.UpdatedBy) -} - func (r *ScorecardResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var state *ScorecardModel var previousState *ScorecardModel @@ -124,8 +112,8 @@ func (r *ScorecardResource) Update(ctx context.Context, req resource.UpdateReque resp.Diagnostics.AddError("failed to update the scorecard", err.Error()) return } - - writeScorecardComputedFieldsToState(state, sp) + + refreshScorecardState(ctx, state, sp, state.Blueprint.ValueString()) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } diff --git a/port/scorecard/schema.go b/port/scorecard/schema.go index 8e49f284..9ec23e98 100644 --- a/port/scorecard/schema.go +++ b/port/scorecard/schema.go @@ -13,6 +13,19 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) +func LevelSchema() map[string]schema.Attribute { + return map[string]schema.Attribute{ + "color": schema.StringAttribute{ + MarkdownDescription: "The color of the level", + Required: true, + }, + "title": schema.StringAttribute{ + MarkdownDescription: "The title of the level", + Required: true, + }, + } +} + func RuleSchema() map[string]schema.Attribute { return map[string]schema.Attribute{ "identifier": schema.StringAttribute{ @@ -26,9 +39,6 @@ func RuleSchema() map[string]schema.Attribute { "level": schema.StringAttribute{ MarkdownDescription: "The level of the rule", Required: true, - Validators: []validator.String{ - stringvalidator.OneOf("Bronze", "Silver", "Gold"), - }, }, "query": schema.SingleNestedAttribute{ MarkdownDescription: "The query of the rule", @@ -70,6 +80,13 @@ func ScorecardSchema() map[string]schema.Attribute { MarkdownDescription: "The title of the scorecard", Required: true, }, + "levels": schema.ListNestedAttribute{ + MarkdownDescription: "The levels of the scorecard. This overrides the default levels (Basic, Bronze, Silver, Gold) if provided", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: LevelSchema(), + }, + }, "rules": schema.ListNestedAttribute{ MarkdownDescription: "The rules of the scorecard", Required: true, @@ -119,7 +136,7 @@ See the [Port documentation](https://docs.getport.io/promote-scorecards/) for mo ## Example Usage -Create a parent blueprint with a child blueprint and an aggregation property to count the parent kids: +This will create a blueprint with a Scorecard measuring the readiness of a microservice. ` + "```hcl" + ` @@ -213,4 +230,117 @@ resource "port_scorecard" "readiness" { ] } + + +## Example Usage with Levels + +This will override the default levels (Basic, Bronze, Silver, Gold) with the provided levels: Not Ready, Partially Ready, Ready. + + +` + "```hcl" + ` + +resource "port_blueprint" "microservice" { + title = "microservice" + icon = "Terraform" + identifier = "microservice" + properties = { + string_props = { + "author" = { + title = "Author" + } + "url" = { + title = "URL" + } + } + boolean_props = { + "required" = { + type = "boolean" + } + } + number_props = { + "sum" = { + type = "number" + } + } + } +} + +resource "port_scorecard" "readiness" { + identifier = "Readiness" + title = "Readiness" + blueprint = port_blueprint.microservice.identifier + levels = [ + { + color = "red" + title = "No Ready" + }, + { + color = "yellow" + title = "Partially Ready" + }, + { + color = "green" + title = "Ready" + } + ] + rules = [ + { + identifier = "hasOwner" + title = "Has Owner" + level = "Ready" + query = { + combinator = "and" + conditions = [ + jsonencode({ + property = "$team" + operator = "isNotEmpty" + }), + jsonencode({ + property = "author", + operator : "=", + value : "myValue" + }) + ] + } + }, + { + identifier = "hasUrl" + title = "Has URL" + level = "Partially Ready" + query = { + combinator = "and" + conditions = [ + jsonencode({ + property = "url" + operator = "isNotEmpty" + }) + ] + } + }, + { + identifier = "checkSumIfRequired" + title = "Check Sum If Required" + level = "Partially Ready" + query = { + combinator = "or" + conditions = [ + jsonencode({ + property = "required" + operator : "=" + value : false + }), + jsonencode({ + property = "sum" + operator : ">" + value : 2 + }) + ] + } + } + ] + depends_on = [ + port_blueprint.microservice + ] +} + ` + "```" diff --git a/port/scorecard/scorecardResourceToPortBody.go b/port/scorecard/scorecardResourceToPortBody.go index 3c78abe7..7f078558 100644 --- a/port/scorecard/scorecardResourceToPortBody.go +++ b/port/scorecard/scorecardResourceToPortBody.go @@ -6,6 +6,18 @@ import ( "github.com/port-labs/terraform-provider-port-labs/v2/internal/cli" ) +func fromTerraformLevelsToCliLevels(tfLevels []Level) []cli.Level { + var levels []cli.Level + for _, stateLevel := range tfLevels { + level := &cli.Level{ + Color: stateLevel.Color.ValueString(), + Title: stateLevel.Title.ValueString(), + } + levels = append(levels, *level) + } + return levels +} + func scorecardResourceToPortBody(ctx context.Context, state *ScorecardModel) (*cli.Scorecard, error) { s := &cli.Scorecard{ Identifier: state.Identifier.ValueString(), @@ -43,5 +55,9 @@ func scorecardResourceToPortBody(ctx context.Context, state *ScorecardModel) (*c s.Rules = rules + if len(state.Levels) > 0 { + s.Levels = fromTerraformLevelsToCliLevels(state.Levels) + } + return s, nil }