diff --git a/examples/cost_centers/main.tf b/examples/cost_centers/main.tf new file mode 100644 index 0000000000..925b1c43bf --- /dev/null +++ b/examples/cost_centers/main.tf @@ -0,0 +1,99 @@ +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. + users = var.users + organizations = var.organizations + repositories = var.repositories +} + +data "github_enterprise_cost_center" "by_id" { + enterprise_slug = var.enterprise_slug + cost_center_id = github_enterprise_cost_center.example.id +} + +data "github_enterprise_cost_centers" "active" { + enterprise_slug = var.enterprise_slug + state = "active" + + depends_on = [github_enterprise_cost_center.example] +} + +output "cost_center" { + description = "Created cost center" + value = { + id = github_enterprise_cost_center.example.id + name = github_enterprise_cost_center.example.name + state = github_enterprise_cost_center.example.state + azure_subscription = github_enterprise_cost_center.example.azure_subscription + } +} + +output "cost_center_resources" { + description = "Effective assignments (read from API)" + value = { + users = sort(tolist(github_enterprise_cost_center.example.users)) + organizations = sort(tolist(github_enterprise_cost_center.example.organizations)) + repositories = sort(tolist(github_enterprise_cost_center.example.repositories)) + } +} + +output "cost_center_from_data_source" { + description = "Cost center fetched by data source" + value = { + id = data.github_enterprise_cost_center.by_id.cost_center_id + name = data.github_enterprise_cost_center.by_id.name + state = data.github_enterprise_cost_center.by_id.state + users = sort(tolist(data.github_enterprise_cost_center.by_id.users)) + organizations = sort(tolist(data.github_enterprise_cost_center.by_id.organizations)) + repositories = sort(tolist(data.github_enterprise_cost_center.by_id.repositories)) + } +} diff --git a/github/data_source_github_enterprise_cost_center.go b/github/data_source_github_enterprise_cost_center.go new file mode 100644 index 0000000000..3ae4ec3520 --- /dev/null +++ b/github/data_source_github_enterprise_cost_center.go @@ -0,0 +1,121 @@ +package github + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseCostCenter() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubEnterpriseCostCenterRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "cost_center_id": { + Type: schema.TypeString, + Required: true, + Description: "The ID of the cost center.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the cost center.", + }, + "state": { + Type: schema.TypeString, + Computed: true, + Description: "The state of the cost center.", + }, + "azure_subscription": { + Type: schema.TypeString, + Computed: true, + Description: "The Azure subscription associated with the cost center.", + }, + "users": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The usernames assigned to this cost center.", + }, + "organizations": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The organization logins assigned to this cost center.", + }, + "repositories": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The repositories (full name) assigned to this cost center.", + }, + "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(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(ctx, ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + + cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(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) + + 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 new file mode 100644 index 0000000000..8ba93dfd91 --- /dev/null +++ b/github/data_source_github_enterprise_cost_center_test.go @@ -0,0 +1,79 @@ +package github + +import ( + "fmt" + "os" + "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") + } + 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" { + slug = "%s" + } + + 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, 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{ + 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..39bb1c8906 --- /dev/null +++ b/github/data_source_github_enterprise_cost_centers.go @@ -0,0 +1,98 @@ +package github + +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{ + ReadContext: dataSourceGithubEnterpriseCostCentersRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "state": { + Type: schema.TypeString, + Optional: true, + ValidateDiagFunc: toDiagFunc(validation.StringInSlice([]string{"active", "deleted"}, false), "state"), + Description: "Filter cost centers by state.", + }, + "cost_centers": { + Type: schema.TypeSet, + Computed: true, + 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(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + state := "" + if v, ok := d.GetOk("state"); ok { + state = v.(string) + } + + ctx = context.WithValue(ctx, ctxId, fmt.Sprintf("%s/cost-centers", enterpriseSlug)) + centers, err := enterpriseCostCentersList(ctx, client, enterpriseSlug, state) + if err != nil { + return diag.FromErr(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..551f9a8d1b --- /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, 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..8c78c4f979 100644 --- a/github/provider.go +++ b/github/provider.go @@ -211,6 +211,7 @@ 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_workflow_repository_permissions": resourceGithubWorkflowRepositoryPermissions(), }, @@ -286,6 +287,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..6c4bcc9538 --- /dev/null +++ b/github/resource_github_enterprise_cost_center.go @@ -0,0 +1,420 @@ +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 resourceGithubEnterpriseCostCenter() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceGithubEnterpriseCostCenterCreate, + ReadContext: resourceGithubEnterpriseCostCenterRead, + UpdateContext: resourceGithubEnterpriseCostCenterUpdate, + DeleteContext: resourceGithubEnterpriseCostCenterDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubEnterpriseCostCenterImport, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + }, + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the cost center.", + }, + "users": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The usernames assigned to this cost center.", + }, + "organizations": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The organization logins assigned to this cost center.", + }, + "repositories": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The repositories (full name) assigned to this cost center.", + }, + "state": { + Type: schema.TypeString, + Computed: true, + Description: "The state of the cost center.", + }, + "azure_subscription": { + Type: schema.TypeString, + Computed: true, + Description: "The Azure subscription associated with the cost center.", + }, + "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(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(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 diag.FromErr(err) + } + + if cc == nil || cc.ID == "" { + return diag.FromErr(fmt.Errorf("failed to create cost center: missing id in response")) + } + + 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) +} + +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(ctx, 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 diag.FromErr(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) + + 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 +} + +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(ctx, ctxId, fmt.Sprintf("%s/%s", enterpriseSlug, costCenterID)) + + cc, err := enterpriseCostCenterGet(ctx, client, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + if strings.EqualFold(cc.State, "deleted") { + return diag.FromErr(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 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) +} + +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(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) + if err != nil { + if is404(err) { + return nil + } + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCostCenterImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + 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 +} + +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_test.go b/github/resource_github_enterprise_cost_center_test.go new file mode 100644 index 0000000000..d1df2a3893 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_test.go @@ -0,0 +1,161 @@ +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 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") + } + 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" + + 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-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) + + 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"), + 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{ + 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.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/util_cost_centers.go b/github/util_cost_centers.go new file mode 100644 index 0000000000..d4292e6109 --- /dev/null +++ b/github/util_cost_centers.go @@ -0,0 +1,209 @@ +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, 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, 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, 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, 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 + } + + 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, 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, 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 +} + +//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 + } + + var result enterpriseCostCenterRemoveResponse + _, err = client.Do(ctx, req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func enterpriseCostCenterSplitResources(resources []enterpriseCostCenterResource) (users, organizations, 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/website/docs/d/enterprise_cost_center.html.markdown b/website/docs/d/enterprise_cost_center.html.markdown new file mode 100644 index 0000000000..1e9f650ba1 --- /dev/null +++ b/website/docs/d/enterprise_cost_center.html.markdown @@ -0,0 +1,37 @@ +--- +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). +* `users` - The usernames currently assigned to the cost center. +* `organizations` - The organization logins currently assigned to the cost center. +* `repositories` - The repositories currently assigned to the cost center. + diff --git a/website/docs/d/enterprise_cost_centers.html.markdown b/website/docs/d/enterprise_cost_centers.html.markdown new file mode 100644 index 0000000000..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..b988c2dd1f --- /dev/null +++ b/website/docs/r/enterprise_cost_center.html.markdown @@ -0,0 +1,57 @@ +--- +layout: "github" +page_title: "Github: github_enterprise_cost_center" +description: |- + Create and manage a GitHub enterprise cost center. +--- + +# github_enterprise_cost_center + +This resource allows you to create and manage a GitHub enterprise cost center. + +Deleting this resource archives the cost center (GitHub calls this state `deleted`). + +## Example Usage + +``` +resource "github_enterprise_cost_center" "example" { + enterprise_slug = "example-enterprise" + name = "platform-cost-center" + + # Authoritatively manage assignments (Terraform will add/remove to match). + users = ["alice", "bob"] + organizations = ["octo-org"] + repositories = ["octo-org/app"] +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `name` - (Required) The name of the cost center. +* `users` - (Optional) Set of usernames to assign to the cost center. Assignment is authoritative. +* `organizations` - (Optional) Set of organization logins to assign to the cost center. Assignment is authoritative. +* `repositories` - (Optional) Set of repositories (full name, e.g. `org/repo`) to assign to the cost center. Assignment is authoritative. + +## Attributes Reference + +The following additional attributes are exported: + +* `id` - The cost center ID. +* `state` - The state of the cost center. +* `azure_subscription` - The Azure subscription associated with the cost center. +* `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 + +GitHub Enterprise Cost Center can be imported using the `enterprise_slug` and the `cost_center_id`, separated by a `/` character. + +``` +$ terraform import github_enterprise_cost_center.example example-enterprise/ +``` + diff --git a/website/github.erb b/website/github.erb index 7db02fc5fc..d5181fa8c6 100644 --- a/website/github.erb +++ b/website/github.erb @@ -100,6 +100,12 @@
  • github_enterprise
  • +
  • + github_enterprise_cost_center +
  • +
  • + github_enterprise_cost_centers +
  • github_external_groups
  • @@ -226,6 +232,9 @@
  • github_enterprise_actions_permissions
  • +
  • + github_enterprise_cost_center +
  • github_enterprise_settings