diff --git a/docs/resources/actions_repository_oidc_subject_claim_customization_template.md b/docs/resources/actions_repository_oidc_subject_claim_customization_template.md index 4ea052c75b..b94841975f 100644 --- a/docs/resources/actions_repository_oidc_subject_claim_customization_template.md +++ b/docs/resources/actions_repository_oidc_subject_claim_customization_template.md @@ -36,9 +36,14 @@ resource "github_actions_repository_oidc_subject_claim_customization_template" " The following arguments are supported: +- `repository` - (Required) The name of the repository. - `use_default` - (Required) Whether to use the default template or not. If `true`, `include_claim_keys` must not be set. - `include_claim_keys` - (Optional) A list of OpenID Connect claims. +## Attributes Reference + +- `repository_id` - The ID of the repository. + ## Import This resource can be imported using the repository's name. diff --git a/github/resource_github_actions_repository_oidc_subject_claim_customization_template.go b/github/resource_github_actions_repository_oidc_subject_claim_customization_template.go index 9b8d41d763..1a0bc2b71e 100644 --- a/github/resource_github_actions_repository_oidc_subject_claim_customization_template.go +++ b/github/resource_github_actions_repository_oidc_subject_claim_customization_template.go @@ -3,21 +3,38 @@ package github import ( "context" "errors" + "net/http" "github.com/google/go-github/v92/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 resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplate() *schema.Resource { return &schema.Resource{ - Create: resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateCreateOrUpdate, - Read: resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateRead, - Update: resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateCreateOrUpdate, - Delete: resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateDelete, + CreateContext: resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateCreate, + ReadContext: resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateRead, + UpdateContext: resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateUpdate, + DeleteContext: resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateDelete, Importer: &schema.ResourceImporter{ - StateContext: schema.ImportStatePassthroughContext, + StateContext: resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateImport, }, + + CustomizeDiff: diffRepository, + + SchemaVersion: 1, + StateUpgraders: []schema.StateUpgrader{ + { + Type: resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateResourceV0().CoreConfigSchema().ImpliedType(), + Upgrade: resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateInstanceStateUpgradeV0, + Version: 0, + }, + }, + + Description: "Creates and manages an OpenID Connect subject claim customization template for a repository.", + Schema: map[string]*schema.Schema{ "repository": { Type: schema.TypeString, @@ -25,6 +42,11 @@ func resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplate() *sch Description: "The name of the repository.", ValidateDiagFunc: validation.ToDiagFunc(validation.StringLenBetween(1, 100)), }, + "repository_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the repository.", + }, "use_default": { Type: schema.TypeBool, Required: true, @@ -43,88 +65,152 @@ func resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplate() *sch } } -func resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateCreateOrUpdate(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client +func resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name - repository := d.Get("repository").(string) - owner := meta.(*Owner).name + repoName := d.Get("repository").(string) - useDefault := d.Get("use_default").(bool) - includeClaimKeys, hasClaimKeys := d.GetOk("include_claim_keys") + template, err := expandRepositoryOIDCSubjectClaimCustomTemplate(d) + if err != nil { + return diag.FromErr(err) + } - if useDefault && hasClaimKeys { - return errors.New("include_claim_keys cannot be set when use_default is true") + tflog.Debug(ctx, "Creating repository OIDC subject claim customization template", map[string]any{"repository": repoName}) + + if _, err := client.Actions.SetRepoOIDCSubjectClaimCustomTemplate(ctx, owner, repoName, template); err != nil { + return diag.FromErr(err) + } + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return diag.FromErr(err) } - customOIDCSubjectClaimTemplate := github.OIDCSubjectClaimCustomTemplate{ - UseDefault: &useDefault, + d.SetId(repoName) + + if err := d.Set("repository_id", int(repo.GetID())); err != nil { + return diag.FromErr(err) } - if includeClaimKeys != nil { + return nil +} - includeClaimKeysVal := includeClaimKeys.([]any) +func resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name - claimsStr := make([]string, len(includeClaimKeysVal)) + repoName := d.Get("repository").(string) - for i, v := range includeClaimKeysVal { - claimsStr[i] = v.(string) + template, _, err := client.Actions.GetRepoOIDCSubjectClaimCustomTemplate(ctx, owner, repoName) + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Removing repository OIDC subject claim customization template from state because it no longer exists in GitHub", map[string]any{"repository": repoName}) + d.SetId("") + return nil } - - customOIDCSubjectClaimTemplate.IncludeClaimKeys = claimsStr + return diag.FromErr(err) } - ctx := context.Background() - _, err := client.Actions.SetRepoOIDCSubjectClaimCustomTemplate(ctx, owner, repository, customOIDCSubjectClaimTemplate) - if err != nil { - return err + if err := d.Set("use_default", template.UseDefault); err != nil { + return diag.FromErr(err) + } + if err := d.Set("include_claim_keys", template.IncludeClaimKeys); err != nil { + return diag.FromErr(err) } - d.SetId(repository) - return resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateRead(d, meta) + return nil } -func resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateRead(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client +func resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name - repository := d.Id() - owner := meta.(*Owner).name + repoName := d.Get("repository").(string) - ctx := context.Background() - template, _, err := client.Actions.GetRepoOIDCSubjectClaimCustomTemplate(ctx, owner, repository) + template, err := expandRepositoryOIDCSubjectClaimCustomTemplate(d) if err != nil { - return deleteResourceOn404AndSwallow304OtherwiseReturnError(err, d, "actions repository oidc subject claim customization template (%s, %s)", owner, repository) + return diag.FromErr(err) } - if err = d.Set("repository", repository); err != nil { - return err - } - if err = d.Set("use_default", template.UseDefault); err != nil { - return err - } - if err = d.Set("include_claim_keys", template.IncludeClaimKeys); err != nil { - return err + tflog.Debug(ctx, "Updating repository OIDC subject claim customization template", map[string]any{"repository": repoName}) + + if _, err := client.Actions.SetRepoOIDCSubjectClaimCustomTemplate(ctx, owner, repoName, template); err != nil { + return diag.FromErr(err) } + d.SetId(repoName) + return nil } -func resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateDelete(d *schema.ResourceData, meta any) error { - // Reset the repository to use the default claims - // https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#using-the-default-subject-claims - client := meta.(*Owner).v3client +func resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName := d.Get("repository").(string) - repository := d.Get("repository").(string) - owner := meta.(*Owner).name + tflog.Debug(ctx, "Resetting repository OIDC subject claim customization template to default", map[string]any{"repository": repoName}) - customOIDCSubjectClaimTemplate := github.OIDCSubjectClaimCustomTemplate{ + // Delete resets the repository to the default claims. + // https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#using-the-default-subject-claims + template := github.OIDCSubjectClaimCustomTemplate{ UseDefault: new(true), } - ctx := context.Background() - _, err := client.Actions.SetRepoOIDCSubjectClaimCustomTemplate(ctx, owner, repository, customOIDCSubjectClaimTemplate) - if err != nil { - return err + if _, err := client.Actions.SetRepoOIDCSubjectClaimCustomTemplate(ctx, owner, repoName, template); err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Repository no longer exists, nothing to reset", map[string]any{"repository": repoName}) + return nil + } + return diag.FromErr(err) } return nil } + +func resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName := d.Id() + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, err + } + + if err := d.Set("repository", repo.GetName()); err != nil { + return nil, err + } + if err := d.Set("repository_id", int(repo.GetID())); err != nil { + return nil, err + } + d.SetId(repo.GetName()) + + return []*schema.ResourceData{d}, nil +} + +func expandRepositoryOIDCSubjectClaimCustomTemplate(d *schema.ResourceData) (github.OIDCSubjectClaimCustomTemplate, error) { + useDefault := d.Get("use_default").(bool) + includeClaimKeys, hasClaimKeys := d.GetOk("include_claim_keys") + + if useDefault && hasClaimKeys { + return github.OIDCSubjectClaimCustomTemplate{}, errors.New("include_claim_keys cannot be set when use_default is true") + } + + template := github.OIDCSubjectClaimCustomTemplate{ + UseDefault: new(useDefault), + } + + if hasClaimKeys { + template.IncludeClaimKeys = expandStringList(includeClaimKeys.([]any)) + } + + return template, nil +} diff --git a/github/resource_github_actions_repository_oidc_subject_claim_customization_template_migration.go b/github/resource_github_actions_repository_oidc_subject_claim_customization_template_migration.go new file mode 100644 index 0000000000..511fe8776e --- /dev/null +++ b/github/resource_github_actions_repository_oidc_subject_claim_customization_template_migration.go @@ -0,0 +1,52 @@ +package github + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateResourceV0() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + Description: "The name of the repository.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringLenBetween(1, 100)), + }, + "use_default": { + Type: schema.TypeBool, + Required: true, + Description: "Whether to use the default template or not. If 'true', 'include_claim_keys' must not be set.", + }, + "include_claim_keys": { + Type: schema.TypeList, + Optional: true, + MinItems: 1, + Description: "A list of OpenID Connect claims.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + } +} + +func resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateInstanceStateUpgradeV0(ctx context.Context, rawState map[string]any, m any) (map[string]any, error) { + tflog.Debug(ctx, "Migrating repository OIDC subject claim customization template from v0 to v1.", rawState) + + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + migratedState, err := migrateRepositoryWithID(ctx, client, owner, rawState) + if err != nil { + return nil, err + } + + tflog.Debug(ctx, "Migrated repository OIDC subject claim customization template to v1.", migratedState) + return migratedState, nil +} diff --git a/github/resource_github_actions_repository_oidc_subject_claim_customization_template_migration_test.go b/github/resource_github_actions_repository_oidc_subject_claim_customization_template_migration_test.go new file mode 100644 index 0000000000..e77641606a --- /dev/null +++ b/github/resource_github_actions_repository_oidc_subject_claim_customization_template_migration_test.go @@ -0,0 +1,90 @@ +package github + +import ( + "regexp" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-github/v92/github" +) + +func Test_resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateInstanceStateUpgradeV0(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + testName string + statusCode int + body *github.Repository + rawState map[string]any + want map[string]any + wantErr *string + }{ + { + testName: "adds_repository_id", + statusCode: 200, + body: &github.Repository{ + ID: new(int64(1234567890)), + Name: new("test-repo"), + }, + rawState: map[string]any{ + "id": "test-repo", + "repository": "test-repo", + "use_default": false, + "include_claim_keys": []any{"repo", "context"}, + }, + want: map[string]any{ + "id": "test-repo", + "repository": "test-repo", + "repository_id": 1234567890, + "use_default": false, + "include_claim_keys": []any{"repo", "context"}, + }, + }, + { + testName: "fails_if_repo_not_found", + statusCode: 404, + body: nil, + rawState: map[string]any{ + "id": "test-repo", + "repository": "test-repo", + }, + wantErr: new("failed to retrieve repository"), + }, + { + testName: "fails_if_repository_missing", + statusCode: 404, + body: nil, + rawState: map[string]any{ + "id": "test-repo", + }, + wantErr: new("repository name not found in state"), + }, + } { + t.Run(tt.testName, func(t *testing.T) { + t.Parallel() + + ts := githubApiMock([]*mockResponse{mustGetTestMockResponse(t, "/repos/test-org/test-repo", tt.statusCode, tt.body)}) + defer ts.Close() + meta := &Owner{name: "test-org", v3client: mustCreateTestGitHubClient(t, ts.URL)} + + got, err := resourceGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplateInstanceStateUpgradeV0(t.Context(), tt.rawState, meta) + if err != nil { + if tt.wantErr == nil { + t.Fatalf("unexpected error: %s", err) + } + if !regexp.MustCompile(regexp.QuoteMeta(*tt.wantErr)).MatchString(err.Error()) { + t.Fatalf("unexpected error: %s", err) + } + return + } + + if tt.wantErr != nil { + t.Fatalf("expected error: %s", *tt.wantErr) + } + + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Fatalf("got %+v, want %+v, diff %s", got, tt.want, diff) + } + }) + } +} diff --git a/github/resource_github_actions_repository_oidc_subject_claim_customization_template_test.go b/github/resource_github_actions_repository_oidc_subject_claim_customization_template_test.go index 987a4ae7be..c0e3327c6e 100644 --- a/github/resource_github_actions_repository_oidc_subject_claim_customization_template_test.go +++ b/github/resource_github_actions_repository_oidc_subject_claim_customization_template_test.go @@ -6,6 +6,10 @@ import ( "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/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) func TestAccGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplate(t *testing.T) { @@ -236,4 +240,80 @@ func TestAccGithubActionsRepositoryOIDCSubjectClaimCustomizationTemplate(t *test }, }) }) + + t.Run("updates_renamed_repo", func(t *testing.T) { + t.Parallel() + skipUnauthenticated(t) + + repo := mustCreateTestRepository(t) + newRepoName := fmt.Sprintf("%s-updated", repo.GetName()) + + config := ` +resource "github_actions_repository_oidc_subject_claim_customization_template" "test" { + repository = "%s" + use_default = false + include_claim_keys = ["repo", "context"] +} +` + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repo.GetName()), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_actions_repository_oidc_subject_claim_customization_template.test", tfjsonpath.New("repository_id"), knownvalue.Int64Exact(repo.GetID())), + }, + }, + { + PreConfig: func() { + mustRenameTestRepository(t, repo, newRepoName) + }, + Config: fmt.Sprintf(config, newRepoName), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_actions_repository_oidc_subject_claim_customization_template.test", plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_actions_repository_oidc_subject_claim_customization_template.test", tfjsonpath.New("repository"), knownvalue.StringExact(newRepoName)), + statecheck.ExpectKnownValue("github_actions_repository_oidc_subject_claim_customization_template.test", tfjsonpath.New("repository_id"), knownvalue.Int64Exact(repo.GetID())), + }, + }, + }, + }) + }) + + t.Run("recreates_changed_repo", func(t *testing.T) { + t.Parallel() + skipUnauthenticated(t) + + repo := mustCreateTestRepository(t) + repo2 := mustCreateTestRepository(t) + + config := ` +resource "github_actions_repository_oidc_subject_claim_customization_template" "test" { + repository = "%s" + use_default = false + include_claim_keys = ["repo", "context"] +} +` + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repo.GetName()), + }, + { + Config: fmt.Sprintf(config, repo2.GetName()), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_actions_repository_oidc_subject_claim_customization_template.test", plancheck.ResourceActionReplace), + }, + }, + }, + }, + }) + }) } diff --git a/templates/resources/actions_repository_oidc_subject_claim_customization_template.md.tmpl b/templates/resources/actions_repository_oidc_subject_claim_customization_template.md.tmpl index f66796c378..416e51e519 100644 --- a/templates/resources/actions_repository_oidc_subject_claim_customization_template.md.tmpl +++ b/templates/resources/actions_repository_oidc_subject_claim_customization_template.md.tmpl @@ -26,9 +26,14 @@ The following table lists the behaviour of `use_default`: The following arguments are supported: +- `repository` - (Required) The name of the repository. - `use_default` - (Required) Whether to use the default template or not. If `true`, `include_claim_keys` must not be set. - `include_claim_keys` - (Optional) A list of OpenID Connect claims. +## Attributes Reference + +- `repository_id` - The ID of the repository. + ## Import This resource can be imported using the repository's name.