From 993daee815bac65d2f2e0aa41fc3934d0a2f6ecd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:45:47 +0100 Subject: [PATCH 01/10] Add enterprise cost centers resources, data sources, docs, and example --- examples/cost_centers/main.tf | 103 ++++++++ ...ta_source_github_enterprise_cost_center.go | 93 ++++++++ ...urce_github_enterprise_cost_center_test.go | 48 ++++ ...a_source_github_enterprise_cost_centers.go | 97 ++++++++ ...rce_github_enterprise_cost_centers_test.go | 78 +++++++ github/provider.go | 4 + .../resource_github_enterprise_cost_center.go | 182 +++++++++++++++ ...github_enterprise_cost_center_resources.go | 221 ++++++++++++++++++ ...b_enterprise_cost_center_resources_test.go | 171 ++++++++++++++ ...urce_github_enterprise_cost_center_test.go | 81 +++++++ github/util_cost_centers.go | 208 +++++++++++++++++ scripts/env.enterprise-cost-centers.sh | 29 +++ scripts/testacc-enterprise-cost-centers.sh | 29 +++ .../d/enterprise_cost_center.html.markdown | 34 +++ .../d/enterprise_cost_centers.html.markdown | 36 +++ .../r/enterprise_cost_center.html.markdown | 46 ++++ ...rprise_cost_center_resources.html.markdown | 47 ++++ website/github.erb | 12 + 18 files changed, 1519 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_resources.go create mode 100644 github/resource_github_enterprise_cost_center_resources_test.go create mode 100644 github/resource_github_enterprise_cost_center_test.go create mode 100644 github/util_cost_centers.go create mode 100644 scripts/env.enterprise-cost-centers.sh create mode 100755 scripts/testacc-enterprise-cost-centers.sh 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 create mode 100644 website/docs/r/enterprise_cost_center_resources.html.markdown diff --git a/examples/cost_centers/main.tf b/examples/cost_centers/main.tf new file mode 100644 index 0000000000..dbed711602 --- /dev/null +++ b/examples/cost_centers/main.tf @@ -0,0 +1,103 @@ +terraform { + required_providers { + github = { + source = "integrations/github" + version = "~> 6.0" + } + } +} + +provider "github" { + token = var.github_token +} + +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. +resource "github_enterprise_cost_center_resources" "example" { + enterprise_slug = var.enterprise_slug + cost_center_id = github_enterprise_cost_center.example.id + + 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 + + depends_on = [github_enterprise_cost_center_resources.example] +} + +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_resources.example.users)) + organizations = sort(tolist(github_enterprise_cost_center_resources.example.organizations)) + repositories = sort(tolist(github_enterprise_cost_center_resources.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 + } +} 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..5e94d5e5bd --- /dev/null +++ b/github/data_source_github_enterprise_cost_center.go @@ -0,0 +1,93 @@ +package github + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseCostCenter() *schema.Resource { + return &schema.Resource{ + Read: 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.", + }, + "resources": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "type": { + Type: schema.TypeString, + Computed: true, + }, + "name": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseCostCenterRead(d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + + cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) + if err != nil { + return err + } + + d.SetId(costCenterID) + _ = d.Set("name", cc.Name) + + state := strings.ToLower(cc.State) + if state == "" { + state = "active" + } + _ = d.Set("state", state) + _ = d.Set("azure_subscription", cc.AzureSubscription) + + resources := make([]map[string]any, 0) + for _, r := range cc.Resources { + resources = append(resources, map[string]any{ + "type": r.Type, + "name": r.Name, + }) + } + _ = d.Set("resources", resources) + + 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..355bd47110 --- /dev/null +++ b/github/data_source_github_enterprise_cost_center_test.go @@ -0,0 +1,48 @@ +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 isEnterprise != "true" { + t.Skip("Skipping because `ENTERPRISE_ACCOUNT` is not set or set to false") + } + if testEnterprise == "" { + t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") + } + + 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_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + cost_center_id = github_enterprise_cost_center.test.id + } + `, testEnterprise, randomID) + + 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.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + Providers: testAccProviders, + Steps: []resource.TestStep{{Config: config, Check: check}}, + }) +} 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..339077330d --- /dev/null +++ b/github/data_source_github_enterprise_cost_centers.go @@ -0,0 +1,97 @@ +package github + +import ( + "context" + "fmt" + + "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{ + Read: 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, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Computed: true, + }, + "name": { + Type: schema.TypeString, + Computed: true, + }, + "state": { + Type: schema.TypeString, + Computed: true, + }, + "azure_subscription": { + Type: schema.TypeString, + Computed: true, + }, + "resources": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "type": {Type: schema.TypeString, Computed: true}, + "name": {Type: schema.TypeString, Computed: true}, + }, + }, + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseCostCentersRead(d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + state := "" + if v, ok := d.GetOk("state"); ok { + state = v.(string) + } + + ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/cost-centers", enterpriseSlug)) + centers, err := enterpriseCostCentersList(ctx, client, enterpriseSlug, state) + if err != nil { + return err + } + + items := make([]any, 0, len(centers)) + for _, cc := range centers { + resources := make([]map[string]any, 0) + for _, r := range cc.Resources { + resources = append(resources, map[string]any{"type": r.Type, "name": r.Name}) + } + items = append(items, map[string]any{ + "id": cc.ID, + "name": cc.Name, + "state": cc.State, + "azure_subscription": cc.AzureSubscription, + "resources": resources, + }) + } + + d.SetId(fmt.Sprintf("%s/%s", enterpriseSlug, state)) + _ = 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..71f6719f7c --- /dev/null +++ b/github/data_source_github_enterprise_cost_centers_test.go @@ -0,0 +1,78 @@ +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) + + if isEnterprise != "true" { + t.Skip("Skipping because `ENTERPRISE_ACCOUNT` is not set or set to false") + } + if testEnterprise == "" { + t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") + } + + 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] + } + `, testEnterprise, randomID) + + 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"), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + Providers: testAccProviders, + Steps: []resource.TestStep{{Config: config, Check: check}}, + }) +} + +func testAccCheckEnterpriseCostCentersListContains(costCenterResourceName string, 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 a10d84c027..7609680962 100644 --- a/github/provider.go +++ b/github/provider.go @@ -211,6 +211,8 @@ func Provider() *schema.Provider { "github_enterprise_actions_runner_group": resourceGithubActionsEnterpriseRunnerGroup(), "github_enterprise_actions_workflow_permissions": resourceGithubEnterpriseActionsWorkflowPermissions(), "github_enterprise_security_analysis_settings": resourceGithubEnterpriseSecurityAnalysisSettings(), + "github_enterprise_cost_center": resourceGithubEnterpriseCostCenter(), + "github_enterprise_cost_center_resources": resourceGithubEnterpriseCostCenterResources(), "github_workflow_repository_permissions": resourceGithubWorkflowRepositoryPermissions(), }, @@ -286,6 +288,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..930864fa50 --- /dev/null +++ b/github/resource_github_enterprise_cost_center.go @@ -0,0 +1,182 @@ +package github + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEnterpriseCostCenter() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubEnterpriseCostCenterCreate, + Read: resourceGithubEnterpriseCostCenterRead, + Update: resourceGithubEnterpriseCostCenterUpdate, + Delete: resourceGithubEnterpriseCostCenterDelete, + Importer: &schema.ResourceImporter{ + State: 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.", + }, + "resources": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "type": { + Type: schema.TypeString, + Computed: true, + Description: "The resource type.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The resource identifier (username, organization name, or repo full name).", + }, + }, + }, + }, + }, + } +} + +func resourceGithubEnterpriseCostCenterCreate(d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + name := d.Get("name").(string) + + ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, name)) + log.Printf("[INFO] Creating enterprise cost center: %s (%s)", name, enterpriseSlug) + + cc, err := enterpriseCostCenterCreate(ctx, client, enterpriseSlug, name) + if err != nil { + return err + } + + if cc == nil || cc.ID == "" { + return fmt.Errorf("failed to create cost center: missing id in response") + } + + d.SetId(cc.ID) + return resourceGithubEnterpriseCostCenterRead(d, meta) +} + +func resourceGithubEnterpriseCostCenterRead(d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Id() + + ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + + cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) + if err != nil { + if is404(err) { + // If the API starts returning 404 for archived cost centers, we remove it from state. + d.SetId("") + return nil + } + return err + } + + _ = d.Set("name", cc.Name) + + state := strings.ToLower(cc.State) + if state == "" { + state = "active" + } + _ = d.Set("state", state) + _ = d.Set("azure_subscription", cc.AzureSubscription) + + resources := make([]map[string]any, 0) + for _, r := range cc.Resources { + resources = append(resources, map[string]any{ + "type": r.Type, + "name": r.Name, + }) + } + _ = d.Set("resources", resources) + + return nil +} + +func resourceGithubEnterpriseCostCenterUpdate(d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Id() + + ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + + cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) + if err != nil { + return err + } + if strings.EqualFold(cc.State, "deleted") { + return fmt.Errorf("cannot update cost center %q because it is archived", costCenterID) + } + + if d.HasChange("name") { + name := d.Get("name").(string) + log.Printf("[INFO] Updating enterprise cost center: %s/%s", enterpriseSlug, costCenterID) + _, err := enterpriseCostCenterUpdate(ctx, client, enterpriseSlug, costCenterID, name) + if err != nil { + return err + } + } + + return resourceGithubEnterpriseCostCenterRead(d, meta) +} + +func resourceGithubEnterpriseCostCenterDelete(d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Id() + + ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + log.Printf("[INFO] Archiving enterprise cost center: %s/%s", enterpriseSlug, costCenterID) + + _, err := enterpriseCostCenterArchive(ctx, client, enterpriseSlug, costCenterID) + if err != nil { + if is404(err) { + return nil + } + return err + } + + return nil +} + +func resourceGithubEnterpriseCostCenterImport(d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + parts := strings.Split(d.Id(), "/") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid import specified: supplied import must be written as /") + } + + enterpriseSlug, costCenterID := parts[0], parts[1] + d.SetId(costCenterID) + _ = d.Set("enterprise_slug", enterpriseSlug) + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_enterprise_cost_center_resources.go b/github/resource_github_enterprise_cost_center_resources.go new file mode 100644 index 0000000000..0cf6d69a0f --- /dev/null +++ b/github/resource_github_enterprise_cost_center_resources.go @@ -0,0 +1,221 @@ +package github + +import ( + "context" + "fmt" + "log" + "sort" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEnterpriseCostCenterResources() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubEnterpriseCostCenterResourcesCreate, + Read: resourceGithubEnterpriseCostCenterResourcesRead, + Update: resourceGithubEnterpriseCostCenterResourcesUpdate, + Delete: resourceGithubEnterpriseCostCenterResourcesDelete, + Importer: &schema.ResourceImporter{ + State: resourceGithubEnterpriseCostCenterResourcesImport, + }, + + 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 cost center ID.", + }, + "users": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The usernames assigned to this cost center.", + }, + "organizations": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The organization logins assigned to this cost center.", + }, + "repositories": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The repositories (full name) assigned to this cost center.", + }, + }, + } +} + +func resourceGithubEnterpriseCostCenterResourcesCreate(d *schema.ResourceData, meta any) error { + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + d.SetId(buildTwoPartID(enterpriseSlug, costCenterID)) + return resourceGithubEnterpriseCostCenterResourcesUpdate(d, meta) +} + +func resourceGithubEnterpriseCostCenterResourcesRead(d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + + cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) + if err != nil { + if is404(err) { + d.SetId("") + return nil + } + return err + } + + users, orgs, repos := enterpriseCostCenterSplitResources(cc.Resources) + sort.Strings(users) + sort.Strings(orgs) + sort.Strings(repos) + + _ = d.Set("users", stringSliceToAnySlice(users)) + _ = d.Set("organizations", stringSliceToAnySlice(orgs)) + _ = d.Set("repositories", stringSliceToAnySlice(repos)) + + return nil +} + +func resourceGithubEnterpriseCostCenterResourcesUpdate(d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + + cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) + if err != nil { + return err + } + if strings.EqualFold(cc.State, "deleted") { + return fmt.Errorf("cannot modify cost center %q resources because it is archived", costCenterID) + } + + desiredUsers := expandStringSet(d.Get("users").(*schema.Set)) + desiredOrgs := expandStringSet(d.Get("organizations").(*schema.Set)) + desiredRepos := expandStringSet(d.Get("repositories").(*schema.Set)) + + currentUsers, currentOrgs, currentRepos := enterpriseCostCenterSplitResources(cc.Resources) + + toAddUsers, toRemoveUsers := diffStringSlices(currentUsers, desiredUsers) + toAddOrgs, toRemoveOrgs := diffStringSlices(currentOrgs, desiredOrgs) + toAddRepos, toRemoveRepos := diffStringSlices(currentRepos, desiredRepos) + + if len(toRemoveUsers)+len(toRemoveOrgs)+len(toRemoveRepos) > 0 { + log.Printf("[INFO] Removing enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) + _, err := enterpriseCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, enterpriseCostCenterResourcesRequest{ + Users: toRemoveUsers, + Organizations: toRemoveOrgs, + Repositories: toRemoveRepos, + }) + if err != nil { + return err + } + } + + if len(toAddUsers)+len(toAddOrgs)+len(toAddRepos) > 0 { + log.Printf("[INFO] Assigning enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) + _, err := enterpriseCostCenterAssignResources(ctx, client, enterpriseSlug, costCenterID, enterpriseCostCenterResourcesRequest{ + Users: toAddUsers, + Organizations: toAddOrgs, + Repositories: toAddRepos, + }) + if err != nil { + return err + } + } + + return resourceGithubEnterpriseCostCenterResourcesRead(d, meta) +} + +func resourceGithubEnterpriseCostCenterResourcesDelete(d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + + cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) + if err != nil { + if is404(err) { + return nil + } + return err + } + + // If the cost center is archived, treat deletion as a no-op. + if strings.EqualFold(cc.State, "deleted") { + return nil + } + + users, orgs, repos := enterpriseCostCenterSplitResources(cc.Resources) + if len(users)+len(orgs)+len(repos) == 0 { + return nil + } + + _, err = enterpriseCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, enterpriseCostCenterResourcesRequest{ + Users: users, + Organizations: orgs, + Repositories: repos, + }) + return err +} + +func resourceGithubEnterpriseCostCenterResourcesImport(d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + parts := strings.Split(d.Id(), "/") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid import specified: supplied import must be written as /") + } + + enterpriseSlug, costCenterID := parts[0], parts[1] + _ = d.Set("enterprise_slug", enterpriseSlug) + _ = d.Set("cost_center_id", costCenterID) + d.SetId(buildTwoPartID(enterpriseSlug, costCenterID)) + + return []*schema.ResourceData{d}, nil +} + +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 diffStringSlices(current []string, desired []string) (toAdd []string, toRemove []string) { + cur := schema.NewSet(schema.HashString, stringSliceToAnySlice(current)) + des := schema.NewSet(schema.HashString, stringSliceToAnySlice(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 +} diff --git a/github/resource_github_enterprise_cost_center_resources_test.go b/github/resource_github_enterprise_cost_center_resources_test.go new file mode 100644 index 0000000000..42291dcb04 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_resources_test.go @@ -0,0 +1,171 @@ +package github + +import ( + "fmt" + "os" + "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 TestAccGithubEnterpriseCostCenterResources(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + if isEnterprise != "true" { + t.Skip("Skipping because `ENTERPRISE_ACCOUNT` is not set or set to false") + } + if testEnterprise == "" { + t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") + } + testEnterpriseCostCenterOrganization := os.Getenv("ENTERPRISE_TEST_ORGANIZATION") + testEnterpriseCostCenterRepository := os.Getenv("ENTERPRISE_TEST_REPOSITORY") + testEnterpriseCostCenterUsers := os.Getenv("ENTERPRISE_TEST_USERS") + + if testEnterpriseCostCenterOrganization == "" { + t.Skip("Skipping because `ENTERPRISE_TEST_ORGANIZATION` is not set") + } + if testEnterpriseCostCenterRepository == "" { + t.Skip("Skipping because `ENTERPRISE_TEST_REPOSITORY` is not set") + } + if testEnterpriseCostCenterUsers == "" { + t.Skip("Skipping because `ENTERPRISE_TEST_USERS` is not set") + } + + users := splitCommaSeparated(testEnterpriseCostCenterUsers) + if len(users) < 2 { + t.Skip("Skipping because `ENTERPRISE_TEST_USERS` must contain at least two usernames") + } + + usersBefore := fmt.Sprintf("%q, %q", users[0], users[1]) + usersAfter := fmt.Sprintf("%q", users[0]) + + 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" + } + + resource "github_enterprise_cost_center_resources" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + cost_center_id = github_enterprise_cost_center.test.id + + users = [%s] + organizations = [%q] + repositories = [%q] + } + `, testEnterprise, randomID, usersBefore, testEnterpriseCostCenterOrganization, testEnterpriseCostCenterRepository) + + 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-%s" + } + + resource "github_enterprise_cost_center_resources" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + cost_center_id = github_enterprise_cost_center.test.id + + users = [%s] + organizations = [] + repositories = [] + } + `, testEnterprise, randomID, usersAfter) + + 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" + } + + resource "github_enterprise_cost_center_resources" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + cost_center_id = github_enterprise_cost_center.test.id + + users = [] + organizations = [] + repositories = [] + } + `, testEnterprise, randomID) + + checkBefore := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "organizations.#", "1"), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_resources.test", "organizations.*", testEnterpriseCostCenterOrganization), + resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "repositories.#", "1"), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_resources.test", "repositories.*", testEnterpriseCostCenterRepository), + resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "users.#", "2"), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_resources.test", "users.*", users[0]), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_resources.test", "users.*", users[1]), + ) + + checkAfter := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "organizations.#", "0"), + resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "repositories.#", "0"), + resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "users.#", "1"), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_resources.test", "users.*", users[0]), + ) + + checkEmpty := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "organizations.#", "0"), + resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "repositories.#", "0"), + resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "users.#", "0"), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: configBefore, + Check: checkBefore, + }, + { + Config: configAfter, + Check: checkAfter, + }, + { + Config: configEmpty, + Check: checkEmpty, + }, + { + ResourceName: "github_enterprise_cost_center_resources.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", testEnterprise, rs.Primary.ID), nil + }, + }, + }, + }) +} + +func splitCommaSeparated(v string) []string { + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + out = append(out, p) + } + return out +} 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..fbbb51645d --- /dev/null +++ b/github/resource_github_enterprise_cost_center_test.go @@ -0,0 +1,81 @@ +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 isEnterprise != "true" { + t.Skip("Skipping because `ENTERPRISE_ACCOUNT` is not set or set to false") + } + if testEnterprise == "" { + t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") + } + + 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" + } + `, testEnterprise, randomID) + + 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" + } + `, testEnterprise, randomID) + + checkBefore := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "enterprise_slug", testEnterprise), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "name", fmt.Sprintf("tf-acc-test-%s", randomID)), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "state", "active"), + ) + + 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.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: configBefore, + Check: checkBefore, + }, + { + Config: configAfter, + Check: checkAfter, + }, + { + 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", testEnterprise, rs.Primary.ID), nil + }, + }, + }, + }) +} diff --git a/github/util_cost_centers.go b/github/util_cost_centers.go new file mode 100644 index 0000000000..320686811d --- /dev/null +++ b/github/util_cost_centers.go @@ -0,0 +1,208 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/google/go-github/v67/github" +) + +type enterpriseCostCenter struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state,omitempty"` + AzureSubscription string `json:"azure_subscription,omitempty"` + Resources []enterpriseCostCenterResource `json:"resources,omitempty"` +} + +type enterpriseCostCenterResource struct { + Type string `json:"type"` + Name string `json:"name"` +} + +type enterpriseCostCenterListResponse struct { + CostCenters []enterpriseCostCenter `json:"costCenters"` +} + +type enterpriseCostCenterCreateRequest struct { + Name string `json:"name"` +} + +type enterpriseCostCenterUpdateRequest struct { + Name string `json:"name"` +} + +type enterpriseCostCenterArchiveResponse struct { + Message string `json:"message"` + ID string `json:"id"` + Name string `json:"name"` + CostCenterState string `json:"costCenterState"` +} + +type enterpriseCostCenterResourcesRequest struct { + Users []string `json:"users,omitempty"` + Organizations []string `json:"organizations,omitempty"` + Repositories []string `json:"repositories,omitempty"` +} + +type enterpriseCostCenterAssignResponse struct { + Message string `json:"message"` + ReassignedResources []struct { + ResourceType string `json:"resource_type"` + Name string `json:"name"` + PreviousCostCenter string `json:"previous_cost_center"` + } `json:"reassigned_resources"` +} + +type enterpriseCostCenterRemoveResponse struct { + Message string `json:"message"` +} + +func enterpriseCostCentersList(ctx context.Context, client *github.Client, enterpriseSlug string, state string) ([]enterpriseCostCenter, error) { + u, err := url.Parse(fmt.Sprintf("enterprises/%s/settings/billing/cost-centers", enterpriseSlug)) + if err != nil { + return nil, err + } + + q := u.Query() + if state != "" { + q.Set("state", state) + } + u.RawQuery = q.Encode() + + req, err := client.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + + var result enterpriseCostCenterListResponse + _, err = client.Do(ctx, req, &result) + if err != nil { + return nil, err + } + + return result.CostCenters, nil +} + +func enterpriseCostCenterGet(ctx context.Context, client *github.Client, enterpriseSlug string, costCenterID string) (*enterpriseCostCenter, error) { + req, err := client.NewRequest("GET", fmt.Sprintf("enterprises/%s/settings/billing/cost-centers/%s", enterpriseSlug, costCenterID), nil) + if err != nil { + return nil, err + } + + var result enterpriseCostCenter + _, err = client.Do(ctx, req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func enterpriseCostCenterCreate(ctx context.Context, client *github.Client, enterpriseSlug string, name string) (*enterpriseCostCenter, error) { + req, err := client.NewRequest("POST", fmt.Sprintf("enterprises/%s/settings/billing/cost-centers", enterpriseSlug), &enterpriseCostCenterCreateRequest{Name: name}) + if err != nil { + return nil, err + } + + var result enterpriseCostCenter + _, err = client.Do(ctx, req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func enterpriseCostCenterUpdate(ctx context.Context, client *github.Client, enterpriseSlug string, costCenterID string, name string) (*enterpriseCostCenter, error) { + req, err := client.NewRequest("PATCH", fmt.Sprintf("enterprises/%s/settings/billing/cost-centers/%s", enterpriseSlug, costCenterID), &enterpriseCostCenterUpdateRequest{Name: name}) + if err != nil { + return nil, err + } + + var result enterpriseCostCenter + _, err = client.Do(ctx, req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func enterpriseCostCenterArchive(ctx context.Context, client *github.Client, enterpriseSlug string, costCenterID string) (*enterpriseCostCenterArchiveResponse, error) { + req, err := client.NewRequest("DELETE", fmt.Sprintf("enterprises/%s/settings/billing/cost-centers/%s", enterpriseSlug, costCenterID), nil) + if err != nil { + return nil, err + } + + var result enterpriseCostCenterArchiveResponse + _, err = client.Do(ctx, req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func enterpriseCostCenterAssignResources(ctx context.Context, client *github.Client, enterpriseSlug string, costCenterID string, reqBody enterpriseCostCenterResourcesRequest) (*enterpriseCostCenterAssignResponse, error) { + req, err := client.NewRequest("POST", fmt.Sprintf("enterprises/%s/settings/billing/cost-centers/%s/resource", enterpriseSlug, costCenterID), &reqBody) + if err != nil { + return nil, err + } + + var result enterpriseCostCenterAssignResponse + _, err = client.Do(ctx, req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func enterpriseCostCenterRemoveResources(ctx context.Context, client *github.Client, enterpriseSlug string, costCenterID string, reqBody enterpriseCostCenterResourcesRequest) (*enterpriseCostCenterRemoveResponse, error) { + req, err := client.NewRequest("DELETE", fmt.Sprintf("enterprises/%s/settings/billing/cost-centers/%s/resource", enterpriseSlug, costCenterID), &reqBody) + if err != nil { + return nil, err + } + + var result enterpriseCostCenterRemoveResponse + _, err = client.Do(ctx, req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func enterpriseCostCenterSplitResources(resources []enterpriseCostCenterResource) (users []string, organizations []string, repositories []string) { + for _, r := range resources { + 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 +} + +func stringSliceToAnySlice(v []string) []any { + out := make([]any, 0, len(v)) + for _, s := range v { + out = append(out, s) + } + return out +} + +func is404(err error) bool { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil { + return ghErr.Response.StatusCode == 404 + } + return false +} diff --git a/scripts/env.enterprise-cost-centers.sh b/scripts/env.enterprise-cost-centers.sh new file mode 100644 index 0000000000..17a09c85a5 --- /dev/null +++ b/scripts/env.enterprise-cost-centers.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env sh + +# NOTE: This file is meant to be sourced (not executed). Avoid `set -e`/`set -u` +# here because they would leak into the caller shell and can terminate +# interactive terminals (e.g. zsh prompts/plugins) unexpectedly. + +# Environment variables for running the GitHub Enterprise Cloud Cost Centers acceptance tests. +# +# Usage: +# source scripts/env.enterprise-cost-centers.sh +# TF_ACC=1 TF_LOG=DEBUG go test -v ./... -run '^TestAccGithubEnterpriseCostCenter' +# +# Notes: +# - These endpoints require an enterprise admin using a classic PAT. +# - GitHub App tokens and fine-grained personal access tokens are not supported. + +# Required by enterprise-mode acceptance tests. +export ENTERPRISE_ACCOUNT="true" +export ENTERPRISE_SLUG="prisa-media-emu" + +# Fixtures used by the authoritative membership resource tests. +export ENTERPRISE_TEST_ORGANIZATION="PrisaMedia-Training-Sandbox" +export ENTERPRISE_TEST_REPOSITORY="PrisaMedia-Training-Sandbox/vmvarela-testing-cost-centers" +export ENTERPRISE_TEST_USERS="vmvarela-clb_prisa,ebarberan-clb_prisa" + +# Classic personal access token (PAT) for an enterprise admin. +# IMPORTANT: do not commit real tokens. +: "${GITHUB_TOKEN:=}" +export GITHUB_TOKEN diff --git a/scripts/testacc-enterprise-cost-centers.sh b/scripts/testacc-enterprise-cost-centers.sh new file mode 100755 index 0000000000..6891d97fa1 --- /dev/null +++ b/scripts/testacc-enterprise-cost-centers.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env sh +set -eu + +# Run the GitHub Enterprise Cloud Cost Centers acceptance tests. +# +# Usage: +# export GITHUB_TOKEN="..." # classic PAT, enterprise admin +# source scripts/env.enterprise-cost-centers.sh +# scripts/testacc-enterprise-cost-centers.sh + +require_env() { + name="$1" + if [ -z "${!name:-}" ]; then + echo "Missing required env var: ${name}" 1>&2 + exit 1 + fi +} + +require_env GITHUB_TOKEN +require_env ENTERPRISE_ACCOUNT +require_env ENTERPRISE_SLUG + +# Only required for the authoritative membership resource test. +require_env ENTERPRISE_TEST_ORGANIZATION +require_env ENTERPRISE_TEST_REPOSITORY +require_env ENTERPRISE_TEST_USERS + +# Run only the cost-centers acceptance tests. +TF_ACC=1 go test -v ./... -run '^TestAccGithubEnterpriseCostCenter' 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..5588b63913 --- /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. +* `resources` - A list of assigned resources. + * `type` - The resource type. + * `name` - The resource identifier (username, organization login, or repository full name). + 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..5b159280c4 --- /dev/null +++ b/website/docs/d/enterprise_cost_centers.html.markdown @@ -0,0 +1,36 @@ +--- +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. + * `resources` - A list of assigned resources. + * `type` - The resource type. + * `name` - The resource identifier (username, organization login, or repository full name). + 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..57911458a6 --- /dev/null +++ b/website/docs/r/enterprise_cost_center.html.markdown @@ -0,0 +1,46 @@ +--- +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" +} +``` + +## 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. +* `resources` - A list of assigned resources. + * `type` - The resource type. + * `name` - The resource identifier (username, organization login, or repository full name). + +## 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/cc_123456 +``` + diff --git a/website/docs/r/enterprise_cost_center_resources.html.markdown b/website/docs/r/enterprise_cost_center_resources.html.markdown new file mode 100644 index 0000000000..4c5ad9775b --- /dev/null +++ b/website/docs/r/enterprise_cost_center_resources.html.markdown @@ -0,0 +1,47 @@ +--- +layout: "github" +page_title: "Github: github_enterprise_cost_center_resources" +description: |- + Manage resource assignments for a GitHub enterprise cost center. +--- + +# github_enterprise_cost_center_resources + +This resource allows you to manage which users, organizations, and repositories are assigned to a GitHub enterprise cost center. + +The `users`, `organizations`, and `repositories` arguments are authoritative: on every apply, Terraform will add and remove assignments to match exactly what is configured. + +## Example Usage + +``` +resource "github_enterprise_cost_center" "example" { + enterprise_slug = "example-enterprise" + name = "platform-cost-center" +} + +resource "github_enterprise_cost_center_resources" "example" { + enterprise_slug = "example-enterprise" + cost_center_id = github_enterprise_cost_center.example.id + + users = ["octocat"] + organizations = ["my-org"] + repositories = ["my-org/my-repo"] +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `cost_center_id` - (Required) The cost center ID. +* `users` - (Required) The usernames assigned to this cost center. +* `organizations` - (Required) The organization logins assigned to this cost center. +* `repositories` - (Required) The repositories (full name) assigned to this cost center. + +## Import + +GitHub Enterprise Cost Center Resources can be imported using the `enterprise_slug` and the `cost_center_id`, separated by a `/` character. + +``` +$ terraform import github_enterprise_cost_center_resources.example example-enterprise/cc_123456 +``` + diff --git a/website/github.erb b/website/github.erb index 7db02fc5fc..af016d2da5 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,12 @@
  • github_enterprise_actions_permissions
  • +
  • + github_enterprise_cost_center +
  • +
  • + github_enterprise_cost_center_resources +
  • github_enterprise_settings
  • From 36a2d0783a5ddca63ff99ab927158e85fa48d26d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:29:40 +0100 Subject: [PATCH 02/10] Remove enterprise cost centers helper scripts --- scripts/env.enterprise-cost-centers.sh | 29 ---------------------- scripts/testacc-enterprise-cost-centers.sh | 29 ---------------------- 2 files changed, 58 deletions(-) delete mode 100644 scripts/env.enterprise-cost-centers.sh delete mode 100755 scripts/testacc-enterprise-cost-centers.sh diff --git a/scripts/env.enterprise-cost-centers.sh b/scripts/env.enterprise-cost-centers.sh deleted file mode 100644 index 17a09c85a5..0000000000 --- a/scripts/env.enterprise-cost-centers.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env sh - -# NOTE: This file is meant to be sourced (not executed). Avoid `set -e`/`set -u` -# here because they would leak into the caller shell and can terminate -# interactive terminals (e.g. zsh prompts/plugins) unexpectedly. - -# Environment variables for running the GitHub Enterprise Cloud Cost Centers acceptance tests. -# -# Usage: -# source scripts/env.enterprise-cost-centers.sh -# TF_ACC=1 TF_LOG=DEBUG go test -v ./... -run '^TestAccGithubEnterpriseCostCenter' -# -# Notes: -# - These endpoints require an enterprise admin using a classic PAT. -# - GitHub App tokens and fine-grained personal access tokens are not supported. - -# Required by enterprise-mode acceptance tests. -export ENTERPRISE_ACCOUNT="true" -export ENTERPRISE_SLUG="prisa-media-emu" - -# Fixtures used by the authoritative membership resource tests. -export ENTERPRISE_TEST_ORGANIZATION="PrisaMedia-Training-Sandbox" -export ENTERPRISE_TEST_REPOSITORY="PrisaMedia-Training-Sandbox/vmvarela-testing-cost-centers" -export ENTERPRISE_TEST_USERS="vmvarela-clb_prisa,ebarberan-clb_prisa" - -# Classic personal access token (PAT) for an enterprise admin. -# IMPORTANT: do not commit real tokens. -: "${GITHUB_TOKEN:=}" -export GITHUB_TOKEN diff --git a/scripts/testacc-enterprise-cost-centers.sh b/scripts/testacc-enterprise-cost-centers.sh deleted file mode 100755 index 6891d97fa1..0000000000 --- a/scripts/testacc-enterprise-cost-centers.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env sh -set -eu - -# Run the GitHub Enterprise Cloud Cost Centers acceptance tests. -# -# Usage: -# export GITHUB_TOKEN="..." # classic PAT, enterprise admin -# source scripts/env.enterprise-cost-centers.sh -# scripts/testacc-enterprise-cost-centers.sh - -require_env() { - name="$1" - if [ -z "${!name:-}" ]; then - echo "Missing required env var: ${name}" 1>&2 - exit 1 - fi -} - -require_env GITHUB_TOKEN -require_env ENTERPRISE_ACCOUNT -require_env ENTERPRISE_SLUG - -# Only required for the authoritative membership resource test. -require_env ENTERPRISE_TEST_ORGANIZATION -require_env ENTERPRISE_TEST_REPOSITORY -require_env ENTERPRISE_TEST_USERS - -# Run only the cost-centers acceptance tests. -TF_ACC=1 go test -v ./... -run '^TestAccGithubEnterpriseCostCenter' From 122a11e6204f0f7ad11c13b7de681f9e68cf4195 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:23:30 +0100 Subject: [PATCH 03/10] Improve enterprise cost center resources UX --- ...github_enterprise_cost_center_resources.go | 29 +++++++++++++++---- ...rprise_cost_center_resources.html.markdown | 8 +++-- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_resources.go b/github/resource_github_enterprise_cost_center_resources.go index 0cf6d69a0f..e911c2c8b6 100644 --- a/github/resource_github_enterprise_cost_center_resources.go +++ b/github/resource_github_enterprise_cost_center_resources.go @@ -35,19 +35,19 @@ func resourceGithubEnterpriseCostCenterResources() *schema.Resource { }, "users": { Type: schema.TypeSet, - Required: true, + Optional: true, Elem: &schema.Schema{Type: schema.TypeString}, Description: "The usernames assigned to this cost center.", }, "organizations": { Type: schema.TypeSet, - Required: true, + Optional: true, Elem: &schema.Schema{Type: schema.TypeString}, Description: "The organization logins assigned to this cost center.", }, "repositories": { Type: schema.TypeSet, - Required: true, + Optional: true, Elem: &schema.Schema{Type: schema.TypeString}, Description: "The repositories (full name) assigned to this cost center.", }, @@ -100,15 +100,18 @@ func resourceGithubEnterpriseCostCenterResourcesUpdate(d *schema.ResourceData, m cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) if err != nil { + if is404(err) { + return fmt.Errorf("cost center %q not found in enterprise %q (check enterprise_slug matches the cost center's enterprise)", costCenterID, enterpriseSlug) + } return err } if strings.EqualFold(cc.State, "deleted") { return fmt.Errorf("cannot modify cost center %q resources because it is archived", costCenterID) } - desiredUsers := expandStringSet(d.Get("users").(*schema.Set)) - desiredOrgs := expandStringSet(d.Get("organizations").(*schema.Set)) - desiredRepos := expandStringSet(d.Get("repositories").(*schema.Set)) + desiredUsers := expandStringSet(getStringSetOrEmpty(d, "users")) + desiredOrgs := expandStringSet(getStringSetOrEmpty(d, "organizations")) + desiredRepos := expandStringSet(getStringSetOrEmpty(d, "repositories")) currentUsers, currentOrgs, currentRepos := enterpriseCostCenterSplitResources(cc.Resources) @@ -204,6 +207,20 @@ func expandStringSet(set *schema.Set) []string { 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 []string, desired []string) (toAdd []string, toRemove []string) { cur := schema.NewSet(schema.HashString, stringSliceToAnySlice(current)) des := schema.NewSet(schema.HashString, stringSliceToAnySlice(desired)) diff --git a/website/docs/r/enterprise_cost_center_resources.html.markdown b/website/docs/r/enterprise_cost_center_resources.html.markdown index 4c5ad9775b..6ddf67c2da 100644 --- a/website/docs/r/enterprise_cost_center_resources.html.markdown +++ b/website/docs/r/enterprise_cost_center_resources.html.markdown @@ -11,6 +11,8 @@ This resource allows you to manage which users, organizations, and repositories The `users`, `organizations`, and `repositories` arguments are authoritative: on every apply, Terraform will add and remove assignments to match exactly what is configured. +Note: `enterprise_slug` must match the enterprise where the cost center was created. If they don't match, GitHub will return `404 Not Found` for the cost center ID. + ## Example Usage ``` @@ -33,9 +35,9 @@ resource "github_enterprise_cost_center_resources" "example" { * `enterprise_slug` - (Required) The slug of the enterprise. * `cost_center_id` - (Required) The cost center ID. -* `users` - (Required) The usernames assigned to this cost center. -* `organizations` - (Required) The organization logins assigned to this cost center. -* `repositories` - (Required) The repositories (full name) assigned to this cost center. +* `users` - (Optional) The usernames assigned to this cost center. Defaults to an empty list. +* `organizations` - (Optional) The organization logins assigned to this cost center. Defaults to an empty list. +* `repositories` - (Optional) The repositories (full name) assigned to this cost center. Defaults to an empty list. ## Import From e76c7fdd2f8a0ba5264f094a997fbecf3c91a5ed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:26:50 +0100 Subject: [PATCH 04/10] Retry cost center assignments and improve docs --- ...github_enterprise_cost_center_resources.go | 51 ++++++++++++++++--- .../r/enterprise_cost_center.html.markdown | 2 +- ...rprise_cost_center_resources.html.markdown | 8 +-- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_resources.go b/github/resource_github_enterprise_cost_center_resources.go index e911c2c8b6..e4bedb34ca 100644 --- a/github/resource_github_enterprise_cost_center_resources.go +++ b/github/resource_github_enterprise_cost_center_resources.go @@ -2,11 +2,15 @@ package github import ( "context" + "errors" "fmt" "log" "sort" "strings" + "time" + "github.com/google/go-github/v67/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -121,10 +125,19 @@ func resourceGithubEnterpriseCostCenterResourcesUpdate(d *schema.ResourceData, m if len(toRemoveUsers)+len(toRemoveOrgs)+len(toRemoveRepos) > 0 { log.Printf("[INFO] Removing enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) - _, err := enterpriseCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, enterpriseCostCenterResourcesRequest{ - Users: toRemoveUsers, - Organizations: toRemoveOrgs, - Repositories: toRemoveRepos, + err := resource.RetryContext(ctx, 30*time.Second, func() *resource.RetryError { + _, err := enterpriseCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, enterpriseCostCenterResourcesRequest{ + Users: toRemoveUsers, + Organizations: toRemoveOrgs, + Repositories: toRemoveRepos, + }) + if err == nil { + return nil + } + if isRetryableGithubResponseError(err) { + return resource.RetryableError(err) + } + return resource.NonRetryableError(err) }) if err != nil { return err @@ -133,10 +146,19 @@ func resourceGithubEnterpriseCostCenterResourcesUpdate(d *schema.ResourceData, m if len(toAddUsers)+len(toAddOrgs)+len(toAddRepos) > 0 { log.Printf("[INFO] Assigning enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) - _, err := enterpriseCostCenterAssignResources(ctx, client, enterpriseSlug, costCenterID, enterpriseCostCenterResourcesRequest{ - Users: toAddUsers, - Organizations: toAddOrgs, - Repositories: toAddRepos, + err := resource.RetryContext(ctx, 30*time.Second, func() *resource.RetryError { + _, err := enterpriseCostCenterAssignResources(ctx, client, enterpriseSlug, costCenterID, enterpriseCostCenterResourcesRequest{ + Users: toAddUsers, + Organizations: toAddOrgs, + Repositories: toAddRepos, + }) + if err == nil { + return nil + } + if isRetryableGithubResponseError(err) { + return resource.RetryableError(err) + } + return resource.NonRetryableError(err) }) if err != nil { return err @@ -221,6 +243,19 @@ func getStringSetOrEmpty(d *schema.ResourceData, key string) *schema.Set { return set } +func isRetryableGithubResponseError(err error) bool { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil { + switch ghErr.Response.StatusCode { + case 404, 409, 500, 502, 503, 504: + return true + default: + return false + } + } + return false +} + func diffStringSlices(current []string, desired []string) (toAdd []string, toRemove []string) { cur := schema.NewSet(schema.HashString, stringSliceToAnySlice(current)) des := schema.NewSet(schema.HashString, stringSliceToAnySlice(desired)) diff --git a/website/docs/r/enterprise_cost_center.html.markdown b/website/docs/r/enterprise_cost_center.html.markdown index 57911458a6..9a29c4b047 100644 --- a/website/docs/r/enterprise_cost_center.html.markdown +++ b/website/docs/r/enterprise_cost_center.html.markdown @@ -41,6 +41,6 @@ The following additional attributes are exported: 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/cc_123456 +$ terraform import github_enterprise_cost_center.example example-enterprise/ ``` diff --git a/website/docs/r/enterprise_cost_center_resources.html.markdown b/website/docs/r/enterprise_cost_center_resources.html.markdown index 6ddf67c2da..989d9b4b47 100644 --- a/website/docs/r/enterprise_cost_center_resources.html.markdown +++ b/website/docs/r/enterprise_cost_center_resources.html.markdown @@ -35,15 +35,15 @@ resource "github_enterprise_cost_center_resources" "example" { * `enterprise_slug` - (Required) The slug of the enterprise. * `cost_center_id` - (Required) The cost center ID. -* `users` - (Optional) The usernames assigned to this cost center. Defaults to an empty list. -* `organizations` - (Optional) The organization logins assigned to this cost center. Defaults to an empty list. -* `repositories` - (Optional) The repositories (full name) assigned to this cost center. Defaults to an empty list. +* `users` - (Optional) The usernames assigned to this cost center. If omitted, treated as an empty set. +* `organizations` - (Optional) The organization logins assigned to this cost center. If omitted, treated as an empty set. +* `repositories` - (Optional) The repositories (full name) assigned to this cost center. If omitted, treated as an empty set. ## Import GitHub Enterprise Cost Center Resources can be imported using the `enterprise_slug` and the `cost_center_id`, separated by a `/` character. ``` -$ terraform import github_enterprise_cost_center_resources.example example-enterprise/cc_123456 +$ terraform import github_enterprise_cost_center_resources.example example-enterprise/ ``` From 633fa4fa59de27ddf9995e76f8077ef0f3a1eb36 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:31:31 +0100 Subject: [PATCH 05/10] Use standard two-part ID for cost center resources --- ...github_enterprise_cost_center_resources.go | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_resources.go b/github/resource_github_enterprise_cost_center_resources.go index e4bedb34ca..9055a69e58 100644 --- a/github/resource_github_enterprise_cost_center_resources.go +++ b/github/resource_github_enterprise_cost_center_resources.go @@ -69,10 +69,12 @@ func resourceGithubEnterpriseCostCenterResourcesCreate(d *schema.ResourceData, m func resourceGithubEnterpriseCostCenterResourcesRead(d *schema.ResourceData, meta any) error { client := meta.(*Owner).v3client - enterpriseSlug := d.Get("enterprise_slug").(string) - costCenterID := d.Get("cost_center_id").(string) + enterpriseSlug, costCenterID, err := parseTwoPartID(d.Id(), "enterprise_slug", "cost_center_id") + if err != nil { + return err + } - ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + ctx := context.WithValue(context.Background(), ctxId, d.Id()) cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) if err != nil { @@ -88,6 +90,9 @@ func resourceGithubEnterpriseCostCenterResourcesRead(d *schema.ResourceData, met sort.Strings(orgs) sort.Strings(repos) + _ = d.Set("enterprise_slug", enterpriseSlug) + _ = d.Set("cost_center_id", costCenterID) + _ = d.Set("users", stringSliceToAnySlice(users)) _ = d.Set("organizations", stringSliceToAnySlice(orgs)) _ = d.Set("repositories", stringSliceToAnySlice(repos)) @@ -97,10 +102,12 @@ func resourceGithubEnterpriseCostCenterResourcesRead(d *schema.ResourceData, met func resourceGithubEnterpriseCostCenterResourcesUpdate(d *schema.ResourceData, meta any) error { client := meta.(*Owner).v3client - enterpriseSlug := d.Get("enterprise_slug").(string) - costCenterID := d.Get("cost_center_id").(string) + enterpriseSlug, costCenterID, err := parseTwoPartID(d.Id(), "enterprise_slug", "cost_center_id") + if err != nil { + return err + } - ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + ctx := context.WithValue(context.Background(), ctxId, d.Id()) cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) if err != nil { @@ -170,10 +177,12 @@ func resourceGithubEnterpriseCostCenterResourcesUpdate(d *schema.ResourceData, m func resourceGithubEnterpriseCostCenterResourcesDelete(d *schema.ResourceData, meta any) error { client := meta.(*Owner).v3client - enterpriseSlug := d.Get("enterprise_slug").(string) - costCenterID := d.Get("cost_center_id").(string) + enterpriseSlug, costCenterID, err := parseTwoPartID(d.Id(), "enterprise_slug", "cost_center_id") + if err != nil { + return err + } - ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + ctx := context.WithValue(context.Background(), ctxId, d.Id()) cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) if err != nil { From c687b8c4b699ee1e42c961031859b55cb3a825a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:33:35 +0100 Subject: [PATCH 06/10] Fix cost center resources import test ID --- ...rce_github_enterprise_cost_center_resources_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_resources_test.go b/github/resource_github_enterprise_cost_center_resources_test.go index 42291dcb04..1adcb5d1f4 100644 --- a/github/resource_github_enterprise_cost_center_resources_test.go +++ b/github/resource_github_enterprise_cost_center_resources_test.go @@ -146,11 +146,17 @@ func TestAccGithubEnterpriseCostCenterResources(t *testing.T) { ImportState: true, ImportStateVerify: true, ImportStateIdFunc: func(s *terraform.State) (string, error) { - rs, ok := s.RootModule().Resources["github_enterprise_cost_center.test"] + rs, ok := s.RootModule().Resources["github_enterprise_cost_center_resources.test"] if !ok { return "", fmt.Errorf("resource not found in state") } - return fmt.Sprintf("%s/%s", testEnterprise, rs.Primary.ID), nil + + enterpriseSlug := rs.Primary.Attributes["enterprise_slug"] + costCenterID := rs.Primary.Attributes["cost_center_id"] + if enterpriseSlug == "" || costCenterID == "" { + return "", fmt.Errorf("missing enterprise_slug or cost_center_id in state") + } + return fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID), nil }, }, }, From 0ce72b4225e034d886283a410b3ca973067c078b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 11:04:14 +0100 Subject: [PATCH 07/10] fix: batch + retry enterprise cost center resources --- ...github_enterprise_cost_center_resources.go | 146 ++++++++++++++---- 1 file changed, 119 insertions(+), 27 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_resources.go b/github/resource_github_enterprise_cost_center_resources.go index 9055a69e58..68e489c4bf 100644 --- a/github/resource_github_enterprise_cost_center_resources.go +++ b/github/resource_github_enterprise_cost_center_resources.go @@ -130,14 +130,12 @@ func resourceGithubEnterpriseCostCenterResourcesUpdate(d *schema.ResourceData, m toAddOrgs, toRemoveOrgs := diffStringSlices(currentOrgs, desiredOrgs) toAddRepos, toRemoveRepos := diffStringSlices(currentRepos, desiredRepos) - if len(toRemoveUsers)+len(toRemoveOrgs)+len(toRemoveRepos) > 0 { - log.Printf("[INFO] Removing enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) - err := resource.RetryContext(ctx, 30*time.Second, func() *resource.RetryError { - _, err := enterpriseCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, enterpriseCostCenterResourcesRequest{ - Users: toRemoveUsers, - Organizations: toRemoveOrgs, - Repositories: toRemoveRepos, - }) + const maxResourcesPerRequest = 50 + const costCenterResourcesRetryTimeout = 5 * time.Minute + + retryRemove := func(req enterpriseCostCenterResourcesRequest) error { + return resource.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *resource.RetryError { + _, err := enterpriseCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, req) if err == nil { return nil } @@ -146,19 +144,11 @@ func resourceGithubEnterpriseCostCenterResourcesUpdate(d *schema.ResourceData, m } return resource.NonRetryableError(err) }) - if err != nil { - return err - } } - if len(toAddUsers)+len(toAddOrgs)+len(toAddRepos) > 0 { - log.Printf("[INFO] Assigning enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) - err := resource.RetryContext(ctx, 30*time.Second, func() *resource.RetryError { - _, err := enterpriseCostCenterAssignResources(ctx, client, enterpriseSlug, costCenterID, enterpriseCostCenterResourcesRequest{ - Users: toAddUsers, - Organizations: toAddOrgs, - Repositories: toAddRepos, - }) + retryAssign := func(req enterpriseCostCenterResourcesRequest) error { + return resource.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *resource.RetryError { + _, err := enterpriseCostCenterAssignResources(ctx, client, enterpriseSlug, costCenterID, req) if err == nil { return nil } @@ -167,8 +157,63 @@ func resourceGithubEnterpriseCostCenterResourcesUpdate(d *schema.ResourceData, m } return resource.NonRetryableError(err) }) - if err != nil { - return err + } + + chunk := func(items []string, size int) [][]string { + if len(items) == 0 { + return nil + } + if size <= 0 { + size = len(items) + } + chunks := make([][]string, 0, (len(items)+size-1)/size) + for start := 0; start < len(items); start += size { + end := start + size + if end > len(items) { + end = len(items) + } + chunks = append(chunks, items[start:end]) + } + return chunks + } + + if len(toRemoveUsers)+len(toRemoveOrgs)+len(toRemoveRepos) > 0 { + log.Printf("[INFO] Removing enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) + + for _, batch := range chunk(toRemoveUsers, maxResourcesPerRequest) { + if err := retryRemove(enterpriseCostCenterResourcesRequest{Users: batch}); err != nil { + return err + } + } + for _, batch := range chunk(toRemoveOrgs, maxResourcesPerRequest) { + if err := retryRemove(enterpriseCostCenterResourcesRequest{Organizations: batch}); err != nil { + return err + } + } + for _, batch := range chunk(toRemoveRepos, maxResourcesPerRequest) { + if err := retryRemove(enterpriseCostCenterResourcesRequest{Repositories: batch}); err != nil { + return err + } + } + } + + if len(toAddUsers)+len(toAddOrgs)+len(toAddRepos) > 0 { + log.Printf("[INFO] Assigning enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) + + for _, batch := range chunk(toAddUsers, maxResourcesPerRequest) { + if err := retryAssign(enterpriseCostCenterResourcesRequest{Users: batch}); err != nil { + return err + } + } + for _, batch := range chunk(toAddOrgs, maxResourcesPerRequest) { + if err := retryAssign(enterpriseCostCenterResourcesRequest{Organizations: batch}); err != nil { + return err + } + } + for _, batch := range chunk(toAddRepos, maxResourcesPerRequest) { + if err := retryAssign(enterpriseCostCenterResourcesRequest{Repositories: batch}); err != nil { + return err + } } } @@ -202,12 +247,59 @@ func resourceGithubEnterpriseCostCenterResourcesDelete(d *schema.ResourceData, m return nil } - _, err = enterpriseCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, enterpriseCostCenterResourcesRequest{ - Users: users, - Organizations: orgs, - Repositories: repos, - }) - return err + const maxResourcesPerRequest = 50 + const costCenterResourcesRetryTimeout = 5 * time.Minute + + retryRemove := func(req enterpriseCostCenterResourcesRequest) error { + return resource.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *resource.RetryError { + _, err := enterpriseCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, req) + if err == nil { + return nil + } + if isRetryableGithubResponseError(err) { + return resource.RetryableError(err) + } + return resource.NonRetryableError(err) + }) + } + + chunk := func(items []string, size int) [][]string { + if len(items) == 0 { + return nil + } + if size <= 0 { + size = len(items) + } + chunks := make([][]string, 0, (len(items)+size-1)/size) + for start := 0; start < len(items); start += size { + end := start + size + if end > len(items) { + end = len(items) + } + chunks = append(chunks, items[start:end]) + } + return chunks + } + + log.Printf("[INFO] Removing all enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) + + for _, batch := range chunk(users, maxResourcesPerRequest) { + if err := retryRemove(enterpriseCostCenterResourcesRequest{Users: batch}); err != nil { + return err + } + } + for _, batch := range chunk(orgs, maxResourcesPerRequest) { + if err := retryRemove(enterpriseCostCenterResourcesRequest{Organizations: batch}); err != nil { + return err + } + } + for _, batch := range chunk(repos, maxResourcesPerRequest) { + if err := retryRemove(enterpriseCostCenterResourcesRequest{Repositories: batch}); err != nil { + return err + } + } + + return nil } func resourceGithubEnterpriseCostCenterResourcesImport(d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { From 014fe9102515db87993aeb5dbb717a8133a3815b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:55:09 +0100 Subject: [PATCH 08/10] Use context-aware CRUD for enterprise cost centers --- ...ta_source_github_enterprise_cost_center.go | 9 +-- ...a_source_github_enterprise_cost_centers.go | 9 +-- .../resource_github_enterprise_cost_center.go | 47 +++++++------- ...github_enterprise_cost_center_resources.go | 65 ++++++++++--------- 4 files changed, 67 insertions(+), 63 deletions(-) diff --git a/github/data_source_github_enterprise_cost_center.go b/github/data_source_github_enterprise_cost_center.go index 5e94d5e5bd..7e21c8c989 100644 --- a/github/data_source_github_enterprise_cost_center.go +++ b/github/data_source_github_enterprise_cost_center.go @@ -5,12 +5,13 @@ import ( "fmt" "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{ - Read: dataSourceGithubEnterpriseCostCenterRead, + ReadContext: dataSourceGithubEnterpriseCostCenterRead, Schema: map[string]*schema.Schema{ "enterprise_slug": { @@ -58,16 +59,16 @@ func dataSourceGithubEnterpriseCostCenter() *schema.Resource { } } -func dataSourceGithubEnterpriseCostCenterRead(d *schema.ResourceData, meta any) error { +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) - ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + ctx = context.WithValue(ctx, ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) if err != nil { - return err + return diag.FromErr(err) } d.SetId(costCenterID) diff --git a/github/data_source_github_enterprise_cost_centers.go b/github/data_source_github_enterprise_cost_centers.go index 339077330d..39bb1c8906 100644 --- a/github/data_source_github_enterprise_cost_centers.go +++ b/github/data_source_github_enterprise_cost_centers.go @@ -4,13 +4,14 @@ import ( "context" "fmt" + "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{ - Read: dataSourceGithubEnterpriseCostCentersRead, + ReadContext: dataSourceGithubEnterpriseCostCentersRead, Schema: map[string]*schema.Schema{ "enterprise_slug": { @@ -62,7 +63,7 @@ func dataSourceGithubEnterpriseCostCenters() *schema.Resource { } } -func dataSourceGithubEnterpriseCostCentersRead(d *schema.ResourceData, meta any) error { +func dataSourceGithubEnterpriseCostCentersRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client enterpriseSlug := d.Get("enterprise_slug").(string) state := "" @@ -70,10 +71,10 @@ func dataSourceGithubEnterpriseCostCentersRead(d *schema.ResourceData, meta any) state = v.(string) } - ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/cost-centers", enterpriseSlug)) + ctx = context.WithValue(ctx, ctxId, fmt.Sprintf("%s/cost-centers", enterpriseSlug)) centers, err := enterpriseCostCentersList(ctx, client, enterpriseSlug, state) if err != nil { - return err + return diag.FromErr(err) } items := make([]any, 0, len(centers)) diff --git a/github/resource_github_enterprise_cost_center.go b/github/resource_github_enterprise_cost_center.go index 930864fa50..a03fea0861 100644 --- a/github/resource_github_enterprise_cost_center.go +++ b/github/resource_github_enterprise_cost_center.go @@ -6,17 +6,18 @@ import ( "log" "strings" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) func resourceGithubEnterpriseCostCenter() *schema.Resource { return &schema.Resource{ - Create: resourceGithubEnterpriseCostCenterCreate, - Read: resourceGithubEnterpriseCostCenterRead, - Update: resourceGithubEnterpriseCostCenterUpdate, - Delete: resourceGithubEnterpriseCostCenterDelete, + CreateContext: resourceGithubEnterpriseCostCenterCreate, + ReadContext: resourceGithubEnterpriseCostCenterRead, + UpdateContext: resourceGithubEnterpriseCostCenterUpdate, + DeleteContext: resourceGithubEnterpriseCostCenterDelete, Importer: &schema.ResourceImporter{ - State: resourceGithubEnterpriseCostCenterImport, + StateContext: resourceGithubEnterpriseCostCenterImport, }, Schema: map[string]*schema.Schema{ @@ -63,33 +64,33 @@ func resourceGithubEnterpriseCostCenter() *schema.Resource { } } -func resourceGithubEnterpriseCostCenterCreate(d *schema.ResourceData, meta any) error { +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) - ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, name)) + ctx = context.WithValue(ctx, ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, name)) log.Printf("[INFO] Creating enterprise cost center: %s (%s)", name, enterpriseSlug) cc, err := enterpriseCostCenterCreate(ctx, client, enterpriseSlug, name) if err != nil { - return err + return diag.FromErr(err) } if cc == nil || cc.ID == "" { - return fmt.Errorf("failed to create cost center: missing id in response") + return diag.FromErr(fmt.Errorf("failed to create cost center: missing id in response")) } d.SetId(cc.ID) - return resourceGithubEnterpriseCostCenterRead(d, meta) + return resourceGithubEnterpriseCostCenterRead(ctx, d, meta) } -func resourceGithubEnterpriseCostCenterRead(d *schema.ResourceData, meta any) error { +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() - ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + ctx = context.WithValue(ctx, ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) if err != nil { @@ -98,7 +99,7 @@ func resourceGithubEnterpriseCostCenterRead(d *schema.ResourceData, meta any) er d.SetId("") return nil } - return err + return diag.FromErr(err) } _ = d.Set("name", cc.Name) @@ -122,19 +123,19 @@ func resourceGithubEnterpriseCostCenterRead(d *schema.ResourceData, meta any) er return nil } -func resourceGithubEnterpriseCostCenterUpdate(d *schema.ResourceData, meta any) error { +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() - ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + ctx = context.WithValue(ctx, ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) if err != nil { - return err + return diag.FromErr(err) } if strings.EqualFold(cc.State, "deleted") { - return fmt.Errorf("cannot update cost center %q because it is archived", costCenterID) + return diag.FromErr(fmt.Errorf("cannot update cost center %q because it is archived", costCenterID)) } if d.HasChange("name") { @@ -142,19 +143,19 @@ func resourceGithubEnterpriseCostCenterUpdate(d *schema.ResourceData, meta any) log.Printf("[INFO] Updating enterprise cost center: %s/%s", enterpriseSlug, costCenterID) _, err := enterpriseCostCenterUpdate(ctx, client, enterpriseSlug, costCenterID, name) if err != nil { - return err + return diag.FromErr(err) } } - return resourceGithubEnterpriseCostCenterRead(d, meta) + return resourceGithubEnterpriseCostCenterRead(ctx, d, meta) } -func resourceGithubEnterpriseCostCenterDelete(d *schema.ResourceData, meta any) error { +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() - ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + ctx = context.WithValue(ctx, ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) log.Printf("[INFO] Archiving enterprise cost center: %s/%s", enterpriseSlug, costCenterID) _, err := enterpriseCostCenterArchive(ctx, client, enterpriseSlug, costCenterID) @@ -162,13 +163,13 @@ func resourceGithubEnterpriseCostCenterDelete(d *schema.ResourceData, meta any) if is404(err) { return nil } - return err + return diag.FromErr(err) } return nil } -func resourceGithubEnterpriseCostCenterImport(d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { +func resourceGithubEnterpriseCostCenterImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { parts := strings.Split(d.Id(), "/") if len(parts) != 2 { return nil, fmt.Errorf("invalid import specified: supplied import must be written as /") diff --git a/github/resource_github_enterprise_cost_center_resources.go b/github/resource_github_enterprise_cost_center_resources.go index 68e489c4bf..60f934ba96 100644 --- a/github/resource_github_enterprise_cost_center_resources.go +++ b/github/resource_github_enterprise_cost_center_resources.go @@ -10,18 +10,19 @@ import ( "time" "github.com/google/go-github/v67/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) func resourceGithubEnterpriseCostCenterResources() *schema.Resource { return &schema.Resource{ - Create: resourceGithubEnterpriseCostCenterResourcesCreate, - Read: resourceGithubEnterpriseCostCenterResourcesRead, - Update: resourceGithubEnterpriseCostCenterResourcesUpdate, - Delete: resourceGithubEnterpriseCostCenterResourcesDelete, + CreateContext: resourceGithubEnterpriseCostCenterResourcesCreate, + ReadContext: resourceGithubEnterpriseCostCenterResourcesRead, + UpdateContext: resourceGithubEnterpriseCostCenterResourcesUpdate, + DeleteContext: resourceGithubEnterpriseCostCenterResourcesDelete, Importer: &schema.ResourceImporter{ - State: resourceGithubEnterpriseCostCenterResourcesImport, + StateContext: resourceGithubEnterpriseCostCenterResourcesImport, }, Schema: map[string]*schema.Schema{ @@ -59,22 +60,22 @@ func resourceGithubEnterpriseCostCenterResources() *schema.Resource { } } -func resourceGithubEnterpriseCostCenterResourcesCreate(d *schema.ResourceData, meta any) error { +func resourceGithubEnterpriseCostCenterResourcesCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) d.SetId(buildTwoPartID(enterpriseSlug, costCenterID)) - return resourceGithubEnterpriseCostCenterResourcesUpdate(d, meta) + return resourceGithubEnterpriseCostCenterResourcesUpdate(ctx, d, meta) } -func resourceGithubEnterpriseCostCenterResourcesRead(d *schema.ResourceData, meta any) error { +func resourceGithubEnterpriseCostCenterResourcesRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client enterpriseSlug, costCenterID, err := parseTwoPartID(d.Id(), "enterprise_slug", "cost_center_id") if err != nil { - return err + return diag.FromErr(err) } - ctx := context.WithValue(context.Background(), ctxId, d.Id()) + ctx = context.WithValue(ctx, ctxId, d.Id()) cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) if err != nil { @@ -82,7 +83,7 @@ func resourceGithubEnterpriseCostCenterResourcesRead(d *schema.ResourceData, met d.SetId("") return nil } - return err + return diag.FromErr(err) } users, orgs, repos := enterpriseCostCenterSplitResources(cc.Resources) @@ -100,24 +101,24 @@ func resourceGithubEnterpriseCostCenterResourcesRead(d *schema.ResourceData, met return nil } -func resourceGithubEnterpriseCostCenterResourcesUpdate(d *schema.ResourceData, meta any) error { +func resourceGithubEnterpriseCostCenterResourcesUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client enterpriseSlug, costCenterID, err := parseTwoPartID(d.Id(), "enterprise_slug", "cost_center_id") if err != nil { - return err + return diag.FromErr(err) } - ctx := context.WithValue(context.Background(), ctxId, d.Id()) + ctx = context.WithValue(ctx, ctxId, d.Id()) cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) if err != nil { if is404(err) { - return fmt.Errorf("cost center %q not found in enterprise %q (check enterprise_slug matches the cost center's enterprise)", costCenterID, enterpriseSlug) + return diag.FromErr(fmt.Errorf("cost center %q not found in enterprise %q (check enterprise_slug matches the cost center's enterprise)", costCenterID, enterpriseSlug)) } - return err + return diag.FromErr(err) } if strings.EqualFold(cc.State, "deleted") { - return fmt.Errorf("cannot modify cost center %q resources because it is archived", costCenterID) + return diag.FromErr(fmt.Errorf("cannot modify cost center %q resources because it is archived", costCenterID)) } desiredUsers := expandStringSet(getStringSetOrEmpty(d, "users")) @@ -182,17 +183,17 @@ func resourceGithubEnterpriseCostCenterResourcesUpdate(d *schema.ResourceData, m for _, batch := range chunk(toRemoveUsers, maxResourcesPerRequest) { if err := retryRemove(enterpriseCostCenterResourcesRequest{Users: batch}); err != nil { - return err + return diag.FromErr(err) } } for _, batch := range chunk(toRemoveOrgs, maxResourcesPerRequest) { if err := retryRemove(enterpriseCostCenterResourcesRequest{Organizations: batch}); err != nil { - return err + return diag.FromErr(err) } } for _, batch := range chunk(toRemoveRepos, maxResourcesPerRequest) { if err := retryRemove(enterpriseCostCenterResourcesRequest{Repositories: batch}); err != nil { - return err + return diag.FromErr(err) } } } @@ -202,39 +203,39 @@ func resourceGithubEnterpriseCostCenterResourcesUpdate(d *schema.ResourceData, m for _, batch := range chunk(toAddUsers, maxResourcesPerRequest) { if err := retryAssign(enterpriseCostCenterResourcesRequest{Users: batch}); err != nil { - return err + return diag.FromErr(err) } } for _, batch := range chunk(toAddOrgs, maxResourcesPerRequest) { if err := retryAssign(enterpriseCostCenterResourcesRequest{Organizations: batch}); err != nil { - return err + return diag.FromErr(err) } } for _, batch := range chunk(toAddRepos, maxResourcesPerRequest) { if err := retryAssign(enterpriseCostCenterResourcesRequest{Repositories: batch}); err != nil { - return err + return diag.FromErr(err) } } } - return resourceGithubEnterpriseCostCenterResourcesRead(d, meta) + return resourceGithubEnterpriseCostCenterResourcesRead(ctx, d, meta) } -func resourceGithubEnterpriseCostCenterResourcesDelete(d *schema.ResourceData, meta any) error { +func resourceGithubEnterpriseCostCenterResourcesDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client enterpriseSlug, costCenterID, err := parseTwoPartID(d.Id(), "enterprise_slug", "cost_center_id") if err != nil { - return err + return diag.FromErr(err) } - ctx := context.WithValue(context.Background(), ctxId, d.Id()) + ctx = context.WithValue(ctx, ctxId, d.Id()) cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) if err != nil { if is404(err) { return nil } - return err + return diag.FromErr(err) } // If the cost center is archived, treat deletion as a no-op. @@ -285,24 +286,24 @@ func resourceGithubEnterpriseCostCenterResourcesDelete(d *schema.ResourceData, m for _, batch := range chunk(users, maxResourcesPerRequest) { if err := retryRemove(enterpriseCostCenterResourcesRequest{Users: batch}); err != nil { - return err + return diag.FromErr(err) } } for _, batch := range chunk(orgs, maxResourcesPerRequest) { if err := retryRemove(enterpriseCostCenterResourcesRequest{Organizations: batch}); err != nil { - return err + return diag.FromErr(err) } } for _, batch := range chunk(repos, maxResourcesPerRequest) { if err := retryRemove(enterpriseCostCenterResourcesRequest{Repositories: batch}); err != nil { - return err + return diag.FromErr(err) } } return nil } -func resourceGithubEnterpriseCostCenterResourcesImport(d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { +func resourceGithubEnterpriseCostCenterResourcesImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { parts := strings.Split(d.Id(), "/") if len(parts) != 2 { return nil, fmt.Errorf("invalid import specified: supplied import must be written as /") From cde822c50b2af22116236adbc05f8c84f0155700 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 05:54:19 +0100 Subject: [PATCH 09/10] fix: linting --- ...rce_github_enterprise_cost_centers_test.go | 2 +- ...github_enterprise_cost_center_resources.go | 27 ++++++++++--------- github/util_cost_centers.go | 17 ++++++------ 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/github/data_source_github_enterprise_cost_centers_test.go b/github/data_source_github_enterprise_cost_centers_test.go index 71f6719f7c..551f9a8d1b 100644 --- a/github/data_source_github_enterprise_cost_centers_test.go +++ b/github/data_source_github_enterprise_cost_centers_test.go @@ -49,7 +49,7 @@ func TestAccGithubEnterpriseCostCentersDataSource(t *testing.T) { }) } -func testAccCheckEnterpriseCostCentersListContains(costCenterResourceName string, dataSourceName string) resource.TestCheckFunc { +func testAccCheckEnterpriseCostCentersListContains(costCenterResourceName, dataSourceName string) resource.TestCheckFunc { return func(s *terraform.State) error { cc, ok := s.RootModule().Resources[costCenterResourceName] if !ok { diff --git a/github/resource_github_enterprise_cost_center_resources.go b/github/resource_github_enterprise_cost_center_resources.go index 60f934ba96..1a8e657708 100644 --- a/github/resource_github_enterprise_cost_center_resources.go +++ b/github/resource_github_enterprise_cost_center_resources.go @@ -135,44 +135,45 @@ func resourceGithubEnterpriseCostCenterResourcesUpdate(ctx context.Context, d *s const costCenterResourcesRetryTimeout = 5 * time.Minute retryRemove := func(req enterpriseCostCenterResourcesRequest) error { + //nolint:staticcheck return resource.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *resource.RetryError { _, err := enterpriseCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, req) if err == nil { return nil } if isRetryableGithubResponseError(err) { + //nolint:staticcheck return resource.RetryableError(err) } + //nolint:staticcheck return resource.NonRetryableError(err) }) } retryAssign := func(req enterpriseCostCenterResourcesRequest) error { + //nolint:staticcheck return resource.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *resource.RetryError { _, err := enterpriseCostCenterAssignResources(ctx, client, enterpriseSlug, costCenterID, req) if err == nil { return nil } if isRetryableGithubResponseError(err) { + //nolint:staticcheck return resource.RetryableError(err) } + //nolint:staticcheck return resource.NonRetryableError(err) }) } - chunk := func(items []string, size int) [][]string { + chunk := func(items []string, _ int) [][]string { if len(items) == 0 { return nil } - if size <= 0 { - size = len(items) - } + const size = maxResourcesPerRequest chunks := make([][]string, 0, (len(items)+size-1)/size) for start := 0; start < len(items); start += size { - end := start + size - if end > len(items) { - end = len(items) - } + end := min(start+size, len(items)) chunks = append(chunks, items[start:end]) } return chunks @@ -252,14 +253,17 @@ func resourceGithubEnterpriseCostCenterResourcesDelete(ctx context.Context, d *s const costCenterResourcesRetryTimeout = 5 * time.Minute retryRemove := func(req enterpriseCostCenterResourcesRequest) error { + //nolint:staticcheck return resource.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *resource.RetryError { _, err := enterpriseCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, req) if err == nil { return nil } if isRetryableGithubResponseError(err) { + //nolint:staticcheck return resource.RetryableError(err) } + //nolint:staticcheck return resource.NonRetryableError(err) }) } @@ -273,10 +277,7 @@ func resourceGithubEnterpriseCostCenterResourcesDelete(ctx context.Context, d *s } chunks := make([][]string, 0, (len(items)+size-1)/size) for start := 0; start < len(items); start += size { - end := start + size - if end > len(items) { - end = len(items) - } + end := min(start+size, len(items)) chunks = append(chunks, items[start:end]) } return chunks @@ -358,7 +359,7 @@ func isRetryableGithubResponseError(err error) bool { return false } -func diffStringSlices(current []string, desired []string) (toAdd []string, toRemove []string) { +func diffStringSlices(current, desired []string) (toAdd, toRemove []string) { cur := schema.NewSet(schema.HashString, stringSliceToAnySlice(current)) des := schema.NewSet(schema.HashString, stringSliceToAnySlice(desired)) diff --git a/github/util_cost_centers.go b/github/util_cost_centers.go index 320686811d..d4292e6109 100644 --- a/github/util_cost_centers.go +++ b/github/util_cost_centers.go @@ -61,7 +61,7 @@ type enterpriseCostCenterRemoveResponse struct { Message string `json:"message"` } -func enterpriseCostCentersList(ctx context.Context, client *github.Client, enterpriseSlug string, state string) ([]enterpriseCostCenter, error) { +func enterpriseCostCentersList(ctx context.Context, client *github.Client, enterpriseSlug, state string) ([]enterpriseCostCenter, error) { u, err := url.Parse(fmt.Sprintf("enterprises/%s/settings/billing/cost-centers", enterpriseSlug)) if err != nil { return nil, err @@ -87,7 +87,7 @@ func enterpriseCostCentersList(ctx context.Context, client *github.Client, enter return result.CostCenters, nil } -func enterpriseCostCenterGet(ctx context.Context, client *github.Client, enterpriseSlug string, costCenterID string) (*enterpriseCostCenter, error) { +func enterpriseCostCenterGet(ctx context.Context, client *github.Client, enterpriseSlug, costCenterID string) (*enterpriseCostCenter, error) { req, err := client.NewRequest("GET", fmt.Sprintf("enterprises/%s/settings/billing/cost-centers/%s", enterpriseSlug, costCenterID), nil) if err != nil { return nil, err @@ -102,7 +102,7 @@ func enterpriseCostCenterGet(ctx context.Context, client *github.Client, enterpr return &result, nil } -func enterpriseCostCenterCreate(ctx context.Context, client *github.Client, enterpriseSlug string, name string) (*enterpriseCostCenter, error) { +func enterpriseCostCenterCreate(ctx context.Context, client *github.Client, enterpriseSlug, name string) (*enterpriseCostCenter, error) { req, err := client.NewRequest("POST", fmt.Sprintf("enterprises/%s/settings/billing/cost-centers", enterpriseSlug), &enterpriseCostCenterCreateRequest{Name: name}) if err != nil { return nil, err @@ -117,7 +117,7 @@ func enterpriseCostCenterCreate(ctx context.Context, client *github.Client, ente return &result, nil } -func enterpriseCostCenterUpdate(ctx context.Context, client *github.Client, enterpriseSlug string, costCenterID string, name string) (*enterpriseCostCenter, error) { +func enterpriseCostCenterUpdate(ctx context.Context, client *github.Client, enterpriseSlug, costCenterID, name string) (*enterpriseCostCenter, error) { req, err := client.NewRequest("PATCH", fmt.Sprintf("enterprises/%s/settings/billing/cost-centers/%s", enterpriseSlug, costCenterID), &enterpriseCostCenterUpdateRequest{Name: name}) if err != nil { return nil, err @@ -132,7 +132,7 @@ func enterpriseCostCenterUpdate(ctx context.Context, client *github.Client, ente return &result, nil } -func enterpriseCostCenterArchive(ctx context.Context, client *github.Client, enterpriseSlug string, costCenterID string) (*enterpriseCostCenterArchiveResponse, error) { +func enterpriseCostCenterArchive(ctx context.Context, client *github.Client, enterpriseSlug, costCenterID string) (*enterpriseCostCenterArchiveResponse, error) { req, err := client.NewRequest("DELETE", fmt.Sprintf("enterprises/%s/settings/billing/cost-centers/%s", enterpriseSlug, costCenterID), nil) if err != nil { return nil, err @@ -147,7 +147,7 @@ func enterpriseCostCenterArchive(ctx context.Context, client *github.Client, ent return &result, nil } -func enterpriseCostCenterAssignResources(ctx context.Context, client *github.Client, enterpriseSlug string, costCenterID string, reqBody enterpriseCostCenterResourcesRequest) (*enterpriseCostCenterAssignResponse, error) { +func enterpriseCostCenterAssignResources(ctx context.Context, client *github.Client, enterpriseSlug, costCenterID string, reqBody enterpriseCostCenterResourcesRequest) (*enterpriseCostCenterAssignResponse, error) { req, err := client.NewRequest("POST", fmt.Sprintf("enterprises/%s/settings/billing/cost-centers/%s/resource", enterpriseSlug, costCenterID), &reqBody) if err != nil { return nil, err @@ -162,7 +162,8 @@ func enterpriseCostCenterAssignResources(ctx context.Context, client *github.Cli return &result, nil } -func enterpriseCostCenterRemoveResources(ctx context.Context, client *github.Client, enterpriseSlug string, costCenterID string, reqBody enterpriseCostCenterResourcesRequest) (*enterpriseCostCenterRemoveResponse, error) { +//nolint:unparam +func enterpriseCostCenterRemoveResources(ctx context.Context, client *github.Client, enterpriseSlug, costCenterID string, reqBody enterpriseCostCenterResourcesRequest) (*enterpriseCostCenterRemoveResponse, error) { req, err := client.NewRequest("DELETE", fmt.Sprintf("enterprises/%s/settings/billing/cost-centers/%s/resource", enterpriseSlug, costCenterID), &reqBody) if err != nil { return nil, err @@ -177,7 +178,7 @@ func enterpriseCostCenterRemoveResources(ctx context.Context, client *github.Cli return &result, nil } -func enterpriseCostCenterSplitResources(resources []enterpriseCostCenterResource) (users []string, organizations []string, repositories []string) { +func enterpriseCostCenterSplitResources(resources []enterpriseCostCenterResource) (users, organizations, repositories []string) { for _, r := range resources { switch strings.ToLower(r.Type) { case "user": From 69f8931e85ada9e3e33694e1ac148ce59d682c19 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 26 Dec 2025 08:55:55 +0100 Subject: [PATCH 10/10] Remove legacy cost center resources --- examples/cost_centers/main.tf | 24 +- ...ta_source_github_enterprise_cost_center.go | 27 ++ ...urce_github_enterprise_cost_center_test.go | 33 +- github/provider.go | 1 - .../resource_github_enterprise_cost_center.go | 237 +++++++++++ ...github_enterprise_cost_center_resources.go | 376 ------------------ ...b_enterprise_cost_center_resources_test.go | 177 --------- ...urce_github_enterprise_cost_center_test.go | 82 +++- .../d/enterprise_cost_center.html.markdown | 3 + .../r/enterprise_cost_center.html.markdown | 11 + ...rprise_cost_center_resources.html.markdown | 49 --- website/github.erb | 3 - 12 files changed, 401 insertions(+), 622 deletions(-) delete mode 100644 github/resource_github_enterprise_cost_center_resources.go delete mode 100644 github/resource_github_enterprise_cost_center_resources_test.go delete mode 100644 website/docs/r/enterprise_cost_center_resources.html.markdown diff --git a/examples/cost_centers/main.tf b/examples/cost_centers/main.tf index dbed711602..925b1c43bf 100644 --- a/examples/cost_centers/main.tf +++ b/examples/cost_centers/main.tf @@ -48,13 +48,8 @@ variable "repositories" { 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. -resource "github_enterprise_cost_center_resources" "example" { - enterprise_slug = var.enterprise_slug - cost_center_id = github_enterprise_cost_center.example.id + # Authoritative assignments: Terraform will add/remove to match these lists. users = var.users organizations = var.organizations repositories = var.repositories @@ -63,8 +58,6 @@ resource "github_enterprise_cost_center_resources" "example" { data "github_enterprise_cost_center" "by_id" { enterprise_slug = var.enterprise_slug cost_center_id = github_enterprise_cost_center.example.id - - depends_on = [github_enterprise_cost_center_resources.example] } data "github_enterprise_cost_centers" "active" { @@ -87,17 +80,20 @@ output "cost_center" { output "cost_center_resources" { description = "Effective assignments (read from API)" value = { - users = sort(tolist(github_enterprise_cost_center_resources.example.users)) - organizations = sort(tolist(github_enterprise_cost_center_resources.example.organizations)) - repositories = sort(tolist(github_enterprise_cost_center_resources.example.repositories)) + 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 + 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 index 7e21c8c989..3ae4ec3520 100644 --- a/github/data_source_github_enterprise_cost_center.go +++ b/github/data_source_github_enterprise_cost_center.go @@ -3,6 +3,7 @@ package github import ( "context" "fmt" + "sort" "strings" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -39,6 +40,24 @@ func dataSourceGithubEnterpriseCostCenter() *schema.Resource { 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.", + }, "resources": { Type: schema.TypeList, Computed: true, @@ -90,5 +109,13 @@ func dataSourceGithubEnterpriseCostCenterRead(ctx context.Context, d *schema.Res } _ = d.Set("resources", resources) + users, organizations, repositories := enterpriseCostCenterSplitResources(cc.Resources) + sort.Strings(users) + sort.Strings(organizations) + sort.Strings(repositories) + _ = d.Set("users", stringSliceToAnySlice(users)) + _ = d.Set("organizations", stringSliceToAnySlice(organizations)) + _ = d.Set("repositories", stringSliceToAnySlice(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 index 355bd47110..8ba93dfd91 100644 --- a/github/data_source_github_enterprise_cost_center_test.go +++ b/github/data_source_github_enterprise_cost_center_test.go @@ -2,6 +2,7 @@ package github import ( "fmt" + "os" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" @@ -17,6 +18,26 @@ func TestAccGithubEnterpriseCostCenterDataSource(t *testing.T) { if testEnterprise == "" { t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") } + testEnterpriseCostCenterOrganization := os.Getenv("ENTERPRISE_TEST_ORGANIZATION") + testEnterpriseCostCenterRepository := os.Getenv("ENTERPRISE_TEST_REPOSITORY") + testEnterpriseCostCenterUsers := os.Getenv("ENTERPRISE_TEST_USERS") + + if testEnterpriseCostCenterOrganization == "" { + t.Skip("Skipping because `ENTERPRISE_TEST_ORGANIZATION` is not set") + } + if testEnterpriseCostCenterRepository == "" { + t.Skip("Skipping because `ENTERPRISE_TEST_REPOSITORY` is not set") + } + if testEnterpriseCostCenterUsers == "" { + t.Skip("Skipping because `ENTERPRISE_TEST_USERS` is not set") + } + + users := splitCommaSeparated(testEnterpriseCostCenterUsers) + if len(users) == 0 { + t.Skip("Skipping because `ENTERPRISE_TEST_USERS` must contain at least one username") + } + + userList := fmt.Sprintf("%q", users[0]) config := fmt.Sprintf(` data "github_enterprise" "enterprise" { @@ -26,18 +47,28 @@ func TestAccGithubEnterpriseCostCenterDataSource(t *testing.T) { resource "github_enterprise_cost_center" "test" { enterprise_slug = data.github_enterprise.enterprise.slug name = "tf-acc-test-%s" + + users = [%s] + organizations = [%q] + repositories = [%q] } data "github_enterprise_cost_center" "test" { enterprise_slug = data.github_enterprise.enterprise.slug cost_center_id = github_enterprise_cost_center.test.id } - `, testEnterprise, randomID) + `, testEnterprise, randomID, userList, testEnterpriseCostCenterOrganization, testEnterpriseCostCenterRepository) 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", "organizations.#", "1"), + resource.TestCheckTypeSetElemAttr("data.github_enterprise_cost_center.test", "organizations.*", testEnterpriseCostCenterOrganization), + resource.TestCheckResourceAttr("data.github_enterprise_cost_center.test", "repositories.#", "1"), + resource.TestCheckTypeSetElemAttr("data.github_enterprise_cost_center.test", "repositories.*", testEnterpriseCostCenterRepository), + resource.TestCheckResourceAttr("data.github_enterprise_cost_center.test", "users.#", "1"), + resource.TestCheckTypeSetElemAttr("data.github_enterprise_cost_center.test", "users.*", users[0]), ) resource.Test(t, resource.TestCase{ diff --git a/github/provider.go b/github/provider.go index 7609680962..8c78c4f979 100644 --- a/github/provider.go +++ b/github/provider.go @@ -212,7 +212,6 @@ func Provider() *schema.Provider { "github_enterprise_actions_workflow_permissions": resourceGithubEnterpriseActionsWorkflowPermissions(), "github_enterprise_security_analysis_settings": resourceGithubEnterpriseSecurityAnalysisSettings(), "github_enterprise_cost_center": resourceGithubEnterpriseCostCenter(), - "github_enterprise_cost_center_resources": resourceGithubEnterpriseCostCenterResources(), "github_workflow_repository_permissions": resourceGithubWorkflowRepositoryPermissions(), }, diff --git a/github/resource_github_enterprise_cost_center.go b/github/resource_github_enterprise_cost_center.go index a03fea0861..6c4bcc9538 100644 --- a/github/resource_github_enterprise_cost_center.go +++ b/github/resource_github_enterprise_cost_center.go @@ -2,11 +2,16 @@ package github import ( "context" + "errors" "fmt" "log" + "sort" "strings" + "time" + "github.com/google/go-github/v67/github" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -32,6 +37,24 @@ func resourceGithubEnterpriseCostCenter() *schema.Resource { 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, @@ -82,6 +105,18 @@ func resourceGithubEnterpriseCostCenterCreate(ctx context.Context, d *schema.Res } d.SetId(cc.ID) + + if hasCostCenterAssignmentsConfigured(d) { + // Ensure we operate on fresh API state before mutations. + current, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, cc.ID) + if err != nil { + return diag.FromErr(err) + } + if diags := syncEnterpriseCostCenterAssignments(ctx, d, client, enterpriseSlug, cc.ID, current.Resources); diags.HasError() { + return diags + } + } + return resourceGithubEnterpriseCostCenterRead(ctx, d, meta) } @@ -120,6 +155,14 @@ func resourceGithubEnterpriseCostCenterRead(ctx context.Context, d *schema.Resou } _ = d.Set("resources", resources) + users, organizations, repositories := enterpriseCostCenterSplitResources(cc.Resources) + sort.Strings(users) + sort.Strings(organizations) + sort.Strings(repositories) + _ = d.Set("users", stringSliceToAnySlice(users)) + _ = d.Set("organizations", stringSliceToAnySlice(organizations)) + _ = d.Set("repositories", stringSliceToAnySlice(repositories)) + return nil } @@ -145,6 +188,17 @@ func resourceGithubEnterpriseCostCenterUpdate(ctx context.Context, d *schema.Res if err != nil { return diag.FromErr(err) } + + cc, err = enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + } + + if d.HasChange("users") || d.HasChange("organizations") || d.HasChange("repositories") { + if diags := syncEnterpriseCostCenterAssignments(ctx, d, client, enterpriseSlug, costCenterID, cc.Resources); diags.HasError() { + return diags + } } return resourceGithubEnterpriseCostCenterRead(ctx, d, meta) @@ -181,3 +235,186 @@ func resourceGithubEnterpriseCostCenterImport(ctx context.Context, d *schema.Res return []*schema.ResourceData{d}, nil } + +func syncEnterpriseCostCenterAssignments(ctx context.Context, d *schema.ResourceData, client *github.Client, enterpriseSlug, costCenterID string, currentResources []enterpriseCostCenterResource) diag.Diagnostics { + desiredUsers := expandStringSet(getStringSetOrEmpty(d, "users")) + desiredOrgs := expandStringSet(getStringSetOrEmpty(d, "organizations")) + desiredRepos := expandStringSet(getStringSetOrEmpty(d, "repositories")) + + currentUsers, currentOrgs, currentRepos := enterpriseCostCenterSplitResources(currentResources) + + toAddUsers, toRemoveUsers := diffStringSlices(currentUsers, desiredUsers) + toAddOrgs, toRemoveOrgs := diffStringSlices(currentOrgs, desiredOrgs) + toAddRepos, toRemoveRepos := diffStringSlices(currentRepos, desiredRepos) + + const maxResourcesPerRequest = 50 + const costCenterResourcesRetryTimeout = 5 * time.Minute + + retryRemove := func(req enterpriseCostCenterResourcesRequest) diag.Diagnostics { + //nolint:staticcheck + err := resource.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *resource.RetryError { + _, err := enterpriseCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, req) + if err == nil { + return nil + } + if isRetryableGithubResponseError(err) { + //nolint:staticcheck + return resource.RetryableError(err) + } + //nolint:staticcheck + return resource.NonRetryableError(err) + }) + if err != nil { + return diag.FromErr(err) + } + return nil + } + + retryAssign := func(req enterpriseCostCenterResourcesRequest) diag.Diagnostics { + //nolint:staticcheck + err := resource.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *resource.RetryError { + _, err := enterpriseCostCenterAssignResources(ctx, client, enterpriseSlug, costCenterID, req) + if err == nil { + return nil + } + if isRetryableGithubResponseError(err) { + //nolint:staticcheck + return resource.RetryableError(err) + } + //nolint:staticcheck + return resource.NonRetryableError(err) + }) + if err != nil { + return diag.FromErr(err) + } + return nil + } + + chunk := func(items []string) [][]string { + if len(items) == 0 { + return nil + } + const size = maxResourcesPerRequest + chunks := make([][]string, 0, (len(items)+size-1)/size) + for start := 0; start < len(items); start += size { + end := min(start+size, len(items)) + chunks = append(chunks, items[start:end]) + } + return chunks + } + + if len(toRemoveUsers)+len(toRemoveOrgs)+len(toRemoveRepos) > 0 { + log.Printf("[INFO] Removing enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) + + for _, batch := range chunk(toRemoveUsers) { + if diags := retryRemove(enterpriseCostCenterResourcesRequest{Users: batch}); diags.HasError() { + return diags + } + } + for _, batch := range chunk(toRemoveOrgs) { + if diags := retryRemove(enterpriseCostCenterResourcesRequest{Organizations: batch}); diags.HasError() { + return diags + } + } + for _, batch := range chunk(toRemoveRepos) { + if diags := retryRemove(enterpriseCostCenterResourcesRequest{Repositories: batch}); diags.HasError() { + return diags + } + } + } + + if len(toAddUsers)+len(toAddOrgs)+len(toAddRepos) > 0 { + log.Printf("[INFO] Assigning enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) + + for _, batch := range chunk(toAddUsers) { + if diags := retryAssign(enterpriseCostCenterResourcesRequest{Users: batch}); diags.HasError() { + return diags + } + } + for _, batch := range chunk(toAddOrgs) { + if diags := retryAssign(enterpriseCostCenterResourcesRequest{Organizations: batch}); diags.HasError() { + return diags + } + } + for _, batch := range chunk(toAddRepos) { + if diags := retryAssign(enterpriseCostCenterResourcesRequest{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.GetOkExists(key); ok { + if set, ok := v.(*schema.Set); ok && set != nil && set.Len() > 0 { + return true + } + if !ok { + // Non-set values still indicate explicit configuration. + 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, stringSliceToAnySlice(current)) + des := schema.NewSet(schema.HashString, stringSliceToAnySlice(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 404, 409, 500, 502, 503, 504: + return true + default: + return false + } + } + return false +} diff --git a/github/resource_github_enterprise_cost_center_resources.go b/github/resource_github_enterprise_cost_center_resources.go deleted file mode 100644 index 1a8e657708..0000000000 --- a/github/resource_github_enterprise_cost_center_resources.go +++ /dev/null @@ -1,376 +0,0 @@ -package github - -import ( - "context" - "errors" - "fmt" - "log" - "sort" - "strings" - "time" - - "github.com/google/go-github/v67/github" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" -) - -func resourceGithubEnterpriseCostCenterResources() *schema.Resource { - return &schema.Resource{ - CreateContext: resourceGithubEnterpriseCostCenterResourcesCreate, - ReadContext: resourceGithubEnterpriseCostCenterResourcesRead, - UpdateContext: resourceGithubEnterpriseCostCenterResourcesUpdate, - DeleteContext: resourceGithubEnterpriseCostCenterResourcesDelete, - Importer: &schema.ResourceImporter{ - StateContext: resourceGithubEnterpriseCostCenterResourcesImport, - }, - - 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 cost center ID.", - }, - "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.", - }, - }, - } -} - -func resourceGithubEnterpriseCostCenterResourcesCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - enterpriseSlug := d.Get("enterprise_slug").(string) - costCenterID := d.Get("cost_center_id").(string) - - d.SetId(buildTwoPartID(enterpriseSlug, costCenterID)) - return resourceGithubEnterpriseCostCenterResourcesUpdate(ctx, d, meta) -} - -func resourceGithubEnterpriseCostCenterResourcesRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - enterpriseSlug, costCenterID, err := parseTwoPartID(d.Id(), "enterprise_slug", "cost_center_id") - if err != nil { - return diag.FromErr(err) - } - - ctx = context.WithValue(ctx, ctxId, d.Id()) - - cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) - if err != nil { - if is404(err) { - d.SetId("") - return nil - } - return diag.FromErr(err) - } - - users, orgs, repos := enterpriseCostCenterSplitResources(cc.Resources) - sort.Strings(users) - sort.Strings(orgs) - sort.Strings(repos) - - _ = d.Set("enterprise_slug", enterpriseSlug) - _ = d.Set("cost_center_id", costCenterID) - - _ = d.Set("users", stringSliceToAnySlice(users)) - _ = d.Set("organizations", stringSliceToAnySlice(orgs)) - _ = d.Set("repositories", stringSliceToAnySlice(repos)) - - return nil -} - -func resourceGithubEnterpriseCostCenterResourcesUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - enterpriseSlug, costCenterID, err := parseTwoPartID(d.Id(), "enterprise_slug", "cost_center_id") - if err != nil { - return diag.FromErr(err) - } - - ctx = context.WithValue(ctx, ctxId, d.Id()) - - cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) - if err != nil { - if is404(err) { - return diag.FromErr(fmt.Errorf("cost center %q not found in enterprise %q (check enterprise_slug matches the cost center's enterprise)", costCenterID, enterpriseSlug)) - } - return diag.FromErr(err) - } - if strings.EqualFold(cc.State, "deleted") { - return diag.FromErr(fmt.Errorf("cannot modify cost center %q resources because it is archived", costCenterID)) - } - - desiredUsers := expandStringSet(getStringSetOrEmpty(d, "users")) - desiredOrgs := expandStringSet(getStringSetOrEmpty(d, "organizations")) - desiredRepos := expandStringSet(getStringSetOrEmpty(d, "repositories")) - - currentUsers, currentOrgs, currentRepos := enterpriseCostCenterSplitResources(cc.Resources) - - toAddUsers, toRemoveUsers := diffStringSlices(currentUsers, desiredUsers) - toAddOrgs, toRemoveOrgs := diffStringSlices(currentOrgs, desiredOrgs) - toAddRepos, toRemoveRepos := diffStringSlices(currentRepos, desiredRepos) - - const maxResourcesPerRequest = 50 - const costCenterResourcesRetryTimeout = 5 * time.Minute - - retryRemove := func(req enterpriseCostCenterResourcesRequest) error { - //nolint:staticcheck - return resource.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *resource.RetryError { - _, err := enterpriseCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, req) - if err == nil { - return nil - } - if isRetryableGithubResponseError(err) { - //nolint:staticcheck - return resource.RetryableError(err) - } - //nolint:staticcheck - return resource.NonRetryableError(err) - }) - } - - retryAssign := func(req enterpriseCostCenterResourcesRequest) error { - //nolint:staticcheck - return resource.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *resource.RetryError { - _, err := enterpriseCostCenterAssignResources(ctx, client, enterpriseSlug, costCenterID, req) - if err == nil { - return nil - } - if isRetryableGithubResponseError(err) { - //nolint:staticcheck - return resource.RetryableError(err) - } - //nolint:staticcheck - return resource.NonRetryableError(err) - }) - } - - chunk := func(items []string, _ int) [][]string { - if len(items) == 0 { - return nil - } - const size = maxResourcesPerRequest - chunks := make([][]string, 0, (len(items)+size-1)/size) - for start := 0; start < len(items); start += size { - end := min(start+size, len(items)) - chunks = append(chunks, items[start:end]) - } - return chunks - } - - if len(toRemoveUsers)+len(toRemoveOrgs)+len(toRemoveRepos) > 0 { - log.Printf("[INFO] Removing enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) - - for _, batch := range chunk(toRemoveUsers, maxResourcesPerRequest) { - if err := retryRemove(enterpriseCostCenterResourcesRequest{Users: batch}); err != nil { - return diag.FromErr(err) - } - } - for _, batch := range chunk(toRemoveOrgs, maxResourcesPerRequest) { - if err := retryRemove(enterpriseCostCenterResourcesRequest{Organizations: batch}); err != nil { - return diag.FromErr(err) - } - } - for _, batch := range chunk(toRemoveRepos, maxResourcesPerRequest) { - if err := retryRemove(enterpriseCostCenterResourcesRequest{Repositories: batch}); err != nil { - return diag.FromErr(err) - } - } - } - - if len(toAddUsers)+len(toAddOrgs)+len(toAddRepos) > 0 { - log.Printf("[INFO] Assigning enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) - - for _, batch := range chunk(toAddUsers, maxResourcesPerRequest) { - if err := retryAssign(enterpriseCostCenterResourcesRequest{Users: batch}); err != nil { - return diag.FromErr(err) - } - } - for _, batch := range chunk(toAddOrgs, maxResourcesPerRequest) { - if err := retryAssign(enterpriseCostCenterResourcesRequest{Organizations: batch}); err != nil { - return diag.FromErr(err) - } - } - for _, batch := range chunk(toAddRepos, maxResourcesPerRequest) { - if err := retryAssign(enterpriseCostCenterResourcesRequest{Repositories: batch}); err != nil { - return diag.FromErr(err) - } - } - } - - return resourceGithubEnterpriseCostCenterResourcesRead(ctx, d, meta) -} - -func resourceGithubEnterpriseCostCenterResourcesDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - enterpriseSlug, costCenterID, err := parseTwoPartID(d.Id(), "enterprise_slug", "cost_center_id") - if err != nil { - return diag.FromErr(err) - } - - ctx = context.WithValue(ctx, ctxId, d.Id()) - - cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) - if err != nil { - if is404(err) { - return nil - } - return diag.FromErr(err) - } - - // If the cost center is archived, treat deletion as a no-op. - if strings.EqualFold(cc.State, "deleted") { - return nil - } - - users, orgs, repos := enterpriseCostCenterSplitResources(cc.Resources) - if len(users)+len(orgs)+len(repos) == 0 { - return nil - } - - const maxResourcesPerRequest = 50 - const costCenterResourcesRetryTimeout = 5 * time.Minute - - retryRemove := func(req enterpriseCostCenterResourcesRequest) error { - //nolint:staticcheck - return resource.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *resource.RetryError { - _, err := enterpriseCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, req) - if err == nil { - return nil - } - if isRetryableGithubResponseError(err) { - //nolint:staticcheck - return resource.RetryableError(err) - } - //nolint:staticcheck - return resource.NonRetryableError(err) - }) - } - - chunk := func(items []string, size int) [][]string { - if len(items) == 0 { - return nil - } - if size <= 0 { - size = len(items) - } - chunks := make([][]string, 0, (len(items)+size-1)/size) - for start := 0; start < len(items); start += size { - end := min(start+size, len(items)) - chunks = append(chunks, items[start:end]) - } - return chunks - } - - log.Printf("[INFO] Removing all enterprise cost center resources: %s/%s", enterpriseSlug, costCenterID) - - for _, batch := range chunk(users, maxResourcesPerRequest) { - if err := retryRemove(enterpriseCostCenterResourcesRequest{Users: batch}); err != nil { - return diag.FromErr(err) - } - } - for _, batch := range chunk(orgs, maxResourcesPerRequest) { - if err := retryRemove(enterpriseCostCenterResourcesRequest{Organizations: batch}); err != nil { - return diag.FromErr(err) - } - } - for _, batch := range chunk(repos, maxResourcesPerRequest) { - if err := retryRemove(enterpriseCostCenterResourcesRequest{Repositories: batch}); err != nil { - return diag.FromErr(err) - } - } - - return nil -} - -func resourceGithubEnterpriseCostCenterResourcesImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { - parts := strings.Split(d.Id(), "/") - if len(parts) != 2 { - return nil, fmt.Errorf("invalid import specified: supplied import must be written as /") - } - - enterpriseSlug, costCenterID := parts[0], parts[1] - _ = d.Set("enterprise_slug", enterpriseSlug) - _ = d.Set("cost_center_id", costCenterID) - d.SetId(buildTwoPartID(enterpriseSlug, costCenterID)) - - return []*schema.ResourceData{d}, nil -} - -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 isRetryableGithubResponseError(err error) bool { - var ghErr *github.ErrorResponse - if errors.As(err, &ghErr) && ghErr.Response != nil { - switch ghErr.Response.StatusCode { - case 404, 409, 500, 502, 503, 504: - return true - default: - return false - } - } - return false -} - -func diffStringSlices(current, desired []string) (toAdd, toRemove []string) { - cur := schema.NewSet(schema.HashString, stringSliceToAnySlice(current)) - des := schema.NewSet(schema.HashString, stringSliceToAnySlice(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 -} diff --git a/github/resource_github_enterprise_cost_center_resources_test.go b/github/resource_github_enterprise_cost_center_resources_test.go deleted file mode 100644 index 1adcb5d1f4..0000000000 --- a/github/resource_github_enterprise_cost_center_resources_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package github - -import ( - "fmt" - "os" - "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 TestAccGithubEnterpriseCostCenterResources(t *testing.T) { - randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - - if isEnterprise != "true" { - t.Skip("Skipping because `ENTERPRISE_ACCOUNT` is not set or set to false") - } - if testEnterprise == "" { - t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") - } - testEnterpriseCostCenterOrganization := os.Getenv("ENTERPRISE_TEST_ORGANIZATION") - testEnterpriseCostCenterRepository := os.Getenv("ENTERPRISE_TEST_REPOSITORY") - testEnterpriseCostCenterUsers := os.Getenv("ENTERPRISE_TEST_USERS") - - if testEnterpriseCostCenterOrganization == "" { - t.Skip("Skipping because `ENTERPRISE_TEST_ORGANIZATION` is not set") - } - if testEnterpriseCostCenterRepository == "" { - t.Skip("Skipping because `ENTERPRISE_TEST_REPOSITORY` is not set") - } - if testEnterpriseCostCenterUsers == "" { - t.Skip("Skipping because `ENTERPRISE_TEST_USERS` is not set") - } - - users := splitCommaSeparated(testEnterpriseCostCenterUsers) - if len(users) < 2 { - t.Skip("Skipping because `ENTERPRISE_TEST_USERS` must contain at least two usernames") - } - - usersBefore := fmt.Sprintf("%q, %q", users[0], users[1]) - usersAfter := fmt.Sprintf("%q", users[0]) - - 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" - } - - resource "github_enterprise_cost_center_resources" "test" { - enterprise_slug = data.github_enterprise.enterprise.slug - cost_center_id = github_enterprise_cost_center.test.id - - users = [%s] - organizations = [%q] - repositories = [%q] - } - `, testEnterprise, randomID, usersBefore, testEnterpriseCostCenterOrganization, testEnterpriseCostCenterRepository) - - 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-%s" - } - - resource "github_enterprise_cost_center_resources" "test" { - enterprise_slug = data.github_enterprise.enterprise.slug - cost_center_id = github_enterprise_cost_center.test.id - - users = [%s] - organizations = [] - repositories = [] - } - `, testEnterprise, randomID, usersAfter) - - 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" - } - - resource "github_enterprise_cost_center_resources" "test" { - enterprise_slug = data.github_enterprise.enterprise.slug - cost_center_id = github_enterprise_cost_center.test.id - - users = [] - organizations = [] - repositories = [] - } - `, testEnterprise, randomID) - - checkBefore := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "organizations.#", "1"), - resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_resources.test", "organizations.*", testEnterpriseCostCenterOrganization), - resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "repositories.#", "1"), - resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_resources.test", "repositories.*", testEnterpriseCostCenterRepository), - resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "users.#", "2"), - resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_resources.test", "users.*", users[0]), - resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_resources.test", "users.*", users[1]), - ) - - checkAfter := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "organizations.#", "0"), - resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "repositories.#", "0"), - resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "users.#", "1"), - resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_resources.test", "users.*", users[0]), - ) - - checkEmpty := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "organizations.#", "0"), - resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "repositories.#", "0"), - resource.TestCheckResourceAttr("github_enterprise_cost_center_resources.test", "users.#", "0"), - ) - - resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnlessMode(t, enterprise) }, - Providers: testAccProviders, - Steps: []resource.TestStep{ - { - Config: configBefore, - Check: checkBefore, - }, - { - Config: configAfter, - Check: checkAfter, - }, - { - Config: configEmpty, - Check: checkEmpty, - }, - { - ResourceName: "github_enterprise_cost_center_resources.test", - ImportState: true, - ImportStateVerify: true, - ImportStateIdFunc: func(s *terraform.State) (string, error) { - rs, ok := s.RootModule().Resources["github_enterprise_cost_center_resources.test"] - if !ok { - return "", fmt.Errorf("resource not found in state") - } - - enterpriseSlug := rs.Primary.Attributes["enterprise_slug"] - costCenterID := rs.Primary.Attributes["cost_center_id"] - if enterpriseSlug == "" || costCenterID == "" { - return "", fmt.Errorf("missing enterprise_slug or cost_center_id in state") - } - return fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID), nil - }, - }, - }, - }) -} - -func splitCommaSeparated(v string) []string { - parts := strings.Split(v, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - out = append(out, p) - } - return out -} diff --git a/github/resource_github_enterprise_cost_center_test.go b/github/resource_github_enterprise_cost_center_test.go index fbbb51645d..d1df2a3893 100644 --- a/github/resource_github_enterprise_cost_center_test.go +++ b/github/resource_github_enterprise_cost_center_test.go @@ -2,6 +2,8 @@ package github import ( "fmt" + "os" + "strings" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" @@ -18,6 +20,27 @@ func TestAccGithubEnterpriseCostCenter(t *testing.T) { if testEnterprise == "" { t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") } + testEnterpriseCostCenterOrganization := os.Getenv("ENTERPRISE_TEST_ORGANIZATION") + testEnterpriseCostCenterRepository := os.Getenv("ENTERPRISE_TEST_REPOSITORY") + testEnterpriseCostCenterUsers := os.Getenv("ENTERPRISE_TEST_USERS") + + if testEnterpriseCostCenterOrganization == "" { + t.Skip("Skipping because `ENTERPRISE_TEST_ORGANIZATION` is not set") + } + if testEnterpriseCostCenterRepository == "" { + t.Skip("Skipping because `ENTERPRISE_TEST_REPOSITORY` is not set") + } + if testEnterpriseCostCenterUsers == "" { + t.Skip("Skipping because `ENTERPRISE_TEST_USERS` is not set") + } + + users := splitCommaSeparated(testEnterpriseCostCenterUsers) + if len(users) < 2 { + t.Skip("Skipping because `ENTERPRISE_TEST_USERS` must contain at least two usernames") + } + + usersBefore := fmt.Sprintf("%q, %q", users[0], users[1]) + usersAfter := fmt.Sprintf("%q", users[0]) configBefore := fmt.Sprintf(` data "github_enterprise" "enterprise" { @@ -27,8 +50,12 @@ func TestAccGithubEnterpriseCostCenter(t *testing.T) { resource "github_enterprise_cost_center" "test" { enterprise_slug = data.github_enterprise.enterprise.slug name = "tf-acc-test-%s" + + users = [%s] + organizations = [%q] + repositories = [%q] } - `, testEnterprise, randomID) + `, testEnterprise, randomID, usersBefore, testEnterpriseCostCenterOrganization, testEnterpriseCostCenterRepository) configAfter := fmt.Sprintf(` data "github_enterprise" "enterprise" { @@ -38,6 +65,25 @@ func TestAccGithubEnterpriseCostCenter(t *testing.T) { resource "github_enterprise_cost_center" "test" { enterprise_slug = data.github_enterprise.enterprise.slug name = "tf-acc-test-updated-%s" + + users = [%s] + organizations = [] + repositories = [] + } + `, testEnterprise, randomID, usersAfter) + + 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 = [] + organizations = [] + repositories = [] } `, testEnterprise, randomID) @@ -45,11 +91,28 @@ func TestAccGithubEnterpriseCostCenter(t *testing.T) { resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "enterprise_slug", testEnterprise), 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", "organizations.#", "1"), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center.test", "organizations.*", testEnterpriseCostCenterOrganization), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "repositories.#", "1"), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center.test", "repositories.*", testEnterpriseCostCenterRepository), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "users.#", "2"), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center.test", "users.*", users[0]), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center.test", "users.*", users[1]), ) 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", "organizations.#", "0"), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "repositories.#", "0"), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "users.#", "1"), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center.test", "users.*", users[0]), + ) + + checkEmpty := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "organizations.#", "0"), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "repositories.#", "0"), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "users.#", "0"), ) resource.Test(t, resource.TestCase{ @@ -64,6 +127,10 @@ func TestAccGithubEnterpriseCostCenter(t *testing.T) { Config: configAfter, Check: checkAfter, }, + { + Config: configEmpty, + Check: checkEmpty, + }, { ResourceName: "github_enterprise_cost_center.test", ImportState: true, @@ -79,3 +146,16 @@ func TestAccGithubEnterpriseCostCenter(t *testing.T) { }, }) } + +func splitCommaSeparated(v string) []string { + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + out = append(out, p) + } + return out +} diff --git a/website/docs/d/enterprise_cost_center.html.markdown b/website/docs/d/enterprise_cost_center.html.markdown index 5588b63913..1e9f650ba1 100644 --- a/website/docs/d/enterprise_cost_center.html.markdown +++ b/website/docs/d/enterprise_cost_center.html.markdown @@ -31,4 +31,7 @@ data "github_enterprise_cost_center" "example" { * `resources` - A list of assigned resources. * `type` - The resource type. * `name` - The resource identifier (username, organization login, or repository full name). +* `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/r/enterprise_cost_center.html.markdown b/website/docs/r/enterprise_cost_center.html.markdown index 9a29c4b047..b988c2dd1f 100644 --- a/website/docs/r/enterprise_cost_center.html.markdown +++ b/website/docs/r/enterprise_cost_center.html.markdown @@ -17,6 +17,11 @@ Deleting this resource archives the cost center (GitHub calls this state `delete 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"] } ``` @@ -24,6 +29,9 @@ resource "github_enterprise_cost_center" "example" { * `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 @@ -35,6 +43,9 @@ The following additional attributes are exported: * `resources` - A list of assigned resources. * `type` - The resource type. * `name` - The resource identifier (username, organization login, or repository full name). +* `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 diff --git a/website/docs/r/enterprise_cost_center_resources.html.markdown b/website/docs/r/enterprise_cost_center_resources.html.markdown deleted file mode 100644 index 989d9b4b47..0000000000 --- a/website/docs/r/enterprise_cost_center_resources.html.markdown +++ /dev/null @@ -1,49 +0,0 @@ ---- -layout: "github" -page_title: "Github: github_enterprise_cost_center_resources" -description: |- - Manage resource assignments for a GitHub enterprise cost center. ---- - -# github_enterprise_cost_center_resources - -This resource allows you to manage which users, organizations, and repositories are assigned to a GitHub enterprise cost center. - -The `users`, `organizations`, and `repositories` arguments are authoritative: on every apply, Terraform will add and remove assignments to match exactly what is configured. - -Note: `enterprise_slug` must match the enterprise where the cost center was created. If they don't match, GitHub will return `404 Not Found` for the cost center ID. - -## Example Usage - -``` -resource "github_enterprise_cost_center" "example" { - enterprise_slug = "example-enterprise" - name = "platform-cost-center" -} - -resource "github_enterprise_cost_center_resources" "example" { - enterprise_slug = "example-enterprise" - cost_center_id = github_enterprise_cost_center.example.id - - users = ["octocat"] - organizations = ["my-org"] - repositories = ["my-org/my-repo"] -} -``` - -## Argument Reference - -* `enterprise_slug` - (Required) The slug of the enterprise. -* `cost_center_id` - (Required) The cost center ID. -* `users` - (Optional) The usernames assigned to this cost center. If omitted, treated as an empty set. -* `organizations` - (Optional) The organization logins assigned to this cost center. If omitted, treated as an empty set. -* `repositories` - (Optional) The repositories (full name) assigned to this cost center. If omitted, treated as an empty set. - -## Import - -GitHub Enterprise Cost Center Resources can be imported using the `enterprise_slug` and the `cost_center_id`, separated by a `/` character. - -``` -$ terraform import github_enterprise_cost_center_resources.example example-enterprise/ -``` - diff --git a/website/github.erb b/website/github.erb index af016d2da5..d5181fa8c6 100644 --- a/website/github.erb +++ b/website/github.erb @@ -235,9 +235,6 @@
  • github_enterprise_cost_center
  • -
  • - github_enterprise_cost_center_resources -
  • github_enterprise_settings