From 0143ec197e8bc0e342eff375347ceda6becde4d7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 08:49:56 +0100 Subject: [PATCH] feat: add support for Enterprise Cost Centers This adds a new resource and data sources for managing GitHub Enterprise Cost Centers via the REST API. New resources: - github_enterprise_cost_center: Create, update, and archive cost centers New data sources: - github_enterprise_cost_center: Retrieve a cost center by ID - github_enterprise_cost_centers: List cost centers with optional state filter Features: - Authoritative management of cost center resource assignments (users, organizations, repositories) - Retry logic with exponential backoff for transient API errors - Batch processing for large resource assignments (max 50 per request) - Import support using enterprise_slug:cost_center_id format Documentation and examples included. --- examples/cost_centers/main.tf | 100 ++++ ...ta_source_github_enterprise_cost_center.go | 90 ++++ ...urce_github_enterprise_cost_center_test.go | 45 ++ ...a_source_github_enterprise_cost_centers.go | 97 ++++ ...rce_github_enterprise_cost_centers_test.go | 42 ++ github/provider.go | 3 + .../resource_github_enterprise_cost_center.go | 466 ++++++++++++++++++ ...urce_github_enterprise_cost_center_test.go | 149 ++++++ github/util.go | 9 + .../d/enterprise_cost_center.html.markdown | 34 ++ .../d/enterprise_cost_centers.html.markdown | 33 ++ .../r/enterprise_cost_center.html.markdown | 54 ++ website/github.erb | 9 + 13 files changed, 1131 insertions(+) create mode 100644 examples/cost_centers/main.tf create mode 100644 github/data_source_github_enterprise_cost_center.go create mode 100644 github/data_source_github_enterprise_cost_center_test.go create mode 100644 github/data_source_github_enterprise_cost_centers.go create mode 100644 github/data_source_github_enterprise_cost_centers_test.go create mode 100644 github/resource_github_enterprise_cost_center.go create mode 100644 github/resource_github_enterprise_cost_center_test.go create mode 100644 website/docs/d/enterprise_cost_center.html.markdown create mode 100644 website/docs/d/enterprise_cost_centers.html.markdown create mode 100644 website/docs/r/enterprise_cost_center.html.markdown diff --git a/examples/cost_centers/main.tf b/examples/cost_centers/main.tf new file mode 100644 index 0000000000..5341ada0c2 --- /dev/null +++ b/examples/cost_centers/main.tf @@ -0,0 +1,100 @@ +terraform { + required_providers { + github = { + source = "integrations/github" + version = "~> 6.0" + } + } +} + +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 = [] +} + +resource "github_enterprise_cost_center" "example" { + enterprise_slug = var.enterprise_slug + name = var.cost_center_name + + # Authoritative assignments: Terraform will add/remove to match these lists. + users = var.users + organizations = var.organizations + repositories = var.repositories +} + +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_resources" { + description = "Effective assignments (read from API)" + value = { + users = sort(tolist(github_enterprise_cost_center.example.users)) + organizations = sort(tolist(github_enterprise_cost_center.example.organizations)) + repositories = sort(tolist(github_enterprise_cost_center.example.repositories)) + } +} + +output "cost_center_from_data_source" { + description = "Cost center fetched by data source" + 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..54fc8cb5ea --- /dev/null +++ b/github/data_source_github_enterprise_cost_center.go @@ -0,0 +1,90 @@ +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) + } + + if err := setCostCenterResourceFields(d, cc); 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..2a4df4a03e --- /dev/null +++ b/github/data_source_github_enterprise_cost_center_test.go @@ -0,0 +1,45 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccGithubEnterpriseCostCenterDataSource(t *testing.T) { + randomID := acctest.RandString(5) + user := testAccConf.username + + 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" + + users = [%q] + } + + 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, user), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("data.github_enterprise_cost_center.test", "cost_center_id", "github_enterprise_cost_center.test", "id"), + resource.TestCheckResourceAttrPair("data.github_enterprise_cost_center.test", "name", "github_enterprise_cost_center.test", "name"), + resource.TestCheckResourceAttr("data.github_enterprise_cost_center.test", "state", "active"), + resource.TestCheckResourceAttr("data.github_enterprise_cost_center.test", "users.#", "1"), + resource.TestCheckTypeSetElemAttr("data.github_enterprise_cost_center.test", "users.*", user), + ), + }}, + }) +} 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..8c05d6dc2a --- /dev/null +++ b/github/data_source_github_enterprise_cost_centers.go @@ -0,0 +1,97 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v81/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, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"active", "deleted"}, false)), + Description: "Filter cost centers by state.", + }, + "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) + var state *string + if v, ok := d.GetOk("state"); ok { + state = github.Ptr(v.(string)) + } + + result, _, err := client.Enterprise.ListCostCenters(ctx, enterpriseSlug, &github.ListCostCenterOptions{State: state}) + 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(), + }) + } + + stateStr := "all" + if state != nil { + stateStr = *state + } + d.SetId(buildTwoPartID(enterpriseSlug, stateStr)) + 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..b8548c9c27 --- /dev/null +++ b/github/data_source_github_enterprise_cost_centers_test.go @@ -0,0 +1,42 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +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, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.github_enterprise_cost_centers.test", "state", "active"), + resource.TestCheckTypeSetElemAttrPair("data.github_enterprise_cost_centers.test", "cost_centers.*.id", "github_enterprise_cost_center.test", "id"), + ), + }}, + }) +} diff --git a/github/provider.go b/github/provider.go index 4f857d27c0..0e755434a3 100644 --- a/github/provider.go +++ b/github/provider.go @@ -214,6 +214,7 @@ 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_workflow_repository_permissions": resourceGithubWorkflowRepositoryPermissions(), }, @@ -289,6 +290,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..11803c7b9b --- /dev/null +++ b/github/resource_github_enterprise_cost_center.go @@ -0,0 +1,466 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "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.", + }, + "users": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The usernames assigned to this cost center.", + }, + "organizations": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The organization logins assigned to this cost center.", + }, + "repositories": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The repositories (full name) assigned to this 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 hasCostCenterAssignmentsConfigured(d) { + if diags := syncEnterpriseCostCenterAssignments(ctx, d, client, enterpriseSlug, cc.ID); diags.HasError() { + return diags + } + } + + // Set computed fields from the API response + state := strings.ToLower(cc.GetState()) + if state == "" { + state = "active" + } + if err := d.Set("state", state); 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 is404(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 err := d.Set("name", cc.Name); err != nil { + return diag.FromErr(err) + } + + state := strings.ToLower(cc.GetState()) + if state == "" { + state = "active" + } + if err := d.Set("state", state); err != nil { + return diag.FromErr(err) + } + if err := d.Set("azure_subscription", cc.GetAzureSubscription()); err != nil { + return diag.FromErr(err) + } + + if err := setCostCenterResourceFields(d, cc); 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() + + // Check current state to prevent updates on archived cost centers + currentState := d.Get("state").(string) + if strings.EqualFold(currentState, "deleted") { + return diag.Errorf("cannot update cost center %q because it is archived", costCenterID) + } + + 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) + } + } + + if d.HasChange("users") || d.HasChange("organizations") || d.HasChange("repositories") { + if diags := syncCostCenterAssignmentsFromState(ctx, d, client, enterpriseSlug, costCenterID); diags.HasError() { + return diags + } + } + + 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 is404(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 := parseTwoPartID(d.Id(), "enterprise_slug", "cost_center_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 + } + + return []*schema.ResourceData{d}, nil +} + +func syncEnterpriseCostCenterAssignments(ctx context.Context, d *schema.ResourceData, client *github.Client, enterpriseSlug, costCenterID string) diag.Diagnostics { + desiredUsers := expandStringSet(getStringSetOrEmpty(d, "users")) + desiredOrgs := expandStringSet(getStringSetOrEmpty(d, "organizations")) + desiredRepos := expandStringSet(getStringSetOrEmpty(d, "repositories")) + + if len(desiredUsers)+len(desiredOrgs)+len(desiredRepos) > 0 { + tflog.Info(ctx, "Assigning enterprise cost center resources", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + + for _, batch := range chunkStringSlice(desiredUsers) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { + return diags + } + } + for _, batch := range chunkStringSlice(desiredOrgs) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { + return diags + } + } + for _, batch := range chunkStringSlice(desiredRepos) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { + return diags + } + } + } + + return nil +} + +// syncCostCenterAssignmentsFromState syncs assignments using d.GetChange (Terraform as source of truth). +func syncCostCenterAssignmentsFromState(ctx context.Context, d *schema.ResourceData, client *github.Client, enterpriseSlug, costCenterID string) diag.Diagnostics { + var toAddUsers, toRemoveUsers, toAddOrgs, toRemoveOrgs, toAddRepos, toRemoveRepos []string + + if d.HasChange("users") { + oldSet, newSet := d.GetChange("users") + toRemoveUsers, toAddUsers = diffSets(oldSet.(*schema.Set), newSet.(*schema.Set)) + } + if d.HasChange("organizations") { + oldSet, newSet := d.GetChange("organizations") + toRemoveOrgs, toAddOrgs = diffSets(oldSet.(*schema.Set), newSet.(*schema.Set)) + } + if d.HasChange("repositories") { + oldSet, newSet := d.GetChange("repositories") + toRemoveRepos, toAddRepos = diffSets(oldSet.(*schema.Set), newSet.(*schema.Set)) + } + + if len(toRemoveUsers)+len(toRemoveOrgs)+len(toRemoveRepos) > 0 { + tflog.Info(ctx, "Removing enterprise cost center resources", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + + for _, batch := range chunkStringSlice(toRemoveUsers) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { + return diags + } + } + for _, batch := range chunkStringSlice(toRemoveOrgs) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { + return diags + } + } + for _, batch := range chunkStringSlice(toRemoveRepos) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { + return diags + } + } + } + + if len(toAddUsers)+len(toAddOrgs)+len(toAddRepos) > 0 { + tflog.Info(ctx, "Assigning enterprise cost center resources", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + + for _, batch := range chunkStringSlice(toAddUsers) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { + return diags + } + } + for _, batch := range chunkStringSlice(toAddOrgs) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { + return diags + } + } + for _, batch := range chunkStringSlice(toAddRepos) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { + return diags + } + } + } + + return nil +} + +func hasCostCenterAssignmentsConfigured(d *schema.ResourceData) bool { + assignmentKeys := []string{"users", "organizations", "repositories"} + for _, key := range assignmentKeys { + if v, ok := d.GetOk(key); ok { + if set, ok := v.(*schema.Set); ok && set != nil && set.Len() > 0 { + return true + } + } + } + return false +} + +func expandStringSet(set *schema.Set) []string { + if set == nil { + return nil + } + + list := set.List() + return expandStringList(list) +} + +func getStringSetOrEmpty(d *schema.ResourceData, key string) *schema.Set { + v, ok := d.GetOk(key) + if !ok || v == nil { + return schema.NewSet(schema.HashString, []any{}) + } + + set, ok := v.(*schema.Set) + if !ok || set == nil { + return schema.NewSet(schema.HashString, []any{}) + } + + return set +} + +// diffSets returns elements to remove (in old but not new) and to add (in new but not old). +func diffSets(oldSet, newSet *schema.Set) (toRemove, toAdd []string) { + for _, v := range oldSet.Difference(newSet).List() { + toRemove = append(toRemove, v.(string)) + } + for _, v := range newSet.Difference(oldSet).List() { + toAdd = append(toAdd, v.(string)) + } + return toRemove, toAdd +} + +func isRetryableGithubResponseError(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 +} + +func costCenterSplitResources(resources []*github.CostCenterResource) (users, organizations, repositories []string) { + for _, r := range resources { + if r == nil { + continue + } + switch strings.ToLower(r.Type) { + case "user": + users = append(users, r.Name) + case "org", "organization": + organizations = append(organizations, r.Name) + case "repo", "repository": + repositories = append(repositories, r.Name) + } + } + return users, organizations, repositories +} + +// setCostCenterResourceFields sets the resource-related fields on the schema.ResourceData. +func setCostCenterResourceFields(d *schema.ResourceData, cc *github.CostCenter) error { + users, organizations, repositories := costCenterSplitResources(cc.Resources) + if err := d.Set("users", flattenStringList(users)); err != nil { + return err + } + if err := d.Set("organizations", flattenStringList(organizations)); err != nil { + return err + } + if err := d.Set("repositories", flattenStringList(repositories)); err != nil { + return err + } + return nil +} + +// Cost center resource management constants and retry functions. +const ( + maxResourcesPerRequest = 50 + costCenterResourcesRetryTimeout = 5 * time.Minute +) + +// chunkStringSlice splits a slice into chunks of the max resources per request. +func chunkStringSlice(items []string) [][]string { + if len(items) == 0 { + return nil + } + chunks := make([][]string, 0, (len(items)+maxResourcesPerRequest-1)/maxResourcesPerRequest) + for start := 0; start < len(items); start += maxResourcesPerRequest { + end := min(start+maxResourcesPerRequest, len(items)) + chunks = append(chunks, items[start:end]) + } + return chunks +} + +// 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 isRetryableGithubResponseError(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 isRetryableGithubResponseError(err) { + return retry.RetryableError(err) + } + return retry.NonRetryableError(err) + }) + if err != nil { + return diag.FromErr(err) + } + 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..75c5096681 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_test.go @@ -0,0 +1,149 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccGithubEnterpriseCostCenter(t *testing.T) { + t.Run("creates cost center without error", func(t *testing.T) { + randomID := acctest.RandString(5) + user := testAccConf.username + + 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" + + users = [%q] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID, user), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "enterprise_slug", testAccConf.enterpriseSlug), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "name", testResourcePrefix+randomID), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "state", "active"), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "users.#", "1"), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center.test", "users.*", user), + ), + }, + }, + }) + }) + + t.Run("updates cost center without error", func(t *testing.T) { + randomID := acctest.RandString(5) + user := testAccConf.username + + 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" + + users = [%q] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID, user), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "name", testResourcePrefix+randomID), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "users.#", "1"), + ), + }, + { + 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" + + users = [%q] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID, user), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "name", testResourcePrefix+"updated-"+randomID), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "users.#", "1"), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center.test", "users.*", user), + ), + }, + { + 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" + + users = [] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "users.#", "0"), + ), + }, + }, + }) + }) + + t.Run("imports cost center without error", func(t *testing.T) { + randomID := acctest.RandString(5) + user := testAccConf.username + + 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" + + users = [%q] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID, user), + }, + { + ResourceName: "github_enterprise_cost_center.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources["github_enterprise_cost_center.test"] + if !ok { + return "", fmt.Errorf("resource not found in state") + } + return buildTwoPartID(testAccConf.enterpriseSlug, rs.Primary.ID), nil + }, + }, + }, + }) + }) +} diff --git a/github/util.go b/github/util.go index b3345c9477..4aeef72839 100644 --- a/github/util.go +++ b/github/util.go @@ -291,6 +291,15 @@ func getTeamSlugContext(ctx context.Context, teamIDString string, meta any) (str return team.GetSlug(), nil } +// is404 checks if the error is a GitHub 404 Not Found response. +func is404(err error) bool { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil { + return ghErr.Response.StatusCode == http.StatusNotFound + } + return false +} + // 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/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..f7fa7a66ef --- /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 `active` and `deleted`. + +## 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..617267050b --- /dev/null +++ b/website/docs/r/enterprise_cost_center.html.markdown @@ -0,0 +1,54 @@ +--- +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. + +Deleting this resource archives the cost center (GitHub calls this state `deleted`). + +## Example Usage + +``` +resource "github_enterprise_cost_center" "example" { + enterprise_slug = "example-enterprise" + name = "platform-cost-center" + + # Authoritatively manage assignments (Terraform will add/remove to match). + users = ["alice", "bob"] + organizations = ["octo-org"] + repositories = ["octo-org/app"] +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `name` - (Required) The name of the cost center. +* `users` - (Optional) Set of usernames to assign to the cost center. Assignment is authoritative. +* `organizations` - (Optional) Set of organization logins to assign to the cost center. Assignment is authoritative. +* `repositories` - (Optional) Set of repositories (full name, e.g. `org/repo`) to assign to the cost center. Assignment is authoritative. + +## 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. +* `users` - The usernames currently assigned to the cost center (mirrors the authoritative input). +* `organizations` - The organization logins currently assigned to the cost center. +* `repositories` - The repositories currently assigned to 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/github.erb b/website/github.erb index 7db02fc5fc..d5181fa8c6 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
  • @@ -226,6 +232,9 @@
  • github_enterprise_actions_permissions
  • +
  • + github_enterprise_cost_center +
  • github_enterprise_settings