From c93b77a4ea3cc12d78e22938bb1d7bdf55f7ed6b Mon Sep 17 00:00:00 2001 From: Amit Shani Date: Tue, 16 Aug 2022 18:19:08 +0300 Subject: [PATCH] Blueprints resource (#2) * added blueprints resource refactor http client * added blueprints resource refactor http client * bump version * fix tests collision * support update blueprint and add test * CR * fix tests --- Makefile | 2 +- docs/index.md | 4 +- docs/resources/blueprint.md | 68 ++++ docs/resources/entity.md | 12 +- .../resources/port-labs_blueprint/main.tf | 33 ++ .../{port_entity => port-labs_entity}/main.tf | 0 port/cli/blueprint.go | 88 +++++ port/cli/client.go | 79 +++++ port/cli/entity.go | 59 ++++ port/cli/models.go | 66 ++++ port/cli/permission.go | 32 ++ port/cli/relation.go | 55 +++ port/entity.go | 20 -- port/provider.go | 49 +-- port/resource_port_blueprint.go | 328 ++++++++++++++++++ port/resource_port_blueprint_test.go | 183 ++++++++++ port/resource_port_entity.go | 88 ++--- 17 files changed, 1039 insertions(+), 127 deletions(-) create mode 100644 docs/resources/blueprint.md create mode 100644 examples/resources/port-labs_blueprint/main.tf rename examples/resources/{port_entity => port-labs_entity}/main.tf (100%) create mode 100644 port/cli/blueprint.go create mode 100644 port/cli/client.go create mode 100644 port/cli/entity.go create mode 100644 port/cli/models.go create mode 100644 port/cli/permission.go create mode 100644 port/cli/relation.go delete mode 100644 port/entity.go create mode 100644 port/resource_port_blueprint.go create mode 100644 port/resource_port_blueprint_test.go diff --git a/Makefile b/Makefile index 95b43910..896d0556 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ HOSTNAME=github.com NAMESPACE=port-labs NAME=port-labs BINARY=terraform-provider-${NAME} -VERSION=0.0.16 +VERSION=0.1.0 OS=$(shell go env GOOS) ARCH=$(shell go env GOARCH) OS_ARCH=${OS}_${ARCH} diff --git a/docs/index.md b/docs/index.md index 9752a393..c6d8345e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,12 +1,12 @@ --- # generated by https://github.com/hashicorp/terraform-plugin-docs -page_title: "port Provider" +page_title: "port-labs Provider" subcategory: "" description: |- --- -# port Provider +# port-labs Provider diff --git a/docs/resources/blueprint.md b/docs/resources/blueprint.md new file mode 100644 index 00000000..6a148eb8 --- /dev/null +++ b/docs/resources/blueprint.md @@ -0,0 +1,68 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "port-labs_blueprint Resource - terraform-provider-port-labs" +subcategory: "" +description: |- + Port blueprint +--- + +# port-labs_blueprint (Resource) + +Port blueprint + + + + +## Schema + +### Required + +- `icon` (String) The icon of the blueprint +- `identifier` (String) The identifier of the blueprint +- `properties` (Block Set, Min: 1) The metadata of the entity (see [below for nested schema](#nestedblock--properties)) +- `title` (String) The display name of the blueprint + +### Optional + +- `data_source` (String) The data source for entities of this blueprint +- `relations` (Block Set) The blueprints that are connected to this blueprint (see [below for nested schema](#nestedblock--relations)) + +### Read-Only + +- `created_at` (String) +- `created_by` (String) +- `id` (String) The ID of this resource. +- `updated_at` (String) +- `updated_by` (String) + + +### Nested Schema for `properties` + +Required: + +- `identifier` (String) The identifier of the property +- `title` (String) The name of this property +- `type` (String) The type of the property + +Optional: + +- `default` (String) The default value of the property +- `description` (String) The description of the property +- `format` (String) The format of the Property + + + +### Nested Schema for `relations` + +Required: + +- `target` (String) The id of the connected blueprint +- `title` (String) The display name of the relation + +Optional: + +- `identifier` (String) The identifier of the relation +- `many` (Boolean) Whether or not the relation is many +- `required` (Boolean) Whether or not the relation is required + + diff --git a/docs/resources/entity.md b/docs/resources/entity.md index 2930058c..46c98dcb 100644 --- a/docs/resources/entity.md +++ b/docs/resources/entity.md @@ -1,6 +1,6 @@ --- # generated by https://github.com/hashicorp/terraform-plugin-docs -page_title: "port-labs_entity Resource - terraform-provider-port" +page_title: "port-labs_entity Resource - terraform-provider-port-labs" subcategory: "" description: |- Port entity @@ -10,8 +10,9 @@ description: |- Port entity - + + ## Schema ### Required @@ -34,24 +35,25 @@ Port entity - `updated_by` (String) - ### Nested Schema for `properties` Required: - `name` (String) The name of this property -- `type` (String) The type of the properrty +- `type` (String) The type of the property Optional: - `items` (List of String) The list of items, in case the type of this property is a list - `value` (String) The value for this property - + ### Nested Schema for `relations` Required: - `identifier` (String) The id of the connected entity - `name` (String) The name of the relation + + diff --git a/examples/resources/port-labs_blueprint/main.tf b/examples/resources/port-labs_blueprint/main.tf new file mode 100644 index 00000000..87aa7e1a --- /dev/null +++ b/examples/resources/port-labs_blueprint/main.tf @@ -0,0 +1,33 @@ +resource "port-labs_blueprint" "environment" { + title = "Environment" + icon = "Environment" + identifier = "hedwig-env" + properties { + identifier = "name" + type = "string" + title = "name" + } + properties { + identifier = "docs-url" + type = "string" + title = "Docs URL" + format = "url" + } +} + +resource "port-labs_blueprint" "vm" { + title = "VM" + icon = "GPU" + identifier = "hedwig-vm" + properties { + identifier = "name" + type = "string" + title = "Name" + } + relations { + identifier = "environment" + title = "Test Relation" + required = "true" + target = port-labs_blueprint.environment.identifier + } +} diff --git a/examples/resources/port_entity/main.tf b/examples/resources/port-labs_entity/main.tf similarity index 100% rename from examples/resources/port_entity/main.tf rename to examples/resources/port-labs_entity/main.tf diff --git a/port/cli/blueprint.go b/port/cli/blueprint.go new file mode 100644 index 00000000..e1b8c4bf --- /dev/null +++ b/port/cli/blueprint.go @@ -0,0 +1,88 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" +) + +func (c *PortClient) ReadBlueprint(ctx context.Context, id string) (*Blueprint, error) { + pb := &PortBody{} + url := "v0.1/blueprints/{identifier}" + resp, err := c.Client.R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + SetQueryParam("exclude_mirror_properties", "true"). + SetResult(pb). + SetPathParam("identifier", id). + Get(url) + if err != nil { + return nil, err + } + if !pb.OK { + return nil, fmt.Errorf("failed to read blueprint, got: %s", resp.Body()) + } + return &pb.Blueprint, nil +} + +func (c *PortClient) CreateBlueprint(ctx context.Context, b *Blueprint) (*Blueprint, error) { + url := "v0.1/blueprints" + resp, err := c.Client.R(). + SetBody(b). + SetContext(ctx). + 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 blueprint, got: %s", resp.Body()) + } + return &pb.Blueprint, nil +} + +func (c *PortClient) UpdateBlueprint(ctx context.Context, b *Blueprint, id string) (*Blueprint, error) { + url := "v0.1/blueprints/{identifier}" + resp, err := c.Client.R(). + SetBody(b). + SetContext(ctx). + SetPathParam("identifier", id). + 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 create blueprint, got: %s", resp.Body()) + } + return &pb.Blueprint, nil +} + +func (c *PortClient) DeleteBlueprint(ctx context.Context, id string) error { + url := "v0.1/blueprints/{identifier}" + resp, err := c.Client.R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + SetPathParam("identifier", id). + Delete(url) + if err != nil { + return err + } + responseBody := make(map[string]interface{}) + err = json.Unmarshal(resp.Body(), &responseBody) + if err != nil { + return err + } + if !(responseBody["ok"].(bool)) { + return fmt.Errorf("failed to delete blueprint. got:\n%s", string(resp.Body())) + } + return nil +} diff --git a/port/cli/client.go b/port/cli/client.go new file mode 100644 index 00000000..908a22f1 --- /dev/null +++ b/port/cli/client.go @@ -0,0 +1,79 @@ +package cli + +import ( + "context" + "encoding/json" + "strings" + + "github.com/go-resty/resty/v2" +) + +type ( + Option func(*PortClient) + PortClient struct { + Client *resty.Client + ClientID string + } +) + +func New(baseURL string, opts ...Option) (*PortClient, error) { + c := &PortClient{ + Client: resty.New(). + SetBaseURL(baseURL). + SetRetryCount(5). + SetRetryWaitTime(300). + // retry when create permission fails because scopes are created async-ly and sometimes (mainly in tests) the scope doesn't exist yet. + AddRetryCondition(func(r *resty.Response, err error) bool { + if err != nil { + return true + } + if !strings.Contains(r.Request.URL, "/permissions") { + return false + } + b := make(map[string]interface{}) + err = json.Unmarshal(r.Body(), &b) + return err != nil || b["ok"] != true + }), + } + for _, opt := range opts { + opt(c) + } + return c, nil +} + +func (c *PortClient) Authenticate(ctx context.Context, clientID, clientSecret string) (string, error) { + url := "v0.1/auth/access_token" + resp, err := c.Client.R(). + SetContext(ctx). + SetQueryParam("client_id", clientID). + SetQueryParam("client_secret", clientSecret). + Get(url) + if err != nil { + return "", err + } + var tokenResp AccessTokenResponse + err = json.Unmarshal(resp.Body(), &tokenResp) + if err != nil { + return "", err + } + c.Client.SetAuthToken(tokenResp.AccessToken) + return tokenResp.AccessToken, nil +} + +func WithHeader(key, val string) Option { + return func(pc *PortClient) { + pc.Client.SetHeader(key, val) + } +} + +func WithClientID(clientID string) Option { + return func(pc *PortClient) { + pc.ClientID = clientID + } +} + +func WithToken(token string) Option { + return func(pc *PortClient) { + pc.Client.SetAuthToken(token) + } +} diff --git a/port/cli/entity.go b/port/cli/entity.go new file mode 100644 index 00000000..5e3fc5c6 --- /dev/null +++ b/port/cli/entity.go @@ -0,0 +1,59 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" +) + +func (c *PortClient) ReadEntity(ctx context.Context, id string) (*Entity, error) { + url := "v0.1/entities/{identifier}" + resp, err := c.Client.R(). + SetHeader("Accept", "application/json"). + SetQueryParam("exclude_mirror_properties", "true"). + SetPathParam("identifier", id). + Get(url) + if err != nil { + return nil, err + } + var pb PortBody + err = json.Unmarshal(resp.Body(), &pb) + if err != nil { + return nil, err + } + return &pb.Entity, nil +} + +func (c *PortClient) CreateEntity(ctx context.Context, e *Entity) (*Entity, error) { + url := "v0.1/entities" + pb := &PortBody{} + resp, err := c.Client.R(). + SetBody(e). + SetQueryParam("upsert", "true"). + SetResult(&pb). + Post(url) + if err != nil { + return nil, err + } + if !pb.OK { + return nil, fmt.Errorf("failed to create entity, got: %s", resp.Body()) + } + return &pb.Entity, nil +} + +func (c *PortClient) DeleteEntity(ctx context.Context, id string) error { + url := "v0.1/entities/{identifier}" + pb := &PortBody{} + resp, err := c.Client.R(). + SetHeader("Accept", "application/json"). + SetPathParam("identifier", id). + SetResult(pb). + Delete(url) + if err != nil { + return err + } + if !pb.OK { + return fmt.Errorf("failed to delete entity, got: %s", resp.Body()) + } + return nil +} diff --git a/port/cli/models.go b/port/cli/models.go new file mode 100644 index 00000000..c1c82bd1 --- /dev/null +++ b/port/cli/models.go @@ -0,0 +1,66 @@ +package cli + +import ( + "time" +) + +type ( + Meta struct { + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + UpdatedBy string `json:"updatedBy,omitempty"` + } + AccessTokenResponse struct { + Ok bool `json:"ok"` + AccessToken string `json:"accessToken"` + ExpiresIn int64 `json:"expiresIn"` + TokenType string `json:"tokenType"` + } + Entity struct { + Meta + Identifier string `json:"identifier,omitempty"` + Title string `json:"title"` + Blueprint string `json:"blueprint"` + Properties map[string]interface{} `json:"properties"` + Relations map[string]string `json:"relations"` + // TODO: add the rest of the fields. + } + + BlueprintProperty struct { + Type string `json:"type,omitempty"` + Title string `json:"title,omitempty"` + Identifier string `json:"identifier,omitempty"` + Default string `json:"default,omitempty"` + Format string `json:"format,omitempty"` + Description string `json:"description,omitempty"` + } + + BlueprintSchema struct { + Properties map[string]BlueprintProperty `json:"properties"` + } + + Blueprint struct { + Meta + Identifier string `json:"identifier,omitempty"` + Title string `json:"title"` + Icon string `json:"icon"` + DataSource string `json:"dataSource"` + Schema BlueprintSchema `json:"schema"` + // TODO: relations + } + + Relation struct { + Identifier string `json:"identifier,omitempty"` + Title string `json:"title,omitempty"` + Target string `json:"target,omitempty"` + Required bool `json:"required,omitempty"` + Many bool `json:"many,omitempty"` + } +) + +type PortBody struct { + OK bool `json:"ok"` + Entity Entity `json:"entity"` + Blueprint Blueprint `json:"blueprint"` +} diff --git a/port/cli/permission.go b/port/cli/permission.go new file mode 100644 index 00000000..29b6a3cb --- /dev/null +++ b/port/cli/permission.go @@ -0,0 +1,32 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" +) + +func (c *PortClient) CreatePermissions(ctx context.Context, clientID string, scopes ...string) error { + url := "v0.1/apps/{app_id}/permissions" + resp, err := c.Client.R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + SetHeader("Content-Type", "application/json"). + SetPathParam("app_id", clientID). + SetBody(map[string]interface{}{ + "permissions": scopes, + }). + Post(url) + if err != nil { + return err + } + b := make(map[string]interface{}) + err = json.Unmarshal(resp.Body(), &b) + if err != nil { + return err + } + if !b["ok"].(bool) { + return fmt.Errorf("failed to create permissions: %s", resp.Body()) + } + return nil +} diff --git a/port/cli/relation.go b/port/cli/relation.go new file mode 100644 index 00000000..711fcd2e --- /dev/null +++ b/port/cli/relation.go @@ -0,0 +1,55 @@ +package cli + +import ( + "context" + "fmt" +) + +func (c *PortClient) CreateRelation(ctx context.Context, bpID string, r *Relation) (string, error) { + url := "v0.1/blueprints/{identifier}/relations" + result := map[string]interface{}{} + resp, err := c.Client.R(). + SetBody(r). + SetContext(ctx). + SetResult(&result). + SetPathParam("identifier", bpID). + Post(url) + if err != nil { + return "", err + } + if !result["ok"].(bool) { + return "", fmt.Errorf("failed to create relation, got: %s", resp.Body()) + } + return result["identifier"].(string), nil +} + +func (c *PortClient) ReadRelations(ctx context.Context, blueprintID string) ([]*Relation, error) { + url := "v0.1/relations" + result := map[string]interface{}{} + resp, err := c.Client.R(). + SetContext(ctx). + SetResult(&result). + Get(url) + if err != nil { + return nil, err + } + if !result["ok"].(bool) { + return nil, fmt.Errorf("failed to create relation, got: %s", resp.Body()) + } + allRelations := result["relations"].([]interface{}) + bpRelations := make([]*Relation, 0) + for _, relation := range allRelations { + r := relation.(map[string]interface{}) + if r["source"] != blueprintID { + continue + } + bpRelations = append(bpRelations, &Relation{ + Target: r["target"].(string), + Required: r["required"].(bool), + Many: r["many"].(bool), + Title: r["title"].(string), + Identifier: r["identifier"].(string), + }) + } + return bpRelations, nil +} diff --git a/port/entity.go b/port/entity.go deleted file mode 100644 index edd20075..00000000 --- a/port/entity.go +++ /dev/null @@ -1,20 +0,0 @@ -package port - -import "time" - -type Entity struct { - Identifier string `json:"identifier"` - Title string `json:"title"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - CreatedBy string `json:"createdBy"` - UpdatedBy string `json:"updatedBy"` - Properties map[string]interface{} `json:"properties"` - - // TODO: add the rest of the fields. -} - -type PortBody struct { - OK bool `json:"ok"` - Entity Entity `json:"entity"` -} diff --git a/port/provider.go b/port/provider.go index 9864c9e8..2071b889 100644 --- a/port/provider.go +++ b/port/provider.go @@ -2,11 +2,10 @@ package port import ( "context" - "encoding/json" - "github.com/go-resty/resty/v2" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/port-labs/terraform-provider-port-labs/port/cli" ) func Provider() *schema.Provider { @@ -36,50 +35,30 @@ func Provider() *schema.Provider { }, }, ResourcesMap: map[string]*schema.Resource{ - "port-labs_entity": newEntityResource(), + "port-labs_entity": newEntityResource(), + "port-labs_blueprint": newBlueprintResource(), }, DataSourcesMap: map[string]*schema.Resource{}, ConfigureContextFunc: providerConfigure, } } -type AccessTokenResponse struct { - Ok bool `json:"ok"` - AccessToken string `json:"accessToken"` - ExpiresIn int64 `json:"expiresIn"` - TokenType string `json:"tokenType"` -} - func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { var diags diag.Diagnostics secret := d.Get("secret").(string) clientID := d.Get("client_id").(string) - token := d.Get("token").(string) + // TODO: verify token or regenerate token + // token := d.Get("token").(string) baseURL := d.Get("base_url").(string) - client := resty.New() - client.SetBaseURL(baseURL) - if token == "" { - url := "v0.1/auth/access_token" - resp, err := client.R(). - SetQueryParam("client_id", clientID). - SetQueryParam("client_secret", secret). - Get(url) - if err != nil { - return nil, diag.FromErr(err) - } - - var tokenResp AccessTokenResponse - err = json.Unmarshal(resp.Body(), &tokenResp) - if err != nil { - return nil, diag.FromErr(err) - } - token = tokenResp.AccessToken - d.Set("token", tokenResp.AccessToken) + c, err := cli.New(baseURL, cli.WithHeader("User-Agent", "terraform-provider-port-labs/0.1"), cli.WithClientID(clientID)) + if err != nil { + return nil, diag.FromErr(err) + } + token, err := c.Authenticate(ctx, clientID, secret) + if err != nil { + return nil, diag.FromErr(err) } - // else { - // // TODO: verify token or regenerate - // } - client.SetAuthToken(token) - return client, diags + d.Set("token", token) + return c, diags } diff --git a/port/resource_port_blueprint.go b/port/resource_port_blueprint.go new file mode 100644 index 00000000..e63dce2b --- /dev/null +++ b/port/resource_port_blueprint.go @@ -0,0 +1,328 @@ +package port + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/port-labs/terraform-provider-port-labs/port/cli" +) + +var ICONS = []string{"Actions", "Airflow", "Ansible", "Argo", "AuditLog", "Aws", "Azure", "Blueprint", "Bucket", "Cloud", "Cluster", "CPU", "Customer", "Datadog", "Day2Operation", "DefaultEntity", "DefaultProperty", "DeployedAt", "Deployment", "DevopsTool", "Docs", "Environment", "Git", "Github", "GitVersion", "GoogleCloud", "GPU", "Grafana", "Infinity", "Jenkins", "Lambda", "Link", "Lock", "Microservice", "Moon", "Node", "Okta", "Package", "Permission", "Relic", "Server", "Service", "Team", "Terraform", "User"} + +func newBlueprintResource() *schema.Resource { + return &schema.Resource{ + Description: "Port blueprint", + CreateContext: createBlueprint, + UpdateContext: createBlueprint, + ReadContext: readBlueprint, + DeleteContext: deleteBlueprint, + Schema: map[string]*schema.Schema{ + "identifier": { + Type: schema.TypeString, + Description: "The identifier of the blueprint", + Required: true, + }, + "title": { + Type: schema.TypeString, + Description: "The display name of the blueprint", + Required: true, + }, + "data_source": { + Type: schema.TypeString, + Description: "The data source for entities of this blueprint", + Default: "Port", + Optional: true, + }, + "icon": { + Type: schema.TypeString, + Description: "The icon of the blueprint", + ValidateFunc: validation.StringInSlice(ICONS, false), + Required: true, + }, + "relations": { + Description: "The blueprints that are connected to this blueprint", + Type: schema.TypeSet, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "identifier": { + Type: schema.TypeString, + Optional: true, + Description: "The identifier of the relation", + }, + "title": { + Type: schema.TypeString, + Required: true, + Description: "The display name of the relation", + }, + "target": { + Type: schema.TypeString, + Required: true, + Description: "The id of the connected blueprint", + }, + "required": { + Type: schema.TypeBool, + Optional: true, + Description: "Whether or not the relation is required", + }, + "many": { + Type: schema.TypeBool, + Optional: true, + Description: "Whether or not the relation is many", + }, + }, + }, + Optional: true, + }, + "properties": { + Description: "The metadata of the entity", + Type: schema.TypeSet, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "identifier": { + Type: schema.TypeString, + Required: true, + Description: "The identifier of the property", + }, + "title": { + Type: schema.TypeString, + Required: true, + Description: "The name of this property", + }, + "type": { + Type: schema.TypeString, + Required: true, + Description: "The type of the property", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "The description of the property", + }, + "default": { + Type: schema.TypeString, + Optional: true, + Description: "The default value of the property", + }, + "format": { + Type: schema.TypeString, + Optional: true, + Description: "The format of the Property", + }, + }, + }, + Required: true, + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + }, + "created_by": { + Type: schema.TypeString, + Computed: true, + }, + "updated_by": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func readBlueprint(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + c := m.(*cli.PortClient) + b, err := c.ReadBlueprint(ctx, d.Id()) + if err != nil { + return diag.FromErr(err) + } + writeBlueprintFieldsToResource(d, b) + relations, err := c.ReadRelations(ctx, d.Id()) + if err != nil { + return diag.FromErr(err) + } + writeBlueprintRelationsToResource(d, relations) + return diags +} + +func writeBlueprintRelationsToResource(d *schema.ResourceData, relations []*cli.Relation) { + rels := schema.Set{F: func(i interface{}) int { + id := (i.(map[string]interface{}))["identifier"].(string) + return schema.HashString(id) + }} + for _, v := range relations { + r := map[string]interface{}{ + "identifier": v.Identifier, + "title": v.Title, + "target": v.Target, + "required": v.Required, + "many": v.Many, + } + rels.Add(r) + } + d.Set("relations", &rels) +} + +func writeBlueprintFieldsToResource(d *schema.ResourceData, b *cli.Blueprint) { + d.SetId(b.Identifier) + d.Set("title", b.Title) + d.Set("icon", b.Icon) + d.Set("data_source", b.DataSource) + d.Set("created_at", b.CreatedAt.String()) + d.Set("created_by", b.CreatedBy) + d.Set("updated_at", b.UpdatedAt.String()) + d.Set("updated_by", b.UpdatedBy) + properties := schema.Set{F: func(i interface{}) int { + id := (i.(map[string]interface{}))["identifier"].(string) + return schema.HashString(id) + }} + for k, v := range b.Schema.Properties { + p := map[string]interface{}{} + p["identifier"] = k + p["title"] = v.Title + p["type"] = v.Type + p["description"] = v.Description + p["default"] = v.Default + p["format"] = v.Format + properties.Add(p) + } + d.Set("properties", &properties) +} + +func blueprintResourceToBody(d *schema.ResourceData) (*cli.Blueprint, error) { + b := &cli.Blueprint{} + if identifier, ok := d.GetOk("identifier"); ok { + b.Identifier = identifier.(string) + } + id := d.Id() + if id != "" { + b.Identifier = id + } + + b.Title = d.Get("title").(string) + b.Icon = d.Get("icon").(string) + if ds, ok := d.GetOk("data_source"); ok { + b.DataSource = ds.(string) + } + + props := d.Get("properties").(*schema.Set) + properties := make(map[string]cli.BlueprintProperty, props.Len()) + for _, prop := range props.List() { + p := prop.(map[string]interface{}) + propFields := cli.BlueprintProperty{} + if t, ok := p["type"]; ok && t != "" { + propFields.Type = t.(string) + } + if t, ok := p["title"]; ok && t != "" { + propFields.Title = t.(string) + } + if d, ok := p["description"]; ok && d != "" { + propFields.Description = d.(string) + } + if d, ok := p["default"]; ok && d != "" { + propFields.Default = d.(string) + } + if f, ok := p["format"]; ok && f != "" { + propFields.Format = f.(string) + } + properties[p["identifier"].(string)] = propFields + } + + b.Schema = cli.BlueprintSchema{Properties: properties} + return b, nil +} + +// patchBlueprintDeletePermission is a workaround for a bug where the creator of a blueprint does not have the permission to delete it. +func patchBlueprintDeletePermission(ctx context.Context, client *cli.PortClient, bpID string) error { + return client.CreatePermissions(ctx, client.ClientID, fmt.Sprintf("delete:blueprints:%s", bpID)) +} + +// patchBlueprintDeletePermission is a workaround for a bug where the creator of a blueprint does not have the permission to delete it. +func patchBlueprintUpdatePermission(ctx context.Context, client *cli.PortClient, bpID string) error { + return client.CreatePermissions(ctx, client.ClientID, fmt.Sprintf("update:blueprints:%s", bpID)) +} + +func deleteBlueprint(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + c := m.(*cli.PortClient) + err := patchBlueprintDeletePermission(ctx, c, d.Id()) + if err != nil { + return diag.FromErr(err) + } + err = c.DeleteBlueprint(ctx, d.Id()) + if err != nil { + return diag.FromErr(err) + } + return diags +} + +func createRelations(ctx context.Context, d *schema.ResourceData, m interface{}) error { + c := m.(*cli.PortClient) + relations, ok := d.GetOk("relations") + if !ok { + return nil + } + for _, relation := range relations.(*schema.Set).List() { + relation := relation.(map[string]interface{}) + r := &cli.Relation{} + if t, ok := relation["title"]; ok { + r.Title = t.(string) + } + if t, ok := relation["target"]; ok { + r.Target = t.(string) + } + if i, ok := relation["identifier"]; ok { + r.Identifier = i.(string) + } + if req, ok := relation["required"]; ok { + r.Required = req.(bool) + } + _, err := c.CreateRelation(ctx, d.Id(), r) + if err != nil { + return err + } + } + return nil +} + +func createBlueprint(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + c := m.(*cli.PortClient) + b, err := blueprintResourceToBody(d) + if err != nil { + return diag.FromErr(err) + } + var bp *cli.Blueprint + if d.Id() != "" { + err = patchBlueprintUpdatePermission(ctx, c, d.Id()) + if err != nil { + return diag.FromErr(err) + } + bp, err = c.UpdateBlueprint(ctx, b, d.Id()) + } else { + bp, err = c.CreateBlueprint(ctx, b) + } + if err != nil { + return diag.FromErr(err) + } + writeBlueprintComputedFieldsToResource(d, bp) + err = createRelations(ctx, d, m) + if err != nil { + return diag.FromErr(err) + } + return diags +} + +func writeBlueprintComputedFieldsToResource(d *schema.ResourceData, b *cli.Blueprint) { + d.SetId(b.Identifier) + d.Set("created_at", b.CreatedAt.String()) + d.Set("created_by", b.CreatedBy) + d.Set("updated_at", b.UpdatedAt.String()) + d.Set("updated_by", b.UpdatedBy) +} diff --git a/port/resource_port_blueprint_test.go b/port/resource_port_blueprint_test.go new file mode 100644 index 00000000..0e77d20b --- /dev/null +++ b/port/resource_port_blueprint_test.go @@ -0,0 +1,183 @@ +package port + +import ( + "fmt" + "testing" + + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func genID() string { + id, err := uuid.GenerateUUID() + if err != nil { + panic(err) + } + return fmt.Sprintf("t-%s", id[:18]) +} + +func TestAccPortBlueprint(t *testing.T) { + identifier := genID() + var testAccActionConfigCreate = fmt.Sprintf(` + provider "port-labs" {} + resource "port-labs_blueprint" "microservice" { + title = "TF Provider Test BP0" + icon = "Terraform" + identifier = "%s" + properties { + identifier = "text" + type = "string" + title = "text" + } + properties { + identifier = "bool" + type = "boolean" + title = "boolean" + } + properties { + identifier = "number" + type = "number" + title = "number" + } + properties { + identifier = "obj" + type = "object" + title = "object" + } + properties { + identifier = "array" + type = "array" + title = "array" + } + } +`, identifier) + resource.Test(t, resource.TestCase{ + Providers: map[string]*schema.Provider{ + "port-labs": Provider(), + }, + Steps: []resource.TestStep{ + { + Config: testAccActionConfigCreate, + }, + }, + }) +} + +func TestAccPortBlueprintWithRelation(t *testing.T) { + identifier1 := genID() + identifier2 := genID() + var testAccActionConfigCreate = fmt.Sprintf(` + provider "port-labs" {} + resource "port-labs_blueprint" "microservice1" { + title = "TF Provider Test BP2" + icon = "Terraform" + identifier = "%s" + properties { + identifier = "text" + type = "string" + title = "text" + } + } + resource "port-labs_blueprint" "microservice2" { + title = "TF Provider Test BP3" + icon = "Terraform" + identifier = "%s" + properties { + identifier = "text" + type = "string" + title = "text" + } + relations { + identifier = "test-rel" + title = "Test Relation" + target = port-labs_blueprint.microservice1.identifier + } + } +`, identifier1, identifier2) + resource.Test(t, resource.TestCase{ + Providers: map[string]*schema.Provider{ + "port-labs": Provider(), + }, + Steps: []resource.TestStep{ + { + Config: testAccActionConfigCreate, + }, + }, + }) +} + +func TestAccPortBlueprintUpdate(t *testing.T) { + identifier := genID() + var testAccActionConfigCreate = fmt.Sprintf(` + provider "port-labs" {} + resource "port-labs_blueprint" "microservice1" { + title = "TF Provider Test BP2" + icon = "Terraform" + identifier = "%s" + properties { + identifier = "text" + type = "string" + title = "text" + } + } +`, identifier) + var testAccActionConfigUpdate = fmt.Sprintf(` + provider "port-labs" {} + resource "port-labs_blueprint" "microservice1" { + title = "TF Provider Test BP2" + icon = "Terraform" + identifier = "%s" + properties { + identifier = "text" + type = "string" + title = "text" + } + properties { + identifier = "number" + type = "number" + title = "num" + } + } +`, identifier) + var testAccActionConfigUpdateAgain = fmt.Sprintf(` + provider "port-labs" {} + resource "port-labs_blueprint" "microservice1" { + title = "TF Provider Test BP2" + icon = "Terraform" + identifier = "%s" + properties { + identifier = "number" + type = "number" + title = "num" + } + } +`, identifier) + resource.Test(t, resource.TestCase{ + Providers: map[string]*schema.Provider{ + "port-labs": Provider(), + }, + Steps: []resource.TestStep{ + { + Config: testAccActionConfigCreate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("port-labs_blueprint.microservice1", "properties.0.title", "text"), + ), + }, + { + Config: testAccActionConfigUpdate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("port-labs_blueprint.microservice1", "properties.0.title", "num"), + resource.TestCheckResourceAttr("port-labs_blueprint.microservice1", "properties.1.title", "text"), + ), + }, + { + Config: testAccActionConfigUpdateAgain, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("port-labs_blueprint.microservice1", "properties.0.title", "num"), + resource.TestCheckResourceAttr("port-labs_blueprint.microservice1", "properties.#", "1"), + ), + }, + }, + }) +} diff --git a/port/resource_port_entity.go b/port/resource_port_entity.go index b5c365eb..60cf8694 100644 --- a/port/resource_port_entity.go +++ b/port/resource_port_entity.go @@ -6,10 +6,10 @@ import ( "fmt" "strconv" - "github.com/go-resty/resty/v2" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/port-labs/terraform-provider-port-labs/port/cli" ) func newEntityResource() *schema.Resource { @@ -68,7 +68,7 @@ func newEntityResource() *schema.Resource { Type: schema.TypeString, ValidateFunc: validation.StringInSlice([]string{"number", "string", "boolean", "array", "object"}, false), Required: true, - Description: "The type of the properrty", + Description: "The type of the property", }, "value": { Type: schema.TypeString, @@ -109,23 +109,11 @@ func newEntityResource() *schema.Resource { func deleteEntity(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { var diags diag.Diagnostics - client := m.(*resty.Client) - url := "v0.1/entities/{identifier}" - resp, err := client.R(). - SetHeader("Accept", "application/json"). - SetPathParam("identifier", d.Id()). - Delete(url) + c := m.(*cli.PortClient) + err := c.DeleteEntity(ctx, d.Id()) if err != nil { return diag.FromErr(err) } - responseBody := make(map[string]interface{}) - err = json.Unmarshal(resp.Body(), &responseBody) - if err != nil { - return diag.FromErr(err) - } - if !(responseBody["ok"].(bool)) { - return diag.FromErr(fmt.Errorf("failed to delete entity. got:\n%s", string(resp.Body()))) - } return diags } @@ -147,44 +135,39 @@ func convert(prop map[string]interface{}) (interface{}, error) { return "", fmt.Errorf("unsupported type %s", valType) } -func entityResourceToBody(d *schema.ResourceData) (map[string]interface{}, error) { - body := make(map[string]interface{}) +func entityResourceToBody(d *schema.ResourceData) (*cli.Entity, error) { + e := &cli.Entity{} if identifier, ok := d.GetOk("identifier"); ok { - body["identifier"] = identifier + e.Identifier = identifier.(string) } id := d.Id() if id != "" { - body["identifier"] = id + e.Identifier = id } - body["title"] = d.Get("title").(string) - body["blueprint"] = d.Get("blueprint").(string) - body["blueprintIdentifier"] = d.Get("blueprint").(string) + e.Title = d.Get("title").(string) + e.Blueprint = d.Get("blueprint").(string) rels := d.Get("relations").(*schema.Set) relations := make(map[string]string) for _, rel := range rels.List() { r := rel.(map[string]interface{}) - bpName := r["name"].(string) - relID := r["identifier"].(string) - relations[bpName] = relID + relations[r["name"].(string)] = r["identifier"].(string) } - body["relations"] = relations + e.Relations = relations props := d.Get("properties").(*schema.Set) - properties := map[string]interface{}{} + properties := make(map[string]interface{}, props.Len()) for _, prop := range props.List() { p := prop.(map[string]interface{}) - var propValue interface{} - var err error - propValue, err = convert(p) + propValue, err := convert(p) if err != nil { return nil, err } properties[p["name"].(string)] = propValue } - body["properties"] = properties - return body, nil + e.Properties = properties + return e, nil } -func writeEntityComputedFieldsToResource(d *schema.ResourceData, e Entity) { +func writeEntityComputedFieldsToResource(d *schema.ResourceData, e *cli.Entity) { d.SetId(e.Identifier) d.Set("created_at", e.CreatedAt.String()) d.Set("created_by", e.CreatedBy) @@ -192,7 +175,7 @@ func writeEntityComputedFieldsToResource(d *schema.ResourceData, e Entity) { d.Set("updated_by", e.UpdatedBy) } -func writeEntityFieldsToResource(d *schema.ResourceData, e Entity) { +func writeEntityFieldsToResource(d *schema.ResourceData, e *cli.Entity) { d.SetId(e.Identifier) d.Set("title", e.Title) d.Set("created_at", e.CreatedAt.String()) @@ -240,49 +223,26 @@ func writeEntityFieldsToResource(d *schema.ResourceData, e Entity) { func createEntity(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { var diags diag.Diagnostics - client := m.(*resty.Client) - url := "v0.1/entities" - body, err := entityResourceToBody(d) + c := m.(*cli.PortClient) + e, err := entityResourceToBody(d) if err != nil { return diag.FromErr(err) } - resp, err := client.R(). - SetBody(body). - SetQueryParam("upsert", "true"). - Post(url) + en, err := c.CreateEntity(ctx, e) if err != nil { return diag.FromErr(err) } - var pb PortBody - err = json.Unmarshal(resp.Body(), &pb) - if err != nil { - return diag.FromErr(err) - } - if !pb.OK { - return diag.FromErr(fmt.Errorf("failed to create entity, got: %s", resp.Body())) - } - writeEntityComputedFieldsToResource(d, pb.Entity) + writeEntityComputedFieldsToResource(d, en) return diags } func readEntity(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { var diags diag.Diagnostics - client := m.(*resty.Client) - url := "v0.1/entities/{identifier}" - resp, err := client.R(). - SetHeader("Accept", "application/json"). - SetQueryParam("exclude_mirror_properties", "true"). - SetPathParam("identifier", d.Id()). - Get(url) - if err != nil { - return diag.FromErr(err) - } - var pb PortBody - err = json.Unmarshal(resp.Body(), &pb) + c := m.(*cli.PortClient) + e, err := c.ReadEntity(ctx, d.Id()) if err != nil { return diag.FromErr(err) } - e := pb.Entity writeEntityFieldsToResource(d, e) if err != nil { return diag.FromErr(err)