diff --git a/docs/index.md b/docs/index.md index a1fe66ac..957cc111 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,15 +1,18 @@ --- + # generated by https://github.com/hashicorp/terraform-plugin-docs + page_title: "port-labs Provider" subcategory: "" description: |- - Interact with Port-labs ---- +Interact with Port-labs # port-labs Provider [getport.io](https://getport.io) +Interact with Port-labs + ## Schema diff --git a/docs/resources/port_team.md b/docs/resources/port_team.md new file mode 100644 index 00000000..aebf4631 --- /dev/null +++ b/docs/resources/port_team.md @@ -0,0 +1,34 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "port_team Resource - terraform-provider-port-labs" +subcategory: "" +description: |- + Team resource +--- + +# port_team (Resource) + +Team resource + + + + +## Schema + +### Required + +- `name` (String) The name of the team + +### Optional + +- `description` (String) The description of the team +- `users` (List of String) The users of the team + +### Read-Only + +- `created_at` (String) The creation date of the team +- `id` (String) The ID of this resource. +- `provider_name` (String) The provider of the team +- `updated_at` (String) The last update date of the team + + diff --git a/examples/resources/port_team/main.tf b/examples/resources/port_team/main.tf new file mode 100644 index 00000000..98770627 --- /dev/null +++ b/examples/resources/port_team/main.tf @@ -0,0 +1,9 @@ +resource "port_team" "example" { + name = "example" + description = "example" + users = [ + "user1@test.com", + "user2@test.com", + "user3@test.com", + ] +} diff --git a/internal/cli/models.go b/internal/cli/models.go index 3569601c..e89fc0a9 100644 --- a/internal/cli/models.go +++ b/internal/cli/models.go @@ -258,6 +258,15 @@ type ( ItemsToParse *string `json:"itemsToParse,omitempty"` Entity *EntityProperty `json:"entity,omitempty"` } + + Team struct { + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Users []string `json:"users,omitempty"` + Provider string `json:"provider,omitempty"` + } ) type PortBody struct { @@ -267,6 +276,25 @@ type PortBody struct { Action Action `json:"action"` Integration Webhook `json:"integration"` Scorecard Scorecard `json:"Scorecard"` + Team Team `json:"team"` +} + +type TeamUserBody struct { + Email string `json:"email"` +} + +type TeamPortBody struct { + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Users []TeamUserBody `json:"users,omitempty"` + Provider string `json:"provider,omitempty"` +} + +type PortTeamBody struct { + OK bool `json:"ok"` + Team TeamPortBody `json:"team"` } type PortProviderModel struct { diff --git a/internal/cli/team.go b/internal/cli/team.go new file mode 100644 index 00000000..968dd718 --- /dev/null +++ b/internal/cli/team.go @@ -0,0 +1,109 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" +) + +func (c *PortClient) ReadTeam(ctx context.Context, teamName string) (*Team, int, error) { + url := "v1/teams/{name}?fields=name&fields=provider&fields=description&fields=createdAt&fields=updatedAt&fields=users.firstName&fields=users.status&fields=users.email" + resp, err := c.Client.R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + SetPathParam("name", teamName). + Get(url) + if err != nil { + return nil, resp.StatusCode(), err + } + + var pt PortTeamBody + err = json.Unmarshal(resp.Body(), &pt) + if err != nil { + return nil, resp.StatusCode(), err + } + + if !pt.OK { + return nil, resp.StatusCode(), fmt.Errorf("failed to read team, got: %s", resp.Body()) + } + team := &Team{ + Name: pt.Team.Name, + Description: pt.Team.Description, + CreatedAt: pt.Team.CreatedAt, + UpdatedAt: pt.Team.UpdatedAt, + } + + team.Users = make([]string, len(pt.Team.Users)) + + for i, u := range pt.Team.Users { + team.Users[i] = u.Email + } + + return team, resp.StatusCode(), nil +} + +func (c *PortClient) CreateTeam(ctx context.Context, team *Team) (*Team, error) { + url := "v1/teams" + resp, err := c.Client.R(). + SetBody(team). + 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 team, got: %s", resp.Body()) + } + return &pb.Team, nil +} + +func (c *PortClient) UpdateTeam(ctx context.Context, teamName string, team *Team) (*Team, error) { + url := "v1/teams/{name}" + resp, err := c.Client.R(). + SetBody(team). + SetContext(ctx). + SetPathParam("name", teamName). + Patch(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 team, got: %s", resp.Body()) + } + return &pb.Team, nil +} + +func (c *PortClient) DeleteTeam(ctx context.Context, teamName string) error { + url := "v1/teams/{name}" + resp, err := c.Client.R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + SetPathParam("name", teamName). + 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 team. got:\n%s", string(resp.Body())) + } + return nil +} diff --git a/port/team/model.go b/port/team/model.go new file mode 100644 index 00000000..fceb2166 --- /dev/null +++ b/port/team/model.go @@ -0,0 +1,15 @@ +package team + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type TeamModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Users []types.String `tfsdk:"users"` + CreatedAt types.String `tfsdk:"created_at"` + UpdatedAt types.String `tfsdk:"updated_at"` + ProviderName types.String `tfsdk:"provider_name"` +} diff --git a/port/team/refreshTeamState.go b/port/team/refreshTeamState.go new file mode 100644 index 00000000..6939a2e3 --- /dev/null +++ b/port/team/refreshTeamState.go @@ -0,0 +1,26 @@ +package team + +import ( + "context" + + "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 refreshTeamState(ctx context.Context, state *TeamModel, t *cli.Team) error { + state.CreatedAt = types.StringValue(t.CreatedAt.String()) + state.UpdatedAt = types.StringValue(t.UpdatedAt.String()) + state.ID = types.StringValue(t.Name) + state.Name = types.StringValue(t.Name) + state.Description = flex.GoStringToFramework(&t.Description) + + if len(t.Users) != 0 { + state.Users = make([]types.String, len(t.Users)) + for i, u := range t.Users { + state.Users[i] = types.StringValue(u) + } + } + + return nil +} diff --git a/port/team/resource.go b/port/team/resource.go new file mode 100644 index 00000000..964fe717 --- /dev/null +++ b/port/team/resource.go @@ -0,0 +1,141 @@ +package team + +import ( + "context" + + "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 = &TeamResource{} +var _ resource.ResourceWithImportState = &TeamResource{} + +func NewTeamResource() resource.Resource { + return &TeamResource{} +} + +type TeamResource struct { + portClient *cli.PortClient +} + +func (r *TeamResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_team" +} + +func (r *TeamResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + r.portClient = req.ProviderData.(*cli.PortClient) +} + +func (r *TeamResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state *TeamModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + name := state.Name.ValueString() + t, statusCode, err := r.portClient.ReadTeam(ctx, name) + if err != nil { + if statusCode == 404 { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("failed to read team", err.Error()) + return + } + + err = refreshTeamState(ctx, state, t) + if err != nil { + resp.Diagnostics.AddError("failed writing team fields to resource", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *TeamResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var state *TeamModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + t, err := TeamResourceToPortBody(ctx, state) + if err != nil { + resp.Diagnostics.AddError("failed to convert team resource to body", err.Error()) + return + } + + tp, err := r.portClient.CreateTeam(ctx, t) + if err != nil { + resp.Diagnostics.AddError("failed to create team", err.Error()) + return + } + + writeTeamComputedFieldsToState(state, tp) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func writeTeamComputedFieldsToState(state *TeamModel, tp *cli.Team) { + state.ID = types.StringValue(tp.Name) + state.CreatedAt = types.StringValue(tp.CreatedAt.String()) + state.UpdatedAt = types.StringValue(tp.UpdatedAt.String()) + state.ProviderName = types.StringValue(tp.Provider) +} + +func (r *TeamResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var state *TeamModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + t, err := TeamResourceToPortBody(ctx, state) + if err != nil { + resp.Diagnostics.AddError("failed to convert team resource to body", err.Error()) + return + } + + tp, err := r.portClient.UpdateTeam(ctx, t.Name, t) + if err != nil { + resp.Diagnostics.AddError("failed to update the team", err.Error()) + return + } + + writeTeamComputedFieldsToState(state, tp) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *TeamResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state *TeamModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.portClient.DeleteTeam(ctx, state.Name.ValueString()) + + if err != nil { + resp.Diagnostics.AddError("failed to delete team", err.Error()) + return + } + +} + +func (r *TeamResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resp.Diagnostics.Append(resp.State.SetAttribute( + ctx, path.Root("name"), req.ID, + )...) +} diff --git a/port/team/resource_test.go b/port/team/resource_test.go new file mode 100644 index 00000000..9b2b087a --- /dev/null +++ b/port/team/resource_test.go @@ -0,0 +1,104 @@ +package team_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/port-labs/terraform-provider-port-labs/internal/acctest" +) + +func TestAccPortTeam(t *testing.T) { + var testAccTeamConfigCreate = ` + resource "port_team" "team" { + name = "Tf-Test" + description = "Test description" + users = [] + }` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: acctest.ProviderConfig + testAccTeamConfigCreate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("port_team.team", "name", "Tf-Test"), + resource.TestCheckResourceAttr("port_team.team", "description", "Test description"), + resource.TestCheckResourceAttr("port_team.team", "users.#", "0"), + ), + }, + }, + }) +} + +func TestAccPortTeamUpdate(t *testing.T) { + var testAccTeamConfigCreate = ` + resource "port_team" "team" { + name = "Tf-Test" + description = "Test description" + users = [] + }` + + var testAccTeamConfigUpdate = ` + resource "port_team" "team" { + name = "Tf-Test" + description = "Test description2" + users = ["devops-port@port-test.io"] + }` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: acctest.ProviderConfig + testAccTeamConfigCreate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("port_team.team", "name", "Tf-Test"), + resource.TestCheckResourceAttr("port_team.team", "description", "Test description"), + resource.TestCheckResourceAttr("port_team.team", "users.#", "0"), + ), + }, + { + Config: acctest.ProviderConfig + testAccTeamConfigUpdate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("port_team.team", "name", "Tf-Test"), + resource.TestCheckResourceAttr("port_team.team", "description", "Test description2"), + resource.TestCheckResourceAttr("port_team.team", "users.#", "1"), + resource.TestCheckResourceAttr("port_team.team", "users.0", "devops-port@port-test.io"), + ), + }, + }, + }) +} + +func TestAccPortTeamImport(t *testing.T) { + var testAccTeamConfigCreate = ` + resource "port_team" "team" { + name = "Tf-Test" + description = "Test description" + users = ["devops-port@port-test.io"] + }` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: acctest.ProviderConfig + testAccTeamConfigCreate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("port_team.team", "name", "Tf-Test"), + resource.TestCheckResourceAttr("port_team.team", "description", "Test description"), + resource.TestCheckResourceAttr("port_team.team", "users.#", "1"), + resource.TestCheckResourceAttr("port_team.team", "users.0", "devops-port@port-test.io"), + ), + }, + { + ResourceName: "port_team.team", + ImportState: true, + ImportStateVerify: true, + ImportStateId: "Tf-Test", + ImportStateVerifyIgnore: []string{"provider_name"}, + }, + }, + }) +} diff --git a/port/team/schema.go b/port/team/schema.go new file mode 100644 index 00000000..1931828d --- /dev/null +++ b/port/team/schema.go @@ -0,0 +1,56 @@ +package team + +import ( + "context" + + "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/types" +) + +func TeamSchema() map[string]schema.Attribute { + return map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the team", + Required: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "The description of the team", + Optional: true, + }, + "users": schema.ListAttribute{ + MarkdownDescription: "The users of the team", + Optional: true, + ElementType: types.StringType, + }, + "provider_name": schema.StringAttribute{ + MarkdownDescription: "The provider of the team", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "created_at": schema.StringAttribute{ + MarkdownDescription: "The creation date of the team", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "updated_at": schema.StringAttribute{ + MarkdownDescription: "The last update date of the team", + Computed: true, + }, + } +} +func (r *TeamResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Team resource", + Attributes: TeamSchema(), + } +} diff --git a/port/team/teamResourceToPortBody.go b/port/team/teamResourceToPortBody.go new file mode 100644 index 00000000..85b7a886 --- /dev/null +++ b/port/team/teamResourceToPortBody.go @@ -0,0 +1,22 @@ +package team + +import ( + "context" + + "github.com/port-labs/terraform-provider-port-labs/internal/cli" +) + +func TeamResourceToPortBody(ctx context.Context, state *TeamModel) (*cli.Team, error) { + tp := &cli.Team{ + Name: state.Name.ValueString(), + Description: state.Description.ValueString(), + } + if state.Users != nil { + tp.Users = make([]string, len(state.Users)) + for i, t := range state.Users { + tp.Users[i] = t.ValueString() + } + } + + return tp, nil +} diff --git a/provider/provider.go b/provider/provider.go index 5eff2c63..f7a39b0d 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -14,6 +14,7 @@ import ( "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/team" "github.com/port-labs/terraform-provider-port-labs/port/webhook" "github.com/port-labs/terraform-provider-port-labs/version" ) @@ -128,6 +129,7 @@ func (p *PortLabsProvider) Resources(ctx context.Context) []func() resource.Reso action.NewActionResource, webhook.NewWebhookResource, scorecard.NewScorecardResource, + team.NewTeamResource, } }