diff --git a/examples/cost_centers/main.tf b/examples/cost_centers/main.tf new file mode 100644 index 0000000000..9c5d505fa7 --- /dev/null +++ b/examples/cost_centers/main.tf @@ -0,0 +1,115 @@ +terraform { + required_providers { + github = { + source = "integrations/github" + version = "~> 6.11" + } + } +} + +provider "github" { + token = var.github_token + owner = var.enterprise_slug +} + +variable "github_token" { + description = "GitHub classic personal access token (PAT) for an enterprise admin" + type = string + sensitive = true +} + +variable "enterprise_slug" { + description = "The GitHub Enterprise slug" + type = string +} + +variable "cost_center_name" { + description = "Name for the cost center" + type = string +} + +variable "users" { + description = "Usernames to assign to the cost center" + type = list(string) + default = [] +} + +variable "organizations" { + description = "Organization logins to assign to the cost center" + type = list(string) + default = [] +} + +variable "repositories" { + description = "Repositories (full name, e.g. org/repo) to assign to the cost center" + type = list(string) + default = [] +} + +# The cost center resource manages only the cost center entity itself. +resource "github_enterprise_cost_center" "example" { + enterprise_slug = var.enterprise_slug + name = var.cost_center_name +} + +# Use separate authoritative resources for assignments. +# These are optional - only create them if you have items to assign. + +resource "github_enterprise_cost_center_users" "example" { + count = length(var.users) > 0 ? 1 : 0 + + enterprise_slug = var.enterprise_slug + cost_center_id = github_enterprise_cost_center.example.id + usernames = var.users +} + +resource "github_enterprise_cost_center_organizations" "example" { + count = length(var.organizations) > 0 ? 1 : 0 + + enterprise_slug = var.enterprise_slug + cost_center_id = github_enterprise_cost_center.example.id + organization_logins = var.organizations +} + +resource "github_enterprise_cost_center_repositories" "example" { + count = length(var.repositories) > 0 ? 1 : 0 + + enterprise_slug = var.enterprise_slug + cost_center_id = github_enterprise_cost_center.example.id + repository_names = var.repositories +} + +# Data sources for reading cost center information +data "github_enterprise_cost_center" "by_id" { + enterprise_slug = var.enterprise_slug + cost_center_id = github_enterprise_cost_center.example.id +} + +data "github_enterprise_cost_centers" "active" { + enterprise_slug = var.enterprise_slug + state = "active" + + depends_on = [github_enterprise_cost_center.example] +} + +output "cost_center" { + description = "Created cost center" + value = { + id = github_enterprise_cost_center.example.id + name = github_enterprise_cost_center.example.name + state = github_enterprise_cost_center.example.state + azure_subscription = github_enterprise_cost_center.example.azure_subscription + } +} + +output "cost_center_from_data_source" { + description = "Cost center fetched by data source (includes all assignments)" + value = { + id = data.github_enterprise_cost_center.by_id.cost_center_id + name = data.github_enterprise_cost_center.by_id.name + state = data.github_enterprise_cost_center.by_id.state + users = sort(tolist(data.github_enterprise_cost_center.by_id.users)) + organizations = sort(tolist(data.github_enterprise_cost_center.by_id.organizations)) + repositories = sort(tolist(data.github_enterprise_cost_center.by_id.repositories)) + } +} diff --git a/github/data_source_github_enterprise_cost_center.go b/github/data_source_github_enterprise_cost_center.go new file mode 100644 index 0000000000..0ec1c00a11 --- /dev/null +++ b/github/data_source_github_enterprise_cost_center.go @@ -0,0 +1,113 @@ +package github + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseCostCenter() *schema.Resource { + return &schema.Resource{ + Description: "Use this data source to retrieve information about a specific enterprise cost center.", + ReadContext: dataSourceGithubEnterpriseCostCenterRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "cost_center_id": { + Type: schema.TypeString, + Required: true, + Description: "The ID of the cost center.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the cost center.", + }, + "state": { + Type: schema.TypeString, + Computed: true, + Description: "The state of the cost center.", + }, + "azure_subscription": { + Type: schema.TypeString, + Computed: true, + Description: "The Azure subscription associated with the cost center.", + }, + "users": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The usernames assigned to this cost center.", + }, + "organizations": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The organization logins assigned to this cost center.", + }, + "repositories": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The repositories (full name) assigned to this cost center.", + }, + }, + } +} + +func dataSourceGithubEnterpriseCostCenterRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(costCenterID) + if err := d.Set("name", cc.Name); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("state", cc.GetState()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("azure_subscription", cc.GetAzureSubscription()); err != nil { + return diag.FromErr(err) + } + + users := make([]string, 0) + organizations := make([]string, 0) + repositories := make([]string, 0) + for _, resource := range cc.Resources { + if resource == nil { + continue + } + switch resource.Type { + case CostCenterResourceTypeUser: + users = append(users, resource.Name) + case CostCenterResourceTypeOrg: + organizations = append(organizations, resource.Name) + case CostCenterResourceTypeRepo: + repositories = append(repositories, resource.Name) + } + } + + if err := d.Set("users", users); err != nil { + return diag.FromErr(err) + } + if err := d.Set("organizations", organizations); err != nil { + return diag.FromErr(err) + } + if err := d.Set("repositories", repositories); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/github/data_source_github_enterprise_cost_center_test.go b/github/data_source_github_enterprise_cost_center_test.go new file mode 100644 index 0000000000..690fe7a69e --- /dev/null +++ b/github/data_source_github_enterprise_cost_center_test.go @@ -0,0 +1,44 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/compare" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "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 TestAccGithubEnterpriseCostCenterDataSource(t *testing.T) { + randomID := acctest.RandString(5) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{{ + Config: fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + + data "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + cost_center_id = github_enterprise_cost_center.test.id + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.CompareValuePairs("data.github_enterprise_cost_center.test", tfjsonpath.New("cost_center_id"), "github_enterprise_cost_center.test", tfjsonpath.New("id"), compare.ValuesSame()), + statecheck.CompareValuePairs("data.github_enterprise_cost_center.test", tfjsonpath.New("name"), "github_enterprise_cost_center.test", tfjsonpath.New("name"), compare.ValuesSame()), + statecheck.ExpectKnownValue("data.github_enterprise_cost_center.test", tfjsonpath.New("state"), knownvalue.StringExact("active")), + }, + }}, + }) +} diff --git a/github/data_source_github_enterprise_cost_centers.go b/github/data_source_github_enterprise_cost_centers.go new file mode 100644 index 0000000000..1c3deddb1a --- /dev/null +++ b/github/data_source_github_enterprise_cost_centers.go @@ -0,0 +1,100 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v83/github" + "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" +) + +func dataSourceGithubEnterpriseCostCenters() *schema.Resource { + return &schema.Resource{ + Description: "Use this data source to retrieve a list of enterprise cost centers.", + ReadContext: dataSourceGithubEnterpriseCostCentersRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "state": { + Type: schema.TypeString, + Optional: true, + Default: "all", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"all", "active", "deleted"}, false)), + Description: "Filter cost centers by state. Valid values are 'all', 'active', and 'deleted'.", + }, + "cost_centers": { + Type: schema.TypeSet, + Computed: true, + Description: "The list of cost centers.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Computed: true, + Description: "The cost center ID.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the cost center.", + }, + "state": { + Type: schema.TypeString, + Computed: true, + Description: "The state of the cost center.", + }, + "azure_subscription": { + Type: schema.TypeString, + Computed: true, + Description: "The Azure subscription associated with the cost center.", + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseCostCentersRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + stateFilter := d.Get("state").(string) + + var opts github.ListCostCenterOptions + if stateFilter != "all" { + opts.State = github.Ptr(stateFilter) + } + + result, _, err := client.Enterprise.ListCostCenters(ctx, enterpriseSlug, &opts) + if err != nil { + return diag.FromErr(err) + } + + items := make([]any, 0, len(result.CostCenters)) + for _, cc := range result.CostCenters { + if cc == nil { + continue + } + items = append(items, map[string]any{ + "id": cc.ID, + "name": cc.Name, + "state": cc.GetState(), + "azure_subscription": cc.GetAzureSubscription(), + }) + } + + id, err := buildID(enterpriseSlug, stateFilter) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + if err := d.Set("cost_centers", items); err != nil { + return diag.FromErr(err) + } + return nil +} diff --git a/github/data_source_github_enterprise_cost_centers_test.go b/github/data_source_github_enterprise_cost_centers_test.go new file mode 100644 index 0000000000..ac9c27b906 --- /dev/null +++ b/github/data_source_github_enterprise_cost_centers_test.go @@ -0,0 +1,46 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/compare" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "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 TestAccGithubEnterpriseCostCentersDataSource(t *testing.T) { + randomID := acctest.RandString(5) + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + + data "github_enterprise_cost_centers" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + state = "active" + depends_on = [github_enterprise_cost_center.test] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{{ + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.github_enterprise_cost_centers.test", tfjsonpath.New("state"), knownvalue.StringExact("active")), + statecheck.CompareValueCollection("data.github_enterprise_cost_centers.test", []tfjsonpath.Path{tfjsonpath.New("cost_centers"), tfjsonpath.New("id")}, "github_enterprise_cost_center.test", tfjsonpath.New("id"), compare.ValuesSame()), + }, + }}, + }) +} diff --git a/github/provider.go b/github/provider.go index 2d5d6dc33a..c08f52a3b8 100644 --- a/github/provider.go +++ b/github/provider.go @@ -217,6 +217,10 @@ func Provider() *schema.Provider { "github_enterprise_actions_workflow_permissions": resourceGithubEnterpriseActionsWorkflowPermissions(), "github_actions_organization_workflow_permissions": resourceGithubActionsOrganizationWorkflowPermissions(), "github_enterprise_security_analysis_settings": resourceGithubEnterpriseSecurityAnalysisSettings(), + "github_enterprise_cost_center": resourceGithubEnterpriseCostCenter(), + "github_enterprise_cost_center_users": resourceGithubEnterpriseCostCenterUsers(), + "github_enterprise_cost_center_organizations": resourceGithubEnterpriseCostCenterOrganizations(), + "github_enterprise_cost_center_repositories": resourceGithubEnterpriseCostCenterRepositories(), "github_workflow_repository_permissions": resourceGithubWorkflowRepositoryPermissions(), }, @@ -294,6 +298,8 @@ func Provider() *schema.Provider { "github_user_external_identity": dataSourceGithubUserExternalIdentity(), "github_users": dataSourceGithubUsers(), "github_enterprise": dataSourceGithubEnterprise(), + "github_enterprise_cost_center": dataSourceGithubEnterpriseCostCenter(), + "github_enterprise_cost_centers": dataSourceGithubEnterpriseCostCenters(), "github_repository_environment_deployment_policies": dataSourceGithubRepositoryEnvironmentDeploymentPolicies(), }, } diff --git a/github/resource_github_enterprise_cost_center.go b/github/resource_github_enterprise_cost_center.go new file mode 100644 index 0000000000..c05142be77 --- /dev/null +++ b/github/resource_github_enterprise_cost_center.go @@ -0,0 +1,186 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v83/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEnterpriseCostCenter() *schema.Resource { + return &schema.Resource{ + Description: "Manages an enterprise cost center in GitHub.", + CreateContext: resourceGithubEnterpriseCostCenterCreate, + ReadContext: resourceGithubEnterpriseCostCenterRead, + UpdateContext: resourceGithubEnterpriseCostCenterUpdate, + DeleteContext: resourceGithubEnterpriseCostCenterDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubEnterpriseCostCenterImport, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + }, + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the cost center.", + }, + "state": { + Type: schema.TypeString, + Computed: true, + Description: "The state of the cost center.", + }, + "azure_subscription": { + Type: schema.TypeString, + Computed: true, + Description: "The Azure subscription associated with the cost center.", + }, + }, + } +} + +func resourceGithubEnterpriseCostCenterCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + name := d.Get("name").(string) + + tflog.Info(ctx, "Creating enterprise cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "name": name, + }) + + cc, _, err := client.Enterprise.CreateCostCenter(ctx, enterpriseSlug, github.CostCenterRequest{Name: name}) + if err != nil { + return diag.FromErr(err) + } + + if cc == nil || cc.ID == "" { + return diag.Errorf("failed to create cost center: missing id in response (unexpected API response; please retry or contact support)") + } + + d.SetId(cc.ID) + + if err := d.Set("state", cc.GetState()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("azure_subscription", cc.GetAzureSubscription()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCostCenterRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Id() + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + tflog.Warn(ctx, "Cost center not found, removing from state", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + // If the cost center is archived (deleted), remove from state + if cc.GetState() == "deleted" { + tflog.Warn(ctx, "Cost center is archived, removing from state", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + d.SetId("") + return nil + } + + if err := d.Set("name", cc.Name); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("state", cc.GetState()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("azure_subscription", cc.GetAzureSubscription()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCostCenterUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Id() + + if d.HasChange("name") { + name := d.Get("name").(string) + tflog.Info(ctx, "Updating enterprise cost center name", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "name": name, + }) + _, _, err := client.Enterprise.UpdateCostCenter(ctx, enterpriseSlug, costCenterID, github.CostCenterRequest{Name: name}) + if err != nil { + return diag.FromErr(err) + } + } + + return nil +} + +func resourceGithubEnterpriseCostCenterDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Id() + + tflog.Info(ctx, "Archiving enterprise cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + + _, _, err := client.Enterprise.DeleteCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + return nil + } + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCostCenterImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + enterpriseSlug, costCenterID, err := parseID2(d.Id()) + if err != nil { + return nil, fmt.Errorf("invalid import ID %q: expected format :", d.Id()) + } + + client := meta.(*Owner).v3client + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return nil, fmt.Errorf("error reading cost center %q in enterprise %q: %w", costCenterID, enterpriseSlug, err) + } + + d.SetId(costCenterID) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return nil, err + } + if err := d.Set("name", cc.Name); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_enterprise_cost_center_organizations.go b/github/resource_github_enterprise_cost_center_organizations.go new file mode 100644 index 0000000000..94e49fe730 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_organizations.go @@ -0,0 +1,231 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v83/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEnterpriseCostCenterOrganizations() *schema.Resource { + return &schema.Resource{ + Description: "Manages organization assignments for a GitHub enterprise cost center (authoritative).", + CreateContext: resourceGithubEnterpriseCostCenterOrganizationsCreate, + ReadContext: resourceGithubEnterpriseCostCenterOrganizationsRead, + UpdateContext: resourceGithubEnterpriseCostCenterOrganizationsUpdate, + DeleteContext: resourceGithubEnterpriseCostCenterOrganizationsDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubEnterpriseCostCenterOrganizationsImport, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + }, + "cost_center_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The ID of the cost center.", + }, + "organization_logins": { + Type: schema.TypeSet, + Required: true, + MinItems: 1, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Organization logins to assign to the cost center. This is authoritative - organizations not in this set will be removed.", + }, + }, + } +} + +func resourceGithubEnterpriseCostCenterOrganizationsCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeOrg { + return diag.Errorf("cost center %q already has organizations assigned; import the existing assignments first or remove them manually", costCenterID) + } + } + + desiredOrgsSet := d.Get("organization_logins").(*schema.Set) + toAdd := expandStringList(desiredOrgsSet.List()) + + tflog.Info(ctx, "Adding organizations to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) + + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { + return diags + } + } + + d.SetId(costCenterID) + return nil +} + +func resourceGithubEnterpriseCostCenterOrganizationsUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + + diff := make(map[string]bool) + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeOrg { + diff[ccResource.Name] = false + } + } + + var toAdd []string + for _, org := range d.Get("organization_logins").(*schema.Set).List() { + name := org.(string) + if _, exists := diff[name]; exists { + diff[name] = true + } else { + toAdd = append(toAdd, name) + } + } + + var toRemove []string + for name, keep := range diff { + if !keep { + toRemove = append(toRemove, name) + } + } + + if len(toRemove) > 0 { + tflog.Info(ctx, "Removing organizations from cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toRemove), + }) + + for _, batch := range chunkStringSlice(toRemove, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { + return diags + } + } + } + + if len(toAdd) > 0 { + tflog.Info(ctx, "Adding organizations to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) + + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { + return diags + } + } + } + + return nil +} + +func resourceGithubEnterpriseCostCenterOrganizationsRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + tflog.Warn(ctx, "Cost center not found, removing from state", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + var organizations []string + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeOrg { + organizations = append(organizations, ccResource.Name) + } + } + + if err := d.Set("organization_logins", flattenStringList(organizations)); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCostCenterOrganizationsDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + return nil + } + return diag.FromErr(err) + } + + var organizations []string + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeOrg { + organizations = append(organizations, ccResource.Name) + } + } + + if len(organizations) > 0 { + tflog.Info(ctx, "Removing all organizations from cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(organizations), + }) + + for _, batch := range chunkStringSlice(organizations, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { + return diags + } + } + } + + return nil +} + +func resourceGithubEnterpriseCostCenterOrganizationsImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + enterpriseSlug, costCenterID, err := parseID2(d.Id()) + if err != nil { + return nil, fmt.Errorf("invalid import ID %q: expected format :", d.Id()) + } + + d.SetId(costCenterID) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return nil, err + } + if err := d.Set("cost_center_id", costCenterID); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_enterprise_cost_center_organizations_test.go b/github/resource_github_enterprise_cost_center_organizations_test.go new file mode 100644 index 0000000000..ab77b42633 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_organizations_test.go @@ -0,0 +1,99 @@ +package github + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "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/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccGithubEnterpriseCostCenterOrganizations(t *testing.T) { + orgLogin := os.Getenv("ENTERPRISE_TEST_ORGANIZATION") + if orgLogin == "" { + t.Skip("ENTERPRISE_TEST_ORGANIZATION not set") + } + + t.Run("manages organization assignments without error", func(t *testing.T) { + randomID := acctest.RandString(5) + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + + resource "github_enterprise_cost_center_organizations" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + cost_center_id = github_enterprise_cost_center.test.id + organization_logins = [%q] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID, orgLogin) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubEnterpriseCostCenterOrganizationsDestroy, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_cost_center_organizations.test", tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), + statecheck.ExpectKnownValue("github_enterprise_cost_center_organizations.test", tfjsonpath.New("organization_logins"), knownvalue.SetSizeExact(1)), + statecheck.ExpectKnownValue("github_enterprise_cost_center_organizations.test", tfjsonpath.New("organization_logins"), knownvalue.SetPartial([]knownvalue.Check{knownvalue.StringExact(orgLogin)})), + }, + }, + { + ResourceName: "github_enterprise_cost_center_organizations.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: testAccConf.enterpriseSlug + ":", + }, + }, + }) + }) +} + +func testAccCheckGithubEnterpriseCostCenterOrganizationsDestroy(s *terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err + } + client := meta.v3client + + for _, rs := range s.RootModule().Resources { + if rs.Type != "github_enterprise_cost_center_organizations" { + continue + } + + enterpriseSlug := rs.Primary.Attributes["enterprise_slug"] + costCenterID := rs.Primary.Attributes["cost_center_id"] + + cc, _, err := client.Enterprise.GetCostCenter(context.Background(), enterpriseSlug, costCenterID) + if errIs404(err) { + return nil + } + if err != nil { + return err + } + + // Check if organizations are still assigned + for _, resource := range cc.Resources { + if resource.Type == CostCenterResourceTypeOrg { + return fmt.Errorf("cost center %s still has organization assignments", costCenterID) + } + } + } + + return nil +} diff --git a/github/resource_github_enterprise_cost_center_repositories.go b/github/resource_github_enterprise_cost_center_repositories.go new file mode 100644 index 0000000000..14fdc672f1 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_repositories.go @@ -0,0 +1,231 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v83/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEnterpriseCostCenterRepositories() *schema.Resource { + return &schema.Resource{ + Description: "Manages repository assignments for a GitHub enterprise cost center (authoritative).", + CreateContext: resourceGithubEnterpriseCostCenterRepositoriesCreate, + ReadContext: resourceGithubEnterpriseCostCenterRepositoriesRead, + UpdateContext: resourceGithubEnterpriseCostCenterRepositoriesUpdate, + DeleteContext: resourceGithubEnterpriseCostCenterRepositoriesDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubEnterpriseCostCenterRepositoriesImport, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + }, + "cost_center_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The ID of the cost center.", + }, + "repository_names": { + Type: schema.TypeSet, + Required: true, + MinItems: 1, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Repository names (full name, e.g. org/repo) to assign to the cost center. This is authoritative - repositories not in this set will be removed.", + }, + }, + } +} + +func resourceGithubEnterpriseCostCenterRepositoriesCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeRepo { + return diag.Errorf("cost center %q already has repositories assigned; import the existing assignments first or remove them manually", costCenterID) + } + } + + desiredReposSet := d.Get("repository_names").(*schema.Set) + toAdd := expandStringList(desiredReposSet.List()) + + tflog.Info(ctx, "Adding repositories to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) + + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { + return diags + } + } + + d.SetId(costCenterID) + return nil +} + +func resourceGithubEnterpriseCostCenterRepositoriesUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + + diff := make(map[string]bool) + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeRepo { + diff[ccResource.Name] = false + } + } + + var toAdd []string + for _, repo := range d.Get("repository_names").(*schema.Set).List() { + name := repo.(string) + if _, exists := diff[name]; exists { + diff[name] = true + } else { + toAdd = append(toAdd, name) + } + } + + var toRemove []string + for name, keep := range diff { + if !keep { + toRemove = append(toRemove, name) + } + } + + if len(toRemove) > 0 { + tflog.Info(ctx, "Removing repositories from cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toRemove), + }) + + for _, batch := range chunkStringSlice(toRemove, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { + return diags + } + } + } + + if len(toAdd) > 0 { + tflog.Info(ctx, "Adding repositories to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) + + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { + return diags + } + } + } + + return nil +} + +func resourceGithubEnterpriseCostCenterRepositoriesRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + tflog.Warn(ctx, "Cost center not found, removing from state", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + var repositories []string + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeRepo { + repositories = append(repositories, ccResource.Name) + } + } + + if err := d.Set("repository_names", flattenStringList(repositories)); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCostCenterRepositoriesDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + return nil + } + return diag.FromErr(err) + } + + var repositories []string + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeRepo { + repositories = append(repositories, ccResource.Name) + } + } + + if len(repositories) > 0 { + tflog.Info(ctx, "Removing all repositories from cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(repositories), + }) + + for _, batch := range chunkStringSlice(repositories, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { + return diags + } + } + } + + return nil +} + +func resourceGithubEnterpriseCostCenterRepositoriesImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + enterpriseSlug, costCenterID, err := parseID2(d.Id()) + if err != nil { + return nil, fmt.Errorf("invalid import ID %q: expected format :", d.Id()) + } + + d.SetId(costCenterID) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return nil, err + } + if err := d.Set("cost_center_id", costCenterID); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_enterprise_cost_center_repositories_test.go b/github/resource_github_enterprise_cost_center_repositories_test.go new file mode 100644 index 0000000000..cbac3871b4 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_repositories_test.go @@ -0,0 +1,99 @@ +package github + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "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/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccGithubEnterpriseCostCenterRepositories(t *testing.T) { + repoName := os.Getenv("ENTERPRISE_TEST_REPOSITORY") + if repoName == "" { + t.Skip("ENTERPRISE_TEST_REPOSITORY not set") + } + + t.Run("manages repository assignments without error", func(t *testing.T) { + randomID := acctest.RandString(5) + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + + resource "github_enterprise_cost_center_repositories" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + cost_center_id = github_enterprise_cost_center.test.id + repository_names = [%q] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID, repoName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubEnterpriseCostCenterRepositoriesDestroy, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_cost_center_repositories.test", tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), + statecheck.ExpectKnownValue("github_enterprise_cost_center_repositories.test", tfjsonpath.New("repository_names"), knownvalue.SetSizeExact(1)), + statecheck.ExpectKnownValue("github_enterprise_cost_center_repositories.test", tfjsonpath.New("repository_names"), knownvalue.SetPartial([]knownvalue.Check{knownvalue.StringExact(repoName)})), + }, + }, + { + ResourceName: "github_enterprise_cost_center_repositories.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: testAccConf.enterpriseSlug + ":", + }, + }, + }) + }) +} + +func testAccCheckGithubEnterpriseCostCenterRepositoriesDestroy(s *terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err + } + client := meta.v3client + + for _, rs := range s.RootModule().Resources { + if rs.Type != "github_enterprise_cost_center_repositories" { + continue + } + + enterpriseSlug := rs.Primary.Attributes["enterprise_slug"] + costCenterID := rs.Primary.Attributes["cost_center_id"] + + cc, _, err := client.Enterprise.GetCostCenter(context.Background(), enterpriseSlug, costCenterID) + if errIs404(err) { + return nil + } + if err != nil { + return err + } + + // Check if repositories are still assigned + for _, resource := range cc.Resources { + if resource.Type == CostCenterResourceTypeRepo { + return fmt.Errorf("cost center %s still has repository assignments", costCenterID) + } + } + } + + return nil +} diff --git a/github/resource_github_enterprise_cost_center_test.go b/github/resource_github_enterprise_cost_center_test.go new file mode 100644 index 0000000000..84171ffdab --- /dev/null +++ b/github/resource_github_enterprise_cost_center_test.go @@ -0,0 +1,112 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "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 TestAccGithubEnterpriseCostCenter(t *testing.T) { + t.Run("creates cost center without error", func(t *testing.T) { + randomID := acctest.RandString(5) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_cost_center.test", tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), + statecheck.ExpectKnownValue("github_enterprise_cost_center.test", tfjsonpath.New("name"), knownvalue.StringExact(testResourcePrefix+randomID)), + statecheck.ExpectKnownValue("github_enterprise_cost_center.test", tfjsonpath.New("state"), knownvalue.StringExact("active")), + }, + }, + }, + }) + }) + + t.Run("updates cost center name without error", func(t *testing.T) { + randomID := acctest.RandString(5) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_cost_center.test", tfjsonpath.New("name"), knownvalue.StringExact(testResourcePrefix+randomID)), + }, + }, + { + Config: fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%supdated-%s" + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_cost_center.test", tfjsonpath.New("name"), knownvalue.StringExact(testResourcePrefix+"updated-"+randomID)), + }, + }, + }, + }) + }) + + t.Run("imports cost center without error", func(t *testing.T) { + randomID := acctest.RandString(5) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), + }, + { + ResourceName: "github_enterprise_cost_center.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: testAccConf.enterpriseSlug + ":", + }, + }, + }) + }) +} diff --git a/github/resource_github_enterprise_cost_center_users.go b/github/resource_github_enterprise_cost_center_users.go new file mode 100644 index 0000000000..ba05017e95 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_users.go @@ -0,0 +1,231 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v83/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEnterpriseCostCenterUsers() *schema.Resource { + return &schema.Resource{ + Description: "Manages user assignments for a GitHub enterprise cost center (authoritative).", + CreateContext: resourceGithubEnterpriseCostCenterUsersCreate, + ReadContext: resourceGithubEnterpriseCostCenterUsersRead, + UpdateContext: resourceGithubEnterpriseCostCenterUsersUpdate, + DeleteContext: resourceGithubEnterpriseCostCenterUsersDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubEnterpriseCostCenterUsersImport, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + }, + "cost_center_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The ID of the cost center.", + }, + "usernames": { + Type: schema.TypeSet, + Required: true, + MinItems: 1, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Usernames to assign to the cost center. This is authoritative - users not in this set will be removed.", + }, + }, + } +} + +func resourceGithubEnterpriseCostCenterUsersCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeUser { + return diag.Errorf("cost center %q already has users assigned; import the existing assignments first or remove them manually", costCenterID) + } + } + + desiredUsersSet := d.Get("usernames").(*schema.Set) + toAdd := expandStringList(desiredUsersSet.List()) + + tflog.Info(ctx, "Adding users to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) + + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { + return diags + } + } + + d.SetId(costCenterID) + return nil +} + +func resourceGithubEnterpriseCostCenterUsersUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + + diff := make(map[string]bool) + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeUser { + diff[ccResource.Name] = false + } + } + + var toAdd []string + for _, user := range d.Get("usernames").(*schema.Set).List() { + name := user.(string) + if _, exists := diff[name]; exists { + diff[name] = true + } else { + toAdd = append(toAdd, name) + } + } + + var toRemove []string + for name, keep := range diff { + if !keep { + toRemove = append(toRemove, name) + } + } + + if len(toRemove) > 0 { + tflog.Info(ctx, "Removing users from cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toRemove), + }) + + for _, batch := range chunkStringSlice(toRemove, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { + return diags + } + } + } + + if len(toAdd) > 0 { + tflog.Info(ctx, "Adding users to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) + + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { + return diags + } + } + } + + return nil +} + +func resourceGithubEnterpriseCostCenterUsersRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + tflog.Warn(ctx, "Cost center not found, removing from state", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + var users []string + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeUser { + users = append(users, ccResource.Name) + } + } + + if err := d.Set("usernames", flattenStringList(users)); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCostCenterUsersDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + return nil + } + return diag.FromErr(err) + } + + var usernames []string + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeUser { + usernames = append(usernames, ccResource.Name) + } + } + + if len(usernames) > 0 { + tflog.Info(ctx, "Removing all users from cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(usernames), + }) + + for _, batch := range chunkStringSlice(usernames, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { + return diags + } + } + } + + return nil +} + +func resourceGithubEnterpriseCostCenterUsersImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + enterpriseSlug, costCenterID, err := parseID2(d.Id()) + if err != nil { + return nil, fmt.Errorf("invalid import ID %q: expected format :", d.Id()) + } + + d.SetId(costCenterID) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return nil, err + } + if err := d.Set("cost_center_id", costCenterID); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_enterprise_cost_center_users_test.go b/github/resource_github_enterprise_cost_center_users_test.go new file mode 100644 index 0000000000..48ee0a35a8 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_users_test.go @@ -0,0 +1,94 @@ +package github + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "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/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccGithubEnterpriseCostCenterUsers(t *testing.T) { + t.Run("manages user assignments without error", func(t *testing.T) { + randomID := acctest.RandString(5) + user := testAccConf.username + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + + resource "github_enterprise_cost_center_users" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + cost_center_id = github_enterprise_cost_center.test.id + usernames = [%q] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID, user) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubEnterpriseCostCenterUsersDestroy, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_cost_center_users.test", tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), + statecheck.ExpectKnownValue("github_enterprise_cost_center_users.test", tfjsonpath.New("usernames"), knownvalue.SetSizeExact(1)), + statecheck.ExpectKnownValue("github_enterprise_cost_center_users.test", tfjsonpath.New("usernames"), knownvalue.SetPartial([]knownvalue.Check{knownvalue.StringExact(user)})), + }, + }, + { + ResourceName: "github_enterprise_cost_center_users.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: testAccConf.enterpriseSlug + ":", + }, + }, + }) + }) +} + +func testAccCheckGithubEnterpriseCostCenterUsersDestroy(s *terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err + } + client := meta.v3client + + for _, rs := range s.RootModule().Resources { + if rs.Type != "github_enterprise_cost_center_users" { + continue + } + + enterpriseSlug := rs.Primary.Attributes["enterprise_slug"] + costCenterID := rs.Primary.Attributes["cost_center_id"] + + cc, _, err := client.Enterprise.GetCostCenter(context.Background(), enterpriseSlug, costCenterID) + if errIs404(err) { + return nil + } + if err != nil { + return err + } + + // Check if users are still assigned + for _, resource := range cc.Resources { + if resource.Type == CostCenterResourceTypeUser { + return fmt.Errorf("cost center %s still has user assignments", costCenterID) + } + } + } + + return nil +} diff --git a/github/util.go b/github/util.go index 0d9bc0d32c..fa71832f32 100644 --- a/github/util.go +++ b/github/util.go @@ -270,6 +270,42 @@ func getTeamSlugContext(ctx context.Context, teamIDString string, meta any) (str return team.GetSlug(), nil } +// errIs404 checks if the error is a GitHub 404 Not Found response. +func errIs404(err error) bool { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil { + return ghErr.Response.StatusCode == http.StatusNotFound + } + return false +} + +// errIsRetryable checks if the error is a retryable GitHub API error. +func errIsRetryable(err error) bool { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil { + switch ghErr.Response.StatusCode { + case http.StatusConflict, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return true + default: + return false + } + } + return false +} + +// chunkStringSlice splits a slice into chunks of the specified max size. +func chunkStringSlice(items []string, maxSize int) [][]string { + if len(items) == 0 { + return nil + } + chunks := make([][]string, 0, (len(items)+maxSize-1)/maxSize) + for start := 0; start < len(items); start += maxSize { + end := min(start+maxSize, len(items)) + chunks = append(chunks, items[start:end]) + } + return chunks +} + // https://docs.github.com/en/actions/reference/encrypted-secrets#naming-your-secrets var secretNameRegexp = regexp.MustCompile("^[a-zA-Z_][a-zA-Z0-9_]*$") diff --git a/github/util_enterprise_cost_center.go b/github/util_enterprise_cost_center.go new file mode 100644 index 0000000000..7ddfe9b201 --- /dev/null +++ b/github/util_enterprise_cost_center.go @@ -0,0 +1,59 @@ +package github + +import ( + "context" + "time" + + "github.com/google/go-github/v83/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" +) + +// Cost center resource management constants and retry functions. +const ( + maxCostCenterResourcesPerRequest = 50 + costCenterResourcesRetryTimeout = 5 * time.Minute + + // CostCenterResourceType constants match the API response values. + CostCenterResourceTypeUser = "User" + CostCenterResourceTypeOrg = "Org" + CostCenterResourceTypeRepo = "Repo" +) + +// retryCostCenterRemoveResources removes resources from a cost center with retry logic. +// Uses retry.RetryContext for exponential backoff on transient errors. +func retryCostCenterRemoveResources(ctx context.Context, client *github.Client, enterpriseSlug, costCenterID string, req github.CostCenterResourceRequest) diag.Diagnostics { + err := retry.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *retry.RetryError { + _, _, err := client.Enterprise.RemoveResourcesFromCostCenter(ctx, enterpriseSlug, costCenterID, req) + if err == nil { + return nil + } + if errIsRetryable(err) { + return retry.RetryableError(err) + } + return retry.NonRetryableError(err) + }) + if err != nil { + return diag.FromErr(err) + } + return nil +} + +// retryCostCenterAddResources adds resources to a cost center with retry logic. +// Uses retry.RetryContext for exponential backoff on transient errors. +func retryCostCenterAddResources(ctx context.Context, client *github.Client, enterpriseSlug, costCenterID string, req github.CostCenterResourceRequest) diag.Diagnostics { + err := retry.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *retry.RetryError { + _, _, err := client.Enterprise.AddResourcesToCostCenter(ctx, enterpriseSlug, costCenterID, req) + if err == nil { + return nil + } + if errIsRetryable(err) { + return retry.RetryableError(err) + } + return retry.NonRetryableError(err) + }) + if err != nil { + return diag.FromErr(err) + } + return nil +} diff --git a/github/util_test.go b/github/util_test.go index 38db46353d..1a720de36c 100644 --- a/github/util_test.go +++ b/github/util_test.go @@ -1,9 +1,12 @@ package github import ( + "fmt" + "net/http" "testing" "unicode" + "github.com/google/go-github/v83/github" "github.com/hashicorp/go-cty/cty" ) @@ -515,3 +518,194 @@ func TestGithubUtilValidateSecretName(t *testing.T) { } } } + +func ghErrorResponse(statusCode int) *github.ErrorResponse { + return &github.ErrorResponse{ + Response: &http.Response{StatusCode: statusCode}, + } +} + +func Test_errIs404(t *testing.T) { + t.Parallel() + + for _, d := range []struct { + testName string + err error + expected bool + }{ + { + testName: "nil_error", + err: nil, + expected: false, + }, + { + testName: "plain_error", + err: fmt.Errorf("some error"), + expected: false, + }, + { + testName: "github_404", + err: ghErrorResponse(http.StatusNotFound), + expected: true, + }, + { + testName: "github_403", + err: ghErrorResponse(http.StatusForbidden), + expected: false, + }, + { + testName: "github_500", + err: ghErrorResponse(http.StatusInternalServerError), + expected: false, + }, + } { + t.Run(d.testName, func(t *testing.T) { + t.Parallel() + + got := errIs404(d.err) + if got != d.expected { + t.Fatalf("expected errIs404 %v but got %v", d.expected, got) + } + }) + } +} + +func Test_errIsRetryable(t *testing.T) { + t.Parallel() + + for _, d := range []struct { + testName string + err error + expected bool + }{ + { + testName: "nil_error", + err: nil, + expected: false, + }, + { + testName: "plain_error", + err: fmt.Errorf("some error"), + expected: false, + }, + { + testName: "github_404_not_retryable", + err: ghErrorResponse(http.StatusNotFound), + expected: false, + }, + { + testName: "github_409_conflict", + err: ghErrorResponse(http.StatusConflict), + expected: true, + }, + { + testName: "github_500_internal_server_error", + err: ghErrorResponse(http.StatusInternalServerError), + expected: true, + }, + { + testName: "github_502_bad_gateway", + err: ghErrorResponse(http.StatusBadGateway), + expected: true, + }, + { + testName: "github_503_service_unavailable", + err: ghErrorResponse(http.StatusServiceUnavailable), + expected: true, + }, + { + testName: "github_504_gateway_timeout", + err: ghErrorResponse(http.StatusGatewayTimeout), + expected: true, + }, + { + testName: "github_400_bad_request", + err: ghErrorResponse(http.StatusBadRequest), + expected: false, + }, + } { + t.Run(d.testName, func(t *testing.T) { + t.Parallel() + + got := errIsRetryable(d.err) + if got != d.expected { + t.Fatalf("expected errIsRetryable %v but got %v", d.expected, got) + } + }) + } +} + +func Test_chunkStringSlice(t *testing.T) { + t.Parallel() + + for _, d := range []struct { + testName string + items []string + maxSize int + expected [][]string + }{ + { + testName: "nil_slice", + items: nil, + maxSize: 3, + expected: nil, + }, + { + testName: "empty_slice", + items: []string{}, + maxSize: 3, + expected: nil, + }, + { + testName: "single_item", + items: []string{"a"}, + maxSize: 3, + expected: [][]string{{"a"}}, + }, + { + testName: "exact_fit", + items: []string{"a", "b", "c"}, + maxSize: 3, + expected: [][]string{{"a", "b", "c"}}, + }, + { + testName: "with_remainder", + items: []string{"a", "b", "c", "d", "e"}, + maxSize: 2, + expected: [][]string{{"a", "b"}, {"c", "d"}, {"e"}}, + }, + { + testName: "chunk_size_one", + items: []string{"a", "b", "c"}, + maxSize: 1, + expected: [][]string{{"a"}, {"b"}, {"c"}}, + }, + { + testName: "chunk_size_larger_than_slice", + items: []string{"a", "b"}, + maxSize: 10, + expected: [][]string{{"a", "b"}}, + }, + } { + t.Run(d.testName, func(t *testing.T) { + t.Parallel() + + got := chunkStringSlice(d.items, d.maxSize) + + if len(got) != len(d.expected) { + t.Fatalf("expected %d chunks but got %d", len(d.expected), len(got)) + } + + for i := range got { + if len(got[i]) != len(d.expected[i]) { + t.Fatalf("expected chunk[%d] to have %d items but got %d", i, len(d.expected[i]), len(got[i])) + } + for j := range got[i] { + if got[i][j] != d.expected[i][j] { + t.Fatalf("expected chunk[%d][%d] %q but got %q", i, j, d.expected[i][j], got[i][j]) + } + } + } + }) + } +} diff --git a/website/docs/d/enterprise_cost_center.html.markdown b/website/docs/d/enterprise_cost_center.html.markdown new file mode 100644 index 0000000000..d168b8a523 --- /dev/null +++ b/website/docs/d/enterprise_cost_center.html.markdown @@ -0,0 +1,34 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_cost_center" +description: |- + Get a GitHub enterprise cost center by ID. +--- + +# github_enterprise_cost_center + +Use this data source to retrieve a GitHub enterprise cost center by ID. + +## Example Usage + +``` +data "github_enterprise_cost_center" "example" { + enterprise_slug = "example-enterprise" + cost_center_id = "cc_123456" +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `cost_center_id` - (Required) The ID of the cost center. + +## Attributes Reference + +* `name` - The name of the cost center. +* `state` - The state of the cost center. +* `azure_subscription` - The Azure subscription associated with the cost center. +* `users` - The usernames currently assigned to the cost center. +* `organizations` - The organization logins currently assigned to the cost center. +* `repositories` - The repositories currently assigned to the cost center. + diff --git a/website/docs/d/enterprise_cost_centers.html.markdown b/website/docs/d/enterprise_cost_centers.html.markdown new file mode 100644 index 0000000000..1f400c9215 --- /dev/null +++ b/website/docs/d/enterprise_cost_centers.html.markdown @@ -0,0 +1,33 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_cost_centers" +description: |- + List GitHub enterprise cost centers. +--- + +# github_enterprise_cost_centers + +Use this data source to list GitHub enterprise cost centers. + +## Example Usage + +``` +data "github_enterprise_cost_centers" "active" { + enterprise_slug = "example-enterprise" + state = "active" +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `state` - (Optional) Filter cost centers by state. Valid values are `all`, `active`, and `deleted`. Defaults to `all`. + +## Attributes Reference + +* `cost_centers` - A set of cost centers. + * `id` - The cost center ID. + * `name` - The name of the cost center. + * `state` - The state of the cost center. + * `azure_subscription` - The Azure subscription associated with the cost center. + diff --git a/website/docs/r/enterprise_cost_center.html.markdown b/website/docs/r/enterprise_cost_center.html.markdown new file mode 100644 index 0000000000..8c3bf413a8 --- /dev/null +++ b/website/docs/r/enterprise_cost_center.html.markdown @@ -0,0 +1,52 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_cost_center" +description: |- + Create and manage a GitHub enterprise cost center. +--- + +# github_enterprise_cost_center + +This resource allows you to create and manage a GitHub enterprise cost center. + +~> **Note:** This resource manages only the cost center entity itself. To assign users, organizations, or repositories, use the separate `github_enterprise_cost_center_users`, `github_enterprise_cost_center_organizations`, and `github_enterprise_cost_center_repositories` resources. + +Deleting this resource archives the cost center (GitHub calls this state `deleted`). + +## Example Usage + +```hcl +resource "github_enterprise_cost_center" "example" { + enterprise_slug = "example-enterprise" + name = "platform-cost-center" +} + +# Use separate resources to manage assignments +resource "github_enterprise_cost_center_users" "example" { + enterprise_slug = "example-enterprise" + cost_center_id = github_enterprise_cost_center.example.id + usernames = ["alice", "bob"] +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `name` - (Required) The name of the cost center. + +## Attributes Reference + +The following additional attributes are exported: + +* `id` - The cost center ID. +* `state` - The state of the cost center. +* `azure_subscription` - The Azure subscription associated with the cost center. + +## Import + +GitHub Enterprise Cost Center can be imported using the `enterprise_slug` and the `cost_center_id`, separated by a `:` character. + +``` +$ terraform import github_enterprise_cost_center.example example-enterprise: +``` + diff --git a/website/docs/r/enterprise_cost_center_organizations.html.markdown b/website/docs/r/enterprise_cost_center_organizations.html.markdown new file mode 100644 index 0000000000..1ca1f7b06d --- /dev/null +++ b/website/docs/r/enterprise_cost_center_organizations.html.markdown @@ -0,0 +1,45 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_cost_center_organizations" +description: |- + Manages organization assignments to a GitHub enterprise cost center. +--- + +# github_enterprise_cost_center_organizations + +This resource manages organization assignments to a GitHub enterprise cost center. + +~> **Note:** This resource is authoritative. It will manage the full set of organizations assigned to the cost center. To add organizations without affecting other assignments, you must include all desired organizations in the `organization_logins` set. + +## Example Usage + +```hcl +resource "github_enterprise_cost_center" "example" { + enterprise_slug = "example-enterprise" + name = "platform-cost-center" +} + +resource "github_enterprise_cost_center_organizations" "example" { + enterprise_slug = "example-enterprise" + cost_center_id = github_enterprise_cost_center.example.id + organization_logins = ["octo-org", "acme-corp"] +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `cost_center_id` - (Required) The ID of the cost center. +* `organization_logins` - (Required) Set of organization logins to assign to the cost center. Must contain at least one organization. + +## Attributes Reference + +This resource exports no additional attributes. + +## Import + +GitHub Enterprise Cost Center Organization assignments can be imported using the `enterprise_slug` and the `cost_center_id`, separated by a `:` character. + +``` +$ terraform import github_enterprise_cost_center_organizations.example example-enterprise: +``` diff --git a/website/docs/r/enterprise_cost_center_repositories.html.markdown b/website/docs/r/enterprise_cost_center_repositories.html.markdown new file mode 100644 index 0000000000..dc1767f8a2 --- /dev/null +++ b/website/docs/r/enterprise_cost_center_repositories.html.markdown @@ -0,0 +1,45 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_cost_center_repositories" +description: |- + Manages repository assignments to a GitHub enterprise cost center. +--- + +# github_enterprise_cost_center_repositories + +This resource manages repository assignments to a GitHub enterprise cost center. + +~> **Note:** This resource is authoritative. It will manage the full set of repositories assigned to the cost center. To add repositories without affecting other assignments, you must include all desired repositories in the `repository_names` set. + +## Example Usage + +```hcl +resource "github_enterprise_cost_center" "example" { + enterprise_slug = "example-enterprise" + name = "platform-cost-center" +} + +resource "github_enterprise_cost_center_repositories" "example" { + enterprise_slug = "example-enterprise" + cost_center_id = github_enterprise_cost_center.example.id + repository_names = ["octo-org/my-app", "acme-corp/backend-service"] +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `cost_center_id` - (Required) The ID of the cost center. +* `repository_names` - (Required) Set of repository names (in `owner/repo` format) to assign to the cost center. Must contain at least one repository. + +## Attributes Reference + +This resource exports no additional attributes. + +## Import + +GitHub Enterprise Cost Center Repository assignments can be imported using the `enterprise_slug` and the `cost_center_id`, separated by a `:` character. + +``` +$ terraform import github_enterprise_cost_center_repositories.example example-enterprise: +``` diff --git a/website/docs/r/enterprise_cost_center_users.html.markdown b/website/docs/r/enterprise_cost_center_users.html.markdown new file mode 100644 index 0000000000..b14a51b270 --- /dev/null +++ b/website/docs/r/enterprise_cost_center_users.html.markdown @@ -0,0 +1,45 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_cost_center_users" +description: |- + Manages user assignments to a GitHub enterprise cost center. +--- + +# github_enterprise_cost_center_users + +This resource manages user assignments to a GitHub enterprise cost center. + +~> **Note:** This resource is authoritative. It will manage the full set of users assigned to the cost center. To add users without affecting other assignments, you must include all desired users in the `usernames` set. + +## Example Usage + +```hcl +resource "github_enterprise_cost_center" "example" { + enterprise_slug = "example-enterprise" + name = "platform-cost-center" +} + +resource "github_enterprise_cost_center_users" "example" { + enterprise_slug = "example-enterprise" + cost_center_id = github_enterprise_cost_center.example.id + usernames = ["alice", "bob", "charlie"] +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `cost_center_id` - (Required) The ID of the cost center. +* `usernames` - (Required) Set of usernames to assign to the cost center. Must contain at least one username. + +## Attributes Reference + +This resource exports no additional attributes. + +## Import + +GitHub Enterprise Cost Center User assignments can be imported using the `enterprise_slug` and the `cost_center_id`, separated by a `:` character. + +``` +$ terraform import github_enterprise_cost_center_users.example example-enterprise: +``` diff --git a/website/github.erb b/website/github.erb index 997536b42f..2c5dd1d4b4 100644 --- a/website/github.erb +++ b/website/github.erb @@ -100,6 +100,12 @@
  • github_enterprise
  • +
  • + github_enterprise_cost_center +
  • +
  • + github_enterprise_cost_centers +
  • github_external_groups
  • @@ -307,6 +313,18 @@
  • github_enterprise_actions_permissions
  • +
  • + github_enterprise_cost_center +
  • +
  • + github_enterprise_cost_center_organizations +
  • +
  • + github_enterprise_cost_center_repositories +
  • +
  • + github_enterprise_cost_center_users +
  • github_enterprise_organization