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..8aa12e1543 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(), @@ -214,6 +215,7 @@ func Provider() *schema.Provider { "github_user_ssh_key": resourceGithubUserSshKey(), "github_enterprise_organization": resourceGithubEnterpriseOrganization(), "github_enterprise_actions_runner_group": resourceGithubActionsEnterpriseRunnerGroup(), + "github_enterprise_network_configuration": resourceGithubEnterpriseNetworkConfiguration(), "github_enterprise_actions_workflow_permissions": resourceGithubEnterpriseActionsWorkflowPermissions(), "github_actions_organization_workflow_permissions": resourceGithubActionsOrganizationWorkflowPermissions(), "github_enterprise_security_analysis_settings": resourceGithubEnterpriseSecurityAnalysisSettings(), diff --git a/github/resource_github_actions_runner_group.go b/github/resource_github_actions_runner_group.go index 3408cb4b9a..e429ccc9d7 100644 --- a/github/resource_github_actions_runner_group.go +++ b/github/resource_github_actions_runner_group.go @@ -9,16 +9,17 @@ import ( "strconv" "github.com/google/go-github/v84/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) func resourceGithubActionsRunnerGroup() *schema.Resource { return &schema.Resource{ - Create: resourceGithubActionsRunnerGroupCreate, - Read: resourceGithubActionsRunnerGroupRead, - Update: resourceGithubActionsRunnerGroupUpdate, - Delete: resourceGithubActionsRunnerGroupDelete, + CreateContext: resourceGithubActionsRunnerGroupCreate, + ReadContext: resourceGithubActionsRunnerGroupRead, + UpdateContext: resourceGithubActionsRunnerGroupUpdate, + DeleteContext: resourceGithubActionsRunnerGroupDelete, Importer: &schema.ResourceImporter{ StateContext: schema.ImportStatePassthroughContext, }, @@ -92,15 +93,73 @@ 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, + ValidateDiagFunc: validation.ToDiagFunc(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 resourceGithubActionsRunnerGroupCreate(d *schema.ResourceData, meta any) error { - err := checkOrganization(meta) +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 nil, resp, nil + } + } + return runnerGroup, resp, err +} + +func setGithubActionsRunnerGroupState(d *schema.ResourceData, runnerGroup *github.RunnerGroup, etag string, selectedRepositoryIDs []int64) error { + if err := d.Set("etag", normalizeEtag(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(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + if err := checkOrganization(meta); err != nil { + return diag.FromErr(err) + } client := meta.(*Owner).v3client orgName := meta.(*Owner).name @@ -118,7 +177,7 @@ func resourceGithubActionsRunnerGroupCreate(d *schema.ResourceData, meta any) er } if visibility != "selected" && hasSelectedRepositories { - return fmt.Errorf("cannot use selected_repository_ids without visibility being set to selected") + return diag.FromErr(fmt.Errorf("cannot use selected_repository_ids without visibility being set to selected")) } selectedRepositoryIDs := []int64{} @@ -131,7 +190,11 @@ func resourceGithubActionsRunnerGroupCreate(d *schema.ResourceData, meta any) er } } - ctx := context.Background() + var networkConfigurationIDPtr *string + if networkConfigurationID, ok := d.GetOk("network_configuration_id"); ok { + value := networkConfigurationID.(string) + networkConfigurationIDPtr = &value + } runnerGroup, resp, err := client.Actions.CreateOrganizationRunnerGroup(ctx, orgName, @@ -142,69 +205,26 @@ func resourceGithubActionsRunnerGroupCreate(d *schema.ResourceData, meta any) er SelectedRepositoryIDs: selectedRepositoryIDs, SelectedWorkflows: selectedWorkflows, AllowsPublicRepositories: &allowsPublicRepositories, + NetworkConfigurationID: networkConfigurationIDPtr, }, ) if err != nil { - return err + return diag.FromErr(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 = setGithubActionsRunnerGroupState(d, runnerGroup, normalizeEtag(resp.Header.Get("ETag")), selectedRepositoryIDs); err != nil { + return diag.FromErr(err) } - if err = d.Set("selected_workflows", runnerGroup.SelectedWorkflows); err != nil { - return err + if err = d.Set("network_configuration_id", runnerGroup.NetworkConfigurationID); err != nil { + return diag.FromErr(err) } - return resourceGithubActionsRunnerGroupRead(d, meta) -} - -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 - } - } - return runnerGroup, resp, err + return nil } -func resourceGithubActionsRunnerGroupRead(d *schema.ResourceData, meta any) error { - err := checkOrganization(meta) - if err != nil { - return err +func resourceGithubActionsRunnerGroupRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + if err := checkOrganization(meta); err != nil { + return diag.FromErr(err) } client := meta.(*Owner).v3client @@ -212,9 +232,9 @@ func resourceGithubActionsRunnerGroupRead(d *schema.ResourceData, meta any) erro runnerGroupID, err := strconv.ParseInt(d.Id(), 10, 64) if err != nil { - return err + return diag.FromErr(err) } - ctx := context.WithValue(context.Background(), ctxId, d.Id()) + ctx = context.WithValue(ctx, ctxId, d.Id()) if !d.IsNewResource() { ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string)) } @@ -223,55 +243,20 @@ 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("") return nil } } - return err + return diag.FromErr(err) } - // if runner group is nil (typically not modified) we can return early if runnerGroup == nil { return nil } - 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 { - return err - } - selectedRepositoryIDs := []int64{} options := github.ListOptions{ PerPage: maxPerPage, @@ -280,7 +265,7 @@ func resourceGithubActionsRunnerGroupRead(d *schema.ResourceData, meta any) erro for { runnerGroupRepositories, resp, err := client.Actions.ListRepositoryAccessRunnerGroup(ctx, orgName, runnerGroupID, &options) if err != nil { - return err + return diag.FromErr(err) } for _, repo := range runnerGroupRepositories.Repositories { @@ -294,17 +279,20 @@ func resourceGithubActionsRunnerGroupRead(d *schema.ResourceData, meta any) erro options.Page = resp.NextPage } - if err = d.Set("selected_repository_ids", selectedRepositoryIDs); err != nil { - return err + runnerGroupEtag := normalizeEtag(resp.Header.Get("ETag")) + if err = setGithubActionsRunnerGroupState(d, runnerGroup, runnerGroupEtag, selectedRepositoryIDs); err != nil { + return diag.FromErr(err) + } + if err = d.Set("network_configuration_id", runnerGroup.NetworkConfigurationID); err != nil { + return diag.FromErr(err) } return nil } -func resourceGithubActionsRunnerGroupUpdate(d *schema.ResourceData, meta any) error { - err := checkOrganization(meta) - if err != nil { - return err +func resourceGithubActionsRunnerGroupUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + if err := checkOrganization(meta); err != nil { + return diag.FromErr(err) } client := meta.(*Owner).v3client @@ -321,22 +309,35 @@ func resourceGithubActionsRunnerGroupUpdate(d *schema.ResourceData, meta any) er } } + var networkConfigurationIDPtr *string + if networkConfigurationID, ok := d.GetOk("network_configuration_id"); ok { + value := networkConfigurationID.(string) + networkConfigurationIDPtr = &value + } else if d.HasChange("network_configuration_id") { + // Field was removed — send empty string to clear it. + // go-github's omitempty omits nil pointers, so empty string is used as a workaround. + empty := "" + networkConfigurationIDPtr = &empty + } + options := github.UpdateRunnerGroupRequest{ Name: &name, Visibility: &visibility, RestrictedToWorkflows: &restrictedToWorkflows, SelectedWorkflows: selectedWorkflows, AllowsPublicRepositories: &allowsPublicRepositories, + NetworkConfigurationID: networkConfigurationIDPtr, } runnerGroupID, err := strconv.ParseInt(d.Id(), 10, 64) if err != nil { - return err + return diag.FromErr(err) } - ctx := context.WithValue(context.Background(), ctxId, d.Id()) + ctx = context.WithValue(ctx, ctxId, d.Id()) - if _, _, err := client.Actions.UpdateOrganizationRunnerGroup(ctx, orgName, runnerGroupID, options); err != nil { - return err + runnerGroup, resp, err := client.Actions.UpdateOrganizationRunnerGroup(ctx, orgName, runnerGroupID, options) + if err != nil { + return diag.FromErr(err) } selectedRepositories, hasSelectedRepositories := d.GetOk("selected_repository_ids") @@ -353,27 +354,39 @@ func resourceGithubActionsRunnerGroupUpdate(d *schema.ResourceData, meta any) er reposOptions := github.SetRepoAccessRunnerGroupRequest{SelectedRepositoryIDs: selectedRepositoryIDs} if _, err := client.Actions.SetRepositoryAccessRunnerGroup(ctx, orgName, runnerGroupID, reposOptions); err != nil { - return err + return diag.FromErr(err) + } + + runnerGroupEtag := normalizeEtag(resp.Header.Get("ETag")) + + if err := setGithubActionsRunnerGroupState(d, runnerGroup, runnerGroupEtag, selectedRepositoryIDs); err != nil { + return diag.FromErr(err) + } + if err := d.Set("network_configuration_id", runnerGroup.NetworkConfigurationID); err != nil { + return diag.FromErr(err) } - return resourceGithubActionsRunnerGroupRead(d, meta) + return nil } -func resourceGithubActionsRunnerGroupDelete(d *schema.ResourceData, meta any) error { - err := checkOrganization(meta) - if err != nil { - return err +func resourceGithubActionsRunnerGroupDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + if err := checkOrganization(meta); err != nil { + return diag.FromErr(err) } client := meta.(*Owner).v3client orgName := meta.(*Owner).name runnerGroupID, err := strconv.ParseInt(d.Id(), 10, 64) if err != nil { - return err + return diag.FromErr(err) } - ctx := context.WithValue(context.Background(), ctxId, d.Id()) + ctx = context.WithValue(ctx, ctxId, d.Id()) log.Printf("[INFO] Deleting organization runner group: %s (%s)", d.Id(), orgName) _, err = client.Actions.DeleteOrganizationRunnerGroup(ctx, orgName, runnerGroupID) - return err + if err != nil { + return diag.FromErr(err) + } + + return nil } diff --git a/github/resource_github_actions_runner_group_helpers.go b/github/resource_github_actions_runner_group_helpers.go new file mode 100644 index 0000000000..c7a51b5f3e --- /dev/null +++ b/github/resource_github_actions_runner_group_helpers.go @@ -0,0 +1,61 @@ +package github + +import ( + "context" + "errors" + "net/http" + + "github.com/google/go-github/v84/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +type runnerGroupNetworking struct { + NetworkConfigurationID *string `json:"network_configuration_id,omitempty"` +} + +func getRunnerGroupNetworking(client *github.Client, ctx context.Context, path string) (*runnerGroupNetworking, *github.Response, error) { + req, err := client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + var runnerGroup runnerGroupNetworking + resp, err := client.Do(ctx, req, &runnerGroup) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotModified { + return nil, resp, nil + } + + return nil, resp, err + } + + return &runnerGroup, resp, nil +} + +func updateRunnerGroupNetworking(client *github.Client, ctx context.Context, path string, networkConfigurationID *string) (*github.Response, error) { + payload := map[string]any{ + "network_configuration_id": networkConfigurationID, + } + + req, err := client.NewRequest("PATCH", path, payload) + if err != nil { + return nil, err + } + + resp, err := client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} + +func setRunnerGroupNetworkingState(d *schema.ResourceData, runnerGroup *runnerGroupNetworking) error { + var networkConfigurationID any + if runnerGroup != nil { + networkConfigurationID = runnerGroup.NetworkConfigurationID + } + + return d.Set("network_configuration_id", networkConfigurationID) +} diff --git a/github/resource_github_actions_runner_group_helpers_test.go b/github/resource_github_actions_runner_group_helpers_test.go new file mode 100644 index 0000000000..dd97bd5067 --- /dev/null +++ b/github/resource_github_actions_runner_group_helpers_test.go @@ -0,0 +1,173 @@ +package github + +import ( + "context" + "net/http" + "net/url" + "testing" + + "github.com/google/go-github/v84/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestGetRunnerGroupNetworking(t *testing.T) { + t.Run("returns networking payload", func(t *testing.T) { + ts := githubApiMock([]*mockResponse{ + { + ExpectedUri: "/orgs/test/actions/runner-groups/123", + ExpectedMethod: "GET", + ResponseBody: `{"network_configuration_id":"network-123"}`, + StatusCode: http.StatusOK, + }, + }) + defer ts.Close() + + httpClient := http.DefaultClient + client := github.NewClient(httpClient) + u, _ := url.Parse(ts.URL + "/") + client.BaseURL = u + + runnerGroup, resp, err := getRunnerGroupNetworking(client, context.Background(), "orgs/test/actions/runner-groups/123") + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.StatusCode != http.StatusOK { + t.Fatalf("expected %d response, got %#v", http.StatusOK, resp) + } + if runnerGroup == nil || runnerGroup.NetworkConfigurationID == nil { + t.Fatalf("expected network configuration payload, got %#v", runnerGroup) + } + if got := *runnerGroup.NetworkConfigurationID; got != "network-123" { + t.Fatalf("expected network configuration id %q, got %q", "network-123", got) + } + }) + + t.Run("swallows 304 not modified", func(t *testing.T) { + ts := githubApiMock([]*mockResponse{ + { + ExpectedUri: "/orgs/test/actions/runner-groups/123", + ExpectedMethod: "GET", + ExpectedHeaders: map[string]string{ + "If-None-Match": "etag-123", + }, + ResponseBody: `{"message":"Not modified"}`, + StatusCode: http.StatusNotModified, + }, + }) + defer ts.Close() + + httpClient := http.DefaultClient + httpClient.Transport = NewEtagTransport(http.DefaultTransport) + client := github.NewClient(httpClient) + u, _ := url.Parse(ts.URL + "/") + client.BaseURL = u + + ctx := context.WithValue(context.Background(), ctxEtag, "etag-123") + runnerGroup, resp, err := getRunnerGroupNetworking(client, ctx, "orgs/test/actions/runner-groups/123") + if err != nil { + t.Fatal(err) + } + if runnerGroup != nil { + t.Fatalf("expected nil runner group on 304, got %#v", runnerGroup) + } + if resp == nil || resp.StatusCode != http.StatusNotModified { + t.Fatalf("expected %d response, got %#v", http.StatusNotModified, resp) + } + }) +} + +func TestUpdateRunnerGroupNetworking(t *testing.T) { + t.Run("sends network configuration id payload", func(t *testing.T) { + ts := githubApiMock([]*mockResponse{ + { + ExpectedUri: "/orgs/test/actions/runner-groups/123", + ExpectedMethod: "PATCH", + ExpectedBody: []byte("{\"network_configuration_id\":\"network-123\"}\n"), + ResponseBody: `{}`, + StatusCode: http.StatusNoContent, + }, + }) + defer ts.Close() + + httpClient := http.DefaultClient + client := github.NewClient(httpClient) + u, _ := url.Parse(ts.URL + "/") + client.BaseURL = u + + networkConfigurationID := "network-123" + resp, err := updateRunnerGroupNetworking(client, context.Background(), "orgs/test/actions/runner-groups/123", &networkConfigurationID) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.StatusCode != http.StatusNoContent { + t.Fatalf("expected %d response, got %#v", http.StatusNoContent, resp) + } + }) + + t.Run("sends null payload when removing networking", func(t *testing.T) { + ts := githubApiMock([]*mockResponse{ + { + ExpectedUri: "/orgs/test/actions/runner-groups/123", + ExpectedMethod: "PATCH", + ExpectedBody: []byte("{\"network_configuration_id\":null}\n"), + ResponseBody: `{}`, + StatusCode: http.StatusNoContent, + }, + }) + defer ts.Close() + + httpClient := http.DefaultClient + client := github.NewClient(httpClient) + u, _ := url.Parse(ts.URL + "/") + client.BaseURL = u + + resp, err := updateRunnerGroupNetworking(client, context.Background(), "orgs/test/actions/runner-groups/123", nil) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.StatusCode != http.StatusNoContent { + t.Fatalf("expected %d response, got %#v", http.StatusNoContent, resp) + } + }) +} + +func TestSetRunnerGroupNetworkingState(t *testing.T) { + t.Run("sets network configuration id", func(t *testing.T) { + d := schema.TestResourceDataRaw(t, map[string]*schema.Schema{ + "network_configuration_id": { + Type: schema.TypeString, + Optional: true, + }, + }, map[string]any{}) + + networkConfigurationID := "network-123" + if err := setRunnerGroupNetworkingState(d, &runnerGroupNetworking{NetworkConfigurationID: &networkConfigurationID}); err != nil { + t.Fatal(err) + } + + got, ok := d.GetOk("network_configuration_id") + if !ok { + t.Fatal("expected network_configuration_id to be set") + } + if got.(string) != networkConfigurationID { + t.Fatalf("expected network configuration id %q, got %q", networkConfigurationID, got.(string)) + } + }) + + t.Run("clears network configuration id", func(t *testing.T) { + d := schema.TestResourceDataRaw(t, map[string]*schema.Schema{ + "network_configuration_id": { + Type: schema.TypeString, + Optional: true, + }, + }, map[string]any{"network_configuration_id": "network-123"}) + + if err := setRunnerGroupNetworkingState(d, nil); err != nil { + t.Fatal(err) + } + + if _, ok := d.GetOk("network_configuration_id"); ok { + t.Fatalf("expected network_configuration_id to be cleared, got %q", d.Get("network_configuration_id")) + } + }) +} diff --git a/github/resource_github_actions_runner_group_test.go b/github/resource_github_actions_runner_group_test.go index a399e5f140..7acb331161 100644 --- a/github/resource_github_actions_runner_group_test.go +++ b/github/resource_github_actions_runner_group_test.go @@ -4,11 +4,14 @@ import ( "fmt" "testing" - "github.com/hashicorp/terraform-plugin-testing/terraform" - + "github.com/hashicorp/terraform-plugin-testing/compare" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/terraform" "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 TestAccGithubActionsRunnerGroup(t *testing.T) { @@ -97,6 +100,147 @@ 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, + }, + { + Config: configWithNetworkConfiguration, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_configuration_id"), knownvalue.NotNull()), + statecheck.CompareValuePairs( + resourceName, + tfjsonpath.New("network_configuration_id"), + networkConfigurationResourceName, + tfjsonpath.New("id"), + compare.ValuesSame(), + ), + }, + }, + { + Config: configWithoutNetworkConfiguration, + }, + }, + }) + }) + + 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()), + statecheck.CompareValuePairs( + resourceName, + tfjsonpath.New("network_configuration_id"), + networkConfigurationResourceName, + tfjsonpath.New("id"), + compare.ValuesSame(), + ), + }, + }, + }, + }) + }) + + t.Run("imports private networking association for hosted runners", func(t *testing.T) { + networkSettingsID := testAccOrganizationNetworkConfigurationID(t) + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + networkConfigurationName := fmt.Sprintf("%snetwork-config-import-%s", testResourcePrefix, randomID) + runnerGroupName := fmt.Sprintf("%srunner-group-import-%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, + }, + { + ResourceName: "github_actions_runner_group.test", + 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_actions_runner_group_unit_test.go b/github/resource_github_actions_runner_group_unit_test.go new file mode 100644 index 0000000000..ece08fe5a9 --- /dev/null +++ b/github/resource_github_actions_runner_group_unit_test.go @@ -0,0 +1,113 @@ +package github + +import ( + "context" + "net/http" + "net/url" + "testing" + + "github.com/google/go-github/v84/github" +) + +func TestGetOrganizationRunnerGroup_ReturnsNilOn304(t *testing.T) { + ts := githubApiMock([]*mockResponse{ + { + ExpectedUri: "/orgs/test-org/actions/runner-groups/123", + ExpectedMethod: "GET", + ExpectedHeaders: map[string]string{ + "If-None-Match": "etag-abc", + }, + ResponseBody: `{"message":"Not Modified"}`, + StatusCode: http.StatusNotModified, + }, + }) + defer ts.Close() + + httpClient := http.DefaultClient + httpClient.Transport = NewEtagTransport(http.DefaultTransport) + client := github.NewClient(httpClient) + u, _ := url.Parse(ts.URL + "/") + client.BaseURL = u + + ctx := context.WithValue(context.Background(), ctxEtag, "etag-abc") + runnerGroup, resp, err := getOrganizationRunnerGroup(client, ctx, "test-org", 123) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if runnerGroup != nil { + t.Fatalf("expected nil runner group on 304, got: %+v", runnerGroup) + } + if resp == nil || resp.StatusCode != http.StatusNotModified { + t.Fatalf("expected 304 response, got: %+v", resp) + } +} + +func TestGetOrganizationRunnerGroup_ReturnsRunnerGroup(t *testing.T) { + ts := githubApiMock([]*mockResponse{ + { + ExpectedUri: "/orgs/test-org/actions/runner-groups/42", + ExpectedMethod: "GET", + ResponseBody: `{"id":42,"name":"my-group","network_configuration_id":"nc-456"}`, + StatusCode: http.StatusOK, + }, + }) + defer ts.Close() + + httpClient := http.DefaultClient + client := github.NewClient(httpClient) + u, _ := url.Parse(ts.URL + "/") + client.BaseURL = u + + runnerGroup, resp, err := getOrganizationRunnerGroup(client, context.Background(), "test-org", 42) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if runnerGroup == nil { + t.Fatal("expected non-nil runner group") + } + if runnerGroup.GetID() != 42 { + t.Fatalf("expected ID 42, got %d", runnerGroup.GetID()) + } + if runnerGroup.GetName() != "my-group" { + t.Fatalf("expected name 'my-group', got %q", runnerGroup.GetName()) + } + if runnerGroup.GetNetworkConfigurationID() != "nc-456" { + t.Fatalf("expected network_configuration_id 'nc-456', got %q", runnerGroup.GetNetworkConfigurationID()) + } + if resp == nil || resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 response, got: %+v", resp) + } +} + +func TestGetEnterpriseRunnerGroup_ReturnsNilOn304(t *testing.T) { + ts := githubApiMock([]*mockResponse{ + { + ExpectedUri: "/enterprises/test-ent/actions/runner-groups/99", + ExpectedMethod: "GET", + ExpectedHeaders: map[string]string{ + "If-None-Match": "etag-xyz", + }, + ResponseBody: `{"message":"Not Modified"}`, + StatusCode: http.StatusNotModified, + }, + }) + defer ts.Close() + + httpClient := http.DefaultClient + httpClient.Transport = NewEtagTransport(http.DefaultTransport) + client := github.NewClient(httpClient) + u, _ := url.Parse(ts.URL + "/") + client.BaseURL = u + + ctx := context.WithValue(context.Background(), ctxEtag, "etag-xyz") + runnerGroup, resp, err := getEnterpriseRunnerGroup(client, ctx, "test-ent", 99) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if runnerGroup != nil { + t.Fatalf("expected nil runner group on 304, got: %+v", runnerGroup) + } + if resp == nil || resp.StatusCode != http.StatusNotModified { + t.Fatalf("expected 304 response, got: %+v", resp) + } +} diff --git a/github/resource_github_enterprise_actions_runner_group.go b/github/resource_github_enterprise_actions_runner_group.go index 9ec89a2f1f..7c7daa7e21 100644 --- a/github/resource_github_enterprise_actions_runner_group.go +++ b/github/resource_github_enterprise_actions_runner_group.go @@ -4,24 +4,25 @@ import ( "context" "errors" "fmt" - "log" "net/http" "strconv" "strings" "github.com/google/go-github/v84/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" ) func resourceGithubActionsEnterpriseRunnerGroup() *schema.Resource { return &schema.Resource{ - Create: resourceGithubActionsEnterpriseRunnerGroupCreate, - Read: resourceGithubActionsEnterpriseRunnerGroupRead, - Update: resourceGithubActionsEnterpriseRunnerGroupUpdate, - Delete: resourceGithubActionsEnterpriseRunnerGroupDelete, + CreateContext: resourceGithubActionsEnterpriseRunnerGroupCreate, + ReadContext: resourceGithubActionsEnterpriseRunnerGroupRead, + UpdateContext: resourceGithubActionsEnterpriseRunnerGroupUpdate, + DeleteContext: resourceGithubActionsEnterpriseRunnerGroupDelete, Importer: &schema.ResourceImporter{ - State: resourceGithubActionsEnterpriseRunnerGroupImport, + StateContext: resourceGithubActionsEnterpriseRunnerGroupImport, }, Schema: map[string]*schema.Schema{ @@ -74,6 +75,12 @@ func resourceGithubActionsEnterpriseRunnerGroup() *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, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringLenBetween(1, 255)), + Description: "The identifier of the hosted compute network configuration to associate with this runner group for GitHub-hosted private networking.", + }, "selected_organization_ids": { Type: schema.TypeSet, Elem: &schema.Schema{ @@ -92,7 +99,45 @@ func resourceGithubActionsEnterpriseRunnerGroup() *schema.Resource { } } -func resourceGithubActionsEnterpriseRunnerGroupCreate(d *schema.ResourceData, meta any) error { +func setGithubActionsEnterpriseRunnerGroupState(d *schema.ResourceData, runnerGroup *github.EnterpriseRunnerGroup, etag string, enterpriseSlug string, selectedOrganizationIDs []int64) error { + if err := d.Set("etag", normalizeEtag(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("name", runnerGroup.GetName()); err != nil { + return err + } + if err := d.Set("runners_url", runnerGroup.GetRunnersURL()); err != nil { + return err + } + if err := d.Set("selected_organizations_url", runnerGroup.GetSelectedOrganizationsURL()); err != nil { + return err + } + if err := d.Set("visibility", runnerGroup.GetVisibility()); err != nil { + return err + } + if err := d.Set("selected_organization_ids", selectedOrganizationIDs); 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 + } + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return err + } + + return nil +} + +func resourceGithubActionsEnterpriseRunnerGroupCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client name := d.Get("name").(string) @@ -110,7 +155,7 @@ func resourceGithubActionsEnterpriseRunnerGroupCreate(d *schema.ResourceData, me } if visibility != "selected" && hasSelectedOrganizations { - return fmt.Errorf("cannot use selected_organization_ids without visibility being set to selected") + return diag.FromErr(fmt.Errorf("cannot use selected_organization_ids without visibility being set to selected")) } selectedOrganizationIDs := []int64{} @@ -123,7 +168,7 @@ func resourceGithubActionsEnterpriseRunnerGroupCreate(d *schema.ResourceData, me } } - ctx := context.Background() + ctx = context.WithValue(ctx, ctxId, d.Id()) enterpriseRunnerGroup, resp, err := client.Enterprise.CreateEnterpriseRunnerGroup(ctx, enterpriseSlug, @@ -137,67 +182,58 @@ func resourceGithubActionsEnterpriseRunnerGroupCreate(d *schema.ResourceData, me }, ) if err != nil { - return err + return diag.FromErr(err) } d.SetId(strconv.FormatInt(enterpriseRunnerGroup.GetID(), 10)) - if err = d.Set("etag", resp.Header.Get("ETag")); err != nil { - return err - } - if err = d.Set("allows_public_repositories", enterpriseRunnerGroup.GetAllowsPublicRepositories()); err != nil { - return err - } - if err = d.Set("default", enterpriseRunnerGroup.GetDefault()); err != nil { - return err - } - if err = d.Set("name", enterpriseRunnerGroup.GetName()); err != nil { - return err - } - if err = d.Set("runners_url", enterpriseRunnerGroup.GetRunnersURL()); err != nil { - return err - } - if err = d.Set("selected_organizations_url", enterpriseRunnerGroup.GetSelectedOrganizationsURL()); err != nil { - return err - } - if err = d.Set("visibility", enterpriseRunnerGroup.GetVisibility()); err != nil { - return err - } - if err = d.Set("selected_organization_ids", selectedOrganizationIDs); err != nil { // Note: enterpriseRunnerGroup has no method to get selected organization IDs - return err - } - if err = d.Set("restricted_to_workflows", enterpriseRunnerGroup.GetRestrictedToWorkflows()); err != nil { - return err + ctx = context.WithValue(ctx, ctxId, d.Id()) + if err = setGithubActionsEnterpriseRunnerGroupState(d, enterpriseRunnerGroup, normalizeEtag(resp.Header.Get("ETag")), enterpriseSlug, selectedOrganizationIDs); err != nil { + return diag.FromErr(err) } - if err = d.Set("selected_workflows", enterpriseRunnerGroup.SelectedWorkflows); err != nil { - return err + + 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 = updateRunnerGroupNetworking(client, ctx, fmt.Sprintf("enterprises/%s/actions/runner-groups/%d", enterpriseSlug, enterpriseRunnerGroup.GetID()), &networkConfigurationIDValue); err != nil { + return diag.FromErr(err) + } + + if err = setRunnerGroupNetworkingState(d, &runnerGroupNetworking{NetworkConfigurationID: &networkConfigurationIDValue}); err != nil { + return diag.FromErr(err) + } + + return nil } - if err = d.Set("enterprise_slug", enterpriseSlug); err != nil { - return err + + if err = setRunnerGroupNetworkingState(d, nil); err != nil { + return diag.FromErr(err) } - return resourceGithubActionsEnterpriseRunnerGroupRead(d, meta) + return nil } func getEnterpriseRunnerGroup(client *github.Client, ctx context.Context, ent string, groupID int64) (*github.EnterpriseRunnerGroup, *github.Response, error) { enterpriseRunnerGroup, resp, err := client.Enterprise.GetEnterpriseRunnerGroup(ctx, ent, groupID) if err != nil { var ghErr *github.ErrorResponse - if errors.As(err, &ghErr) { + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotModified { // ignore error StatusNotModified - return enterpriseRunnerGroup, resp, nil + return nil, resp, nil } } return enterpriseRunnerGroup, resp, err } -func resourceGithubActionsEnterpriseRunnerGroupRead(d *schema.ResourceData, meta any) error { +func resourceGithubActionsEnterpriseRunnerGroupRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client enterpriseSlug := d.Get("enterprise_slug").(string) runnerGroupID, err := strconv.ParseInt(d.Id(), 10, 64) if err != nil { - return err + return diag.FromErr(err) } - ctx := context.WithValue(context.Background(), ctxId, d.Id()) + ctx = context.WithValue(ctx, ctxId, d.Id()) + ctx = tflog.SetField(ctx, "id", d.Id()) if !d.IsNewResource() { ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string)) } @@ -206,50 +242,24 @@ func resourceGithubActionsEnterpriseRunnerGroupRead(d *schema.ResourceData, meta if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) { - if ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[INFO] Removing enterprise runner group %s/%s from state because it no longer exists in GitHub", - enterpriseSlug, d.Id()) + if ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Removing enterprise runner group from state because it no longer exists in GitHub") d.SetId("") return nil } } - return err + return diag.FromErr(err) } - // if runner group is nil (typically not modified) we can return early if enterpriseRunnerGroup == nil { return nil } - if err = d.Set("etag", resp.Header.Get("ETag")); err != nil { - return err - } - if err = d.Set("allows_public_repositories", enterpriseRunnerGroup.GetAllowsPublicRepositories()); err != nil { - return err - } - if err = d.Set("default", enterpriseRunnerGroup.GetDefault()); err != nil { - return err - } - if err = d.Set("name", enterpriseRunnerGroup.GetName()); err != nil { - return err - } - if err = d.Set("runners_url", enterpriseRunnerGroup.GetRunnersURL()); err != nil { - return err - } - if err = d.Set("selected_organizations_url", enterpriseRunnerGroup.GetSelectedOrganizationsURL()); err != nil { - return err - } - if err = d.Set("visibility", enterpriseRunnerGroup.GetVisibility()); err != nil { - return err - } - if err = d.Set("restricted_to_workflows", enterpriseRunnerGroup.GetRestrictedToWorkflows()); err != nil { - return err - } - if err = d.Set("selected_workflows", enterpriseRunnerGroup.SelectedWorkflows); err != nil { - return err - } - if err = d.Set("enterprise_slug", enterpriseSlug); err != nil { - return err + runnerGroupEtag := normalizeEtag(resp.Header.Get("ETag")) + + runnerGroupNetworking, _, err := getRunnerGroupNetworking(client, ctx, fmt.Sprintf("enterprises/%s/actions/runner-groups/%d", enterpriseSlug, runnerGroupID)) + if err != nil { + return diag.FromErr(err) } selectedOrganizationIDs := []int64{} @@ -260,7 +270,7 @@ func resourceGithubActionsEnterpriseRunnerGroupRead(d *schema.ResourceData, meta for { enterpriseRunnerGroupOrganizations, resp, err := client.Enterprise.ListOrganizationAccessRunnerGroup(ctx, enterpriseSlug, runnerGroupID, &optionsOrgs) if err != nil { - return err + return diag.FromErr(err) } for _, org := range enterpriseRunnerGroupOrganizations.Organizations { @@ -274,14 +284,19 @@ func resourceGithubActionsEnterpriseRunnerGroupRead(d *schema.ResourceData, meta optionsOrgs.Page = resp.NextPage } - if err = d.Set("selected_organization_ids", selectedOrganizationIDs); err != nil { - return err + if err = setGithubActionsEnterpriseRunnerGroupState(d, enterpriseRunnerGroup, runnerGroupEtag, enterpriseSlug, selectedOrganizationIDs); err != nil { + return diag.FromErr(err) + } + if runnerGroupNetworking != nil { + if err = setRunnerGroupNetworkingState(d, runnerGroupNetworking); err != nil { + return diag.FromErr(err) + } } return nil } -func resourceGithubActionsEnterpriseRunnerGroupUpdate(d *schema.ResourceData, meta any) error { +func resourceGithubActionsEnterpriseRunnerGroupUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client name := d.Get("name").(string) @@ -306,12 +321,31 @@ func resourceGithubActionsEnterpriseRunnerGroupUpdate(d *schema.ResourceData, me runnerGroupID, err := strconv.ParseInt(d.Id(), 10, 64) if err != nil { - return err + return diag.FromErr(err) } - ctx := context.WithValue(context.Background(), ctxId, d.Id()) + ctx = context.WithValue(ctx, ctxId, d.Id()) + ctx = tflog.SetField(ctx, "id", d.Id()) - if _, _, err := client.Enterprise.UpdateEnterpriseRunnerGroup(ctx, enterpriseSlug, runnerGroupID, options); err != nil { - return err + runnerGroup, resp, err := client.Enterprise.UpdateEnterpriseRunnerGroup(ctx, enterpriseSlug, runnerGroupID, options) + if err != nil { + return diag.FromErr(err) + } + + var networkConfigurationIDValue *string + if networkConfigurationID, ok := d.GetOk("network_configuration_id"); ok { + value := networkConfigurationID.(string) + networkConfigurationIDValue = &value + } + + if d.HasChange("network_configuration_id") { + if _, err := updateRunnerGroupNetworking(client, ctx, fmt.Sprintf("enterprises/%s/actions/runner-groups/%d", enterpriseSlug, runnerGroupID), networkConfigurationIDValue); err != nil { + return diag.FromErr(err) + } + } + + var networkingState *runnerGroupNetworking + if networkConfigurationIDValue != nil { + networkingState = &runnerGroupNetworking{NetworkConfigurationID: networkConfigurationIDValue} } selectedOrganizations, hasSelectedOrganizations := d.GetOk("selected_organization_ids") @@ -328,27 +362,41 @@ func resourceGithubActionsEnterpriseRunnerGroupUpdate(d *schema.ResourceData, me orgOptions := github.SetOrgAccessRunnerGroupRequest{SelectedOrganizationIDs: selectedOrganizationIDs} if _, err := client.Enterprise.SetOrganizationAccessRunnerGroup(ctx, enterpriseSlug, runnerGroupID, orgOptions); err != nil { - return err + return diag.FromErr(err) + } + + runnerGroupEtag := normalizeEtag(resp.Header.Get("ETag")) + + if err := setGithubActionsEnterpriseRunnerGroupState(d, runnerGroup, runnerGroupEtag, enterpriseSlug, selectedOrganizationIDs); err != nil { + return diag.FromErr(err) + } + if err := setRunnerGroupNetworkingState(d, networkingState); err != nil { + return diag.FromErr(err) } - return resourceGithubActionsEnterpriseRunnerGroupRead(d, meta) + return nil } -func resourceGithubActionsEnterpriseRunnerGroupDelete(d *schema.ResourceData, meta any) error { +func resourceGithubActionsEnterpriseRunnerGroupDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client enterpriseSlug := d.Get("enterprise_slug").(string) enterpriseRunnerGroupID, err := strconv.ParseInt(d.Id(), 10, 64) if err != nil { - return err + return diag.FromErr(err) } - ctx := context.WithValue(context.Background(), ctxId, d.Id()) + ctx = context.WithValue(ctx, ctxId, d.Id()) + ctx = tflog.SetField(ctx, "id", d.Id()) - log.Printf("[INFO] Deleting enterprise runner group: %s/%s (%s)", enterpriseSlug, d.Get("name"), d.Id()) + tflog.Debug(ctx, "Deleting enterprise runner group") _, err = client.Enterprise.DeleteEnterpriseRunnerGroup(ctx, enterpriseSlug, enterpriseRunnerGroupID) - return err + if err != nil { + return diag.FromErr(err) + } + + return nil } -func resourceGithubActionsEnterpriseRunnerGroupImport(d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { +func resourceGithubActionsEnterpriseRunnerGroupImport(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_actions_runner_group_test.go b/github/resource_github_enterprise_actions_runner_group_test.go index fec43eb55d..6536a95c55 100644 --- a/github/resource_github_enterprise_actions_runner_group_test.go +++ b/github/resource_github_enterprise_actions_runner_group_test.go @@ -2,11 +2,16 @@ package github import ( "fmt" + "os" "testing" + "github.com/hashicorp/terraform-plugin-testing/compare" "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 TestAccGithubActionsEnterpriseRunnerGroup(t *testing.T) { @@ -193,4 +198,142 @@ func TestAccGithubActionsEnterpriseRunnerGroup(t *testing.T) { }, }) }) + + t.Run("manages runner group network configuration", func(t *testing.T) { + networkSettingsID := testAccEnterpriseNetworkConfigurationID(t) + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + resourceName := "github_enterprise_actions_runner_group.test" + networkConfigurationResourceName := "github_enterprise_network_configuration.test" + groupName := fmt.Sprintf("tf-acc-test-%s", randomID) + networkConfigurationName := fmt.Sprintf("%senterprise-network-config-%s", testResourcePrefix, randomID) + + configWithoutNetworking := fmt.Sprintf(` + resource "github_enterprise_network_configuration" "test" { + enterprise_slug = %q + name = %q + compute_service = "actions" + network_settings_ids = [%q] + } + + resource "github_enterprise_actions_runner_group" "test" { + enterprise_slug = %q + name = %q + visibility = "all" + } + `, testAccConf.enterpriseSlug, networkConfigurationName, networkSettingsID, testAccConf.enterpriseSlug, groupName) + + configWithNetworking := fmt.Sprintf(` + resource "github_enterprise_network_configuration" "test" { + enterprise_slug = %q + name = %q + compute_service = "actions" + network_settings_ids = [%q] + } + + resource "github_enterprise_actions_runner_group" "test" { + enterprise_slug = %q + name = %q + visibility = "all" + network_configuration_id = github_enterprise_network_configuration.test.id + } + `, testAccConf.enterpriseSlug, networkConfigurationName, networkSettingsID, testAccConf.enterpriseSlug, groupName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: configWithoutNetworking, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("name"), knownvalue.StringExact(groupName)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("visibility"), knownvalue.StringExact("all")), + }, + }, + { + Config: configWithNetworking, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_configuration_id"), knownvalue.NotNull()), + statecheck.CompareValuePairs( + resourceName, + tfjsonpath.New("network_configuration_id"), + networkConfigurationResourceName, + tfjsonpath.New("id"), + compare.ValuesSame(), + ), + }, + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: fmt.Sprintf(`%s/`, testAccConf.enterpriseSlug), + }, + { + Config: configWithoutNetworking, + }, + }, + }) + }) + + t.Run("creates runner group network configuration on create", func(t *testing.T) { + networkSettingsID := testAccEnterpriseNetworkConfigurationID(t) + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + resourceName := "github_enterprise_actions_runner_group.test" + networkConfigurationResourceName := "github_enterprise_network_configuration.test" + groupName := fmt.Sprintf("tf-acc-test-create-%s", randomID) + networkConfigurationName := fmt.Sprintf("%senterprise-network-config-create-%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` + resource "github_enterprise_network_configuration" "test" { + enterprise_slug = %q + name = %q + compute_service = "actions" + network_settings_ids = [%q] + } + + resource "github_enterprise_actions_runner_group" "test" { + enterprise_slug = %q + name = %q + visibility = "all" + network_configuration_id = github_enterprise_network_configuration.test.id + } + `, testAccConf.enterpriseSlug, networkConfigurationName, networkSettingsID, testAccConf.enterpriseSlug, groupName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_configuration_id"), knownvalue.NotNull()), + statecheck.CompareValuePairs( + resourceName, + tfjsonpath.New("network_configuration_id"), + networkConfigurationResourceName, + tfjsonpath.New("id"), + compare.ValuesSame(), + ), + }, + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: fmt.Sprintf(`%s/`, testAccConf.enterpriseSlug), + }, + }, + }) + }) +} + +func testAccEnterpriseNetworkConfigurationID(t *testing.T) string { + t.Helper() + + networkSettingsID := os.Getenv("GITHUB_TEST_ENTERPRISE_NETWORK_SETTINGS_ID") + if networkSettingsID == "" { + t.Skip("GITHUB_TEST_ENTERPRISE_NETWORK_SETTINGS_ID not set") + } + + return networkSettingsID } diff --git a/github/resource_github_enterprise_network_configuration.go b/github/resource_github_enterprise_network_configuration.go new file mode 100644 index 0000000000..4a1d6fe088 --- /dev/null +++ b/github/resource_github_enterprise_network_configuration.go @@ -0,0 +1,242 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/go-github/v84/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" +) + +func resourceGithubEnterpriseNetworkConfiguration() *schema.Resource { + return &schema.Resource{ + Description: "This resource allows you to create and manage hosted compute network configurations for a GitHub enterprise.", + CreateContext: resourceGithubEnterpriseNetworkConfigurationCreate, + ReadContext: resourceGithubEnterpriseNetworkConfigurationRead, + UpdateContext: resourceGithubEnterpriseNetworkConfigurationUpdate, + DeleteContext: resourceGithubEnterpriseNetworkConfigurationDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubEnterpriseNetworkConfigurationImport, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "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 resourceGithubEnterpriseNetworkConfigurationCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + enterpriseSlug := d.Get("enterprise_slug").(string) + ctx = tflog.SetField(ctx, "enterprise_slug", enterpriseSlug) + + client := meta.(*Owner).v3client + computeService := github.ComputeService(d.Get("compute_service").(string)) + networkSettingsIDs := []string{d.Get("network_settings_ids").([]any)[0].(string)} + + tflog.Debug(ctx, "Creating enterprise network configuration", map[string]any{ + "name": d.Get("name").(string), + "compute_service": d.Get("compute_service").(string), + "network_settings_ids": networkSettingsIDs, + }) + + configuration, _, err := client.Enterprise.CreateEnterpriseNetworkConfiguration(ctx, enterpriseSlug, github.NetworkConfigurationRequest{ + Name: github.Ptr(d.Get("name").(string)), + ComputeService: &computeService, + NetworkSettingsIDs: networkSettingsIDs, + }) + if err != nil { + return enterpriseNetworkConfigurationDiagnostics(err) + } + + d.SetId(configuration.GetID()) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err := setEnterpriseNetworkConfigurationState(d, configuration); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseNetworkConfigurationRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + enterpriseSlug := d.Get("enterprise_slug").(string) + ctx = tflog.SetField(ctx, "enterprise_slug", enterpriseSlug) + ctx = tflog.SetField(ctx, "id", d.Id()) + + client := meta.(*Owner).v3client + networkConfigurationID := d.Id() + ctx = context.WithValue(ctx, ctxId, networkConfigurationID) + + configuration, resp, err := client.Enterprise.GetEnterpriseNetworkConfiguration(ctx, enterpriseSlug, networkConfigurationID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Enterprise 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 := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err := setEnterpriseNetworkConfigurationState(d, configuration); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseNetworkConfigurationUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + enterpriseSlug := d.Get("enterprise_slug").(string) + ctx = tflog.SetField(ctx, "enterprise_slug", enterpriseSlug) + ctx = tflog.SetField(ctx, "id", d.Id()) + + client := meta.(*Owner).v3client + 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 enterprise network configuration", map[string]any{ + "name": d.Get("name").(string), + "compute_service": d.Get("compute_service").(string), + "network_settings_ids": networkSettingsIDs, + }) + + configuration, _, err := client.Enterprise.UpdateEnterpriseNetworkConfiguration(ctx, enterpriseSlug, networkConfigurationID, github.NetworkConfigurationRequest{ + Name: github.Ptr(d.Get("name").(string)), + ComputeService: &computeService, + NetworkSettingsIDs: networkSettingsIDs, + }) + if err != nil { + return enterpriseNetworkConfigurationDiagnostics(err) + } + + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err := setEnterpriseNetworkConfigurationState(d, configuration); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseNetworkConfigurationDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + enterpriseSlug := d.Get("enterprise_slug").(string) + ctx = tflog.SetField(ctx, "enterprise_slug", enterpriseSlug) + ctx = tflog.SetField(ctx, "id", d.Id()) + + client := meta.(*Owner).v3client + + tflog.Debug(ctx, "Deleting enterprise network configuration") + _, err := client.Enterprise.DeleteEnterpriseNetworkConfiguration(ctx, enterpriseSlug, 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 resourceGithubEnterpriseNetworkConfigurationImport(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, networkConfigurationID := parts[0], parts[1] + d.SetId(networkConfigurationID) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} + +func setEnterpriseNetworkConfigurationState(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 enterpriseNetworkConfigurationDiagnostics(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 enterprise scope; enterprise-level configurations may fail when the backing GitHub.Network/networkSettings resource was created with an organization databaseId", err)) + } + + return diag.FromErr(err) +} diff --git a/github/resource_github_enterprise_network_configuration_test.go b/github/resource_github_enterprise_network_configuration_test.go new file mode 100644 index 0000000000..1b3e0b2370 --- /dev/null +++ b/github/resource_github_enterprise_network_configuration_test.go @@ -0,0 +1,155 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/google/go-github/v84/github" + "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/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccGithubEnterpriseNetworkConfiguration(t *testing.T) { + t.Run("create", func(t *testing.T) { + networkSettingsID := testAccEnterpriseNetworkConfigurationID(t) + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + resourceName := "github_enterprise_network_configuration.test" + configurationName := fmt.Sprintf("%senterprise-network-config-%s", testResourcePrefix, randomID) + + config := testAccEnterpriseNetworkConfigurationConfig(configurationName, "actions", networkSettingsID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubEnterpriseNetworkConfigurationDestroy, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), + 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 := testAccEnterpriseNetworkConfigurationID(t) + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + resourceName := "github_enterprise_network_configuration.test" + beforeName := fmt.Sprintf("%senterprise-network-config-%s-a", testResourcePrefix, randomID) + afterName := fmt.Sprintf("%senterprise-network-config-%s-b", testResourcePrefix, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubEnterpriseNetworkConfigurationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccEnterpriseNetworkConfigurationConfig(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: testAccEnterpriseNetworkConfigurationConfig(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 := testAccEnterpriseNetworkConfigurationID(t) + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + configurationName := fmt.Sprintf("%senterprise-network-config-%s", testResourcePrefix, randomID) + + config := testAccEnterpriseNetworkConfigurationConfig(configurationName, "actions", networkSettingsID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubEnterpriseNetworkConfigurationDestroy, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_network_configuration.test", tfjsonpath.New("id"), knownvalue.NotNull()), + }, + }, + { + ResourceName: "github_enterprise_network_configuration.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: fmt.Sprintf(`%s/`, testAccConf.enterpriseSlug), + }, + }, + }) + }) +} + +func testAccCheckGithubEnterpriseNetworkConfigurationDestroy(s *terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err + } + + client := meta.v3client + + for _, rs := range s.RootModule().Resources { + if rs.Type != "github_enterprise_network_configuration" { + continue + } + + enterpriseSlug := rs.Primary.Attributes["enterprise_slug"] + if enterpriseSlug == "" { + enterpriseSlug = testAccConf.enterpriseSlug + } + + _, _, err := client.Enterprise.GetEnterpriseNetworkConfiguration(context.Background(), enterpriseSlug, rs.Primary.ID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + continue + } + + return err + } + + return fmt.Errorf("enterprise network configuration still exists: %s", rs.Primary.ID) + } + + return nil +} + +func testAccEnterpriseNetworkConfigurationConfig(name, computeService, networkSettingsID string) string { + return fmt.Sprintf(` +resource "github_enterprise_network_configuration" "test" { + enterprise_slug = %q + name = %q + compute_service = %q + network_settings_ids = [%q] +} +`, testAccConf.enterpriseSlug, name, computeService, networkSettingsID) +} diff --git a/github/resource_github_etag_unit_test.go b/github/resource_github_etag_unit_test.go index 267f40f485..abe32ec4e6 100644 --- a/github/resource_github_etag_unit_test.go +++ b/github/resource_github_etag_unit_test.go @@ -120,3 +120,41 @@ func TestEtagSchemaConsistency(t *testing.T) { }) } } + +func TestNormalizeEtag(t *testing.T) { + testCases := []struct { + name string + etag string + want string + }{ + { + name: "empty etag", + etag: "", + want: "", + }, + { + name: "strong etag unchanged", + etag: `"abc123"`, + want: `"abc123"`, + }, + { + name: "weak etag normalized", + etag: `W/"abc123"`, + want: `"abc123"`, + }, + { + name: "weak etag with whitespace normalized", + etag: " W/\"abc123\" ", + want: `"abc123"`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := normalizeEtag(tc.etag) + if got != tc.want { + t.Fatalf("normalizeEtag(%q) = %q, want %q", tc.etag, got, tc.want) + } + }) + } +} diff --git a/github/resource_github_organization_network_configuration.go b/github/resource_github_organization_network_configuration.go new file mode 100644 index 0000000000..ca64318a2d --- /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/v84/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..0661889170 --- /dev/null +++ b/github/resource_github_organization_network_configuration_test.go @@ -0,0 +1,160 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "testing" + + "github.com/google/go-github/v84/github" + "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/terraform" + "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, + CheckDestroy: testAccCheckGithubOrganizationNetworkConfigurationDestroy, + 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, + CheckDestroy: testAccCheckGithubOrganizationNetworkConfigurationDestroy, + 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, + CheckDestroy: testAccCheckGithubOrganizationNetworkConfigurationDestroy, + 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 testAccCheckGithubOrganizationNetworkConfigurationDestroy(s *terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err + } + + client := meta.v3client + orgName := meta.name + + for _, rs := range s.RootModule().Resources { + if rs.Type != "github_organization_network_configuration" { + continue + } + + _, _, err := client.Organizations.GetNetworkConfiguration(context.Background(), orgName, rs.Primary.ID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + continue + } + + return err + } + + return fmt.Errorf("organization network configuration still exists: %s", rs.Primary.ID) + } + + return nil +} + +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/github/util.go b/github/util.go index e16e568e04..fcee1c427d 100644 --- a/github/util.go +++ b/github/util.go @@ -110,6 +110,17 @@ func caseInsensitive() schema.SchemaDiffSuppressFunc { } } +// GitHub can return the same runner group with a strong ETag on create/import +// paths and a weak ETag on subsequent reads. We store a canonical form in +// state so runner group refresh/import comparisons do not drift solely because +// the API toggled the wire format between "etag" and W/"etag". +func normalizeEtag(etag string) string { + etag = strings.TrimSpace(etag) + etag = strings.TrimPrefix(etag, "W/") + + return etag +} + // wrapErrors is provided to easily turn errors into diag.Diagnostics // until we go through the provider and replace error usage. func wrapErrors(errs []error) diag.Diagnostics { 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..9a0950b393 100644 --- a/website/docs/r/actions_runner_group.html.markdown +++ b/website/docs/r/actions_runner_group.html.markdown @@ -34,6 +34,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 +48,7 @@ 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. ## Import diff --git a/website/docs/r/enterprise_actions_runner_group.html.markdown b/website/docs/r/enterprise_actions_runner_group.html.markdown index 202bb74d02..adf467f13e 100644 --- a/website/docs/r/enterprise_actions_runner_group.html.markdown +++ b/website/docs/r/enterprise_actions_runner_group.html.markdown @@ -33,6 +33,20 @@ resource "github_enterprise_actions_runner_group" "example" { restricted_to_workflows = true selected_workflows = ["my-organization/my-repo/.github/workflows/cool-workflow.yaml@refs/tags/v1"] } + +resource "github_enterprise_network_configuration" "example" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "my-network-config" + compute_service = "actions" + network_settings_ids = ["23456789ABCDEF1"] +} + +resource "github_enterprise_actions_runner_group" "private_networked" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "private-networked-runners" + visibility = "all" + network_configuration_id = github_enterprise_network_configuration.example.id +} ``` ## Argument Reference @@ -45,6 +59,7 @@ The following arguments are supported: * `allows_public_repositories` - (Optional) Whether public repositories can be added to the runner group. Defaults to false. * `restricted_to_workflows` - (Optional) If true, the runner group will be restricted to running only the workflows specified in the selected_workflows array. Defaults to false. * `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. +* `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 @@ -55,6 +70,11 @@ The following additional attributes are exported: * `etag` - An etag representing the runner group object * `runners_url` - The GitHub API URL for the runner group's runners * `selected_organizations_url` - The GitHub API URL for the runner group's selected organizations +* `network_configuration_id` - The ID of the hosted compute network configuration associated with this runner group + +## Notes + +Use `github_enterprise_network_configuration` to manage the hosted compute network configuration, then set `network_configuration_id` on `github_enterprise_actions_runner_group` so GitHub-hosted runners assigned to that enterprise runner group use the private networking association. ## Import diff --git a/website/docs/r/enterprise_network_configuration.html.markdown b/website/docs/r/enterprise_network_configuration.html.markdown new file mode 100644 index 0000000000..88d7413f43 --- /dev/null +++ b/website/docs/r/enterprise_network_configuration.html.markdown @@ -0,0 +1,64 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_network_configuration" +description: |- + Creates and manages hosted compute network configurations for a GitHub enterprise. +--- + +# github_enterprise_network_configuration + +This resource allows you to create and manage hosted compute network configurations for a GitHub Enterprise. Network configurations allow GitHub-hosted compute services, such as Actions hosted runners, to connect to your private network resources. + +~> **Note:** This resource is enterprise-only and is available for GitHub Enterprise Cloud enterprises. See the [GitHub documentation](https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/network-configurations) for more information. + +## Example Usage + +```hcl +data "github_enterprise" "enterprise" { + slug = "my-enterprise" +} + +resource "github_enterprise_network_configuration" "example" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "my-network-config" + compute_service = "actions" + network_settings_ids = ["23456789ABCDEF1"] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `enterprise_slug` - (Required) The slug of the enterprise. + +* `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 enterprise accounts. +* GitHub currently allows exactly one `network_settings_ids` value per enterprise network configuration. +* The `network_settings_ids` value must reference an existing hosted compute network settings resource configured outside this provider. +* Use `github_enterprise_actions_runner_group.network_configuration_id` to associate this configuration with GitHub-hosted runners through an enterprise runner group. + +## Import + +Enterprise network configurations can be imported using the enterprise slug and network configuration ID: + +```shell +terraform import github_enterprise_network_configuration.example enterprise-slug/1234567890ABCDEF +``` + +The network configuration ID can be found using the [list hosted compute network configurations for an enterprise](https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/network-configurations#list-hosted-compute-network-configurations-for-an-enterprise) API. 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.