diff --git a/README.md b/README.md index 05f70c6..00611e4 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Resources: - `forgejo_repository` ([documentation](docs/resources/repository.md)) - `forgejo_repository_action_secret` ([documentation](docs/resources/repository_action_secret.md)) - `forgejo_ssh_key` ([documentation](docs/resources/ssh_key.md)) +- `forgejo_team` ([documentation](docs/resources/team.md)) - `forgejo_user` ([documentation](docs/resources/user.md)) Data Sources: @@ -33,6 +34,7 @@ Data Sources: - `forgejo_organization` ([documentation](docs/data-sources/organization.md)) - `forgejo_repository` ([documentation](docs/data-sources/repository.md)) - `forgejo_ssh_key` ([documentation](docs/data-sources/ssh_key.md)) +- `forgejo_team` ([documentation](docs/data-sources/team.md)) - `forgejo_user` ([documentation](docs/data-sources/user.md)) ## Using the Provider diff --git a/docs/data-sources/team.md b/docs/data-sources/team.md new file mode 100644 index 0000000..d1adefb --- /dev/null +++ b/docs/data-sources/team.md @@ -0,0 +1,30 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "forgejo_team Data Source - forgejo" +subcategory: "" +description: |- + Forgejo team data source. +--- + +# forgejo_team (Data Source) + +Forgejo team data source. + + + + +## Schema + +### Required + +- `name` (String) Name of the team. +- `organization_id` (Number) ID of the owning organization. + +### Read-Only + +- `can_create_org_repo` (Boolean) Can create repositories? +- `description` (String) Description of the team. +- `id` (Number) Numeric identifier of the team. +- `includes_all_repositories` (Boolean) Has access to all repositories? +- `permission` (String) Permissions within the owning organization. +- `units` (Set of String) Set of units. diff --git a/docs/resources/team.md b/docs/resources/team.md new file mode 100644 index 0000000..30f5804 --- /dev/null +++ b/docs/resources/team.md @@ -0,0 +1,33 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "forgejo_team Resource - forgejo" +subcategory: "" +description: |- + Forgejo team resource. +--- + +# forgejo_team (Resource) + +Forgejo team resource. + + + + +## Schema + +### Required + +- `name` (String) Name of the team. +- `organization_id` (Number) ID of the owning organization. + +### Optional + +- `can_create_org_repo` (Boolean) Can create repositories? +- `description` (String) Description of the team. +- `includes_all_repositories` (Boolean) Has access to all repositories? +- `permission` (String) Permissions within the owning organization. **Note**: If you set `admin` or `owner` here, make sure to set all units. This is due to an SDK limitation. +- `units` (Set of String) Set of units. **Note**: If the permission is `admin` or `owner` this should include all units due to an SDK limitation. + +### Read-Only + +- `id` (Number) Numeric identifier of the team. diff --git a/internal/provider/organization_data_source.go b/internal/provider/organization_data_source.go index 7307285..ccf78f8 100644 --- a/internal/provider/organization_data_source.go +++ b/internal/provider/organization_data_source.go @@ -169,3 +169,37 @@ func (d *organizationDataSource) Read(ctx context.Context, req datasource.ReadRe func NewOrganizationDataSource() datasource.DataSource { return &organizationDataSource{} } + +// Use Forgejo client to get an organization by ID. +func getOrganizationByID(ctx context.Context, client *forgejo.Client, orgID types.Int64) (organization *forgejo.Organization, err error) { + tflog.Info(ctx, "Getting organization by its ID", map[string]any{ + "organization_id": orgID, + }) + + organizations, resp, err := client.AdminListOrgs(forgejo.AdminListOrgsOptions{}) + if err != nil { + tflog.Error(ctx, "Error", map[string]any{ + "status": resp.Status, + }) + + switch resp.StatusCode { + case 403: + err = fmt.Errorf( + "not allowed to list organizations: %s", + err, + ) + default: + err = fmt.Errorf("unknown error: %s", err) + } + return nil, err + } + + for _, potentialOrganization := range organizations { + if orgID.Equal(types.Int64Value(potentialOrganization.ID)) { + organization = potentialOrganization + break + } + } + + return organization, nil +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5025bdf..0573e32 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -245,6 +245,7 @@ func (p *forgejoProvider) DataSources(_ context.Context) []func() datasource.Dat NewOrganizationDataSource, NewRepositoryDataSource, NewSSHKeyDataSource, + NewTeamDataSource, NewUserDataSource, } } @@ -261,6 +262,7 @@ func (p *forgejoProvider) Resources(_ context.Context) []func() resource.Resourc NewRepositoryResource, NewSSHKeyResource, NewBranchProtectionResource, + NewTeamResource, NewUserResource, } } diff --git a/internal/provider/team_data_source.go b/internal/provider/team_data_source.go new file mode 100644 index 0000000..a7554b1 --- /dev/null +++ b/internal/provider/team_data_source.go @@ -0,0 +1,215 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &teamDataSource{} + _ datasource.DataSourceWithConfigure = &teamDataSource{} +) + +// teamDataSource is the data source implementation. +type teamDataSource struct { + client *forgejo.Client +} + +// teamDataSourceModel maps the data source schema data. +// https://pkg.go.dev/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2#Team +type teamDataSourceModel struct { + ID types.Int64 `tfsdk:"id"` + Name types.String `tfsdk:"name"` + OrganizationID types.Int64 `tfsdk:"organization_id"` + CanCreateOrgRepo types.Bool `tfsdk:"can_create_org_repo"` + Description types.String `tfsdk:"description"` + IncludesAllRepositories types.Bool `tfsdk:"includes_all_repositories"` + Permission types.String `tfsdk:"permission"` + Units types.Set `tfsdk:"units"` +} + +// Metadata returns the data source type name. +func (d *teamDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_team" +} + +// Schema defines the schema for the data source. +func (d *teamDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Forgejo team data source.", + + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + Description: "Numeric identifier of the team.", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "Name of the team.", + Required: true, + }, + "organization_id": schema.Int64Attribute{ + Description: "ID of the owning organization.", + Required: true, + }, + "can_create_org_repo": schema.BoolAttribute{ + Description: "Can create repositories?", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "Description of the team.", + Computed: true, + }, + "includes_all_repositories": schema.BoolAttribute{ + Description: "Has access to all repositories?", + Computed: true, + }, + "permission": schema.StringAttribute{ + Description: "Permissions within the owning organization.", + Computed: true, + }, + "units": schema.SetAttribute{ + Description: "Set of units.", + ElementType: types.StringType, + Computed: true, + }, + }, + } +} + +// Configure adds the provider configured client to the data source. +func (d *teamDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*forgejo.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf( + "Expected *forgejo.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData, + ), + ) + + return + } + + d.client = client +} + +// Read refreshes the Terraform state with the latest data. +func (d *teamDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + defer un(trace(ctx, "Read team data source")) + + var data teamDataSourceModel + + // Read Terraform configuration data into model. + diags := req.Config.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Get team by name", map[string]any{ + "name": data.Name.ValueString(), + "organization_id": data.OrganizationID.ValueInt64(), + }) + + // Use Forgejo client to get team by name. + team, err := getOrgTeamByName(ctx, d.client, data.OrganizationID, data.Name) + if err != nil { + resp.Diagnostics.AddError("Unable to get team by name", err.Error()) + return + } + + if team == nil { + err = fmt.Errorf( + "no Team with name '%s' was found", + data.Name.String(), + ) + resp.Diagnostics.AddError("Unable to get team by name", err.Error()) + return + } + + // Map response body to model. + data.ID = types.Int64Value(team.ID) + data.Name = types.StringValue(team.Name) + data.Description = types.StringValue(team.Description) + if team.Organization != nil { + data.OrganizationID = types.Int64Value(team.Organization.ID) + } + data.Permission = types.StringValue(string(team.Permission)) + data.CanCreateOrgRepo = types.BoolValue(team.CanCreateOrgRepo) + data.IncludesAllRepositories = types.BoolValue(team.IncludesAllRepositories) + data.Units, diags = types.SetValueFrom(ctx, types.StringType, team.Units) + + resp.Diagnostics.Append(diags...) + + // Save data into Terraform state. + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +// NewTeamDataSource is a helper function to simplify the provider implementation. +func NewTeamDataSource() datasource.DataSource { + return &teamDataSource{} +} + +// Use Forgejo client to get team by name. +func getOrgTeamByName(ctx context.Context, client *forgejo.Client, orgID types.Int64, teamName types.String) (team *forgejo.Team, err error) { + tflog.Info(ctx, "Getting team in org", map[string]any{ + "team": teamName, + "organization_id": orgID, + }) + + organization, err := getOrganizationByID(ctx, client, orgID) + if err != nil { + return nil, err + } + + if organization == nil { + err = fmt.Errorf( + "no Organization with id '%d' was found", + orgID.ValueInt64(), + ) + return nil, err + } + + teams, resp, err := client.ListOrgTeams(organization.UserName, forgejo.ListTeamsOptions{}) + if err != nil { + tflog.Error(ctx, "Error", map[string]any{ + "status": resp.Status, + }) + + switch resp.StatusCode { + case 404: + err = fmt.Errorf( + "the Organization with name '%s' was not found: %s", + organization.UserName, + err, + ) + default: + err = fmt.Errorf("unknown error: %s", err) + } + return nil, err + } + + for _, potentialTeam := range teams { + if teamName.Equal(types.StringValue(potentialTeam.Name)) { + team = potentialTeam + break + } + } + + return team, nil +} diff --git a/internal/provider/team_data_source_test.go b/internal/provider/team_data_source_test.go new file mode 100644 index 0000000..e5811a7 --- /dev/null +++ b/internal/provider/team_data_source_test.go @@ -0,0 +1,71 @@ +package provider_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/compare" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccTeamDataSource(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Read testing (non-existent org) + { + Config: providerConfig + ` +data "forgejo_team" "test" { + name = "tftest" + organization_id = 1011 +}`, + ExpectError: regexp.MustCompile("no Organization with id '1011' was found"), + }, + // Read testing (non-existent team) + { + Config: providerConfig + ` +resource "forgejo_organization" "test" { + name = "test_org" +} +data "forgejo_team" "test" { + name = "test_team" + organization_id = forgejo_organization.test.id +}`, + ExpectError: regexp.MustCompile("no Team with name '\"test_team\"' was found"), + }, + // Read testing + { + Config: providerConfig + ` +resource "forgejo_organization" "test" { + name = "test_org" +} +resource "forgejo_team" "test" { + name = "test_team" + organization_id = forgejo_organization.test.id + can_create_org_repo = true + includes_all_repositories = true + permission = "read" + units = ["repo.code"] +} +data "forgejo_team" "test" { + name = forgejo_team.test.name + organization_id = forgejo_team.test.organization_id +}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.forgejo_team.test", tfjsonpath.New("name"), knownvalue.StringExact("test_team")), + statecheck.CompareValuePairs("data.forgejo_team.test", tfjsonpath.New("organization_id"), "forgejo_organization.test", tfjsonpath.New("id"), compare.ValuesSame()), + statecheck.ExpectKnownValue("data.forgejo_team.test", tfjsonpath.New("can_create_org_repo"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("data.forgejo_team.test", tfjsonpath.New("includes_all_repositories"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("data.forgejo_team.test", tfjsonpath.New("permission"), knownvalue.StringExact("read")), + statecheck.ExpectKnownValue("data.forgejo_team.test", tfjsonpath.New("units"), knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("repo.code"), + })), + }, + }, + }, + }) +} diff --git a/internal/provider/team_resource.go b/internal/provider/team_resource.go new file mode 100644 index 0000000..ea50248 --- /dev/null +++ b/internal/provider/team_resource.go @@ -0,0 +1,469 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &teamResource{} + _ resource.ResourceWithConfigure = &teamResource{} +) + +// teamResource is the resource implementation. +type teamResource struct { + client *forgejo.Client +} + +// teamResourceModel maps the resource schema data. +type teamResourceModel struct { + ID types.Int64 `tfsdk:"id"` + Name types.String `tfsdk:"name"` + OrganizationID types.Int64 `tfsdk:"organization_id"` + CanCreateOrgRepo types.Bool `tfsdk:"can_create_org_repo"` + Description types.String `tfsdk:"description"` + IncludesAllRepositories types.Bool `tfsdk:"includes_all_repositories"` + Permission types.String `tfsdk:"permission"` + Units types.Set `tfsdk:"units"` +} + +// from is a helper function to populate Terraform data model from an API struct. +func (m *teamResourceModel) from(t *forgejo.Team, ctx context.Context) (diags diag.Diagnostics) { + m.ID = types.Int64Value(t.ID) + m.Name = types.StringValue(t.Name) + m.Description = types.StringValue(t.Description) + if t.Organization != nil { + m.OrganizationID = types.Int64Value(t.Organization.ID) + } + m.Permission = types.StringValue(string(t.Permission)) + m.CanCreateOrgRepo = types.BoolValue(t.CanCreateOrgRepo) + m.IncludesAllRepositories = types.BoolValue(t.IncludesAllRepositories) + m.Units, diags = types.SetValueFrom(ctx, types.StringType, t.Units) + + return diags +} + +// to is a helper function to save Terraform data model into an API struct. +func (m *teamResourceModel) to(o *forgejo.EditTeamOption, ctx context.Context) (diags diag.Diagnostics) { + if o == nil { + o = new(forgejo.EditTeamOption) + } + + o.Name = m.Name.ValueString() + o.Description = m.Description.ValueStringPointer() + o.Permission = forgejo.AccessMode(m.Permission.ValueString()) + o.CanCreateOrgRepo = m.CanCreateOrgRepo.ValueBoolPointer() + o.IncludesAllRepositories = m.IncludesAllRepositories.ValueBoolPointer() + diags = m.Units.ElementsAs(ctx, &o.Units, false) + + return diags +} + +// Metadata returns the resource type name. +func (r *teamResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_team" +} + +// Schema defines the schema for the resource. +func (r *teamResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Forgejo team resource.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + Description: "Numeric identifier of the team.", + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "Name of the team.", + Required: true, + }, + "organization_id": schema.Int64Attribute{ + Description: "ID of the owning organization.", + Required: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "can_create_org_repo": schema.BoolAttribute{ + Description: "Can create repositories?", + Computed: true, + Optional: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "description": schema.StringAttribute{ + Description: "Description of the team.", + Computed: true, + Optional: true, + Default: stringdefault.StaticString(""), + }, + "includes_all_repositories": schema.BoolAttribute{ + Description: "Has access to all repositories?", + Computed: true, + Optional: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "permission": schema.StringAttribute{ + Description: "Permissions within the owning organization. **Note**: If you set `admin` or `owner` here, make sure to set all units. This is due to an SDK limitation.", + Computed: true, + Optional: true, + Default: stringdefault.StaticString("read"), + Validators: []validator.String{ + stringvalidator.OneOf( + "read", + "write", + "admin", + "owner", + ), + }, + }, + "units": schema.SetAttribute{ + Description: "Set of units. **Note**: If the permission is `admin` or `owner` this should include all units due to an SDK limitation.", + ElementType: types.StringType, + Computed: true, + Optional: true, + Default: setdefault.StaticValue( + types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("repo.code"), + }, + ), + ), + Validators: []validator.Set{ + setvalidator.ValueStringsAre( + stringvalidator.OneOf( + "repo.code", + "repo.issues", + "repo.pulls", + "repo.ext_issues", + "repo.wiki", + "repo.ext_wiki", + "repo.releases", + "repo.projects", + "repo.packages", + "repo.actions", + ), + ), + }, + }, + }, + } +} + +// Configure adds the provider configured client to the resource. +func (r *teamResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*forgejo.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf( + "Expected *forgejo.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData, + ), + ) + return + } + + r.client = client +} + +// Create creates the resource and sets the initial Terraform state. +func (r *teamResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + defer un(trace(ctx, "Create team resource")) + + var data teamResourceModel + + // Read Terraform plan data into model. + diags := req.Plan.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + team, err := createTeam(ctx, r.client, data.OrganizationID, data.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Team creation error", err.Error()) + return + } + + opts := forgejo.EditTeamOption{} + diags = data.to(&opts, ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + team, err = editTeam(ctx, r.client, team.ID, opts) + if err != nil { + resp.Diagnostics.AddError("Unable to edit team", err.Error()) + return + } + + diags = data.from(team, ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +// Read refreshes the Terraform state with the latest data. +func (r *teamResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + defer un(trace(ctx, "Read team resource")) + + var data teamResourceModel + + // Read Terraform prior state data into the model + diags := req.State.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Get team from org", map[string]any{ + "team": data.Name, + "organization_id": data.OrganizationID, + }) + + team, err := getOrgTeamByName(ctx, r.client, data.OrganizationID, data.Name) + if err != nil { + resp.Diagnostics.AddError("Unable to get team by name", err.Error()) + return + } + + if team == nil { + resp.Diagnostics.AddError("Unable to get team by name", fmt.Sprintf("No team found called %s within organisation ID %s.", data.Name, data.OrganizationID)) + return + } + + diags = data.from(team, ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *teamResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + defer un(trace(ctx, "Update team resource")) + + var data teamResourceModel + + // Read Terraform plan data into the model. + diags := req.Plan.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Update team from org", map[string]any{ + "team": data.Name.ValueString(), + "organization_id": data.OrganizationID.ValueInt64(), + }) + + opts := forgejo.EditTeamOption{} + diags = data.to(&opts, ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + team, err := editTeam(ctx, r.client, data.ID.ValueInt64(), opts) + if err != nil { + resp.Diagnostics.AddError("Unable to edit team", err.Error()) + return + } + + if team == nil { + resp.Diagnostics.AddError("Unable edit team", fmt.Sprintf("No team found called %s within organisation ID %d.", data.Name.ValueString(), data.OrganizationID.ValueInt64())) + return + } + + diags = data.from(team, ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Save data into Terraform state. + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *teamResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + defer un(trace(ctx, "Delete team resource")) + + var data teamResourceModel + + // Read Terraform prior state data into the model. + diags := req.State.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Delete team from org", map[string]any{ + "team": data.Name.ValueString(), + "organization_id": data.OrganizationID.ValueInt64(), + }) + + // Use Forgejo client to delete existing team. + res, err := r.client.DeleteTeam(data.ID.ValueInt64()) + if err == nil { + return + } + tflog.Error(ctx, "Error", map[string]any{ + "status": res.Status, + }) + + switch res.StatusCode { + case 404: + err = fmt.Errorf( + "the Team with name '%s' was not found: %s", + data.Name.String(), + err, + ) + default: + err = fmt.Errorf("unknown error: %s", err) + } + resp.Diagnostics.AddError("Unable to delete team", err.Error()) +} + +// NewTeamResource is a helper function to simplify the provider implementation. +func NewTeamResource() resource.Resource { + return &teamResource{} +} + +func createTeam(ctx context.Context, client *forgejo.Client, organizationID types.Int64, teamName string) (team *forgejo.Team, err error) { + tflog.Info(ctx, "Add team to org", map[string]any{ + "team": teamName, + "organization_id": organizationID, + }) + + opts := forgejo.CreateTeamOption{ + Name: teamName, + Permission: forgejo.AccessMode("read"), + Units: []forgejo.RepoUnitType{forgejo.RepoUnitType("repo.code")}, + } + + err = opts.Validate() + if err != nil { + err = fmt.Errorf("input validation error: %s", err.Error()) + return + } + + organization, err := getOrganizationByID(ctx, client, organizationID) + if err != nil { + return nil, err + } + + if organization == nil { + err = fmt.Errorf( + "no Organization with id '%d' was found", + organizationID.ValueInt64(), + ) + return nil, err + } + + team, resp, err := client.CreateTeam(organization.UserName, opts) + if err == nil { + return team, nil + } + + tflog.Error(ctx, "Error", map[string]any{ + "status": resp.Status, + }) + + switch resp.StatusCode { + case 403: + err = fmt.Errorf( + "the Team with owner '%s' and name '%s' is forbidden: %s", + organization.UserName, + teamName, + err, + ) + case 404: + err = fmt.Errorf( + "the Organization with name '%s' was not found: %s", + organization.UserName, + err, + ) + case 422: + err = fmt.Errorf("input validation error: %s", err) + default: + err = fmt.Errorf("unknown error: %s", err) + } + return team, err +} + +func editTeam(ctx context.Context, client *forgejo.Client, teamID int64, opts forgejo.EditTeamOption) (team *forgejo.Team, err error) { + tflog.Info(ctx, "Edit team", map[string]any{ + "team_id": teamID, + }) + + err = opts.Validate() + if err != nil { + err = fmt.Errorf("input validation error: %s", err) + return nil, err + } + + resp, err := client.EditTeam(teamID, opts) + if err == nil { + team, resp, err = client.GetTeam(teamID) + if err == nil { + return team, nil + } + } + + tflog.Error(ctx, "Error", map[string]any{ + "status": resp.Status, + }) + + switch resp.StatusCode { + case 404: + err = fmt.Errorf( + "the Team with ID '%d' was not found: %s", + teamID, + err, + ) + default: + err = fmt.Errorf("unknown error: %s", err) + } + return nil, err +} diff --git a/internal/provider/team_resource_test.go b/internal/provider/team_resource_test.go new file mode 100644 index 0000000..eaf64ce --- /dev/null +++ b/internal/provider/team_resource_test.go @@ -0,0 +1,210 @@ +package provider_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/compare" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccTeamResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing (non-existent org) + { + Config: providerConfig + ` +resource "forgejo_team" "test" { + name = "tftest" + organization_id = 1011 +}`, + ExpectError: regexp.MustCompile("no Organization with id '1011' was found"), + }, + // Create and Read testing + { + Config: providerConfig + ` +resource "forgejo_organization" "test" { + name = "team_test_org" +} +resource "forgejo_team" "test" { + name = "test_team" + organization_id = forgejo_organization.test.id + can_create_org_repo = true + description = "Test team." + includes_all_repositories = false + permission = "read" + units = ["repo.issues"] +}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("name"), knownvalue.StringExact("test_team")), + statecheck.CompareValuePairs("forgejo_team.test", tfjsonpath.New("organization_id"), "forgejo_organization.test", tfjsonpath.New("id"), compare.ValuesSame()), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("can_create_org_repo"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("description"), knownvalue.StringExact("Test team.")), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("includes_all_repositories"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("permission"), knownvalue.StringExact("read")), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("units"), knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("repo.issues"), + })), + }, + }, + // Not allowed to create the same team twice. + { + Config: providerConfig + ` +resource "forgejo_organization" "test" { + name = "team_test_org" +} +resource "forgejo_team" "test" { + name = "test_team" + organization_id = forgejo_organization.test.id + can_create_org_repo = true + description = "Test team." + includes_all_repositories = false + permission = "read" + units = ["repo.issues"] +} +resource "forgejo_team" "test2" { + # Make sure this second team is created later. + name = forgejo_team.test.name + organization_id = forgejo_organization.test.id + permission = "write" + units = ["repo.code"] +}`, + ExpectError: regexp.MustCompile("team already exists"), + }, + // Update and Read testing + { + Config: providerConfig + ` +resource "forgejo_organization" "test" { + name = "team_test_org" +} +resource "forgejo_team" "test" { + name = "test_team" + organization_id = forgejo_organization.test.id + can_create_org_repo = false + description = "Updated test team." + includes_all_repositories = true + permission = "write" + units = ["repo.issues"] +}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("name"), knownvalue.StringExact("test_team")), + statecheck.CompareValuePairs("forgejo_team.test", tfjsonpath.New("organization_id"), "forgejo_organization.test", tfjsonpath.New("id"), compare.ValuesSame()), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("can_create_org_repo"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("description"), knownvalue.StringExact("Updated test team.")), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("includes_all_repositories"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("permission"), knownvalue.StringExact("write")), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("units"), knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("repo.issues"), + })), + }, + }, + // Update and Read testing (rename) + { + Config: providerConfig + ` +resource "forgejo_organization" "test" { + name = "team_test_org" +} +resource "forgejo_team" "test" { + name = "renamed_test_team" + organization_id = forgejo_organization.test.id + description = "Updated test team." + permission = "write" + units = ["repo.pulls"] +}`, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("forgejo_team.test", plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("name"), knownvalue.StringExact("renamed_test_team")), + statecheck.CompareValuePairs("forgejo_team.test", tfjsonpath.New("organization_id"), "forgejo_organization.test", tfjsonpath.New("id"), compare.ValuesSame()), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("can_create_org_repo"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("description"), knownvalue.StringExact("Updated test team.")), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("includes_all_repositories"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("permission"), knownvalue.StringExact("write")), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("units"), knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("repo.pulls"), + })), + }, + }, + // Changing the parent organization recreates the resource. + { + Config: providerConfig + ` +resource "forgejo_organization" "test" { + name = "team_test_org" +} +resource "forgejo_organization" "new_test" { + name = "new_test" +} +resource "forgejo_team" "test" { + name = "renamed_test_team" + organization_id = forgejo_organization.new_test.id + permission = "write" + units = ["repo.pulls"] +}`, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("forgejo_team.test", plancheck.ResourceActionDestroyBeforeCreate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("name"), knownvalue.StringExact("renamed_test_team")), + statecheck.CompareValuePairs("forgejo_team.test", tfjsonpath.New("organization_id"), "forgejo_organization.new_test", tfjsonpath.New("id"), compare.ValuesSame()), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("can_create_org_repo"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("description"), knownvalue.StringExact("")), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("includes_all_repositories"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("permission"), knownvalue.StringExact("write")), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("units"), knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("repo.pulls"), + })), + }, + }, + // Admin permission needs all units. + { + Config: providerConfig + ` +resource "forgejo_organization" "new_test" { + name = "new_test" +} +resource "forgejo_team" "test" { + name = "renamed_test_team" + organization_id = forgejo_organization.new_test.id + permission = "admin" + units = ["repo.code", "repo.issues", "repo.pulls", "repo.ext_issues", "repo.wiki", "repo.ext_wiki", "repo.releases", "repo.projects", "repo.packages", "repo.actions"] +}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("name"), knownvalue.StringExact("renamed_test_team")), + statecheck.CompareValuePairs("forgejo_team.test", tfjsonpath.New("organization_id"), "forgejo_organization.new_test", tfjsonpath.New("id"), compare.ValuesSame()), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("can_create_org_repo"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("description"), knownvalue.StringExact("")), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("includes_all_repositories"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("permission"), knownvalue.StringExact("admin")), + statecheck.ExpectKnownValue("forgejo_team.test", tfjsonpath.New("units"), knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("repo.code"), + knownvalue.StringExact("repo.issues"), + knownvalue.StringExact("repo.pulls"), + knownvalue.StringExact("repo.ext_issues"), + knownvalue.StringExact("repo.wiki"), + knownvalue.StringExact("repo.ext_wiki"), + knownvalue.StringExact("repo.releases"), + knownvalue.StringExact("repo.projects"), + knownvalue.StringExact("repo.packages"), + knownvalue.StringExact("repo.actions"), + })), + }, + }, + // Delete testing automatically occurs in TestCase + }, + }) +}