diff --git a/examples/hosted_runner/main.tf b/examples/hosted_runner/main.tf index 0f1838cb40..4c1a46115d 100644 --- a/examples/hosted_runner/main.tf +++ b/examples/hosted_runner/main.tf @@ -27,8 +27,8 @@ resource "github_actions_hosted_runner" "advanced" { source = "github" } - size = "8-core" - runner_group_id = github_actions_runner_group.example.id - maximum_runners = 10 - enable_static_ip = true + size = "8-core" + runner_group_id = github_actions_runner_group.example.id + maximum_runners = 10 + public_ip_enabled = true } diff --git a/github/provider.go b/github/provider.go index 2d5d6dc33a..97c6c4a1e3 100644 --- a/github/provider.go +++ b/github/provider.go @@ -173,6 +173,7 @@ func Provider() *schema.Provider { "github_organization_block": resourceOrganizationBlock(), "github_organization_custom_role": resourceGithubOrganizationCustomRole(), "github_organization_custom_properties": resourceGithubOrganizationCustomProperties(), + "github_organization_network_configuration": resourceGithubOrganizationNetworkConfiguration(), "github_organization_project": resourceGithubOrganizationProject(), "github_organization_repository_role": resourceGithubOrganizationRepositoryRole(), "github_organization_role": resourceGithubOrganizationRole(), diff --git a/github/resource_github_actions_runner_group.go b/github/resource_github_actions_runner_group.go index eb4c85a495..9bdf3dfd35 100644 --- a/github/resource_github_actions_runner_group.go +++ b/github/resource_github_actions_runner_group.go @@ -13,6 +13,10 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) +type organizationRunnerGroup struct { + NetworkConfigurationID *string `json:"network_configuration_id,omitempty"` +} + func resourceGithubActionsRunnerGroup() *schema.Resource { return &schema.Resource{ Create: resourceGithubActionsRunnerGroupCreate, @@ -92,10 +96,115 @@ func resourceGithubActionsRunnerGroup() *schema.Resource { Optional: true, Description: "List of workflows the runner group should be allowed to run. This setting will be ignored unless restricted_to_workflows is set to 'true'.", }, + "network_configuration_id": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringLenBetween(1, 255), + Description: "The identifier of the hosted compute network configuration to associate with this runner group for GitHub-hosted private networking.", + }, }, } } +func getOrganizationRunnerGroupNetworking(client *github.Client, ctx context.Context, org string, groupID int64) (*organizationRunnerGroup, *github.Response, error) { + req, err := client.NewRequest("GET", fmt.Sprintf("orgs/%s/actions/runner-groups/%d", org, groupID), nil) + if err != nil { + return nil, nil, err + } + + var runnerGroup organizationRunnerGroup + resp, err := client.Do(ctx, req, &runnerGroup) + if err != nil { + return nil, resp, err + } + + return &runnerGroup, resp, nil +} + +func getOrganizationRunnerGroup(client *github.Client, ctx context.Context, org string, groupID int64) (*github.RunnerGroup, *github.Response, error) { + runnerGroup, resp, err := client.Actions.GetOrganizationRunnerGroup(ctx, org, groupID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotModified { + // ignore error StatusNotModified + return runnerGroup, resp, nil + } + } + return runnerGroup, resp, err +} + +func updateOrganizationRunnerGroupNetworking(client *github.Client, ctx context.Context, org string, groupID int64, networkConfigurationID *string) (*github.Response, error) { + payload := map[string]any{ + "network_configuration_id": networkConfigurationID, + } + + req, err := client.NewRequest("PATCH", fmt.Sprintf("orgs/%s/actions/runner-groups/%d", org, groupID), payload) + if err != nil { + return nil, err + } + + resp, err := client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} + +func setGithubActionsRunnerGroupNetworkingState(d *schema.ResourceData, runnerGroup *organizationRunnerGroup) error { + if runnerGroup != nil && runnerGroup.NetworkConfigurationID != nil && *runnerGroup.NetworkConfigurationID != "" { + if err := d.Set("network_configuration_id", *runnerGroup.NetworkConfigurationID); err != nil { + return err + } + } else { + if err := d.Set("network_configuration_id", nil); err != nil { + return err + } + } + return nil +} + +func setGithubActionsRunnerGroupState(d *schema.ResourceData, runnerGroup *github.RunnerGroup, etag string, selectedRepositoryIDs []int64) error { + if err := d.Set("etag", etag); err != nil { + return err + } + if err := d.Set("allows_public_repositories", runnerGroup.GetAllowsPublicRepositories()); err != nil { + return err + } + if err := d.Set("default", runnerGroup.GetDefault()); err != nil { + return err + } + if err := d.Set("id", strconv.FormatInt(runnerGroup.GetID(), 10)); err != nil { + return err + } + if err := d.Set("inherited", runnerGroup.GetInherited()); err != nil { + return err + } + if err := d.Set("name", runnerGroup.GetName()); err != nil { + return err + } + if err := d.Set("runners_url", runnerGroup.GetRunnersURL()); err != nil { + return err + } + if err := d.Set("selected_repositories_url", runnerGroup.GetSelectedRepositoriesURL()); err != nil { + return err + } + if err := d.Set("visibility", runnerGroup.GetVisibility()); err != nil { + return err + } + if err := d.Set("selected_repository_ids", selectedRepositoryIDs); err != nil { + return err + } + if err := d.Set("restricted_to_workflows", runnerGroup.GetRestrictedToWorkflows()); err != nil { + return err + } + if err := d.Set("selected_workflows", runnerGroup.SelectedWorkflows); err != nil { + return err + } + + return nil +} + func resourceGithubActionsRunnerGroupCreate(d *schema.ResourceData, meta any) error { err := checkOrganization(meta) if err != nil { @@ -148,57 +257,28 @@ func resourceGithubActionsRunnerGroupCreate(d *schema.ResourceData, meta any) er return err } d.SetId(strconv.FormatInt(runnerGroup.GetID(), 10)) - if err = d.Set("etag", resp.Header.Get("ETag")); err != nil { - return err - } - if err = d.Set("allows_public_repositories", runnerGroup.GetAllowsPublicRepositories()); err != nil { - return err - } - if err = d.Set("default", runnerGroup.GetDefault()); err != nil { - return err - } - - if err = d.Set("id", strconv.FormatInt(runnerGroup.GetID(), 10)); err != nil { - return err - } - if err = d.Set("inherited", runnerGroup.GetInherited()); err != nil { - return err - } - if err = d.Set("name", runnerGroup.GetName()); err != nil { - return err - } - if err = d.Set("runners_url", runnerGroup.GetRunnersURL()); err != nil { - return err - } - if err = d.Set("selected_repositories_url", runnerGroup.GetSelectedRepositoriesURL()); err != nil { - return err - } - if err = d.Set("visibility", runnerGroup.GetVisibility()); err != nil { - return err - } - if err = d.Set("selected_repository_ids", selectedRepositoryIDs); err != nil { // Note: runnerGroup has no method to get selected repository IDs - return err - } - if err = d.Set("restricted_to_workflows", runnerGroup.GetRestrictedToWorkflows()); err != nil { - return err - } - if err = d.Set("selected_workflows", runnerGroup.SelectedWorkflows); err != nil { + if err = setGithubActionsRunnerGroupState(d, runnerGroup, resp.Header.Get("ETag"), selectedRepositoryIDs); err != nil { return err } - return resourceGithubActionsRunnerGroupRead(d, meta) -} + if networkConfigurationID, ok := d.GetOk("network_configuration_id"); ok { + networkConfigurationIDValue := networkConfigurationID.(string) + // The create endpoint does not accept network_configuration_id, so private networking + // must be attached with a follow-up PATCH after the runner group has been created. + if _, err = updateOrganizationRunnerGroupNetworking(client, ctx, orgName, runnerGroup.GetID(), &networkConfigurationIDValue); err != nil { + return err + } -func getOrganizationRunnerGroup(client *github.Client, ctx context.Context, org string, groupID int64) (*github.RunnerGroup, *github.Response, error) { - runnerGroup, resp, err := client.Actions.GetOrganizationRunnerGroup(ctx, org, groupID) - if err != nil { - var ghErr *github.ErrorResponse - if errors.As(err, &ghErr) { - // ignore error StatusNotModified - return runnerGroup, resp, nil + if err = setGithubActionsRunnerGroupNetworkingState(d, &organizationRunnerGroup{NetworkConfigurationID: &networkConfigurationIDValue}); err != nil { + return err + } + } else { + if err = setGithubActionsRunnerGroupNetworkingState(d, nil); err != nil { + return err } } - return runnerGroup, resp, err + + return nil } func resourceGithubActionsRunnerGroupRead(d *schema.ResourceData, meta any) error { @@ -223,7 +303,7 @@ func resourceGithubActionsRunnerGroupRead(d *schema.ResourceData, meta any) erro if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) { - if ghErr.Response.StatusCode == http.StatusNotFound { + if ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { log.Printf("[INFO] Removing organization runner group %s/%s from state because it no longer exists in GitHub", orgName, d.Id()) d.SetId("") @@ -237,38 +317,10 @@ func resourceGithubActionsRunnerGroupRead(d *schema.ResourceData, meta any) erro if runnerGroup == nil { return nil } + runnerGroupEtag := resp.Header.Get("ETag") - if err = d.Set("etag", resp.Header.Get("ETag")); err != nil { - return err - } - if err = d.Set("allows_public_repositories", runnerGroup.GetAllowsPublicRepositories()); err != nil { - return err - } - if err = d.Set("default", runnerGroup.GetDefault()); err != nil { - return err - } - if err = d.Set("id", strconv.FormatInt(runnerGroup.GetID(), 10)); err != nil { - return err - } - if err = d.Set("inherited", runnerGroup.GetInherited()); err != nil { - return err - } - if err = d.Set("name", runnerGroup.GetName()); err != nil { - return err - } - if err = d.Set("runners_url", runnerGroup.GetRunnersURL()); err != nil { - return err - } - if err = d.Set("selected_repositories_url", runnerGroup.GetSelectedRepositoriesURL()); err != nil { - return err - } - if err = d.Set("visibility", runnerGroup.GetVisibility()); err != nil { - return err - } - if err = d.Set("restricted_to_workflows", runnerGroup.GetRestrictedToWorkflows()); err != nil { - return err - } - if err = d.Set("selected_workflows", runnerGroup.SelectedWorkflows); err != nil { + runnerGroupNetworking, _, err := getOrganizationRunnerGroupNetworking(client, context.WithValue(context.Background(), ctxId, d.Id()), orgName, runnerGroupID) + if err != nil { return err } @@ -294,7 +346,10 @@ func resourceGithubActionsRunnerGroupRead(d *schema.ResourceData, meta any) erro options.Page = resp.NextPage } - if err = d.Set("selected_repository_ids", selectedRepositoryIDs); err != nil { + if err = setGithubActionsRunnerGroupState(d, runnerGroup, runnerGroupEtag, selectedRepositoryIDs); err != nil { + return err + } + if err = setGithubActionsRunnerGroupNetworkingState(d, runnerGroupNetworking); err != nil { return err } @@ -339,6 +394,18 @@ func resourceGithubActionsRunnerGroupUpdate(d *schema.ResourceData, meta any) er return err } + if d.HasChange("network_configuration_id") { + var networkConfigurationIDValue *string + if networkConfigurationID, ok := d.GetOk("network_configuration_id"); ok { + value := networkConfigurationID.(string) + networkConfigurationIDValue = &value + } + + if _, err := updateOrganizationRunnerGroupNetworking(client, ctx, orgName, runnerGroupID, networkConfigurationIDValue); err != nil { + return err + } + } + selectedRepositories, hasSelectedRepositories := d.GetOk("selected_repository_ids") selectedRepositoryIDs := []int64{} diff --git a/github/resource_github_actions_runner_group_test.go b/github/resource_github_actions_runner_group_test.go index a399e5f140..84a42f7eb2 100644 --- a/github/resource_github_actions_runner_group_test.go +++ b/github/resource_github_actions_runner_group_test.go @@ -4,13 +4,26 @@ import ( "fmt" "testing" - "github.com/hashicorp/terraform-plugin-testing/terraform" - "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/compare" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) +func testCheckRunnerGroupNetworkConfigurationMatches(resourceName, networkConfigurationResourceName string) statecheck.StateCheck { + return statecheck.CompareValuePairs( + resourceName, + tfjsonpath.New("network_configuration_id"), + networkConfigurationResourceName, + tfjsonpath.New("id"), + compare.ValuesSame(), + ) +} + func TestAccGithubActionsRunnerGroup(t *testing.T) { t.Run("creates runner groups without error", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) @@ -97,6 +110,115 @@ func TestAccGithubActionsRunnerGroup(t *testing.T) { }) }) + t.Run("manages private networking association for hosted runners", func(t *testing.T) { + networkSettingsID := testAccOrganizationNetworkConfigurationID(t) + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + resourceName := "github_actions_runner_group.test" + networkConfigurationResourceName := "github_organization_network_configuration.test" + networkConfigurationName := fmt.Sprintf("%snetwork-config-%s", testResourcePrefix, randomID) + runnerGroupName := fmt.Sprintf("%srunner-group-%s", testResourcePrefix, randomID) + + configWithoutNetworkConfiguration := fmt.Sprintf(` + resource "github_organization_network_configuration" "test" { + name = %q + compute_service = "actions" + network_settings_ids = [%q] + } + + resource "github_actions_runner_group" "test" { + name = %q + visibility = "all" + } + `, networkConfigurationName, networkSettingsID, runnerGroupName) + + configWithNetworkConfiguration := fmt.Sprintf(` + resource "github_organization_network_configuration" "test" { + name = %q + compute_service = "actions" + network_settings_ids = [%q] + } + + resource "github_actions_runner_group" "test" { + name = %q + visibility = "all" + network_configuration_id = github_organization_network_configuration.test.id + } + `, networkConfigurationName, networkSettingsID, runnerGroupName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: configWithoutNetworkConfiguration, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_configuration_id"), knownvalue.Null()), + }, + }, + { + Config: configWithNetworkConfiguration, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_configuration_id"), knownvalue.NotNull()), + testCheckRunnerGroupNetworkConfigurationMatches(resourceName, networkConfigurationResourceName), + }, + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: configWithoutNetworkConfiguration, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_configuration_id"), knownvalue.Null()), + }, + }, + }, + }) + }) + + t.Run("creates private networking association for hosted runners on create", func(t *testing.T) { + networkSettingsID := testAccOrganizationNetworkConfigurationID(t) + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + resourceName := "github_actions_runner_group.test" + networkConfigurationResourceName := "github_organization_network_configuration.test" + networkConfigurationName := fmt.Sprintf("%snetwork-config-create-%s", testResourcePrefix, randomID) + runnerGroupName := fmt.Sprintf("%srunner-group-create-%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` + resource "github_organization_network_configuration" "test" { + name = %q + compute_service = "actions" + network_settings_ids = [%q] + } + + resource "github_actions_runner_group" "test" { + name = %q + visibility = "all" + network_configuration_id = github_organization_network_configuration.test.id + } + `, networkConfigurationName, networkSettingsID, runnerGroupName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_configuration_id"), knownvalue.NotNull()), + testCheckRunnerGroupNetworkConfigurationMatches(resourceName, networkConfigurationResourceName), + }, + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + t.Run("manages runner visibility", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) repoName := fmt.Sprintf("%srepo-act-runner-%s", testResourcePrefix, randomID) diff --git a/github/resource_github_organization_network_configuration.go b/github/resource_github_organization_network_configuration.go new file mode 100644 index 0000000000..948da3d7dd --- /dev/null +++ b/github/resource_github_organization_network_configuration.go @@ -0,0 +1,231 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "time" + + "github.com/google/go-github/v83/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +var organizationNetworkConfigurationNamePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) + +func resourceGithubOrganizationNetworkConfiguration() *schema.Resource { + return &schema.Resource{ + Description: "This resource allows you to create and manage hosted compute network configurations for a GitHub organization.", + CreateContext: resourceGithubOrganizationNetworkConfigurationCreate, + ReadContext: resourceGithubOrganizationNetworkConfigurationRead, + UpdateContext: resourceGithubOrganizationNetworkConfigurationUpdate, + DeleteContext: resourceGithubOrganizationNetworkConfigurationDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.All( + validation.StringLenBetween(1, 100), + validation.StringMatch( + organizationNetworkConfigurationNamePattern, + "name may only contain upper and lowercase letters a-z, numbers 0-9, '.', '-', and '_'", + ), + )), + Description: "Name of the network configuration. Must be between 1 and 100 characters and may only contain upper and lowercase letters a-z, numbers 0-9, '.', '-', and '_'.", + }, + "compute_service": { + Type: schema.TypeString, + Optional: true, + Default: "none", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"none", "actions"}, false)), + Description: "The hosted compute service to use for the network configuration. Can be one of: 'none', 'actions'. Defaults to 'none'.", + }, + "network_settings_ids": { + Type: schema.TypeList, + Required: true, + MinItems: 1, + MaxItems: 1, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Description: "An array containing exactly one network settings ID. A network settings resource can only be associated with one network configuration at a time.", + }, + "created_on": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the network configuration was created.", + }, + }, + } +} + +func resourceGithubOrganizationNetworkConfigurationCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + ctx = tflog.SetField(ctx, "organization", meta.(*Owner).name) + + if err := checkOrganization(meta); err != nil { + return diag.FromErr(err) + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + computeService := github.ComputeService(d.Get("compute_service").(string)) + networkSettingsIDs := []string{d.Get("network_settings_ids").([]any)[0].(string)} + + tflog.Debug(ctx, "Creating organization network configuration", map[string]any{ + "name": d.Get("name").(string), + "compute_service": d.Get("compute_service").(string), + "network_settings_ids": networkSettingsIDs, + }) + + configuration, _, err := client.Organizations.CreateNetworkConfiguration(ctx, orgName, github.NetworkConfigurationRequest{ + Name: github.Ptr(d.Get("name").(string)), + ComputeService: &computeService, + NetworkSettingsIDs: networkSettingsIDs, + }) + if err != nil { + return organizationNetworkConfigurationDiagnostics(err) + } + + d.SetId(configuration.GetID()) + if err := setOrganizationNetworkConfigurationState(d, configuration); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationNetworkConfigurationRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + ctx = tflog.SetField(ctx, "id", d.Id()) + ctx = tflog.SetField(ctx, "organization", meta.(*Owner).name) + + if err := checkOrganization(meta); err != nil { + return diag.FromErr(err) + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + networkConfigurationID := d.Id() + ctx = context.WithValue(ctx, ctxId, networkConfigurationID) + + configuration, resp, err := client.Organizations.GetNetworkConfiguration(ctx, orgName, networkConfigurationID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Organization network configuration not found, removing from state", map[string]any{"id": networkConfigurationID}) + d.SetId("") + return nil + } + + return diag.FromErr(err) + } + + if resp != nil && resp.StatusCode == http.StatusNotModified { + return nil + } + + if err := setOrganizationNetworkConfigurationState(d, configuration); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationNetworkConfigurationUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + ctx = tflog.SetField(ctx, "id", d.Id()) + ctx = tflog.SetField(ctx, "organization", meta.(*Owner).name) + + if err := checkOrganization(meta); err != nil { + return diag.FromErr(err) + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + networkConfigurationID := d.Id() + ctx = context.WithValue(ctx, ctxId, networkConfigurationID) + computeService := github.ComputeService(d.Get("compute_service").(string)) + networkSettingsIDs := []string{d.Get("network_settings_ids").([]any)[0].(string)} + + tflog.Debug(ctx, "Updating organization network configuration", map[string]any{ + "name": d.Get("name").(string), + "compute_service": d.Get("compute_service").(string), + "network_settings_ids": networkSettingsIDs, + }) + + configuration, _, err := client.Organizations.UpdateNetworkConfiguration(ctx, orgName, networkConfigurationID, github.NetworkConfigurationRequest{ + Name: github.Ptr(d.Get("name").(string)), + ComputeService: &computeService, + NetworkSettingsIDs: networkSettingsIDs, + }) + if err != nil { + return organizationNetworkConfigurationDiagnostics(err) + } + + if err := setOrganizationNetworkConfigurationState(d, configuration); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationNetworkConfigurationDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + ctx = tflog.SetField(ctx, "id", d.Id()) + ctx = tflog.SetField(ctx, "organization", meta.(*Owner).name) + + if err := checkOrganization(meta); err != nil { + return diag.FromErr(err) + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + + tflog.Debug(ctx, "Deleting organization network configuration") + _, err := client.Organizations.DeleteNetworkConfigurations(ctx, orgName, d.Id()) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + return nil + } + + return diag.FromErr(err) + } + + return nil +} + +func setOrganizationNetworkConfigurationState(d *schema.ResourceData, configuration *github.NetworkConfiguration) error { + if err := d.Set("name", configuration.GetName()); err != nil { + return err + } + if configuration.ComputeService != nil { + if err := d.Set("compute_service", string(*configuration.ComputeService)); err != nil { + return err + } + } + if err := d.Set("network_settings_ids", configuration.NetworkSettingsIDs); err != nil { + return err + } + if configuration.CreatedOn != nil { + if err := d.Set("created_on", configuration.CreatedOn.Format(time.RFC3339)); err != nil { + return err + } + } + + return nil +} + +func organizationNetworkConfigurationDiagnostics(err error) diag.Diagnostics { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusUnprocessableEntity { + return diag.FromErr(fmt.Errorf("%w. if you are using Azure private networking, ensure the provided network settings GitHubId matches the organization scope; organization-level configurations may fail when the backing GitHub.Network/networkSettings resource was created with an enterprise databaseId", err)) + } + + return diag.FromErr(err) +} diff --git a/github/resource_github_organization_network_configuration_test.go b/github/resource_github_organization_network_configuration_test.go new file mode 100644 index 0000000000..a1fc6ae931 --- /dev/null +++ b/github/resource_github_organization_network_configuration_test.go @@ -0,0 +1,122 @@ +package github + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccGithubOrganizationNetworkConfiguration(t *testing.T) { + t.Run("create", func(t *testing.T) { + networkSettingsID := testAccOrganizationNetworkConfigurationID(t) + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + resourceName := "github_organization_network_configuration.test" + configurationName := fmt.Sprintf("%snetwork-config-%s", testResourcePrefix, randomID) + + config := testAccOrganizationNetworkConfigurationConfig(configurationName, "actions", networkSettingsID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("name"), knownvalue.StringExact(configurationName)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("compute_service"), knownvalue.StringExact("actions")), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_settings_ids"), knownvalue.ListExact([]knownvalue.Check{knownvalue.StringExact(networkSettingsID)})), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("created_on"), knownvalue.NotNull()), + }, + }, + }, + }) + }) + + t.Run("update", func(t *testing.T) { + networkSettingsID := testAccOrganizationNetworkConfigurationID(t) + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + resourceName := "github_organization_network_configuration.test" + beforeName := fmt.Sprintf("%snetwork-config-%s-a", testResourcePrefix, randomID) + afterName := fmt.Sprintf("%snetwork-config-%s-b", testResourcePrefix, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: testAccOrganizationNetworkConfigurationConfig(beforeName, "actions", networkSettingsID), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("name"), knownvalue.StringExact(beforeName)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("compute_service"), knownvalue.StringExact("actions")), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_settings_ids"), knownvalue.ListExact([]knownvalue.Check{knownvalue.StringExact(networkSettingsID)})), + }, + }, + { + Config: testAccOrganizationNetworkConfigurationConfig(afterName, "none", networkSettingsID), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("name"), knownvalue.StringExact(afterName)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("compute_service"), knownvalue.StringExact("none")), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_settings_ids"), knownvalue.ListExact([]knownvalue.Check{knownvalue.StringExact(networkSettingsID)})), + }, + }, + }, + }) + }) + + t.Run("import", func(t *testing.T) { + networkSettingsID := testAccOrganizationNetworkConfigurationID(t) + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + configurationName := fmt.Sprintf("%snetwork-config-%s", testResourcePrefix, randomID) + + config := testAccOrganizationNetworkConfigurationConfig(configurationName, "actions", networkSettingsID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_organization_network_configuration.test", tfjsonpath.New("id"), knownvalue.NotNull()), + }, + }, + { + ResourceName: "github_organization_network_configuration.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) +} + +func testAccOrganizationNetworkConfigurationID(t *testing.T) string { + t.Helper() + + networkSettingsID := os.Getenv("GITHUB_TEST_NETWORK_SETTINGS_ID") + if networkSettingsID == "" { + t.Skip("GITHUB_TEST_NETWORK_SETTINGS_ID not set") + } + + return networkSettingsID +} + +func testAccOrganizationNetworkConfigurationConfig(name, computeService, networkSettingsID string) string { + return fmt.Sprintf(` +resource "github_organization_network_configuration" "test" { + name = %q + compute_service = %q + network_settings_ids = [%q] +} +`, name, computeService, networkSettingsID) +} diff --git a/website/docs/r/actions_hosted_runner.html.markdown b/website/docs/r/actions_hosted_runner.html.markdown index a78b0188f7..597300b700 100644 --- a/website/docs/r/actions_hosted_runner.html.markdown +++ b/website/docs/r/actions_hosted_runner.html.markdown @@ -72,6 +72,8 @@ The following arguments are supported: * `public_ip_enabled` - (Optional) Whether to enable static public IP for the runner. Note there are account limits. To list limits, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/limits`. Defaults to false. * `image_version` - (Optional) The version of the runner image to deploy. This is only relevant for runners using custom images. +~> **Note:** GitHub private networking for GitHub-hosted runners is configured on the runner group, not directly on the hosted runner. To attach a hosted runner to private networking, associate the runner group with a `github_organization_network_configuration` via `github_actions_runner_group.network_configuration_id`, then place the hosted runner in that group. + ## Timeouts The `timeouts` block allows you to specify timeouts for certain actions: @@ -135,6 +137,7 @@ $ terraform import github_actions_hosted_runner.example 123456 * Deletion of hosted runners is asynchronous. The provider will poll for up to 10 minutes (configurable via timeouts) to confirm deletion. * Runner creation and updates may take several minutes as GitHub provisions the infrastructure. * Static public IPs are subject to account limits. Check your organization's limits before enabling. +* `public_ip_enabled` controls static public IP allocation and is separate from GitHub private networking. ## Getting Available Images and Sizes diff --git a/website/docs/r/actions_runner_group.html.markdown b/website/docs/r/actions_runner_group.html.markdown index 2c0c02fe84..2a4508a6ac 100644 --- a/website/docs/r/actions_runner_group.html.markdown +++ b/website/docs/r/actions_runner_group.html.markdown @@ -22,6 +22,18 @@ resource "github_actions_runner_group" "example" { visibility = "selected" selected_repository_ids = [github_repository.example.repo_id] } + +resource "github_organization_network_configuration" "private_network" { + name = "private-network" + compute_service = "actions" + network_settings_ids = ["123456789ABCDEF"] +} + +resource "github_actions_runner_group" "private_networked" { + name = "private-networked-runners" + visibility = "all" + network_configuration_id = github_organization_network_configuration.private_network.id +} ``` ## Argument Reference @@ -34,6 +46,7 @@ The following arguments are supported: * `selected_workflows` - (Optional) List of workflows the runner group should be allowed to run. This setting will be ignored unless restricted_to_workflows is set to true. * `visibility` - (Optional) Visibility of a runner group. Whether the runner group can include `all`, `selected`, or `private` repositories. A value of `private` is not currently supported due to limitations in the GitHub API. * `allows_public_repositories` - (Optional) Whether public repositories can be added to the runner group. Defaults to false. +* `network_configuration_id` - (Optional) The ID of a hosted compute network configuration to associate with this runner group. This is the GitHub-side linkage used for GitHub-hosted private networking. ## Attributes Reference @@ -47,6 +60,15 @@ The following arguments are supported: * `visibility` - The visibility of the runner group * `restricted_to_workflows` - If true, the runner group will be restricted to running only the workflows specified in the selected_workflows array. Defaults to false. * `selected_workflows` - List of workflows the runner group should be allowed to run. This setting will be ignored unless restricted_to_workflows is set to true. +* `network_configuration_id` - The ID of the hosted compute network configuration associated with this runner group + +## Private networking + +GitHub private networking for GitHub-hosted runners is attached through the runner group, not directly on the hosted runner. + +Use `github_organization_network_configuration` to manage the hosted compute network configuration, then set `network_configuration_id` on `github_actions_runner_group` so any `github_actions_hosted_runner` placed in that group uses the private networking association. + +For Azure private networking, `network_configuration_id` should reference the GitHub organization network configuration ID, not the Azure ARM resource ID for `GitHub.Network/networkSettings`. ## Import diff --git a/website/docs/r/organization_network_configuration.html.markdown b/website/docs/r/organization_network_configuration.html.markdown new file mode 100644 index 0000000000..7ae18d555a --- /dev/null +++ b/website/docs/r/organization_network_configuration.html.markdown @@ -0,0 +1,59 @@ +--- +layout: "github" +page_title: "GitHub: github_organization_network_configuration" +description: |- + Creates and manages hosted compute network configurations for a GitHub organization. +--- + +# github_organization_network_configuration + +This resource allows you to create and manage hosted compute network configurations for a GitHub Organization. Network configurations allow GitHub-hosted compute services, such as Actions hosted runners, to connect to your private network resources. + +~> **Note:** This resource is organization-only and is available for GitHub Enterprise Cloud organizations. See the [GitHub documentation](https://docs.github.com/en/enterprise-cloud@latest/rest/orgs/network-configurations) for more information. + +~> **Note:** Organization-level network configurations are only available when enterprise policy allows organizations to create their own hosted compute network configurations. Otherwise, organizations can only inherit enterprise-level network configurations. + +## Example Usage + +```hcl +resource "github_organization_network_configuration" "example" { + name = "my-network-config" + compute_service = "actions" + network_settings_ids = ["23456789ABCDEF1"] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) The name of the network configuration. Must be between 1 and 100 characters and may only contain upper and lowercase letters a-z, numbers 0-9, `.`, `-`, and `_`. + +* `compute_service` - (Optional) The hosted compute service to use for the network configuration. Can be one of `none` or `actions`. Defaults to `none`. + +* `network_settings_ids` - (Required) An array containing exactly one network settings ID. Network settings resources are configured separately through your cloud provider. For Azure private networking, use the `GitHubId` returned by the Azure `GitHub.Network/networkSettings` resource, not the Azure ARM resource ID. A network settings resource can only be associated with one network configuration at a time. + +## Attributes Reference + +In addition to the arguments above, the following attributes are exported: + +* `id` - The ID of the network configuration. + +* `created_on` - The timestamp when the network configuration was created. + +## Notes + +* This resource can only be used with organization accounts. +* GitHub currently allows exactly one `network_settings_ids` value per organization network configuration. +* The `network_settings_ids` value must reference an existing hosted compute network settings resource configured outside this provider. +* For organization-scoped configurations backed by Azure private networking, create the Azure `GitHub.Network/networkSettings` resource using the GitHub organization's `databaseId`. Using a mismatched scope, such as an enterprise `databaseId` for an organization configuration, can cause GitHub to reject the configuration. + +## Import + +Organization network configurations can be imported using the network configuration ID: + +```shell +terraform import github_organization_network_configuration.example 1234567890ABCDEF +``` + +The network configuration ID can be found using the [list hosted compute network configurations for an organization](https://docs.github.com/en/enterprise-cloud@latest/rest/orgs/network-configurations#list-hosted-compute-network-configurations-for-an-organization) API. diff --git a/website/github.erb b/website/github.erb index 997536b42f..15439e0854 100644 --- a/website/github.erb +++ b/website/github.erb @@ -352,6 +352,9 @@