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..0c2cca82f5 --- /dev/null +++ b/github/data_source_github_enterprise_cost_center.go @@ -0,0 +1,94 @@ +package github + +import ( + "context" + "sort" + "strings" + + "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) + _ = d.Set("name", cc.Name) + + state := strings.ToLower(cc.GetState()) + if state == "" { + state = "active" + } + _ = d.Set("state", state) + _ = d.Set("azure_subscription", cc.GetAzureSubscription()) + + users, organizations, repositories := costCenterSplitResources(cc.Resources) + sort.Strings(users) + sort.Strings(organizations) + sort.Strings(repositories) + _ = d.Set("users", flattenStringList(users)) + _ = d.Set("organizations", flattenStringList(organizations)) + _ = d.Set("repositories", flattenStringList(repositories)) + + 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..852dac4cf9 --- /dev/null +++ b/github/data_source_github_enterprise_cost_center_test.go @@ -0,0 +1,53 @@ +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.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + if testAccConf.username == "" { + t.Skip("Skipping because `GITHUB_USERNAME` is not set") + } + + // Use username for testing + 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 = "tf-acc-test-%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, randomID, user) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{{ + Config: config, + 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..9605b946c0 --- /dev/null +++ b/github/data_source_github_enterprise_cost_centers.go @@ -0,0 +1,96 @@ +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: toDiagFunc(validation.StringInSlice([]string{"active", "deleted"}, false), "state"), + 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 { + s := v.(string) + state = &s + } + + 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 := "" + if state != nil { + stateStr = *state + } + d.SetId(buildTwoPartID(enterpriseSlug, stateStr)) + _ = d.Set("cost_centers", items) + 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..3d19e53abc --- /dev/null +++ b/github/data_source_github_enterprise_cost_centers_test.go @@ -0,0 +1,72 @@ +package github + +import ( + "fmt" + "strings" + "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 TestAccGithubEnterpriseCostCentersDataSource(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-test-%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, 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"), + testAccCheckEnterpriseCostCentersListContains("github_enterprise_cost_center.test", "data.github_enterprise_cost_centers.test"), + ), + }}, + }) +} + +func testAccCheckEnterpriseCostCentersListContains(costCenterResourceName, dataSourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + cc, ok := s.RootModule().Resources[costCenterResourceName] + if !ok { + return fmt.Errorf("resource %q not found in state", costCenterResourceName) + } + ccID := cc.Primary.ID + if ccID == "" { + return fmt.Errorf("resource %q has empty ID", costCenterResourceName) + } + + ds, ok := s.RootModule().Resources[dataSourceName] + if !ok { + return fmt.Errorf("data source %q not found in state", dataSourceName) + } + + for k, v := range ds.Primary.Attributes { + if strings.HasPrefix(k, "cost_centers.") && strings.HasSuffix(k, ".id") { + if v == ccID { + return nil + } + } + } + + return fmt.Errorf("expected cost center id %q to be present in %q", ccID, dataSourceName) + } +} diff --git a/github/provider.go b/github/provider.go index 480bcba3fb..1f00381221 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..d1d24442b5 --- /dev/null +++ b/github/resource_github_enterprise_cost_center.go @@ -0,0 +1,451 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "sort" + "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") + } + + d.SetId(cc.ID) + + if hasCostCenterAssignmentsConfigured(d) { + if diags := syncEnterpriseCostCenterAssignments(ctx, d, client, enterpriseSlug, cc.ID, nil); diags.HasError() { + return diags + } + } + + // Set computed fields from the API response + state := strings.ToLower(cc.GetState()) + if state == "" { + state = "active" + } + _ = d.Set("state", state) + _ = d.Set("azure_subscription", cc.GetAzureSubscription()) + + 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) + } + + _ = d.Set("name", cc.Name) + + state := strings.ToLower(cc.GetState()) + if state == "" { + state = "active" + } + _ = d.Set("state", state) + _ = d.Set("azure_subscription", cc.GetAzureSubscription()) + + setCostCenterResourceFields(d, cc) + + 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) + } + + var updatedCC *github.CostCenter + + 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, + }) + cc, _, err := client.Enterprise.UpdateCostCenter(ctx, enterpriseSlug, costCenterID, github.CostCenterRequest{Name: name}) + if err != nil { + return diag.FromErr(err) + } + updatedCC = cc + } + + if d.HasChange("users") || d.HasChange("organizations") || d.HasChange("repositories") { + // Get current resources from API only if we need to sync assignments + var currentResources []*github.CostCenterResource + if updatedCC != nil { + currentResources = updatedCC.Resources + } else { + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + currentResources = cc.Resources + } + if diags := syncEnterpriseCostCenterAssignments(ctx, d, client, enterpriseSlug, costCenterID, currentResources); diags.HasError() { + return diags + } + } + + // Fetch final state to set computed fields + final, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + + _ = d.Set("name", final.Name) + state := strings.ToLower(final.GetState()) + if state == "" { + state = "active" + } + _ = d.Set("state", state) + _ = d.Set("azure_subscription", final.GetAzureSubscription()) + setCostCenterResourceFields(d, final) + + 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) + _ = d.Set("enterprise_slug", enterpriseSlug) + + return []*schema.ResourceData{d}, nil +} + +func syncEnterpriseCostCenterAssignments(ctx context.Context, d *schema.ResourceData, client *github.Client, enterpriseSlug, costCenterID string, currentResources []*github.CostCenterResource) diag.Diagnostics { + desiredUsers := expandStringSet(getStringSetOrEmpty(d, "users")) + desiredOrgs := expandStringSet(getStringSetOrEmpty(d, "organizations")) + desiredRepos := expandStringSet(getStringSetOrEmpty(d, "repositories")) + + currentUsers, currentOrgs, currentRepos := costCenterSplitResources(currentResources) + + toAddUsers, toRemoveUsers := diffStringSlices(currentUsers, desiredUsers) + toAddOrgs, toRemoveOrgs := diffStringSlices(currentOrgs, desiredOrgs) + toAddRepos, toRemoveRepos := diffStringSlices(currentRepos, desiredRepos) + + 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() + out := make([]string, 0, len(list)) + for _, v := range list { + out = append(out, v.(string)) + } + sort.Strings(out) + return out +} + +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 +} + +func diffStringSlices(current, desired []string) (toAdd, toRemove []string) { + cur := schema.NewSet(schema.HashString, flattenStringList(current)) + des := schema.NewSet(schema.HashString, flattenStringList(desired)) + + for _, v := range des.Difference(cur).List() { + toAdd = append(toAdd, v.(string)) + } + for _, v := range cur.Difference(des).List() { + toRemove = append(toRemove, v.(string)) + } + + sort.Strings(toAdd) + sort.Strings(toRemove) + return toAdd, toRemove +} + +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) { + users, organizations, repositories := costCenterSplitResources(cc.Resources) + sort.Strings(users) + sort.Strings(organizations) + sort.Strings(repositories) + _ = d.Set("users", flattenStringList(users)) + _ = d.Set("organizations", flattenStringList(organizations)) + _ = d.Set("repositories", flattenStringList(repositories)) +} + +// 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..3b92b8fd73 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_test.go @@ -0,0 +1,110 @@ +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) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + if testAccConf.username == "" { + t.Skip("Skipping because `GITHUB_USERNAME` is not set") + } + + // Use username for testing + user := testAccConf.username + + configBefore := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-test-%s" + + users = [%q] + } + `, testAccConf.enterpriseSlug, randomID, user) + + configAfter := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-test-updated-%s" + + users = [%q] + } + `, testAccConf.enterpriseSlug, randomID, user) + + configEmpty := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-test-%s" + + users = [] + } + `, testAccConf.enterpriseSlug, randomID) + + checkBefore := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "enterprise_slug", testAccConf.enterpriseSlug), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "name", fmt.Sprintf("tf-acc-test-%s", 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), + ) + + checkAfter := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "name", fmt.Sprintf("tf-acc-test-updated-%s", 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), + ) + + checkEmpty := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "users.#", "0"), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: configBefore, + Check: checkBefore, + }, + { + Config: configAfter, + Check: checkAfter, + }, + { + Config: configEmpty, + Check: checkEmpty, + }, + { + 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 fmt.Sprintf("%s:%s", 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