diff --git a/docs/resources/port_scorecard.md b/docs/resources/port_scorecard.md new file mode 100644 index 00000000..1cd17e18 --- /dev/null +++ b/docs/resources/port_scorecard.md @@ -0,0 +1,63 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "port_scorecard Resource - terraform-provider-port-labs" +subcategory: "" +description: |- + scorecard resource +--- + +# port_scorecard (Resource) + +scorecard resource + + + +## Schema + +### Required + +- `blueprint` (String) The blueprint of the scorecard +- `identifier` (String) The identifier of the scorecard +- `rules` (Attributes List) The rules of the scorecard (see [below for nested schema](#nestedatt--rules)) +- `title` (String) The title of the scorecard + +### Read-Only + +- `created_at` (String) The creation date of the scorecard +- `created_by` (String) The creator of the scorecard +- `id` (String) The ID of this resource. +- `updated_at` (String) The last update date of the scorecard +- `updated_by` (String) The last updater of the scorecard + + + +### Nested Schema for `rules` + +Required: + +- `identifier` (String) The identifier of the rule +- `level` (String) The level of the rule +- `query` (Attributes) The query of the rule (see [below for nested schema](#nestedatt--rules--query)) +- `title` (String) The title of the rule + + + +### Nested Schema for `rules.query` + +Required: + +- `combinator` (String) The combinator of the query +- `conditions` (Attributes List) The conditions of the query (see [below for nested schema](#nestedatt--rules--query--conditions)) + + + +### Nested Schema for `rules.query.conditions` + +Required: + +- `operator` (String) The operator of the condition +- `property` (String) The property of the condition + +Optional: + +- `value` (String) The value of the condition diff --git a/docs/resources/port_webhook.md b/docs/resources/port_webhook.md index afbd2d35..0e5c06ca 100644 --- a/docs/resources/port_webhook.md +++ b/docs/resources/port_webhook.md @@ -32,6 +32,8 @@ Webhook resource - `id` (String) The ID of this resource. - `updated_at` (String) The last update date of the webhook - `updated_by` (String) The last updater of the webhook +- `url` (String) The url of the webhook +- `webhook_key` (String) The webhook key of the webhook ### Nested Schema for `mappings` diff --git a/internal/cli/models.go b/internal/cli/models.go index a9ad5bf8..3569601c 100644 --- a/internal/cli/models.go +++ b/internal/cli/models.go @@ -196,6 +196,32 @@ type ( Many *bool `json:"many,omitempty"` } + Scorecard struct { + Meta + Identifier string `json:"identifier,omitempty"` + Title string `json:"title,omitempty"` + Blueprint string `json:"blueprint,omitempty"` + Rules []Rule `json:"rules,omitempty"` + } + + Rule struct { + Identifier string `json:"identifier,omitempty"` + Title string `json:"title,omitempty"` + Level string `json:"level,omitempty"` + Query Query `json:"query,omitempty"` + } + + Query struct { + Combinator string `json:"combinator,omitempty"` + Conditions []Condition `json:"conditions,omitempty"` + } + + Condition struct { + Property string `json:"property,omitempty"` + Operator string `json:"operator,omitempty"` + Value *string `json:"value,omitempty"` + } + Webhook struct { Meta Identifier string `json:"identifier,omitempty"` @@ -240,6 +266,7 @@ type PortBody struct { Blueprint Blueprint `json:"blueprint"` Action Action `json:"action"` Integration Webhook `json:"integration"` + Scorecard Scorecard `json:"Scorecard"` } type PortProviderModel struct { @@ -248,3 +275,7 @@ type PortProviderModel struct { Token types.String `tfsdk:"token"` BaseUrl types.String `tfsdk:"base_url"` } + +type PortBodyDelete struct { + Ok bool `json:"ok"` +} diff --git a/internal/cli/scorecard.go b/internal/cli/scorecard.go new file mode 100644 index 00000000..333365c8 --- /dev/null +++ b/internal/cli/scorecard.go @@ -0,0 +1,92 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" +) + +func (c *PortClient) ReadScorecard(ctx context.Context, blueprintID string, scorecardID string) (*Scorecard, int, error) { + pb := &PortBody{} + url := "v1/blueprints/{blueprint_identifier}/scorecards/{scorecard_identifier}" + resp, err := c.Client.R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + SetResult(pb). + SetPathParam("blueprint_identifier", blueprintID). + SetPathParam("scorecard_identifier", scorecardID). + Get(url) + if err != nil { + return nil, resp.StatusCode(), err + } + if !pb.OK { + return nil, resp.StatusCode(), fmt.Errorf("failed to read scorecard, got: %s", resp.Body()) + } + return &pb.Scorecard, resp.StatusCode(), nil +} + +func (c *PortClient) CreateScorecard(ctx context.Context, blueprintID string, scorecard *Scorecard) (*Scorecard, error) { + url := "v1/blueprints/{blueprint_identifier}/scorecards" + resp, err := c.Client.R(). + SetBody(scorecard). + SetContext(ctx). + SetPathParam("blueprint_identifier", blueprintID). + Post(url) + if err != nil { + return nil, err + } + var pb PortBody + err = json.Unmarshal(resp.Body(), &pb) + if err != nil { + return nil, err + } + if !pb.OK { + return nil, fmt.Errorf("failed to create scorecard, got: %s", resp.Body()) + } + return &pb.Scorecard, nil +} + +func (c *PortClient) UpdateScorecard(ctx context.Context, blueprintID string, scorecardId string, scorecard *Scorecard) (*Scorecard, error) { + url := "v1/blueprints/{blueprint_identifier}/scorecards/{scorecard_identifier}" + resp, err := c.Client.R(). + SetBody(scorecard). + SetContext(ctx). + SetPathParam("blueprint_identifier", blueprintID). + SetPathParam("scorecard_identifier", scorecardId). + Put(url) + if err != nil { + return nil, err + } + var pb PortBody + err = json.Unmarshal(resp.Body(), &pb) + if err != nil { + return nil, err + } + if !pb.OK { + return nil, fmt.Errorf("failed to update scorecard, got: %s", resp.Body()) + } + return &pb.Scorecard, nil +} + +func (c *PortClient) DeleteScorecard(ctx context.Context, blueprintID string, scorecardID string) error { + url := "v1/blueprints/{blueprint_identifier}/scorecards/{scorecard_identifier}" + resp, err := c.Client.R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + SetPathParam("blueprint_identifier", blueprintID). + SetPathParam("scorecard_identifier", scorecardID). + Delete(url) + if err != nil { + return err + } + var pb PortBodyDelete + err = json.Unmarshal(resp.Body(), &pb) + if err != nil { + return err + } + + if !(pb.Ok) { + return fmt.Errorf("failed to delete scorecard. got:\n%s", string(resp.Body())) + } + return nil +} diff --git a/port/action/resource.go b/port/action/resource.go index c0450544..28cd67e4 100644 --- a/port/action/resource.go +++ b/port/action/resource.go @@ -56,7 +56,7 @@ func (r *ActionResource) Read(ctx context.Context, req resource.ReadRequest, res } blueprintIdentifier := state.Blueprint.ValueString() - a, statusCode, err := r.portClient.ReadAction(ctx, state.Blueprint.ValueString(), state.Identifier.ValueString()) + a, statusCode, err := r.portClient.ReadAction(ctx, blueprintIdentifier, state.Identifier.ValueString()) if err != nil { if statusCode == 404 { resp.State.RemoveResource(ctx) diff --git a/port/scorecard/model.go b/port/scorecard/model.go new file mode 100644 index 00000000..5c76783f --- /dev/null +++ b/port/scorecard/model.go @@ -0,0 +1,35 @@ +package scorecard + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type Condition struct { + Operator types.String `tfsdk:"operator"` + Property types.String `tfsdk:"property"` + Value types.String `tfsdk:"value"` +} + +type Query struct { + Combinator types.String `tfsdk:"combinator"` + Conditions []Condition `tfsdk:"conditions"` +} + +type Rule struct { + Identifier types.String `tfsdk:"identifier"` + Title types.String `tfsdk:"title"` + Level types.String `tfsdk:"level"` + Query *Query `tfsdk:"query"` +} + +type ScorecardModel struct { + ID types.String `tfsdk:"id"` + Identifier types.String `tfsdk:"identifier"` + Blueprint types.String `tfsdk:"blueprint"` + Title types.String `tfsdk:"title"` + Rules []Rule `tfsdk:"rules"` + CreatedAt types.String `tfsdk:"created_at"` + CreatedBy types.String `tfsdk:"created_by"` + UpdatedAt types.String `tfsdk:"updated_at"` + UpdatedBy types.String `tfsdk:"updated_by"` +} diff --git a/port/scorecard/refreshScorecardState.go b/port/scorecard/refreshScorecardState.go new file mode 100644 index 00000000..45621e22 --- /dev/null +++ b/port/scorecard/refreshScorecardState.go @@ -0,0 +1,50 @@ +package scorecard + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/port-labs/terraform-provider-port-labs/internal/cli" + "github.com/port-labs/terraform-provider-port-labs/internal/flex" +) + +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) + state.Blueprint = types.StringValue(blueprintIdentifier) + state.Title = types.StringValue(s.Title) + state.CreatedAt = types.StringValue(s.CreatedAt.String()) + state.CreatedBy = types.StringValue(s.CreatedBy) + state.UpdatedAt = types.StringValue(s.UpdatedAt.String()) + state.UpdatedBy = types.StringValue(s.UpdatedBy) + + stateRules := []Rule{} + for _, rule := range s.Rules { + stateRule := &Rule{ + Title: types.StringValue(rule.Title), + Level: types.StringValue(rule.Level), + Identifier: types.StringValue(rule.Identifier), + } + stateQuery := &Query{ + Combinator: types.StringValue(rule.Query.Combinator), + } + stateConditions := []Condition{} + for _, condition := range rule.Query.Conditions { + stateCondition := &Condition{ + Operator: types.StringValue(condition.Operator), + Property: types.StringValue(condition.Property), + Value: flex.GoStringToFramework(condition.Value), + } + stateConditions = append(stateConditions, *stateCondition) + } + stateQuery.Conditions = stateConditions + + stateRule.Query = stateQuery + + stateRules = append(stateRules, *stateRule) + } + + state.Rules = stateRules + +} diff --git a/port/scorecard/resouce.go b/port/scorecard/resouce.go new file mode 100644 index 00000000..c9c4cbe4 --- /dev/null +++ b/port/scorecard/resouce.go @@ -0,0 +1,149 @@ +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/internal/cli" +) + +// Ensure provider defined types fully satisfy framework interfaces +var _ resource.Resource = &ScorecardResource{} +var _ resource.ResourceWithImportState = &ScorecardResource{} + +func NewScorecardResource() resource.Resource { + return &ScorecardResource{} +} + +type ScorecardResource struct { + portClient *cli.PortClient +} + +func (r *ScorecardResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_scorecard" +} + +func (r *ScorecardResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + r.portClient = req.ProviderData.(*cli.PortClient) +} + +func (r *ScorecardResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state *ScorecardModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + identifier := state.Identifier.ValueString() + blueprintIdentifier := state.Blueprint.ValueString() + s, statusCode, err := r.portClient.ReadScorecard(ctx, blueprintIdentifier, identifier) + if err != nil { + if statusCode == 404 { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("failed to read scorecard", err.Error()) + return + } + + refreshScorecardState(ctx, state, s, blueprintIdentifier) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *ScorecardResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var state *ScorecardModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + s, err := scorecardResourceToPortBody(ctx, state) + if err != nil { + resp.Diagnostics.AddError("failed to convert scorecard resource to body", err.Error()) + return + } + + sp, err := r.portClient.CreateScorecard(ctx, state.Blueprint.ValueString(), s) + if err != nil { + resp.Diagnostics.AddError("failed to create scorecard", err.Error()) + return + } + + writeScorecardComputedFieldsToState(state, sp) + + 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 + resp.Diagnostics.Append(req.Plan.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + s, err := scorecardResourceToPortBody(ctx, state) + if err != nil { + resp.Diagnostics.AddError("failed to convert scorecard resource to body", err.Error()) + return + } + + sp, err := r.portClient.UpdateScorecard(ctx, state.Blueprint.ValueString(), state.Identifier.ValueString(), s) + if err != nil { + resp.Diagnostics.AddError("failed to update the scorecard", err.Error()) + return + } + + writeScorecardComputedFieldsToState(state, sp) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *ScorecardResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state *ScorecardModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.portClient.DeleteScorecard(ctx, state.Blueprint.ValueString(), state.Identifier.ValueString()) + + if err != nil { + resp.Diagnostics.AddError("failed to delete scorecard", err.Error()) + return + } + +} + +func (r *ScorecardResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, ":") + + if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { + resp.Diagnostics.AddError("invalid import ID", "import ID must be in the format :") + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("blueprint"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("identifier"), idParts[1])...) +} diff --git a/port/scorecard/resource_test.go b/port/scorecard/resource_test.go new file mode 100644 index 00000000..ff6be6ec --- /dev/null +++ b/port/scorecard/resource_test.go @@ -0,0 +1,298 @@ +package scorecard_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/port-labs/terraform-provider-port-labs/internal/acctest" + "github.com/port-labs/terraform-provider-port-labs/internal/utils" +) + +func testAccCreateBlueprintConfig(identifier string) string { + return fmt.Sprintf(` + resource "port_blueprint" "microservice" { + title = "TF test microservice" + icon = "Terraform" + identifier = "%s" + properties = { + string_props = { + "author" = { + type = "string" + title = "text" + } + "url" = { + type = "string" + title = "text" + } + } + } + } + `, identifier) +} + +func TestAccPortScorecardBasic(t *testing.T) { + blueprintIdentifier := utils.GenID() + scorecardIdentifier := utils.GenID() + var testAccActionConfigCreate = testAccCreateBlueprintConfig(blueprintIdentifier) + fmt.Sprintf(` + resource "port_scorecard" "test" { + identifier = "%s" + title = "Scorecard 1" + blueprint = "%s" + rules = [{ + identifier = "hasTeam" + title = "Has Team" + level = "Gold" + query = { + combinator = "and" + conditions = [{ + property = "$team" + operator = "isNotEmpty" + }] + } + }] + + depends_on = [ + port_blueprint.microservice + ] + }`, scorecardIdentifier, blueprintIdentifier) + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: acctest.ProviderConfig + testAccActionConfigCreate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("port_scorecard.test", "title", "Scorecard 1"), + resource.TestCheckResourceAttr("port_scorecard.test", "blueprint", blueprintIdentifier), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.#", "1"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.identifier", "hasTeam"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.title", "Has Team"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.level", "Gold"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.combinator", "and"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.#", "1"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.0.property", "$team"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.0.operator", "isNotEmpty"), + ), + }, + }, + }) +} + +func TestAccPortScorecard(t *testing.T) { + blueprintIdentifier := utils.GenID() + scorecardIdentifier := utils.GenID() + var testAccActionConfigCreate = testAccCreateBlueprintConfig(blueprintIdentifier) + fmt.Sprintf(` + resource "port_scorecard" "test" { + identifier = "%s" + title = "Scorecard 1" + blueprint = "%s" + rules = [{ + identifier = "test1" + title = "Test1" + level = "Gold" + query = { + combinator = "and" + conditions = [{ + property = "$team" + operator = "isNotEmpty" + }, + { + property = "author", + "operator" : "=", + "value" : "myValue" + }] + } + }, + { + identifier = "test2" + title = "Test2" + level = "Silver" + query = { + combinator = "and" + conditions = [{ + property = "url" + operator = "isNotEmpty" + }] + } + }] + + depends_on = [ + port_blueprint.microservice + ] + }`, scorecardIdentifier, blueprintIdentifier) + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: acctest.ProviderConfig + testAccActionConfigCreate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("port_scorecard.test", "title", "Scorecard 1"), + resource.TestCheckResourceAttr("port_scorecard.test", "blueprint", blueprintIdentifier), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.#", "2"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.identifier", "test1"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.title", "Test1"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.level", "Gold"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.combinator", "and"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.#", "2"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.0.property", "$team"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.0.operator", "isNotEmpty"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.1.property", "author"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.1.operator", "="), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.1.value", "myValue"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.1.identifier", "test2"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.1.title", "Test2"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.1.level", "Silver"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.1.query.combinator", "and"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.1.query.conditions.#", "1"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.1.query.conditions.0.property", "url"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.1.query.conditions.0.operator", "isNotEmpty"), + ), + }, + }, + }) +} + +func TestAccPortScorecardUpdate(t *testing.T) { + blueprintIdentifier := utils.GenID() + scorecardIdentifier := utils.GenID() + var testAccActionConfigCreate = testAccCreateBlueprintConfig(blueprintIdentifier) + fmt.Sprintf(` + resource "port_scorecard" "test" { + identifier = "%s" + title = "Scorecard 1" + blueprint = "%s" + rules = [{ + identifier = "hasTeam" + title = "Has Team" + level = "Gold" + query = { + combinator = "and" + conditions = [{ + property = "$team" + operator = "isNotEmpty" + }] + } + }] + + depends_on = [ + port_blueprint.microservice + ] + }`, scorecardIdentifier, blueprintIdentifier) + + var testAccActionConfigUpdate = testAccCreateBlueprintConfig(blueprintIdentifier) + fmt.Sprintf(` + resource "port_scorecard" "test" { + identifier = "%s" + title = "Scorecard 2" + blueprint = "%s" + rules = [{ + identifier = "hasTeam" + title = "Has Team" + level = "Bronze" + query = { + combinator = "or" + conditions = [{ + property = "$team" + operator = "isNotEmpty" + }] + } + }] + depends_on = [ + port_blueprint.microservice + ] + }`, scorecardIdentifier, blueprintIdentifier) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + + Steps: []resource.TestStep{ + { + Config: acctest.ProviderConfig + testAccActionConfigCreate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("port_scorecard.test", "title", "Scorecard 1"), + resource.TestCheckResourceAttr("port_scorecard.test", "blueprint", blueprintIdentifier), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.#", "1"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.identifier", "hasTeam"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.title", "Has Team"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.level", "Gold"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.combinator", "and"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.#", "1"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.0.property", "$team"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.0.operator", "isNotEmpty"), + ), + }, + { + Config: acctest.ProviderConfig + testAccActionConfigUpdate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("port_scorecard.test", "title", "Scorecard 2"), + resource.TestCheckResourceAttr("port_scorecard.test", "blueprint", blueprintIdentifier), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.#", "1"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.identifier", "hasTeam"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.title", "Has Team"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.level", "Bronze"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.combinator", "or"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.#", "1"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.0.property", "$team"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.0.operator", "isNotEmpty"), + ), + }, + }, + }) +} + +func TestAccPortScorecardImport(t *testing.T) { + blueprintIdentifier := utils.GenID() + scorecardIdentifier := utils.GenID() + var testAccActionConfigCreate = testAccCreateBlueprintConfig(blueprintIdentifier) + fmt.Sprintf(` + resource "port_scorecard" "test" { + identifier = "%s" + title = "Scorecard 1" + blueprint = "%s" + rules = [{ + identifier = "hasTeam" + title = "Has Team" + level = "Gold" + query = { + combinator = "and" + conditions = [{ + property = "$team" + operator = "isNotEmpty" + }] + } + }] + + depends_on = [ + port_blueprint.microservice + ] + }`, scorecardIdentifier, blueprintIdentifier) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + + Steps: []resource.TestStep{ + { + Config: acctest.ProviderConfig + testAccActionConfigCreate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("port_scorecard.test", "title", "Scorecard 1"), + resource.TestCheckResourceAttr("port_scorecard.test", "blueprint", blueprintIdentifier), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.#", "1"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.identifier", "hasTeam"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.title", "Has Team"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.level", "Gold"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.combinator", "and"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.#", "1"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.0.property", "$team"), + resource.TestCheckResourceAttr("port_scorecard.test", "rules.0.query.conditions.0.operator", "isNotEmpty"), + ), + }, + { + ResourceName: "port_scorecard.test", + ImportState: true, + ImportStateVerify: true, + ImportStateId: fmt.Sprintf("%s:%s", blueprintIdentifier, scorecardIdentifier), + }, + }, + }) +} diff --git a/port/scorecard/schema.go b/port/scorecard/schema.go new file mode 100644 index 00000000..3ce69277 --- /dev/null +++ b/port/scorecard/schema.go @@ -0,0 +1,124 @@ +package scorecard + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "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" +) + +func ConditionSchema() map[string]schema.Attribute { + return map[string]schema.Attribute{ + "operator": schema.StringAttribute{ + MarkdownDescription: "The operator of the condition", + Required: true, + }, + "property": schema.StringAttribute{ + MarkdownDescription: "The property of the condition", + Required: true, + }, + "value": schema.StringAttribute{ + MarkdownDescription: "The value of the condition", + Optional: true, + }, + } +} + +func RuleSchema() map[string]schema.Attribute { + return map[string]schema.Attribute{ + "identifier": schema.StringAttribute{ + MarkdownDescription: "The identifier of the rule", + Required: true, + }, + "title": schema.StringAttribute{ + MarkdownDescription: "The title of the rule", + Required: true, + }, + "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", + Required: true, + Attributes: map[string]schema.Attribute{ + "combinator": schema.StringAttribute{ + MarkdownDescription: "The combinator of the query", + Validators: []validator.String{ + stringvalidator.OneOf("and", "or"), + }, + Required: true, + }, + "conditions": schema.ListNestedAttribute{ + MarkdownDescription: "The conditions of the query", + Required: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: ConditionSchema(), + }, + }, + }, + }, + } +} +func ScorecardSchema() map[string]schema.Attribute { + return map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "identifier": schema.StringAttribute{ + MarkdownDescription: "The identifier of the scorecard", + Required: true, + }, + "blueprint": schema.StringAttribute{ + MarkdownDescription: "The blueprint of the scorecard", + Required: true, + }, + "title": schema.StringAttribute{ + MarkdownDescription: "The title of the scorecard", + Required: true, + }, + "rules": schema.ListNestedAttribute{ + MarkdownDescription: "The rules of the scorecard", + Required: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: RuleSchema(), + }, + }, + "created_at": schema.StringAttribute{ + MarkdownDescription: "The creation date of the scorecard", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "created_by": schema.StringAttribute{ + MarkdownDescription: "The creator of the scorecard", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "updated_at": schema.StringAttribute{ + MarkdownDescription: "The last update date of the scorecard", + Computed: true, + }, + "updated_by": schema.StringAttribute{ + MarkdownDescription: "The last updater of the scorecard", + Computed: true, + }, + } +} + +func (r *ScorecardResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "scorecard resource", + Attributes: ScorecardSchema(), + } +} diff --git a/port/scorecard/scorecardkResourceToPortBody.go b/port/scorecard/scorecardkResourceToPortBody.go new file mode 100644 index 00000000..2a3546db --- /dev/null +++ b/port/scorecard/scorecardkResourceToPortBody.go @@ -0,0 +1,50 @@ +package scorecard + +import ( + "context" + + "github.com/port-labs/terraform-provider-port-labs/internal/cli" +) + +func scorecardResourceToPortBody(ctx context.Context, state *ScorecardModel) (*cli.Scorecard, error) { + s := &cli.Scorecard{ + Identifier: state.Identifier.ValueString(), + Title: state.Title.ValueString(), + } + + rules := []cli.Rule{} + + for _, stateRule := range state.Rules { + rule := &cli.Rule{ + Level: stateRule.Level.ValueString(), + Identifier: stateRule.Identifier.ValueString(), + Title: stateRule.Title.ValueString(), + } + + query := &cli.Query{ + Combinator: stateRule.Query.Combinator.ValueString(), + } + + conditions := []cli.Condition{} + for _, stateCondition := range stateRule.Query.Conditions { + condition := &cli.Condition{ + Property: stateCondition.Property.ValueString(), + Operator: stateCondition.Operator.ValueString(), + } + + if !stateCondition.Value.IsNull() { + value := stateCondition.Value.ValueString() + condition.Value = &value + } + + conditions = append(conditions, *condition) + } + query.Conditions = conditions + rule.Query = *query + rules = append(rules, *rule) + } + + s.Rules = rules + + return s, nil +} diff --git a/provider/provider.go b/provider/provider.go index a007ea4b..5eff2c63 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -13,6 +13,7 @@ import ( "github.com/port-labs/terraform-provider-port-labs/port/action" "github.com/port-labs/terraform-provider-port-labs/port/blueprint" "github.com/port-labs/terraform-provider-port-labs/port/entity" + "github.com/port-labs/terraform-provider-port-labs/port/scorecard" "github.com/port-labs/terraform-provider-port-labs/port/webhook" "github.com/port-labs/terraform-provider-port-labs/version" ) @@ -126,6 +127,7 @@ func (p *PortLabsProvider) Resources(ctx context.Context) []func() resource.Reso entity.NewEntityResource, action.NewActionResource, webhook.NewWebhookResource, + scorecard.NewScorecardResource, } }