diff --git a/github/data_source_github_actions_environment_secrets.go b/github/data_source_github_actions_environment_secrets.go index 220a96b279..9877756823 100644 --- a/github/data_source_github_actions_environment_secrets.go +++ b/github/data_source_github_actions_environment_secrets.go @@ -107,11 +107,11 @@ func dataSourceGithubActionsEnvironmentSecretsRead(ctx context.Context, d *schem options.Page = resp.NextPage } - if id, err := buildID(repoName, escapeIDPart(envName)); err != nil { + id, err := buildID(repoName, escapeIDPart(envName)) + if err != nil { return diag.FromErr(err) - } else { - d.SetId(id) } + d.SetId(id) if err := d.Set("secrets", all_secrets); err != nil { return diag.FromErr(err) diff --git a/github/data_source_github_actions_environment_variables.go b/github/data_source_github_actions_environment_variables.go index e2c510df67..6accf9bf64 100644 --- a/github/data_source_github_actions_environment_variables.go +++ b/github/data_source_github_actions_environment_variables.go @@ -107,11 +107,11 @@ func dataSourceGithubActionsEnvironmentVariablesRead(ctx context.Context, d *sch options.Page = resp.NextPage } - if id, err := buildID(repoName, escapeIDPart(envName)); err != nil { + id, err := buildID(repoName, escapeIDPart(envName)) + if err != nil { return diag.FromErr(err) - } else { - d.SetId(id) } + d.SetId(id) if err := d.Set("variables", all_variables); err != nil { return diag.FromErr(err) diff --git a/github/data_source_github_repository_environment_deployment_policies.go b/github/data_source_github_repository_environment_deployment_policies.go index 2d26e2bbd5..ca801d97fc 100644 --- a/github/data_source_github_repository_environment_deployment_policies.go +++ b/github/data_source_github_repository_environment_deployment_policies.go @@ -65,11 +65,11 @@ func dataSourceGithubRepositoryEnvironmentDeploymentPoliciesRead(ctx context.Con results = append(results, policyMap) } - if id, err := buildID(repoName, escapeIDPart(envName)); err != nil { + id, err := buildID(repoName, escapeIDPart(envName)) + if err != nil { return diag.FromErr(err) - } else { - d.SetId(id) } + d.SetId(id) if err = d.Set("policies", results); err != nil { return diag.FromErr(err) diff --git a/github/resource_github_actions_environment_secret.go b/github/resource_github_actions_environment_secret.go index 593ebcb9b2..cc4b85930c 100644 --- a/github/resource_github_actions_environment_secret.go +++ b/github/resource_github_actions_environment_secret.go @@ -10,23 +10,33 @@ import ( "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) func resourceGithubActionsEnvironmentSecret() *schema.Resource { return &schema.Resource{ - CreateContext: resourceGithubActionsEnvironmentSecretCreateOrUpdate, - ReadContext: resourceGithubActionsEnvironmentSecretRead, - DeleteContext: resourceGithubActionsEnvironmentSecretDelete, + SchemaVersion: 1, + StateUpgraders: []schema.StateUpgrader{ + { + Type: resourceGithubActionsEnvironmentSecretV0().CoreConfigSchema().ImpliedType(), + Upgrade: resourceGithubActionsEnvironmentSecretStateUpgradeV0, + Version: 0, + }, + }, Schema: map[string]*schema.Schema{ "repository": { Type: schema.TypeString, Required: true, - ForceNew: true, Description: "Name of the repository.", }, + "repository_id": { + Type: schema.TypeInt, + Computed: true, + Description: "ID of the repository.", + }, "environment": { Type: schema.TypeString, Required: true, @@ -40,22 +50,26 @@ func resourceGithubActionsEnvironmentSecret() *schema.Resource { Description: "Name of the secret.", ValidateDiagFunc: validateSecretNameFunc, }, + "key_id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ConflictsWith: []string{"plaintext_value"}, + Description: "ID of the public key used to encrypt the secret.", + }, "encrypted_value": { Type: schema.TypeString, Optional: true, - ForceNew: true, - Sensitive: true, + ExactlyOneOf: []string{"encrypted_value", "plaintext_value"}, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsBase64), Description: "Encrypted value of the secret using the GitHub public key in Base64 format.", - ConflictsWith: []string{"plaintext_value"}, - ValidateDiagFunc: toDiagFunc(validation.StringIsBase64, "encrypted_value"), }, "plaintext_value": { - Type: schema.TypeString, - Optional: true, - ForceNew: true, - Sensitive: true, - Description: "Plaintext value of the secret to be encrypted.", - ConflictsWith: []string{"encrypted_value"}, + Type: schema.TypeString, + Optional: true, + Sensitive: true, + ExactlyOneOf: []string{"encrypted_value", "plaintext_value"}, + Description: "Plaintext value of the secret to be encrypted.", }, "created_at": { Type: schema.TypeString, @@ -67,33 +81,61 @@ func resourceGithubActionsEnvironmentSecret() *schema.Resource { Computed: true, Description: "Date of 'actions_environment_secret' update.", }, + "remote_updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of remote 'actions_environment_secret' update.", + }, + }, + + CustomizeDiff: customdiff.All( + diffRepository, + diffSecret, + ), + + CreateContext: resourceGithubActionsEnvironmentSecretCreate, + ReadContext: resourceGithubActionsEnvironmentSecretRead, + UpdateContext: resourceGithubActionsEnvironmentSecretUpdate, + DeleteContext: resourceGithubActionsEnvironmentSecretDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubActionsEnvironmentSecretImport, }, } } -func resourceGithubActionsEnvironmentSecretCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name +func resourceGithubActionsEnvironmentSecretCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name repoName := d.Get("repository").(string) envName := d.Get("environment").(string) secretName := d.Get("secret_name").(string) - plaintextValue := d.Get("plaintext_value").(string) - var encryptedValue string + keyID := d.Get("key_id").(string) + encryptedValue := d.Get("encrypted_value").(string) + + escapedEnvName := url.PathEscape(envName) repo, _, err := client.Repositories.Get(ctx, owner, repoName) if err != nil { return diag.FromErr(err) } + repoID := int(repo.GetID()) - keyId, publicKey, err := getEnvironmentPublicKeyDetails(ctx, repo.GetID(), url.PathEscape(envName), meta) - if err != nil { - return diag.FromErr(err) + var publicKey string + if len(keyID) == 0 || len(encryptedValue) == 0 { + ki, pk, err := getEnvironmentPublicKeyDetails(ctx, meta, repoID, escapedEnvName) + if err != nil { + return diag.FromErr(err) + } + + keyID = ki + publicKey = pk } - if encryptedText, ok := d.GetOk("encrypted_value"); ok { - encryptedValue = encryptedText.(string) - } else { + if len(encryptedValue) == 0 { + plaintextValue := d.Get("plaintext_value").(string) + encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) if err != nil { return diag.FromErr(err) @@ -101,14 +143,13 @@ func resourceGithubActionsEnvironmentSecretCreateOrUpdate(ctx context.Context, d encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) } - // Create an EncryptedSecret and encrypt the plaintext value into it - eSecret := &github.EncryptedSecret{ + secret := github.EncryptedSecret{ Name: secretName, - KeyID: keyId, + KeyID: keyID, EncryptedValue: encryptedValue, } - _, err = client.Actions.CreateOrUpdateEnvSecret(ctx, int(repo.GetID()), url.PathEscape(envName), eSecret) + _, err = client.Actions.CreateOrUpdateEnvSecret(ctx, repoID, escapedEnvName, &secret) if err != nil { return diag.FromErr(err) } @@ -116,31 +157,47 @@ func resourceGithubActionsEnvironmentSecretCreateOrUpdate(ctx context.Context, d id, err := buildID(repoName, escapeIDPart(envName), secretName) if err != nil { return diag.FromErr(err) - } else { - d.SetId(id) } + d.SetId(id) - return resourceGithubActionsEnvironmentSecretRead(ctx, d, meta) -} - -func resourceGithubActionsEnvironmentSecretRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - - repoName, envNamePart, secretName, err := parseID3(d.Id()) - if err != nil { + if err := d.Set("repository_id", repoID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("key_id", keyID); err != nil { return diag.FromErr(err) } - envName := unescapeIDPart(envNamePart) + // GitHub API does not return on create so we have to lookup the secret to get timestamps + if secret, _, err := client.Actions.GetEnvSecret(ctx, repoID, escapedEnvName, secretName); err == nil { + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } - repo, _, err := client.Repositories.Get(ctx, owner, repoName) + return nil +} + +func resourceGithubActionsEnvironmentSecretRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + + repoName := d.Get("repository").(string) + repoID := d.Get("repository_id").(int) + envName := d.Get("environment").(string) + secretName := d.Get("secret_name").(string) + + secret, _, err := client.Actions.GetEnvSecret(ctx, repoID, url.PathEscape(envName), secretName) if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) { if ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[INFO] Removing environment secret %s from state because it no longer exists in GitHub", - d.Id()) + log.Printf("[INFO] Removing environment secret %s from state because it no longer exists in GitHub", d.Id()) d.SetId("") return nil } @@ -148,49 +205,102 @@ func resourceGithubActionsEnvironmentSecretRead(ctx context.Context, d *schema.R return diag.FromErr(err) } - secret, _, err := client.Actions.GetEnvSecret(ctx, int(repo.GetID()), url.PathEscape(envName), secretName) + id, err := buildID(repoName, escapeIDPart(envName), secretName) if err != nil { - var ghErr *github.ErrorResponse - if errors.As(err, &ghErr) { - if ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[INFO] Removing environment secret %s from state because it no longer exists in GitHub", - d.Id()) - d.SetId("") - return nil - } + return diag.FromErr(err) + } + d.SetId(id) + + // Due to the eventually consistent behavior of this API we may not get created_at/updated_at + // values on the first read after creation, so we only set them here if they are not already set. + if len(d.Get("created_at").(string)) == 0 { + if err = d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) } + } + if len(d.Get("updated_at").(string)) == 0 { + if err = d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } + if err = d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { return diag.FromErr(err) } - if err = d.Set("encrypted_value", d.Get("encrypted_value")); err != nil { + return nil +} + +func resourceGithubActionsEnvironmentSecretUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + + repoName := d.Get("repository").(string) + repoID := d.Get("repository_id").(int) + envName := d.Get("environment").(string) + secretName := d.Get("secret_name").(string) + keyID := d.Get("key_id").(string) + encryptedValue := d.Get("encrypted_value").(string) + + escapedEnvName := url.PathEscape(envName) + + var publicKey string + if len(keyID) == 0 || len(encryptedValue) == 0 { + ki, pk, err := getEnvironmentPublicKeyDetails(ctx, meta, repoID, escapedEnvName) + if err != nil { + return diag.FromErr(err) + } + + keyID = ki + publicKey = pk + } + + if len(encryptedValue) == 0 { + plaintextValue := d.Get("plaintext_value").(string) + + encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) + if err != nil { + return diag.FromErr(err) + } + encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) + } + + secret := github.EncryptedSecret{ + Name: secretName, + KeyID: keyID, + EncryptedValue: encryptedValue, + } + + _, err := client.Actions.CreateOrUpdateEnvSecret(ctx, repoID, escapedEnvName, &secret) + if err != nil { return diag.FromErr(err) } - if err = d.Set("plaintext_value", d.Get("plaintext_value")); err != nil { + + id, err := buildID(repoName, escapeIDPart(envName), secretName) + if err != nil { return diag.FromErr(err) } - if err = d.Set("created_at", secret.CreatedAt.String()); err != nil { + d.SetId(id) + + if err := d.Set("key_id", keyID); err != nil { return diag.FromErr(err) } - // This is a drift detection mechanism based on timestamps. - // - // If we do not currently store the "updated_at" field, it means we've only - // just created the resource and the value is most likely what we want it to - // be. - // - // If the resource is changed externally in the meantime then reading back - // the last update timestamp will return a result different than the - // timestamp we've persisted in the state. In this case, we can no longer - // trust that the value matches what is in the state file. - // - // To solve this, we must unset the values and allow Terraform to decide whether or - // not this resource should be modified or left as-is (ignore_changes). - if updatedAt, ok := d.GetOk("updated_at"); ok && updatedAt != secret.UpdatedAt.String() { - log.Printf("[INFO] The environment secret %s has been externally updated in GitHub", d.Id()) - _ = d.Set("encrypted_value", "") - _ = d.Set("plaintext_value", "") - } else if !ok { - if err = d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + // GitHub API does not return on update so we have to lookup the secret to get timestamps + if secret, _, err := client.Actions.GetEnvSecret(ctx, repoID, escapedEnvName, secretName); err == nil { + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } else { + if err := d.Set("updated_at", nil); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", nil); err != nil { return diag.FromErr(err) } } @@ -198,34 +308,75 @@ func resourceGithubActionsEnvironmentSecretRead(ctx context.Context, d *schema.R return nil } -func resourceGithubActionsEnvironmentSecretDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name +func resourceGithubActionsEnvironmentSecretDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client - repoName, envNamePart, secretName, err := parseID3(d.Id()) + repoID := d.Get("repository_id").(int) + envName := d.Get("environment").(string) + secretName := d.Get("secret_name").(string) + + log.Printf("[INFO] Deleting actions environment secret: %s", d.Id()) + _, err := client.Actions.DeleteEnvSecret(ctx, repoID, url.PathEscape(envName), secretName) if err != nil { return diag.FromErr(err) } + return nil +} + +func resourceGithubActionsEnvironmentSecretImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName, envNamePart, secretName, err := parseID3(d.Id()) + if err != nil { + return nil, err + } + envName := unescapeIDPart(envNamePart) repo, _, err := client.Repositories.Get(ctx, owner, repoName) if err != nil { - return diag.FromErr(err) + return nil, err } - log.Printf("[INFO] Deleting environment secret: %s", d.Id()) - _, err = client.Actions.DeleteEnvSecret(ctx, int(repo.GetID()), url.PathEscape(envName), secretName) + repoID := int(repo.GetID()) + + secret, _, err := client.Actions.GetEnvSecret(ctx, repoID, url.PathEscape(envName), secretName) if err != nil { - return diag.FromErr(err) + return nil, err } - return nil + if err := d.Set("repository", repoName); err != nil { + return nil, err + } + if err := d.Set("repository_id", repoID); err != nil { + return nil, err + } + if err := d.Set("environment", envName); err != nil { + return nil, err + } + if err := d.Set("secret_name", secretName); err != nil { + return nil, err + } + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return nil, err + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return nil, err + } + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil } -func getEnvironmentPublicKeyDetails(ctx context.Context, repoID int64, envNameEscaped string, meta any) (string, string, error) { - client := meta.(*Owner).v3client +func getEnvironmentPublicKeyDetails(ctx context.Context, meta *Owner, repoID int, envNameEscaped string) (string, string, error) { + client := meta.v3client - publicKey, _, err := client.Actions.GetEnvPublicKey(ctx, int(repoID), envNameEscaped) + publicKey, _, err := client.Actions.GetEnvPublicKey(ctx, repoID, envNameEscaped) if err != nil { return "", "", err } diff --git a/github/resource_github_actions_environment_secret_migration.go b/github/resource_github_actions_environment_secret_migration.go new file mode 100644 index 0000000000..bedebc9e36 --- /dev/null +++ b/github/resource_github_actions_environment_secret_migration.go @@ -0,0 +1,103 @@ +package github + +import ( + "context" + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceGithubActionsEnvironmentSecretV0() *schema.Resource { + return &schema.Resource{ + SchemaVersion: 0, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + Description: "Name of the repository.", + }, + "environment": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the environment.", + }, + "secret_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the secret.", + ValidateDiagFunc: validateSecretNameFunc, + }, + "encrypted_value": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "Encrypted value of the secret using the GitHub public key in Base64 format.", + ConflictsWith: []string{"plaintext_value"}, + ValidateDiagFunc: toDiagFunc(validation.StringIsBase64, "encrypted_value"), + }, + "plaintext_value": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + Description: "Plaintext value of the secret to be encrypted.", + ConflictsWith: []string{"encrypted_value"}, + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'actions_environment_secret' creation.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'actions_environment_secret' update.", + }, + }, + } +} + +func resourceGithubActionsEnvironmentSecretStateUpgradeV0(ctx context.Context, rawState map[string]any, m any) (map[string]any, error) { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + log.Printf("[DEBUG] GitHub Actions Environment Secret Attributes before migration: %#v", rawState) + + repoName, ok := rawState["repository"].(string) + if !ok { + return nil, fmt.Errorf("repository not found or is not a string") + } + + envName, ok := rawState["environment"].(string) + if !ok { + return nil, fmt.Errorf("environment not found or is not a string") + } + + secretName, ok := rawState["secret_name"].(string) + if !ok { + return nil, fmt.Errorf("secret_name not found or is not a string") + } + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve repository %s: %w", repoName, err) + } + + repoID := int(repo.GetID()) + + id, err := buildID(repoName, escapeIDPart(envName), secretName) + if err != nil { + return nil, fmt.Errorf("failed to build id for repository %s, environment %s, secret %s: %w", repoName, envName, secretName, err) + } + rawState["id"] = id + rawState["repository_id"] = repoID + + log.Printf("[DEBUG] GitHub Actions Environment Secret Attributes after migration: %#v", rawState) + + return rawState, nil +} diff --git a/github/resource_github_actions_environment_secret_migration_test.go b/github/resource_github_actions_environment_secret_migration_test.go new file mode 100644 index 0000000000..830edcc4ca --- /dev/null +++ b/github/resource_github_actions_environment_secret_migration_test.go @@ -0,0 +1,53 @@ +package github + +// TODO: Enable this test once we have a pattern to create a mock client for the test. + +// import ( +// "context" +// "reflect" +// "testing" +// ) + +// func Test_resourceGithubActionsEnvironmentSecretStateUpgradeV0(t *testing.T) { +// t.Parallel() + +// for _, d := range []struct { +// testName string +// rawState map[string]any +// want map[string]any +// shouldError bool +// }{ +// { +// testName: "migrates v0 to v1", +// rawState: map[string]any{ +// "id": "my-repo:my-environment:MY_SECRET", +// "repository": "my-repo", +// "environment": "my-environment", +// "secret_name": "MY_SECRET", +// "value": "my-value", +// }, +// want: map[string]any{ +// "id": "my-repo:my-environment:MY_SECRET", +// "repository": "my-repo", +// "repository_id": 123456, +// "environment": "my-environment", +// "secret_name": "MY_SECRET", +// "value": "my-value", +// }, +// shouldError: false, +// }, +// } { +// t.Run(d.testName, func(t *testing.T) { +// t.Parallel() + +// got, err := resourceGithubActionsEnvironmentSecretStateUpgradeV0(context.Background(), d.rawState, nil) +// if (err != nil) != d.shouldError { +// t.Fatalf("unexpected error state") +// } + +// if !d.shouldError && !reflect.DeepEqual(got, d.want) { +// t.Fatalf("got %+v, want %+v", got, d.want) +// } +// }) +// } +// } diff --git a/github/resource_github_actions_environment_secret_test.go b/github/resource_github_actions_environment_secret_test.go index e96800d6f7..6b73af8a6c 100644 --- a/github/resource_github_actions_environment_secret_test.go +++ b/github/resource_github_actions_environment_secret_test.go @@ -1,82 +1,43 @@ package github import ( + "context" "encoding/base64" "fmt" - "strings" + "net/url" "testing" + "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) func TestAccGithubActionsEnvironmentSecret(t *testing.T) { - t.Run("creates and updates secrets without error", func(t *testing.T) { + t.Run("create_plaintext", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-env-secret-%s", testResourcePrefix, randomID) - secretValue := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) - updatedSecretValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + envName := "test" + secretName := "test" + value := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } - - resource "github_repository_environment" "test" { - repository = github_repository.test.name - environment = "environment / test" - } - - resource "github_actions_environment_secret" "plaintext_secret" { - repository = github_repository.test.name - environment = github_repository_environment.test.environment - secret_name = "test_plaintext_secret_name" - plaintext_value = "%s" - } - - resource "github_actions_environment_secret" "encrypted_secret" { - repository = github_repository.test.name - environment = github_repository_environment.test.environment - secret_name = "test_encrypted_secret_name" - encrypted_value = "%s" - } - `, repoName, secretValue, secretValue) - - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_environment_secret.plaintext_secret", "plaintext_value", - secretValue, - ), - resource.TestCheckResourceAttr( - "github_actions_environment_secret.encrypted_secret", "encrypted_value", - secretValue, - ), - resource.TestCheckResourceAttrSet( - "github_actions_environment_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_environment_secret.plaintext_secret", "updated_at", - ), - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_environment_secret.plaintext_secret", "plaintext_value", - updatedSecretValue, - ), - resource.TestCheckResourceAttr( - "github_actions_environment_secret.encrypted_secret", "encrypted_value", - updatedSecretValue, - ), - resource.TestCheckResourceAttrSet( - "github_actions_environment_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_environment_secret.plaintext_secret", "updated_at", - ), - ), - } +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" +} + +resource "github_actions_environment_secret" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + secret_name = "%s" + plaintext_value = "%s" +} +`, repoName, envName, secretName, value) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, @@ -84,179 +45,673 @@ func TestAccGithubActionsEnvironmentSecret(t *testing.T) { Steps: []resource.TestStep{ { Config: config, - Check: checks["before"], + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_environment_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "environment", envName), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_actions_environment_secret.test", "encrypted_value"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_with_env_name_id_separator_character", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + envName := "env:test" + secretName := "test" + value := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" +} + +resource "github_actions_environment_secret" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + secret_name = "%s" + plaintext_value = "%s" +} +`, repoName, envName, secretName, value) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_environment_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "environment", envName), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_actions_environment_secret.test", "encrypted_value"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_update_plaintext", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + envName := "test" + secretName := "test" + value := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + updatedValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + + config := ` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" +} + +resource "github_actions_environment_secret" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + secret_name = "%s" + plaintext_value = "%s" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName, envName, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_environment_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "environment", envName), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_actions_environment_secret.test", "encrypted_value"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, repoName, envName, secretName, updatedValue), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_environment_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "environment", envName), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "plaintext_value", updatedValue), + resource.TestCheckNoResourceAttr("github_actions_environment_secret.test", "encrypted_value"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_update_encrypted", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + envName := "test" + secretName := "test" + value := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + updatedValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + + config := ` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" +} + +resource "github_actions_environment_secret" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + secret_name = "%s" + encrypted_value = "%s" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName, envName, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_environment_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "environment", envName), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "secret_name", secretName), + resource.TestCheckNoResourceAttr("github_actions_environment_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "encrypted_value", value), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + ), }, { - Config: strings.Replace(config, - secretValue, - updatedSecretValue, 2), - Check: checks["after"], + Config: fmt.Sprintf(config, repoName, envName, secretName, updatedValue), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_environment_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "environment", envName), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "secret_name", secretName), + resource.TestCheckNoResourceAttr("github_actions_environment_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "encrypted_value", updatedValue), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + ), }, }, }) }) - t.Run("deletes secrets without error", func(t *testing.T) { + t.Run("create_update_encrypted_with_key", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-env-secret-%s", testResourcePrefix, randomID) - secretValue := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + envName := "test" + secretName := "test" + value := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + updatedValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + + config := ` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" +} + +data "github_actions_environment_public_key" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment +} + +resource "github_actions_environment_secret" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + key_id = data.github_actions_environment_public_key.test.key_id + secret_name = "%s" + encrypted_value = "%s" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName, envName, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_environment_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "environment", envName), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "secret_name", secretName), + resource.TestCheckNoResourceAttr("github_actions_environment_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "encrypted_value", value), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, repoName, envName, secretName, updatedValue), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_environment_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "environment", envName), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "secret_name", secretName), + resource.TestCheckNoResourceAttr("github_actions_environment_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_actions_environment_secret.test", "encrypted_value", updatedValue), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("update_on_drift", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + envName := "test" + secretName := "test" config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } - - resource "github_repository_environment" "test" { - repository = github_repository.test.name - environment = "environment / test" - } - - resource "github_actions_environment_secret" "plaintext_secret" { - repository = github_repository.test.name - environment = github_repository_environment.test.environment - secret_name = "test_plaintext_secret_name" - plaintext_value = "%s" - } - - resource "github_actions_environment_secret" "encrypted_secret" { - repository = github_repository.test.name - environment = github_repository_environment.test.environment - secret_name = "test_encrypted_secret_name" - encrypted_value = "%s" - } - `, repoName, secretValue, secretValue) +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" +} + +resource "github_actions_environment_secret" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + secret_name = "%s" + plaintext_value = "test" +} +`, repoName, envName, secretName) + var beforeCreatedAt string resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_actions_environment_secret.test"].Primary.Attributes["created_at"] + return nil + }, + ), }, { - Config: config, - Destroy: true, + PreConfig: func() { + meta, err := getTestMeta() + if err != nil { + t.Fatal(err.Error()) + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + escapedEnvName := url.PathEscape(envName) + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + t.Fatal(err.Error()) + } + repoID := int(repo.GetID()) + + keyID, _, err := getEnvironmentPublicKeyDetails(ctx, meta, repoID, escapedEnvName) + if err != nil { + t.Fatal(err.Error()) + } + + _, err = client.Actions.CreateOrUpdateEnvSecret(ctx, repoID, escapedEnvName, &github.EncryptedSecret{ + Name: secretName, + EncryptedValue: base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")), + KeyID: keyID, + }) + if err != nil { + t.Fatal(err.Error()) + } + }, + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_actions_environment_secret.test"].Primary.Attributes["created_at"] + + if afterCreatedAt != beforeCreatedAt { + return fmt.Errorf("expected resource to be updated, but created_at has been modified: %s", beforeCreatedAt) + } + return nil + }, + ), + }, + }, + }) + }) + + t.Run("lifecycle_can_ignore_drift", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + envName := "test" + secretName := "test" + + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" +} + +resource "github_actions_environment_secret" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + secret_name = "%s" + plaintext_value = "test" + + lifecycle { + ignore_changes = [remote_updated_at] + } +} +`, repoName, envName, secretName) + + var beforeUpdatedAt string + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + beforeUpdatedAt = s.RootModule().Resources["github_actions_environment_secret.test"].Primary.Attributes["updated_at"] + return nil + }, + ), + }, + { + PreConfig: func() { + meta, err := getTestMeta() + if err != nil { + t.Fatal(err.Error()) + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + escapedEnvName := url.PathEscape(envName) + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + t.Fatal(err.Error()) + } + repoID := int(repo.GetID()) + + keyID, _, err := getEnvironmentPublicKeyDetails(ctx, meta, repoID, escapedEnvName) + if err != nil { + t.Fatal(err.Error()) + } + + _, err = client.Actions.CreateOrUpdateEnvSecret(ctx, repoID, escapedEnvName, &github.EncryptedSecret{ + Name: secretName, + EncryptedValue: base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")), + KeyID: keyID, + }) + if err != nil { + t.Fatal(err.Error()) + } + }, + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + afterUpdatedAt := s.RootModule().Resources["github_actions_environment_secret.test"].Primary.Attributes["updated_at"] + + if afterUpdatedAt != beforeUpdatedAt { + return fmt.Errorf("expected resource to ignore drift, but updated_at has been modified: %s", beforeUpdatedAt) + } + return nil + }, + ), }, }, }) }) + + t.Run("update_renamed_repo", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + updatedRepoName := fmt.Sprintf("%s%s-updated", testResourcePrefix, randomID) + + // TODO: Remove lifecycle ignore_changes block when repo rename is supported + config := ` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "test" + + lifecycle { + ignore_changes = all + } } -func TestAccGithubActionsEnvironmentSecretIgnoreChanges(t *testing.T) { - randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-env-secret-ic-%s", testResourcePrefix, randomID) - - t.Run("creates environment secrets using lifecycle ignore_changes", func(t *testing.T) { - secretValue := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) - modifiedSecretValue := base64.StdEncoding.EncodeToString([]byte("a_modified_super_secret_value")) - - configFmtStr := ` - resource "github_repository" "test" { - name = "%s" - - # TODO: provider appears to have issues destroying repositories while running the tests. - # - # Even with Organization Admin an error is seen: - # Error: DELETE https://api./tf-acc-test-: "403 Must have admin rights to Repository. []" - # - # Workaround to using 'archive_on_destroy' instead. - archive_on_destroy = true - - visibility = "private" - } - - resource "github_repository_environment" "test" { - repository = github_repository.test.name - environment = "environment / test" - } - - resource "github_actions_environment_secret" "plaintext_secret" { - repository = github_repository.test.name - environment = github_repository_environment.test.environment - secret_name = "test_plaintext_secret_name" - plaintext_value = "%s" - - lifecycle { - ignore_changes = [plaintext_value] - } - } - - resource "github_actions_environment_secret" "encrypted_secret" { - repository = github_repository.test.name - environment = github_repository_environment.test.environment - secret_name = "test_encrypted_secret_name" - encrypted_value = "%s" - - lifecycle { - ignore_changes = [encrypted_value] - } - } - ` - - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_environment_secret.plaintext_secret", "plaintext_value", - secretValue, - ), - resource.TestCheckResourceAttr( - "github_actions_environment_secret.encrypted_secret", "encrypted_value", - secretValue, - ), - resource.TestCheckResourceAttrSet( - "github_actions_environment_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_environment_secret.plaintext_secret", "updated_at", - ), - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_environment_secret.plaintext_secret", "plaintext_value", - secretValue, - ), - resource.TestCheckResourceAttr( - "github_actions_environment_secret.encrypted_secret", "encrypted_value", - secretValue, - ), - resource.TestCheckResourceAttrSet( - "github_actions_environment_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_environment_secret.plaintext_secret", "updated_at", - ), - ), - } +resource "github_actions_environment_secret" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + secret_name = "test" + plaintext_value = "test" +} +` + var beforeCreatedAt string resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: fmt.Sprintf(configFmtStr, repoName, secretValue, secretValue), - Check: checks["before"], + Config: fmt.Sprintf(config, repoName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_actions_environment_secret.test"].Primary.Attributes["created_at"] + return nil + }, + ), }, { - Config: fmt.Sprintf(configFmtStr, repoName, secretValue, secretValue), - Check: checks["after"], + Config: fmt.Sprintf(config, updatedRepoName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_actions_environment_secret.test"].Primary.Attributes["created_at"] + + if afterCreatedAt != beforeCreatedAt { + return fmt.Errorf("expected resource to not be recreated, but created_at has been modified: %s", beforeCreatedAt) + } + return nil + }, + ), + }, + }, + }) + }) + + t.Run("recreate_changed_repo", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + repoName2 := fmt.Sprintf("%supdated-%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "test" +} + +resource "github_repository" "test2" { + name = "%s" +} + +resource "github_repository_environment" "test2" { + repository = github_repository.test2.name + environment = "test" +} + +resource "github_actions_environment_secret" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + secret_name = "test" + plaintext_value = "test" +} +`, repoName, repoName2) + + configUpdated := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "test" +} + +resource "github_repository" "test2" { + name = "%s" +} + +resource "github_repository_environment" "test2" { + repository = github_repository.test2.name + environment = "test" +} + +resource "github_actions_environment_secret" "test" { + repository = github_repository.test2.name + environment = github_repository_environment.test2.environment + secret_name = "test" + plaintext_value = "test" +} +`, repoName, repoName2) + + var beforeCreatedAt string + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_actions_environment_secret.test"].Primary.Attributes["created_at"] + return nil + }, + ), }, { - // In this case the values change in the config, but the lifecycle ignore_changes should - // not cause the actual values to be updated. This would also be the case when a secret - // is externally modified (when what is in state does not match what is given). - Config: fmt.Sprintf(configFmtStr, repoName, modifiedSecretValue, modifiedSecretValue), + Config: configUpdated, Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_environment_secret.plaintext_secret", "plaintext_value", - secretValue, // Should still have the original value in state. - ), - resource.TestCheckResourceAttr( - "github_actions_environment_secret.encrypted_secret", "encrypted_value", - secretValue, // Should still have the original value in state. - ), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_secret.test", "updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_actions_environment_secret.test"].Primary.Attributes["created_at"] + + if afterCreatedAt == beforeCreatedAt { + return fmt.Errorf("expected resource to be recreated, but created_at has not been modified: %s", beforeCreatedAt) + } + return nil + }, ), }, }, }) }) + + t.Run("destroy", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "%s" + } + + resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "test" + } + + resource "github_actions_environment_secret" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + secret_name = "test" + plaintext_value = "test" + } +`, repoName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + }, + { + Config: config, + Destroy: true, + }, + }, + }) + }) + + t.Run("import", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + envName := "test" + secretName := "test" + + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" +} + +resource "github_actions_environment_secret" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + secret_name = "%s" + plaintext_value = "test" +} +`, repoName, envName, secretName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + }, + { + ResourceName: "github_actions_environment_secret.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"key_id", "plaintext_value"}, + }, + }, + }) + }) } diff --git a/github/resource_github_actions_environment_variable.go b/github/resource_github_actions_environment_variable.go index 7005ad03cd..e2569dba16 100644 --- a/github/resource_github_actions_environment_variable.go +++ b/github/resource_github_actions_environment_variable.go @@ -14,12 +14,13 @@ import ( func resourceGithubActionsEnvironmentVariable() *schema.Resource { return &schema.Resource{ - CreateContext: resourceGithubActionsEnvironmentVariableCreateOrUpdate, - ReadContext: resourceGithubActionsEnvironmentVariableRead, - UpdateContext: resourceGithubActionsEnvironmentVariableCreateOrUpdate, - DeleteContext: resourceGithubActionsEnvironmentVariableDelete, - Importer: &schema.ResourceImporter{ - StateContext: resourceGithubActionsEnvironmentVariableImport, + SchemaVersion: 1, + StateUpgraders: []schema.StateUpgrader{ + { + Type: resourceGithubActionsEnvironmentVariableV0().CoreConfigSchema().ImpliedType(), + Upgrade: resourceGithubActionsEnvironmentVariableStateUpgradeV0, + Version: 0, + }, }, Schema: map[string]*schema.Schema{ @@ -28,6 +29,11 @@ func resourceGithubActionsEnvironmentVariable() *schema.Resource { Required: true, Description: "Name of the repository.", }, + "repository_id": { + Type: schema.TypeInt, + Computed: true, + Description: "ID of the repository.", + }, "environment": { Type: schema.TypeString, Required: true, @@ -57,65 +63,84 @@ func resourceGithubActionsEnvironmentVariable() *schema.Resource { Description: "Date of 'actions_variable' update.", }, }, + + CustomizeDiff: diffRepository, + + CreateContext: resourceGithubActionsEnvironmentVariableCreate, + ReadContext: resourceGithubActionsEnvironmentVariableRead, + UpdateContext: resourceGithubActionsEnvironmentVariableUpdate, + DeleteContext: resourceGithubActionsEnvironmentVariableDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubActionsEnvironmentVariableImport, + }, } } -func resourceGithubActionsEnvironmentVariableCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name +func resourceGithubActionsEnvironmentVariableCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name repoName := d.Get("repository").(string) envName := d.Get("environment").(string) - name := d.Get("variable_name").(string) + varName := d.Get("variable_name").(string) + + escapedEnvName := url.PathEscape(envName) - variable := &github.ActionsVariable{ - Name: name, + variable := github.ActionsVariable{ + Name: varName, Value: d.Get("value").(string), } - // Try to create the variable first - _, err := client.Actions.CreateEnvVariable(ctx, owner, repoName, url.PathEscape(envName), variable) + _, err := client.Actions.CreateEnvVariable(ctx, owner, repoName, escapedEnvName, &variable) if err != nil { - var ghErr *github.ErrorResponse - if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusConflict { - // Variable already exists, try to update instead - // If it fails here, we want to return the error otherwise continue - _, err = client.Actions.UpdateEnvVariable(ctx, owner, repoName, url.PathEscape(envName), variable) - if err != nil { - return diag.FromErr(err) - } - } else { - return diag.FromErr(err) - } + return diag.FromErr(err) } - if id, err := buildID(repoName, escapeIDPart(envName), name); err != nil { + id, err := buildID(repoName, escapeIDPart(envName), varName) + if err != nil { return diag.FromErr(err) - } else { - d.SetId(id) } + d.SetId(id) - return resourceGithubActionsEnvironmentVariableRead(ctx, d, meta) -} - -func resourceGithubActionsEnvironmentVariableRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - - repoName, envNamePart, name, err := parseID3(d.Id()) + repo, _, err := client.Repositories.Get(ctx, owner, repoName) if err != nil { return diag.FromErr(err) } + repoID := int(repo.GetID()) - envName := unescapeIDPart(envNamePart) + if err := d.Set("repository_id", repoID); err != nil { + return diag.FromErr(err) + } + + // GitHub API does not return on create so we have to lookup the variable to get timestamps + if variable, _, err := client.Actions.GetEnvVariable(ctx, owner, repoName, escapedEnvName, varName); err == nil { + if err := d.Set("created_at", variable.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", variable.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } - variable, _, err := client.Actions.GetEnvVariable(ctx, owner, repoName, url.PathEscape(envName), name) + return nil +} + +func resourceGithubActionsEnvironmentVariableRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName := d.Get("repository").(string) + envName := d.Get("environment").(string) + varName := d.Get("variable_name").(string) + + variable, _, err := client.Actions.GetEnvVariable(ctx, owner, repoName, url.PathEscape(envName), varName) if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) { if ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[INFO] Removing actions variable %s from state because it no longer exists in GitHub", - d.Id()) + log.Printf("[INFO] Removing actions variable %s from state because it no longer exists in GitHub", d.Id()) d.SetId("") return nil } @@ -123,28 +148,73 @@ func resourceGithubActionsEnvironmentVariableRead(ctx context.Context, d *schema return diag.FromErr(err) } - _ = d.Set("repository", repoName) - _ = d.Set("environment", envName) - _ = d.Set("variable_name", name) - _ = d.Set("value", variable.Value) - _ = d.Set("created_at", variable.CreatedAt.String()) - _ = d.Set("updated_at", variable.UpdatedAt.String()) + if err = d.Set("value", variable.Value); err != nil { + return diag.FromErr(err) + } + if err = d.Set("created_at", variable.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err = d.Set("updated_at", variable.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } return nil } -func resourceGithubActionsEnvironmentVariableDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name +func resourceGithubActionsEnvironmentVariableUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name - repoName, envNamePart, name, err := parseID3(d.Id()) + repoName := d.Get("repository").(string) + envName := d.Get("environment").(string) + varName := d.Get("variable_name").(string) + + escapedEnvName := url.PathEscape(envName) + + variable := github.ActionsVariable{ + Name: varName, + Value: d.Get("value").(string), + } + + _, err := client.Actions.UpdateEnvVariable(ctx, owner, repoName, escapedEnvName, &variable) if err != nil { return diag.FromErr(err) } - envName := unescapeIDPart(envNamePart) + id, err := buildID(repoName, escapeIDPart(envName), varName) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + // GitHub API does not return on create so we have to lookup the variable to get timestamps + if variable, _, err := client.Actions.GetEnvVariable(ctx, owner, repoName, escapedEnvName, varName); err == nil { + if err := d.Set("created_at", variable.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", variable.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } else { + if err := d.Set("updated_at", nil); err != nil { + return diag.FromErr(err) + } + } + + return nil +} - _, err = client.Actions.DeleteEnvVariable(ctx, owner, repoName, url.PathEscape(envName), name) +func resourceGithubActionsEnvironmentVariableDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName := d.Get("repository").(string) + envName := d.Get("environment").(string) + varName := d.Get("variable_name").(string) + + _, err := client.Actions.DeleteEnvVariable(ctx, owner, repoName, url.PathEscape(envName), varName) if err != nil { return diag.FromErr(err) } @@ -152,8 +222,25 @@ func resourceGithubActionsEnvironmentVariableDelete(ctx context.Context, d *sche return nil } -func resourceGithubActionsEnvironmentVariableImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { - repoName, envNamePart, name, err := parseID3(d.Id()) +func resourceGithubActionsEnvironmentVariableImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName, envNamePart, varName, err := parseID3(d.Id()) + if err != nil { + return nil, err + } + + envName := unescapeIDPart(envNamePart) + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, err + } + repoID := int(repo.GetID()) + + variable, _, err := client.Actions.GetEnvVariable(ctx, owner, repoName, url.PathEscape(envName), varName) if err != nil { return nil, err } @@ -161,10 +248,22 @@ func resourceGithubActionsEnvironmentVariableImport(ctx context.Context, d *sche if err := d.Set("repository", repoName); err != nil { return nil, err } - if err := d.Set("environment", unescapeIDPart(envNamePart)); err != nil { + if err := d.Set("repository_id", repoID); err != nil { + return nil, err + } + if err := d.Set("environment", envName); err != nil { + return nil, err + } + if err := d.Set("variable_name", varName); err != nil { + return nil, err + } + if err := d.Set("value", variable.Value); err != nil { + return nil, err + } + if err := d.Set("created_at", variable.CreatedAt.String()); err != nil { return nil, err } - if err := d.Set("variable_name", name); err != nil { + if err := d.Set("updated_at", variable.UpdatedAt.String()); err != nil { return nil, err } diff --git a/github/resource_github_actions_environment_variable_migration.go b/github/resource_github_actions_environment_variable_migration.go new file mode 100644 index 0000000000..cc205c34ed --- /dev/null +++ b/github/resource_github_actions_environment_variable_migration.go @@ -0,0 +1,92 @@ +package github + +import ( + "context" + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubActionsEnvironmentVariableV0() *schema.Resource { + return &schema.Resource{ + SchemaVersion: 0, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + Description: "Name of the repository.", + }, + "environment": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the environment.", + }, + "variable_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the variable.", + ValidateDiagFunc: validateSecretNameFunc, + }, + "value": { + Type: schema.TypeString, + Required: true, + Description: "Value of the variable.", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'actions_variable' creation.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'actions_variable' update.", + }, + }, + } +} + +func resourceGithubActionsEnvironmentVariableStateUpgradeV0(ctx context.Context, rawState map[string]any, m any) (map[string]any, error) { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + log.Printf("[DEBUG] GitHub Actions Environment Variable Attributes before migration: %#v", rawState) + + repoName, ok := rawState["repository"].(string) + if !ok { + return nil, fmt.Errorf("repository not found or is not a string") + } + + envName, ok := rawState["environment"].(string) + if !ok { + return nil, fmt.Errorf("environment not found or is not a string") + } + + varName, ok := rawState["variable_name"].(string) + if !ok { + return nil, fmt.Errorf("variable_name not found or is not a string") + } + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve repository %s: %w", repoName, err) + } + + repoID := int(repo.GetID()) + + id, err := buildID(repoName, escapeIDPart(envName), varName) + if err != nil { + return nil, fmt.Errorf("failed to build id for repository %s, environment %s, variable %s: %w", repoName, envName, varName, err) + } + rawState["id"] = id + rawState["repository_id"] = repoID + + log.Printf("[DEBUG] GitHub Actions Environment Variable Attributes after migration: %#v", rawState) + + return rawState, nil +} diff --git a/github/resource_github_actions_environment_variable_migration_test.go b/github/resource_github_actions_environment_variable_migration_test.go new file mode 100644 index 0000000000..6b43858ca3 --- /dev/null +++ b/github/resource_github_actions_environment_variable_migration_test.go @@ -0,0 +1,53 @@ +package github + +// TODO: Enable this test once we have a pattern to create a mock client for the test. + +// import ( +// "context" +// "reflect" +// "testing" +// ) + +// func Test_resourceGithubActionsEnvironmentVariableStateUpgradeV0(t *testing.T) { +// t.Parallel() + +// for _, d := range []struct { +// testName string +// rawState map[string]any +// want map[string]any +// shouldError bool +// }{ +// { +// testName: "migrates v0 to v1", +// rawState: map[string]any{ +// "id": "my-repo:my-environment:MY_VARIABLE", +// "repository": "my-repo", +// "environment": "my-environment", +// "variable_name": "MY_VARIABLE", +// "value": "my-value", +// }, +// want: map[string]any{ +// "id": "my-repo:my-environment:MY_VARIABLE", +// "repository": "my-repo", +// "repository_id": 123456, +// "environment": "my-environment", +// "variable_name": "MY_VARIABLE", +// "value": "my-value", +// }, +// shouldError: false, +// }, +// } { +// t.Run(d.testName, func(t *testing.T) { +// t.Parallel() + +// got, err := resourceGithubActionsEnvironmentVariableStateUpgradeV0(context.Background(), d.rawState, nil) +// if (err != nil) != d.shouldError { +// t.Fatalf("unexpected error state") +// } + +// if !d.shouldError && !reflect.DeepEqual(got, d.want) { +// t.Fatalf("got %+v, want %+v", got, d.want) +// } +// }) +// } +// } diff --git a/github/resource_github_actions_environment_variable_test.go b/github/resource_github_actions_environment_variable_test.go index 5aaa927203..cdfe4adda5 100644 --- a/github/resource_github_actions_environment_variable_test.go +++ b/github/resource_github_actions_environment_variable_test.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "net/url" - "strings" + "regexp" "testing" "github.com/google/go-github/v82/github" @@ -14,100 +14,336 @@ import ( ) func TestAccGithubActionsEnvironmentVariable(t *testing.T) { - t.Run("creates and updates environment variables without error", func(t *testing.T) { + t.Run("create", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-env-var-%s", testResourcePrefix, randomID) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + envName := "test" + varName := "test" + value := "my_variable_value" + + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" +} + +resource "github_actions_environment_variable" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + variable_name = "%s" + value = "%s" +} +`, repoName, envName, varName, value) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_environment_variable.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_environment_variable.test", "environment", envName), + resource.TestCheckResourceAttr("github_actions_environment_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_environment_variable.test", "value", value), + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_with_env_name_id_separator_character", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + envName := "env:test" + varName := "test" + value := "my_variable_value" + + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" +} + +resource "github_actions_environment_variable" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + variable_name = "%s" + value = "%s" +} +`, repoName, envName, varName, value) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_environment_variable.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_environment_variable.test", "environment", envName), + resource.TestCheckResourceAttr("github_actions_environment_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_environment_variable.test", "value", value), + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_update", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + envName := "test" + varName := "test" value := "my_variable_value" updatedValue := "my_updated_variable_value" + config := ` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" +} + +resource "github_actions_environment_variable" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + variable_name = "%s" + value = "%s" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName, envName, varName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_environment_variable.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_environment_variable.test", "environment", envName), + resource.TestCheckResourceAttr("github_actions_environment_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_environment_variable.test", "value", value), + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, repoName, envName, varName, updatedValue), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_environment_variable.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_environment_variable.test", "environment", envName), + resource.TestCheckResourceAttr("github_actions_environment_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_environment_variable.test", "value", updatedValue), + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("update_renamed_repo", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + updatedRepoName := fmt.Sprintf("%s%s-updated", testResourcePrefix, randomID) + + // TODO: Remove lifecycle ignore_changes block when repo rename is supported + config := ` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "test" + + lifecycle { + ignore_changes = all + } +} + +resource "github_actions_environment_variable" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + variable_name = "test" + value = "test" +} +` + + var beforeCreatedAt string + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_actions_environment_variable.test"].Primary.Attributes["created_at"] + return nil + }, + ), + }, + { + Config: fmt.Sprintf(config, updatedRepoName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_actions_environment_variable.test"].Primary.Attributes["created_at"] + + if afterCreatedAt != beforeCreatedAt { + return fmt.Errorf("expected resource to not be recreated, but created_at has been modified: %s", beforeCreatedAt) + } + return nil + }, + ), + }, + }, + }) + }) + + t.Run("recreate_changed_repo", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + repoName2 := fmt.Sprintf("%supdated-%s", testResourcePrefix, randomID) + config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } - - resource "github_repository_environment" "test" { - repository = github_repository.test.name - environment = "environment / test" - } - - resource "github_actions_environment_variable" "variable" { - repository = github_repository.test.name - environment = github_repository_environment.test.environment - variable_name = "test_variable" - value = "%s" - } - `, repoName, value) - - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_environment_variable.variable", "value", - value, - ), - resource.TestCheckResourceAttrSet( - "github_actions_environment_variable.variable", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_environment_variable.variable", "updated_at", - ), - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_environment_variable.variable", "value", - updatedValue, - ), - resource.TestCheckResourceAttrSet( - "github_actions_environment_variable.variable", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_environment_variable.variable", "updated_at", - ), - ), - } +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "test" +} + +resource "github_repository" "test2" { + name = "%s" +} + +resource "github_repository_environment" "test2" { + repository = github_repository.test2.name + environment = "test" +} + +resource "github_actions_environment_variable" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + variable_name = "test_variable" + value = "test" +} +`, repoName, repoName2) + + configUpdated := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "test" +} + +resource "github_repository" "test2" { + name = "%s" +} +resource "github_repository_environment" "test2" { + repository = github_repository.test2.name + environment = "test" +} + +resource "github_actions_environment_variable" "test" { + repository = github_repository.test2.name + environment = github_repository_environment.test2.environment + variable_name = "test_variable" + value = "test" +} +`, repoName, repoName2) + + var beforeCreatedAt string resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, - Check: checks["before"], + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_actions_environment_variable.test"].Primary.Attributes["created_at"] + return nil + }, + ), }, { - Config: strings.Replace(config, - value, - updatedValue, 1), - Check: checks["after"], + Config: configUpdated, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_environment_variable.test", "updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_actions_environment_variable.test"].Primary.Attributes["created_at"] + + if afterCreatedAt == beforeCreatedAt { + return fmt.Errorf("expected resource to be recreated, but created_at has not been modified: %s", beforeCreatedAt) + } + return nil + }, + ), }, }, }) }) - t.Run("deletes environment variables without error", func(t *testing.T) { + t.Run("destroy", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-env-var-%s", testResourcePrefix, randomID) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } - - resource "github_repository_environment" "test" { - repository = github_repository.test.name - environment = "environment / test" - } - - resource "github_actions_environment_variable" "variable" { - repository = github_repository.test.name - environment = github_repository_environment.test.environment - variable_name = "test_variable" - value = "my_variable_value" - } - `, repoName) +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "test" +} + +resource "github_actions_environment_variable" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + variable_name = "test_variable" + value = "my_variable_value" +} +`, repoName) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ + { + Config: config, + }, { Config: config, Destroy: true, @@ -116,30 +352,30 @@ func TestAccGithubActionsEnvironmentVariable(t *testing.T) { }) }) - t.Run("imports environment variables without error", func(t *testing.T) { + t.Run("import", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-env-var-%s", testResourcePrefix, randomID) - value := "my_variable_value" - envName := "environment / test" + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + envName := "test" varName := "test_variable" + value := "my_variable_value" config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } - - resource "github_repository_environment" "test" { - repository = github_repository.test.name - environment = "%s" - } - - resource "github_actions_environment_variable" "variable" { - repository = github_repository.test.name - environment = github_repository_environment.test.environment - variable_name = "%s" - value = "%s" - } - `, repoName, envName, varName, value) +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" +} + +resource "github_actions_environment_variable" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + variable_name = "%s" + value = "%s" +} +`, repoName, envName, varName, value) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, @@ -149,61 +385,49 @@ func TestAccGithubActionsEnvironmentVariable(t *testing.T) { Config: config, }, { - ResourceName: "github_actions_environment_variable.variable", + ResourceName: "github_actions_environment_variable.test", ImportState: true, ImportStateVerify: true, }, }, }) }) + + t.Run("error_on_existing", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + envName := "test" + varName := "test_variable" + + baseConfig := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" } -func TestAccGithubActionsEnvironmentVariable_alreadyExists(t *testing.T) { - randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-env-var-exist-%s", testResourcePrefix, randomID) - envName := "environment / test" - varName := "test_variable" - value := "my_variable_value" - - config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - vulnerability_alerts = true - } - - resource "github_repository_environment" "test" { - repository = github_repository.test.name - environment = "%s" - } - - resource "github_actions_environment_variable" "variable" { - repository = github_repository.test.name - environment = github_repository_environment.test.environment - variable_name = "%s" - value = "%s" - } - `, repoName, envName, varName, value) - - resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnauthenticated(t) }, - ProviderFactories: providerFactories, - Steps: []resource.TestStep{ - { - // First, create the repository and environment. - Config: fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - vulnerability_alerts = true - } +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" +} +`, repoName, envName) - resource "github_repository_environment" "test" { - repository = github_repository.test.name - environment = "%s" - } - `, repoName, envName), - Check: resource.ComposeTestCheckFunc( - func(s *terraform.State) error { - // Now that the repo and env are created, create the variable using the API. + config := fmt.Sprintf(` +%s + +resource "github_actions_environment_variable" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + variable_name = "%s" + value = "test" +} +`, baseConfig, varName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: baseConfig, + Check: func(*terraform.State) error { meta, err := getTestMeta() if err != nil { return err @@ -212,22 +436,30 @@ func TestAccGithubActionsEnvironmentVariable_alreadyExists(t *testing.T) { owner := meta.name ctx := context.Background() - variable := &github.ActionsVariable{ + _, err = client.Actions.CreateEnvVariable(ctx, owner, repoName, url.PathEscape(envName), &github.ActionsVariable{ Name: varName, - Value: value, + Value: "test", + }) + return err + }, + }, + { + Config: config, + ExpectError: regexp.MustCompile(`Variable already exists`), + Check: func(*terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err } - _, err = client.Actions.CreateEnvVariable(ctx, owner, repoName, url.PathEscape(envName), variable) + client := meta.v3client + owner := meta.name + ctx := context.Background() + + _, err = client.Actions.DeleteEnvVariable(ctx, owner, repoName, url.PathEscape(envName), varName) return err }, - ), - }, - { - // Now, run the full config. Terraform should detect the existing variable and "adopt" it. - Config: config, - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_actions_environment_variable.variable", "value", value), - ), + }, }, - }, + }) }) } diff --git a/github/resource_github_actions_organization_secret.go b/github/resource_github_actions_organization_secret.go index 17c5876ce2..22960944e9 100644 --- a/github/resource_github_actions_organization_secret.go +++ b/github/resource_github_actions_organization_secret.go @@ -9,34 +9,18 @@ import ( "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) func resourceGithubActionsOrganizationSecret() *schema.Resource { return &schema.Resource{ - CreateContext: resourceGithubActionsOrganizationSecretCreateOrUpdate, - ReadContext: resourceGithubActionsOrganizationSecretRead, - DeleteContext: resourceGithubActionsOrganizationSecretDelete, - Importer: &schema.ResourceImporter{ - StateContext: func(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { - if err := d.Set("secret_name", d.Id()); err != nil { - return nil, err - } - if err := d.Set("destroy_on_drift", true); err != nil { - return nil, err - } - return []*schema.ResourceData{d}, nil - }, - }, - - // Schema migration added in v6.7.1 to handle the addition of destroy_on_drift field - // Resources created before v6.7.0 need the field populated with default value SchemaVersion: 1, StateUpgraders: []schema.StateUpgrader{ { - Type: resourceGithubActionsOrganizationSecretResourceV0().CoreConfigSchema().ImpliedType(), - Upgrade: resourceGithubActionsOrganizationSecretInstanceStateUpgradeV0, + Type: resourceGithubActionsOrganizationSecretV0().CoreConfigSchema().ImpliedType(), + Upgrade: resourceGithubActionsOrganizationSecretStateUpgradeV0, Version: 0, }, }, @@ -49,94 +33,113 @@ func resourceGithubActionsOrganizationSecret() *schema.Resource { Description: "Name of the secret.", ValidateDiagFunc: validateSecretNameFunc, }, + "key_id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "ID of the public key used to encrypt the secret.", + ConflictsWith: []string{"plaintext_value"}, + }, "encrypted_value": { Type: schema.TypeString, - ForceNew: true, Optional: true, Sensitive: true, - ConflictsWith: []string{"plaintext_value"}, + ExactlyOneOf: []string{"encrypted_value", "plaintext_value"}, Description: "Encrypted value of the secret using the GitHub public key in Base64 format.", - ValidateDiagFunc: toDiagFunc(validation.StringIsBase64, "encrypted_value"), + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsBase64), }, "plaintext_value": { - Type: schema.TypeString, - ForceNew: true, - Optional: true, - Sensitive: true, - ConflictsWith: []string{"encrypted_value"}, - Description: "Plaintext value of the secret to be encrypted.", + Type: schema.TypeString, + Optional: true, + Sensitive: true, + ExactlyOneOf: []string{"encrypted_value", "plaintext_value"}, + Description: "Plaintext value of the secret to be encrypted.", }, "visibility": { Type: schema.TypeString, Required: true, - ForceNew: true, - ValidateDiagFunc: validateValueFunc([]string{"all", "private", "selected"}), - Description: "Configures the access that repositories have to the organization secret. Must be one of 'all', 'private', or 'selected'. 'selected_repository_ids' is required if set to 'selected'.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"all", "private", "selected"}, false)), + Description: "Configures the access that repositories have to the organization secret. Must be one of 'all', 'private', or 'selected'.", }, "selected_repository_ids": { Type: schema.TypeSet, + Set: schema.HashInt, Elem: &schema.Schema{ Type: schema.TypeInt, }, - Set: schema.HashInt, Optional: true, - ForceNew: true, - Description: "An array of repository ids that can access the organization secret.", + Description: "An array of repository IDs that can access the organization secret.", }, "created_at": { Type: schema.TypeString, Computed: true, - Description: "Date of 'actions_secret' creation.", + Description: "Date of secret creation.", }, "updated_at": { Type: schema.TypeString, Computed: true, - Description: "Date of 'actions_secret' update.", + Description: "Date of secret update.", + }, + "remote_updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of secret update at the remote.", }, "destroy_on_drift": { - Type: schema.TypeBool, - Default: true, - Optional: true, - ForceNew: true, - Description: "Boolean indicating whether to recreate the secret if it's modified outside of Terraform. When `true` (default), Terraform will delete and recreate the secret if it detects external changes. When `false`, Terraform will acknowledge external changes but not recreate the secret.", + Type: schema.TypeBool, + Optional: true, + Deprecated: "This is no longer required and will be removed in a future release. Drift detection is now always performed, and external changes will result in the secret being updated to match the Terraform configuration. If you want to ignore external changes, you can use the `lifecycle` block with `ignore_changes` on the `remote_updated_at` field.", }, }, + + CustomizeDiff: customdiff.All( + diffSecret, + diffSecretVariableVisibility, + ), + + CreateContext: resourceGithubActionsOrganizationSecretCreate, + ReadContext: resourceGithubActionsOrganizationSecretRead, + UpdateContext: resourceGithubActionsOrganizationSecretUpdate, + DeleteContext: resourceGithubActionsOrganizationSecretDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubActionsOrganizationSecretImport, + }, } } -func resourceGithubActionsOrganizationSecretCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name +func resourceGithubActionsOrganizationSecretCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name secretName := d.Get("secret_name").(string) - plaintextValue := d.Get("plaintext_value").(string) - var encryptedValue string - + keyID := d.Get("key_id").(string) + encryptedValue := d.Get("encrypted_value").(string) visibility := d.Get("visibility").(string) - selectedRepositories, hasSelectedRepositories := d.GetOk("selected_repository_ids") + repoIDs := github.SelectedRepoIDs{} - if visibility != "selected" && hasSelectedRepositories { - return diag.Errorf("cannot use selected_repository_ids without visibility being set to selected") - } - - selectedRepositoryIDs := []int64{} - - if hasSelectedRepositories { - ids := selectedRepositories.(*schema.Set).List() + if v, ok := d.GetOk("selected_repository_ids"); ok { + ids := v.(*schema.Set).List() for _, id := range ids { - selectedRepositoryIDs = append(selectedRepositoryIDs, int64(id.(int))) + repoIDs = append(repoIDs, int64(id.(int))) } } - keyId, publicKey, err := getOrganizationPublicKeyDetails(owner, meta) - if err != nil { - return diag.FromErr(err) + var publicKey string + if len(keyID) == 0 || len(encryptedValue) == 0 { + ki, pk, err := getOrganizationPublicKeyDetails(ctx, meta) + if err != nil { + return diag.FromErr(err) + } + + keyID = ki + publicKey = pk } - if encryptedText, ok := d.GetOk("encrypted_value"); ok { - encryptedValue = encryptedText.(string) - } else { + if len(encryptedValue) == 0 { + plaintextValue := d.Get("plaintext_value").(string) + encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) if err != nil { return diag.FromErr(err) @@ -144,35 +147,54 @@ func resourceGithubActionsOrganizationSecretCreateOrUpdate(ctx context.Context, encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) } - // Create an EncryptedSecret and encrypt the plaintext value into it - eSecret := &github.EncryptedSecret{ + secret := github.EncryptedSecret{ Name: secretName, - KeyID: keyId, - Visibility: visibility, - SelectedRepositoryIDs: selectedRepositoryIDs, + KeyID: keyID, EncryptedValue: encryptedValue, + Visibility: visibility, + SelectedRepositoryIDs: repoIDs, } - _, err = client.Actions.CreateOrUpdateOrgSecret(ctx, owner, eSecret) + _, err := client.Actions.CreateOrUpdateOrgSecret(ctx, owner, &secret) if err != nil { return diag.FromErr(err) } d.SetId(secretName) - return resourceGithubActionsOrganizationSecretRead(ctx, d, meta) + + if err := d.Set("key_id", keyID); err != nil { + return diag.FromErr(err) + } + + // GitHub API does not return on create so we have to lookup the secret to get timestamps + if secret, _, err := client.Actions.GetOrgSecret(ctx, owner, secretName); err == nil { + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } + + return nil } -func resourceGithubActionsOrganizationSecretRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name +func resourceGithubActionsOrganizationSecretRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + secretName := d.Get("secret_name").(string) - secret, _, err := client.Actions.GetOrgSecret(ctx, owner, d.Id()) + secret, _, err := client.Actions.GetOrgSecret(ctx, owner, secretName) if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) { if ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[INFO] Removing actions secret %s from state because it no longer exists in GitHub", - d.Id()) + log.Printf("[INFO] Removing actions organization secret %s from state because it no longer exists in GitHub", d.Id()) d.SetId("") return nil } @@ -180,6 +202,22 @@ func resourceGithubActionsOrganizationSecretRead(ctx context.Context, d *schema. return diag.FromErr(err) } + // Due to the eventually consistent behavior of this API we may not get created_at/updated_at + // values on the first read after creation, so we only set them here if they are not already set. + if len(d.Get("created_at").(string)) == 0 { + if err = d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + } + if len(d.Get("updated_at").(string)) == 0 { + if err = d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } + if err = d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err = d.Set("created_at", secret.CreatedAt.String()); err != nil { return diag.FromErr(err) } @@ -187,20 +225,19 @@ func resourceGithubActionsOrganizationSecretRead(ctx context.Context, d *schema. return diag.FromErr(err) } - selectedRepositoryIDs := []int64{} - + repoIDs := []int64{} if secret.Visibility == "selected" { opt := &github.ListOptions{ - PerPage: 30, + PerPage: maxPerPage, } for { - results, resp, err := client.Actions.ListSelectedReposForOrgSecret(ctx, owner, d.Id(), opt) + results, resp, err := client.Actions.ListSelectedReposForOrgSecret(ctx, owner, secretName, opt) if err != nil { return diag.FromErr(err) } for _, repo := range results.Repositories { - selectedRepositoryIDs = append(selectedRepositoryIDs, repo.GetID()) + repoIDs = append(repoIDs, repo.GetID()) } if resp.NextPage == 0 { @@ -210,77 +247,173 @@ func resourceGithubActionsOrganizationSecretRead(ctx context.Context, d *schema. } } - if err = d.Set("selected_repository_ids", selectedRepositoryIDs); err != nil { + if err := d.Set("selected_repository_ids", repoIDs); err != nil { return diag.FromErr(err) } - // This is a drift detection mechanism based on timestamps. - // - // If we do not currently store the "updated_at" field, it means we've only - // just created the resource and the value is most likely what we want it to - // be. - // - // If the resource is changed externally in the meantime then reading back - // the last update timestamp will return a result different than the - // timestamp we've persisted in the state. In that case, we can no longer - // trust that the value (which we don't see) is equal to what we've declared - // previously. - destroyOnDrift := d.Get("destroy_on_drift").(bool) - storedUpdatedAt, hasStoredUpdatedAt := d.GetOk("updated_at") - - if hasStoredUpdatedAt && storedUpdatedAt != secret.UpdatedAt.String() { - log.Printf("[INFO] The secret %s has been externally updated in GitHub", d.Id()) - - if destroyOnDrift { - // Original behavior: mark for recreation - d.SetId("") - return nil - } else { - // Alternative approach: set sensitive values to empty to trigger update plan - // This tells Terraform that the current state is unknown and needs reconciliation - if err = d.Set("encrypted_value", ""); err != nil { - return diag.FromErr(err) - } - if err = d.Set("plaintext_value", ""); err != nil { - return diag.FromErr(err) - } - log.Printf("[INFO] Detected drift but destroy_on_drift=false, clearing sensitive values to trigger update") + return nil +} + +func resourceGithubActionsOrganizationSecretUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + secretName := d.Get("secret_name").(string) + keyID := d.Get("key_id").(string) + encryptedValue := d.Get("encrypted_value").(string) + visibility := d.Get("visibility").(string) + repoIDs := github.SelectedRepoIDs{} + + if v, ok := d.GetOk("selected_repository_ids"); ok { + ids := v.(*schema.Set).List() + + for _, id := range ids { + repoIDs = append(repoIDs, int64(id.(int))) + } + } + + var publicKey string + if len(keyID) == 0 || len(encryptedValue) == 0 { + ki, pk, err := getOrganizationPublicKeyDetails(ctx, meta) + if err != nil { + return diag.FromErr(err) + } + + keyID = ki + publicKey = pk + } + + if len(encryptedValue) == 0 { + plaintextValue := d.Get("plaintext_value").(string) + + encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) + if err != nil { + return diag.FromErr(err) + } + encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) + } + + secret := github.EncryptedSecret{ + Name: secretName, + KeyID: keyID, + EncryptedValue: encryptedValue, + Visibility: visibility, + SelectedRepositoryIDs: repoIDs, + } + + _, err := client.Actions.CreateOrUpdateOrgSecret(ctx, owner, &secret) + if err != nil { + return diag.FromErr(err) + } + + if err := d.Set("key_id", keyID); err != nil { + return diag.FromErr(err) + } + + // GitHub API does not return on update so we have to lookup the secret to get timestamps + if secret, _, err := client.Actions.GetOrgSecret(ctx, owner, secretName); err == nil { + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) } } else { - // No drift detected, preserve the configured values in state - if err = d.Set("encrypted_value", d.Get("encrypted_value")); err != nil { + if err := d.Set("updated_at", nil); err != nil { return diag.FromErr(err) } - if err = d.Set("plaintext_value", d.Get("plaintext_value")); err != nil { + if err := d.Set("remote_updated_at", nil); err != nil { return diag.FromErr(err) } } - // Always update the timestamp to prevent repeated drift detection - if err = d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return nil +} + +func resourceGithubActionsOrganizationSecretDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + secretName := d.Get("secret_name").(string) + + log.Printf("[INFO] Deleting actions organization secret: %s", d.Id()) + _, err := client.Actions.DeleteOrgSecret(ctx, owner, secretName) + if err != nil { return diag.FromErr(err) } return nil } -func resourceGithubActionsOrganizationSecretDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - orgName := meta.(*Owner).name - ctx = context.WithValue(ctx, ctxId, d.Id()) +func resourceGithubActionsOrganizationSecretImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + secretName := d.Id() + + secret, _, err := client.Actions.GetOrgSecret(ctx, owner, secretName) + if err != nil { + return nil, err + } + + if err := d.Set("secret_name", secretName); err != nil { + return nil, err + } + if err := d.Set("visibility", secret.Visibility); err != nil { + return nil, err + } + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return nil, err + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return nil, err + } + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return nil, err + } + + selectedRepositoryIDs := []int64{} + if secret.Visibility == "selected" { + opt := &github.ListOptions{ + PerPage: maxPerPage, + } + for { + results, resp, err := client.Actions.ListSelectedReposForOrgSecret(ctx, owner, secretName, opt) + if err != nil { + return nil, err + } + + for _, repo := range results.Repositories { + selectedRepositoryIDs = append(selectedRepositoryIDs, repo.GetID()) + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + } + + if err := d.Set("selected_repository_ids", selectedRepositoryIDs); err != nil { + return nil, err + } - log.Printf("[INFO] Deleting secret: %s", d.Id()) - _, err := client.Actions.DeleteOrgSecret(ctx, orgName, d.Id()) - return diag.FromErr(err) + return []*schema.ResourceData{d}, nil } -func getOrganizationPublicKeyDetails(owner string, meta any) (keyId, pkValue string, err error) { - client := meta.(*Owner).v3client - ctx := context.Background() +func getOrganizationPublicKeyDetails(ctx context.Context, meta *Owner) (string, string, error) { + client := meta.v3client + owner := meta.name publicKey, _, err := client.Actions.GetOrgPublicKey(ctx, owner) if err != nil { - return keyId, pkValue, err + return "", "", err } return publicKey.GetKeyID(), publicKey.GetKey(), err diff --git a/github/resource_github_actions_organization_secret_drift_test.go b/github/resource_github_actions_organization_secret_drift_test.go deleted file mode 100644 index d2e33e702d..0000000000 --- a/github/resource_github_actions_organization_secret_drift_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package github - -import ( - "testing" - - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" -) - -// Test for the organization secret drift detection fix. -func TestGithubActionsOrganizationSecretDriftDetectionFix(t *testing.T) { - t.Run("always updates timestamp regardless of drift detection", func(t *testing.T) { - // This test verifies the fix for the issue where updated_at was not - // being set when drift was detected, causing repeated drift detection - - d := schema.TestResourceDataRaw(t, resourceGithubActionsOrganizationSecret().Schema, map[string]any{ - "secret_name": "test-secret", - "plaintext_value": "test-value", - "visibility": "private", - "destroy_on_drift": true, - "updated_at": "2023-01-01T00:00:00Z", // Old timestamp - }) - d.SetId("test-secret") - - // Simulate the updated_at logic from the read function - // This is what the actual GitHub API would return (newer timestamp) - newTimestamp := "2023-01-01T12:00:00Z" - - // Simulate the drift detection logic from resourceGithubActionsOrganizationSecretRead - destroyOnDrift := d.Get("destroy_on_drift").(bool) - if updatedAt, ok := d.GetOk("updated_at"); ok && destroyOnDrift && updatedAt != newTimestamp { - // This would log the drift and clear the ID - d.SetId("") - } - - // This is the key fix - always update the timestamp - err := d.Set("updated_at", newTimestamp) - if err != nil { - t.Fatal(err) - } - - // Verify that the timestamp was updated even though drift was detected - if d.Get("updated_at").(string) != newTimestamp { - t.Error("Expected updated_at to be set to new timestamp after drift detection") - } - - // Verify that the ID was cleared due to drift detection - if d.Id() != "" { - t.Error("Expected ID to be cleared due to drift detection with destroy_on_drift=true") - } - }) - - t.Run("does not clear ID when destroy_on_drift is false", func(t *testing.T) { - d := schema.TestResourceDataRaw(t, resourceGithubActionsOrganizationSecret().Schema, map[string]any{ - "secret_name": "test-secret", - "plaintext_value": "test-value", - "visibility": "private", - "destroy_on_drift": false, - "updated_at": "2023-01-01T00:00:00Z", // Old timestamp - }) - d.SetId("test-secret") - - newTimestamp := "2023-01-01T12:00:00Z" - - // Simulate the drift detection logic - destroyOnDrift := d.Get("destroy_on_drift").(bool) - if updatedAt, ok := d.GetOk("updated_at"); ok && destroyOnDrift && updatedAt != newTimestamp { - d.SetId("") - } - - // Always update the timestamp - err := d.Set("updated_at", newTimestamp) - if err != nil { - t.Fatal(err) - } - - // Verify that the ID was NOT cleared when destroy_on_drift=false - if d.Id() != "test-secret" { - t.Error("Expected ID to remain when destroy_on_drift=false") - } - - // Verify that the timestamp was still updated - if d.Get("updated_at").(string) != newTimestamp { - t.Error("Expected updated_at to be updated even when destroy_on_drift=false") - } - }) -} diff --git a/github/resource_github_actions_organization_secret_migration.go b/github/resource_github_actions_organization_secret_migration.go index 37d2b72db5..940880d164 100644 --- a/github/resource_github_actions_organization_secret_migration.go +++ b/github/resource_github_actions_organization_secret_migration.go @@ -8,8 +8,10 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) -func resourceGithubActionsOrganizationSecretResourceV0() *schema.Resource { +func resourceGithubActionsOrganizationSecretV0() *schema.Resource { return &schema.Resource{ + SchemaVersion: 0, + Schema: map[string]*schema.Schema{ "secret_name": { Type: schema.TypeString, @@ -66,8 +68,9 @@ func resourceGithubActionsOrganizationSecretResourceV0() *schema.Resource { } } -func resourceGithubActionsOrganizationSecretInstanceStateUpgradeV0(ctx context.Context, rawState map[string]any, meta any) (map[string]any, error) { +func resourceGithubActionsOrganizationSecretStateUpgradeV0(ctx context.Context, rawState map[string]any, _ any) (map[string]any, error) { log.Printf("[DEBUG] GitHub Actions Organization Secret Attributes before migration: %#v", rawState) + // Add the destroy_on_drift field with default value true if it doesn't exist if _, ok := rawState["destroy_on_drift"]; !ok { rawState["destroy_on_drift"] = true diff --git a/github/resource_github_actions_organization_secret_migration_test.go b/github/resource_github_actions_organization_secret_migration_test.go index 9091bf63ea..76069ddda0 100644 --- a/github/resource_github_actions_organization_secret_migration_test.go +++ b/github/resource_github_actions_organization_secret_migration_test.go @@ -1,55 +1,75 @@ package github import ( + "context" "reflect" "testing" ) -func testResourceGithubActionsOrganizationSecretInstanceStateDataV0() map[string]any { - return map[string]any{ - "id": "test-secret", - "secret_name": "test-secret", - "visibility": "private", - "created_at": "2023-01-01T00:00:00Z", - "updated_at": "2023-01-01T00:00:00Z", - "plaintext_value": "secret-value", - } -} - -func testResourceGithubActionsOrganizationSecretInstanceStateDataV0_WithDrift() map[string]any { - v0 := testResourceGithubActionsOrganizationSecretInstanceStateDataV0() - v0["destroy_on_drift"] = false - return v0 -} +func Test_resourceGithubActionsOrganizationSecretStateUpgradeV0(t *testing.T) { + t.Parallel() -func testResourceGithubActionsOrganizationSecretInstanceStateDataV1() map[string]any { - v0 := testResourceGithubActionsOrganizationSecretInstanceStateDataV0() - v0["destroy_on_drift"] = true - return v0 -} - -func TestGithub_MigrateActionsOrganizationSecretStateV0toV1(t *testing.T) { - t.Run("without destroy_on_drift", func(t *testing.T) { - expected := testResourceGithubActionsOrganizationSecretInstanceStateDataV1() - actual, err := resourceGithubActionsOrganizationSecretInstanceStateUpgradeV0(t.Context(), testResourceGithubActionsOrganizationSecretInstanceStateDataV0(), nil) - if err != nil { - t.Fatalf("error migrating state: %s", err) - } + for _, d := range []struct { + testName string + rawState map[string]any + want map[string]any + shouldError bool + }{ + { + testName: "migrates_v0_to_v1", + rawState: map[string]any{ + "id": "test-secret", + "secret_name": "test-secret", + "visibility": "private", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + "plaintext_value": "secret-value", + }, + want: map[string]any{ + "id": "test-secret", + "secret_name": "test-secret", + "visibility": "private", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + "plaintext_value": "secret-value", + "destroy_on_drift": true, + }, + shouldError: false, + }, + { + testName: "migrates_v0_to_v1_with_existing_destroy_on_drift", + rawState: map[string]any{ + "id": "test-secret", + "secret_name": "test-secret", + "visibility": "private", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + "plaintext_value": "secret-value", + "destroy_on_drift": false, + }, + want: map[string]any{ + "id": "test-secret", + "secret_name": "test-secret", + "visibility": "private", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + "plaintext_value": "secret-value", + "destroy_on_drift": false, + }, + shouldError: false, + }, + } { + t.Run(d.testName, func(t *testing.T) { + t.Parallel() - if !reflect.DeepEqual(expected, actual) { - t.Fatalf("\n\nexpected:\n\n%#v\n\ngot:\n\n%#v\n\n", expected, actual) - } - }) + got, err := resourceGithubActionsOrganizationSecretStateUpgradeV0(context.Background(), d.rawState, nil) + if (err != nil) != d.shouldError { + t.Fatalf("unexpected error state") + } - t.Run("with destroy_on_drift", func(t *testing.T) { - expected := testResourceGithubActionsOrganizationSecretInstanceStateDataV0_WithDrift() - actual, err := resourceGithubActionsOrganizationSecretInstanceStateUpgradeV0(t.Context(), testResourceGithubActionsOrganizationSecretInstanceStateDataV0_WithDrift(), nil) - if err != nil { - t.Fatalf("error migrating state: %s", err) - } - - if !reflect.DeepEqual(expected, actual) { - t.Fatalf("\n\nexpected:\n\n%#v\n\ngot:\n\n%#v\n\n", expected, actual) - } - }) + if !d.shouldError && !reflect.DeepEqual(got, d.want) { + t.Fatalf("got %+v, want %+v", got, d.want) + } + }) + } } diff --git a/github/resource_github_actions_organization_secret_test.go b/github/resource_github_actions_organization_secret_test.go index 9edd4d4abe..e846b3e7f8 100644 --- a/github/resource_github_actions_organization_secret_test.go +++ b/github/resource_github_actions_organization_secret_test.go @@ -1,346 +1,607 @@ package github import ( + "context" "encoding/base64" "fmt" - "strings" + "regexp" "testing" "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) func TestAccGithubActionsOrganizationSecret(t *testing.T) { - t.Run("creates and updates secrets without error", func(t *testing.T) { - secretValue := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) - updatedSecretValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + t.Run("create_update_plaintext", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) - config := fmt.Sprintf(` - resource "github_actions_organization_secret" "plaintext_secret" { - secret_name = "test_plaintext_secret" - plaintext_value = "%s" - visibility = "private" - } - - resource "github_actions_organization_secret" "encrypted_secret" { - secret_name = "test_encrypted_secret" - encrypted_value = "%s" - visibility = "private" - destroy_on_drift = false - } - `, secretValue, secretValue) - - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_organization_secret.plaintext_secret", "plaintext_value", - secretValue, - ), - resource.TestCheckResourceAttr( - "github_actions_organization_secret.encrypted_secret", "encrypted_value", - secretValue, - ), - resource.TestCheckResourceAttrSet( - "github_actions_organization_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_organization_secret.plaintext_secret", "updated_at", - ), - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_organization_secret.plaintext_secret", "plaintext_value", - updatedSecretValue, - ), - resource.TestCheckResourceAttr( - "github_actions_organization_secret.encrypted_secret", "encrypted_value", - updatedSecretValue, - ), - resource.TestCheckResourceAttrSet( - "github_actions_organization_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_organization_secret.plaintext_secret", "updated_at", - ), - ), - } + config := ` +resource "github_actions_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" +} +` resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, - Check: checks["before"], + Config: fmt.Sprintf(config, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, secretName, valueUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "plaintext_value", valueUpdated), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_update_encrypted", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) + + config := ` +resource "github_actions_organization_secret" "test" { + secret_name = "%s" + encrypted_value = "%s" + visibility = "all" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "encrypted_value", value), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), }, { - Config: strings.Replace(config, - secretValue, - updatedSecretValue, 2), - Check: checks["after"], + Config: fmt.Sprintf(config, secretName, valueUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "encrypted_value", valueUpdated), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), }, }, }) }) - t.Run("deletes secrets without error", func(t *testing.T) { + t.Run("create_update_encrypted_with_key", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) + config := ` - resource "github_actions_organization_secret" "plaintext_secret" { - secret_name = "test_plaintext_secret" - visibility = "private" - } +data "github_actions_organization_public_key" "default" {} - resource "github_actions_organization_secret" "encrypted_secret" { - secret_name = "test_encrypted_secret" - visibility = "private" - } - ` +resource "github_actions_organization_secret" "test" { + secret_name = "%s" + key_id = data.github_actions_organization_public_key.default.key_id + encrypted_value = "%s" + visibility = "all" +} +` resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, - Destroy: true, + Config: fmt.Sprintf(config, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "encrypted_value", value), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, secretName, valueUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "encrypted_value", valueUpdated), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), }, }, }) }) - t.Run("imports secrets without error", func(t *testing.T) { - secretValue := "super_secret_value" + t.Run("create_update_visibility_all", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) - config := fmt.Sprintf(` - resource "github_actions_organization_secret" "test_secret" { - secret_name = "test_plaintext_secret" - plaintext_value = "%s" - visibility = "private" - } - `, secretValue) - - check := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_organization_secret.test_secret", "plaintext_value", - secretValue, - ), - ) + config := ` +resource "github_actions_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" +} +` resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, - Check: check, + Config: fmt.Sprintf(config, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), }, { - ResourceName: "github_actions_organization_secret.test_secret", - ImportState: true, - ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"plaintext_value"}, + Config: fmt.Sprintf(config, secretName, valueUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "plaintext_value", valueUpdated), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), }, }, }) }) + + t.Run("create_update_visibility_private", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) + + config := ` +resource "github_actions_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "private" } +` -func TestAccGithubActionsOrganizationSecret_DestroyOnDrift(t *testing.T) { - t.Run("destroyOnDrift false", func(t *testing.T) { - destroyOnDrift := false - t.Run("should ignore drift when ignore_changes lifecycle is configured", func(t *testing.T) { - // Verify https://github.com/integrations/terraform-provider-github/issues/2614 - randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - config := fmt.Sprintf(` - resource "github_actions_organization_secret" "test_secret" { - secret_name = "test_secret_%s" - plaintext_value = "test_value" - visibility = "private" - - destroy_on_drift = %t - lifecycle { - ignore_changes = [plaintext_value] - } - } - `, randomID, destroyOnDrift) - - resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnlessHasOrgs(t) }, - ProviderFactories: providerFactories, - Steps: []resource.TestStep{ - { - Config: config, - }, - { - Config: config, - Check: resource.ComposeTestCheckFunc( - func(s *terraform.State) error { - rs, ok := s.RootModule().Resources["github_actions_organization_secret.test_secret"] - if !ok { - t.Errorf("not found: github_actions_organization_secret.test_secret") - } - // Now that the secret is created, update it to trigger a drift. - meta, err := getTestMeta() - if err != nil { - return err - } - client := meta.v3client - owner := meta.name - ctx := t.Context() - - keyId, publicKey, err := getOrganizationPublicKeyDetails(owner, meta) - if err != nil { - t.Errorf("Failed to get organization public key details: %v", err) - } - - encryptedSecret, err := createEncryptedSecret(rs.Primary, "foo", keyId, publicKey) - if err != nil { - t.Errorf("Failed to create encrypted secret: %v", err) - } - _, err = client.Actions.CreateOrUpdateOrgSecret(ctx, owner, encryptedSecret) - if err != nil { - t.Errorf("Failed to create or update organization secret: %v", err) - } - return err - }, - ), - }, - { - Config: config, - PlanOnly: true, - ExpectNonEmptyPlan: false, - }, + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", "private"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, secretName, valueUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "plaintext_value", valueUpdated), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", "private"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), }, - }) + }, }) }) - // t.Run("destroyOnDrift true", func(t *testing.T) { - // destroyOnDrift := true - // }) + + t.Run("create_update_visibility_selected", func(t *testing.T) { + repoName0 := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)) + repoName1 := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)) + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) + + config := ` +resource "github_repository" "test_0" { + name = "%s" +} + +resource "github_repository" "test_1" { + name = "%s" } -func TestGithubActionsOrganizationSecret_DestroyOnDrift(t *testing.T) { - t.Run("destroyOnDrift false clears sensitive values instead of recreating", func(t *testing.T) { - originalTimestamp := "2023-01-01T00:00:00Z" - newTimestamp := "2023-01-02T00:00:00Z" - - d := schema.TestResourceDataRaw(t, resourceGithubActionsOrganizationSecret().Schema, map[string]any{ - "secret_name": "test-secret", - "plaintext_value": "original-value", - "encrypted_value": "original-encrypted", - "visibility": "private", - "destroy_on_drift": false, - "updated_at": originalTimestamp, +resource "github_actions_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "selected" + + selected_repository_ids = [github_repository.test_%s.repo_id] +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName0, repoName1, secretName, value, "0"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", "selected"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "1"), + resource.TestCheckResourceAttrPair("github_actions_organization_secret.test", "selected_repository_ids.0", "github_repository.test_0", "repo_id"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, repoName0, repoName1, secretName, valueUpdated, "1"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "plaintext_value", valueUpdated), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", "selected"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "1"), + resource.TestCheckResourceAttrPair("github_actions_organization_secret.test", "selected_repository_ids.0", "github_repository.test_1", "repo_id"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), + }, + }, }) - d.SetId("test-secret") - - // Simulate drift detection logic when destroy_on_drift is false - destroyOnDrift := d.Get("destroy_on_drift").(bool) - storedUpdatedAt, hasStoredUpdatedAt := d.GetOk("updated_at") - - if hasStoredUpdatedAt && storedUpdatedAt != newTimestamp { - if destroyOnDrift { - // Would clear ID for recreation - d.SetId("") - } else { - // Should clear sensitive values to trigger update - _ = d.Set("encrypted_value", "") - _ = d.Set("plaintext_value", "") - } - _ = d.Set("updated_at", newTimestamp) - } - - // Should NOT have cleared the ID when destroy_on_drift=false - if d.Id() == "" { - t.Error("Expected ID to be preserved when destroy_on_drift=false, but it was cleared") - } - - // Should have cleared sensitive values to trigger update plan - if plaintextValue := d.Get("plaintext_value").(string); plaintextValue != "" { - t.Errorf("Expected plaintext_value to be cleared for update plan, got %s", plaintextValue) - } - - if encryptedValue := d.Get("encrypted_value").(string); encryptedValue != "" { - t.Errorf("Expected encrypted_value to be cleared for update plan, got %s", encryptedValue) - } - - // Should have updated the timestamp - if updatedAt := d.Get("updated_at").(string); updatedAt != newTimestamp { - t.Errorf("Expected timestamp to be updated to %s, got %s", newTimestamp, updatedAt) - } }) - t.Run("destroyOnDrift true still recreates resource on drift", func(t *testing.T) { - originalTimestamp := "2023-01-01T00:00:00Z" - newTimestamp := "2023-01-02T00:00:00Z" + t.Run("create_update_change_visibility", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + visibility := "all" + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) + visibilityUpdated := "private" + + config := ` +resource "github_actions_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "%s" +} +` - d := schema.TestResourceDataRaw(t, resourceGithubActionsOrganizationSecret().Schema, map[string]any{ - "secret_name": "test-secret", - "plaintext_value": "original-value", - "visibility": "private", - "destroy_on_drift": true, // Explicitly set to true - "updated_at": originalTimestamp, + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, secretName, value, visibility), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", visibility), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, secretName, valueUpdated, visibilityUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "plaintext_value", valueUpdated), + resource.TestCheckNoResourceAttr("github_actions_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "visibility", visibilityUpdated), + resource.TestCheckResourceAttr("github_actions_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + ), + }, + }, }) - d.SetId("test-secret") - - // Simulate drift detection logic when destroy_on_drift is true - destroyOnDrift := d.Get("destroy_on_drift").(bool) - storedUpdatedAt, hasStoredUpdatedAt := d.GetOk("updated_at") - - if hasStoredUpdatedAt && storedUpdatedAt != newTimestamp { - if destroyOnDrift { - // Should clear ID for recreation (original behavior) - d.SetId("") - return // Exit early like the real function would - } - } - - // Should have cleared the ID for recreation when destroy_on_drift=true - if d.Id() != "" { - t.Error("Expected ID to be cleared for recreation when destroy_on_drift=true, but it was preserved") - } }) - t.Run("destroy_on_drift field defaults", func(t *testing.T) { - // Test that destroy_on_drift defaults to true for backward compatibility - schema := resourceGithubActionsOrganizationSecret().Schema["destroy_on_drift"] - if schema.Default != true { - t.Error("destroy_on_drift should default to true for backward compatibility") - } + t.Run("update_on_drift", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + + config := fmt.Sprintf(` +resource "github_actions_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" +} +`, secretName, value) + + var beforeCreatedAt string + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_actions_organization_secret.test"].Primary.Attributes["created_at"] + return nil + }, + ), + }, + { + PreConfig: func() { + meta, err := getTestMeta() + if err != nil { + t.Fatal(err.Error()) + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + keyID, _, err := getOrganizationPublicKeyDetails(ctx, meta) + if err != nil { + t.Fatal(err.Error()) + } + + _, err = client.Actions.CreateOrUpdateOrgSecret(ctx, owner, &github.EncryptedSecret{ + Name: secretName, + EncryptedValue: base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")), + KeyID: keyID, + Visibility: "all", + }) + if err != nil { + t.Fatal(err.Error()) + } + }, + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_actions_organization_secret.test"].Primary.Attributes["created_at"] + + if afterCreatedAt != beforeCreatedAt { + return fmt.Errorf("expected resource to be updated, but created_at has been modified: %s", beforeCreatedAt) + } + return nil + }, + ), + }, + }, + }) }) - t.Run("default destroy_on_drift is true", func(t *testing.T) { - d := schema.TestResourceDataRaw(t, resourceGithubActionsOrganizationSecret().Schema, map[string]any{ - "secret_name": "test-secret", - "plaintext_value": "test-value", - "visibility": "private", - // destroy_on_drift not set, should default to true + t.Run("lifecycle_can_ignore_drift", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + + config := fmt.Sprintf(` +resource "github_actions_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" + + lifecycle { + ignore_changes = [remote_updated_at] + } +} +`, secretName, value) + + var beforeUpdatedAt string + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + beforeUpdatedAt = s.RootModule().Resources["github_actions_organization_secret.test"].Primary.Attributes["updated_at"] + return nil + }, + ), + }, + { + PreConfig: func() { + meta, err := getTestMeta() + if err != nil { + t.Fatal(err.Error()) + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + keyID, _, err := getOrganizationPublicKeyDetails(ctx, meta) + if err != nil { + t.Fatal(err.Error()) + } + + _, err = client.Actions.CreateOrUpdateOrgSecret(ctx, owner, &github.EncryptedSecret{ + Name: secretName, + EncryptedValue: base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")), + KeyID: keyID, + Visibility: "all", + }) + if err != nil { + t.Fatal(err.Error()) + } + }, + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + afterUpdatedAt := s.RootModule().Resources["github_actions_organization_secret.test"].Primary.Attributes["updated_at"] + if afterUpdatedAt != beforeUpdatedAt { + return fmt.Errorf("expected resource to ignore drift, but updated_at has been modified: %s", beforeUpdatedAt) + } + return nil + }, + ), + }, + }, }) + }) - destroyOnDrift := d.Get("destroy_on_drift").(bool) - if !destroyOnDrift { - t.Error("Expected destroy_on_drift to default to true") - } + t.Run("destroy", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + + config := fmt.Sprintf(` +resource "github_actions_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" +} +`, secretName, value) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + }, + { + Config: config, + Destroy: true, + }, + }, + }) }) + + t.Run("import", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + + config := fmt.Sprintf(` +resource "github_actions_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" } +`, secretName, value) -func createEncryptedSecret(is *terraform.InstanceState, plaintextValue, keyId, publicKey string) (*github.EncryptedSecret, error) { - secretName := is.Attributes["secret_name"] - visibility := is.Attributes["visibility"] + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + }, + { + ResourceName: "github_actions_organization_secret.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"key_id", "plaintext_value", "destroy_on_drift"}, + }, + }, + }) + }) - encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) - if err != nil { - return nil, err - } - encryptedValue := base64.StdEncoding.EncodeToString(encryptedBytes) - - return &github.EncryptedSecret{ - Name: secretName, - KeyID: keyId, - Visibility: visibility, - EncryptedValue: encryptedValue, - }, nil + t.Run("error_on_invalid_selected_repository_ids", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + + config := fmt.Sprintf(` +resource "github_actions_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" + + selected_repository_ids = [123456] +} +`, secretName, value) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("cannot use selected_repository_ids without visibility being set to selected"), + }, + }, + }) + }) } diff --git a/github/resource_github_actions_organization_variable.go b/github/resource_github_actions_organization_variable.go index 4c1b675e7c..a69be916a1 100644 --- a/github/resource_github_actions_organization_variable.go +++ b/github/resource_github_actions_organization_variable.go @@ -3,24 +3,17 @@ package github import ( "context" "errors" - "fmt" "log" "net/http" "github.com/google/go-github/v82/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 resourceGithubActionsOrganizationVariable() *schema.Resource { return &schema.Resource{ - Create: resourceGithubActionsOrganizationVariableCreate, - Read: resourceGithubActionsOrganizationVariableRead, - Update: resourceGithubActionsOrganizationVariableUpdate, - Delete: resourceGithubActionsOrganizationVariableDelete, - Importer: &schema.ResourceImporter{ - StateContext: schema.ImportStatePassthroughContext, - }, - Schema: map[string]*schema.Schema{ "variable_name": { Type: schema.TypeString, @@ -34,166 +27,250 @@ func resourceGithubActionsOrganizationVariable() *schema.Resource { Required: true, Description: "Value of the variable.", }, - "created_at": { - Type: schema.TypeString, - Computed: true, - Description: "Date of 'actions_variable' creation.", - }, - "updated_at": { - Type: schema.TypeString, - Computed: true, - Description: "Date of 'actions_variable' update.", - }, "visibility": { Type: schema.TypeString, Required: true, - ValidateDiagFunc: validateValueFunc([]string{"all", "private", "selected"}), - ForceNew: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"all", "private", "selected"}, false)), Description: "Configures the access that repositories have to the organization variable. Must be one of 'all', 'private', or 'selected'. 'selected_repository_ids' is required if set to 'selected'.", }, "selected_repository_ids": { Type: schema.TypeSet, + Set: schema.HashInt, Elem: &schema.Schema{ Type: schema.TypeInt, }, - Set: schema.HashInt, Optional: true, Description: "An array of repository ids that can access the organization variable.", }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'actions_variable' creation.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'actions_variable' update.", + }, + }, + + CustomizeDiff: diffSecretVariableVisibility, + + CreateContext: resourceGithubActionsOrganizationVariableCreate, + ReadContext: resourceGithubActionsOrganizationVariableRead, + UpdateContext: resourceGithubActionsOrganizationVariableUpdate, + DeleteContext: resourceGithubActionsOrganizationVariableDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubActionsOrganizationVariableImport, }, } } -func resourceGithubActionsOrganizationVariableCreate(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - ctx := context.Background() - - name := d.Get("variable_name").(string) +func resourceGithubActionsOrganizationVariableCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + varName := d.Get("variable_name").(string) visibility := d.Get("visibility").(string) - selectedRepositories, hasSelectedRepositories := d.GetOk("selected_repository_ids") + repoIDs := github.SelectedRepoIDs{} - if visibility != "selected" && hasSelectedRepositories { - return fmt.Errorf("cannot use selected_repository_ids without visibility being set to selected") - } - - selectedRepositoryIDs := []int64{} - - if hasSelectedRepositories { - ids := selectedRepositories.(*schema.Set).List() + if v, ok := d.GetOk("selected_repository_ids"); ok { + ids := v.(*schema.Set).List() for _, id := range ids { - selectedRepositoryIDs = append(selectedRepositoryIDs, int64(id.(int))) + repoIDs = append(repoIDs, int64(id.(int))) } } - repoIDs := github.SelectedRepoIDs(selectedRepositoryIDs) - variable := &github.ActionsVariable{ - Name: name, + Name: varName, Value: d.Get("value").(string), - Visibility: &visibility, - SelectedRepositoryIDs: &repoIDs, + Visibility: github.Ptr(visibility), + SelectedRepositoryIDs: github.Ptr(repoIDs), } _, err := client.Actions.CreateOrgVariable(ctx, owner, variable) if err != nil { - return err + return diag.FromErr(err) } - d.SetId(name) - return resourceGithubActionsOrganizationVariableRead(d, meta) + d.SetId(varName) + + // GitHub API does not return on create so we have to lookup the variable to get timestamps + if variable, _, err := client.Actions.GetOrgVariable(ctx, owner, varName); err == nil { + if err := d.Set("created_at", variable.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", variable.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } + + return nil } -func resourceGithubActionsOrganizationVariableUpdate(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - ctx := context.Background() +func resourceGithubActionsOrganizationVariableRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name - name := d.Get("variable_name").(string) + varName := d.Get("variable_name").(string) - visibility := d.Get("visibility").(string) - selectedRepositories, hasSelectedRepositories := d.GetOk("selected_repository_ids") + variable, _, err := client.Actions.GetOrgVariable(ctx, owner, varName) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Removing actions variable %s from state because it no longer exists in GitHub", d.Id()) + d.SetId("") + return nil + } + } + return diag.FromErr(err) + } - if visibility != "selected" && hasSelectedRepositories { - return fmt.Errorf("cannot use selected_repository_ids without visibility being set to selected") + if err := d.Set("value", variable.Value); err != nil { + return diag.FromErr(err) + } + if err := d.Set("visibility", variable.Visibility); err != nil { + return diag.FromErr(err) + } + if err := d.Set("created_at", variable.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", variable.UpdatedAt.String()); err != nil { + return diag.FromErr(err) } - selectedRepositoryIDs := []int64{} + repoIDs := []int64{} + if variable.GetVisibility() == "selected" { + opt := &github.ListOptions{ + PerPage: maxPerPage, + } + for { + results, resp, err := client.Actions.ListSelectedReposForOrgVariable(ctx, owner, varName, opt) + if err != nil { + return diag.FromErr(err) + } - if hasSelectedRepositories { - ids := selectedRepositories.(*schema.Set).List() + for _, repo := range results.Repositories { + repoIDs = append(repoIDs, repo.GetID()) + } - for _, id := range ids { - selectedRepositoryIDs = append(selectedRepositoryIDs, int64(id.(int))) + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage } } - repoIDs := github.SelectedRepoIDs(selectedRepositoryIDs) + if err := d.Set("selected_repository_ids", repoIDs); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubActionsOrganizationVariableUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + varName := d.Get("variable_name").(string) + visibility := d.Get("visibility").(string) + repoIDs := github.SelectedRepoIDs{} + + if v, ok := d.GetOk("selected_repository_ids"); ok { + ids := v.(*schema.Set).List() + + for _, id := range ids { + repoIDs = append(repoIDs, int64(id.(int))) + } + } variable := &github.ActionsVariable{ - Name: name, + Name: varName, Value: d.Get("value").(string), - Visibility: &visibility, - SelectedRepositoryIDs: &repoIDs, + Visibility: github.Ptr(visibility), + SelectedRepositoryIDs: github.Ptr(repoIDs), } _, err := client.Actions.UpdateOrgVariable(ctx, owner, variable) if err != nil { - return err + return diag.FromErr(err) } - d.SetId(name) - return resourceGithubActionsOrganizationVariableRead(d, meta) + d.SetId(varName) + + // GitHub API does not return on create so we have to lookup the variable to get timestamps + if variable, _, err := client.Actions.GetOrgVariable(ctx, owner, varName); err == nil { + if err := d.Set("created_at", variable.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", variable.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } else { + if err := d.Set("updated_at", nil); err != nil { + return diag.FromErr(err) + } + } + + return nil } -func resourceGithubActionsOrganizationVariableRead(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - ctx := context.Background() +func resourceGithubActionsOrganizationVariableDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name - name := d.Id() + varName := d.Get("variable_name").(string) - variable, _, err := client.Actions.GetOrgVariable(ctx, owner, name) + _, err := client.Actions.DeleteOrgVariable(ctx, owner, varName) if err != nil { - var ghErr *github.ErrorResponse - if errors.As(err, &ghErr) { - if ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[INFO] Removing actions variable %s from state because it no longer exists in GitHub", - d.Id()) - d.SetId("") - return nil - } - } - return err + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubActionsOrganizationVariableImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + varName := d.Id() + + variable, _, err := client.Actions.GetOrgVariable(ctx, owner, varName) + if err != nil { + return nil, err } - if err = d.Set("variable_name", name); err != nil { - return err + if err := d.Set("variable_name", varName); err != nil { + return nil, err } - if err = d.Set("value", variable.Value); err != nil { - return err + if err := d.Set("value", variable.Value); err != nil { + return nil, err } - if err = d.Set("created_at", variable.CreatedAt.String()); err != nil { - return err + if err := d.Set("visibility", variable.Visibility); err != nil { + return nil, err } - if err = d.Set("updated_at", variable.UpdatedAt.String()); err != nil { - return err + if err := d.Set("created_at", variable.CreatedAt.String()); err != nil { + return nil, err } - if err = d.Set("visibility", *variable.Visibility); err != nil { - return err + if err := d.Set("updated_at", variable.UpdatedAt.String()); err != nil { + return nil, err } selectedRepositoryIDs := []int64{} - - if *variable.Visibility == "selected" { + if variable.GetVisibility() == "selected" { opt := &github.ListOptions{ - PerPage: 30, + PerPage: maxPerPage, } for { - results, resp, err := client.Actions.ListSelectedReposForOrgVariable(ctx, owner, d.Id(), opt) + results, resp, err := client.Actions.ListSelectedReposForOrgVariable(ctx, owner, varName, opt) if err != nil { - return err + return nil, err } for _, repo := range results.Repositories { @@ -207,21 +284,9 @@ func resourceGithubActionsOrganizationVariableRead(d *schema.ResourceData, meta } } - if err = d.Set("selected_repository_ids", selectedRepositoryIDs); err != nil { - return err + if err := d.Set("selected_repository_ids", selectedRepositoryIDs); err != nil { + return nil, err } - return nil -} - -func resourceGithubActionsOrganizationVariableDelete(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - ctx := context.WithValue(context.Background(), ctxId, d.Id()) - - name := d.Id() - - _, err := client.Actions.DeleteOrgVariable(ctx, owner, name) - - return err + return []*schema.ResourceData{d}, nil } diff --git a/github/resource_github_actions_organization_variable_test.go b/github/resource_github_actions_organization_variable_test.go index 52ac31fb21..9f4100c3c0 100644 --- a/github/resource_github_actions_organization_variable_test.go +++ b/github/resource_github_actions_organization_variable_test.go @@ -1,142 +1,229 @@ package github import ( + "context" "fmt" - "strings" + "regexp" "testing" + "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) func TestAccGithubActionsOrganizationVariable(t *testing.T) { - t.Run("creates and updates a private organization variable without error", func(t *testing.T) { - value := "my_variable_value" - updatedValue := "my_updated_variable_value" + t.Run("create_update_visibility_all", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + varName := fmt.Sprintf("test_%s", randomID) + value := "foo" + valueUpdated := "bar" - config := fmt.Sprintf(` - resource "github_actions_organization_variable" "variable" { - variable_name = "test_variable" - value = "%s" - visibility = "private" - } - `, value) - - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_organization_variable.variable", "value", - value, - ), - resource.TestCheckResourceAttr( - "github_actions_organization_variable.variable", "visibility", - "private", - ), - resource.TestCheckResourceAttrSet( - "github_actions_organization_variable.variable", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_organization_variable.variable", "updated_at", - ), - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_organization_variable.variable", "value", - updatedValue, - ), - resource.TestCheckResourceAttrSet( - "github_actions_organization_variable.variable", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_organization_variable.variable", "updated_at", - ), - ), - } + config := ` +resource "github_actions_organization_variable" "test" { + variable_name = "%s" + value = "%s" + visibility = "all" +} +` resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, - Check: checks["before"], + Config: fmt.Sprintf(config, varName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "value", value), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "updated_at"), + ), }, { - Config: strings.Replace(config, - value, - updatedValue, 1), - Check: checks["after"], + Config: fmt.Sprintf(config, varName, valueUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "value", valueUpdated), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "updated_at"), + ), }, }, }) }) - t.Run("creates an organization variable scoped to a repo without error", func(t *testing.T) { - value := "my_variable_value" - randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-org-var-%s", testResourcePrefix, randomID) + t.Run("create_update_visibility_private", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + varName := fmt.Sprintf("test_%s", randomID) + value := "foo" + valueUpdated := "bar" - config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } - - resource "github_actions_organization_variable" "variable" { - variable_name = "test_variable" - value = "%s" - visibility = "selected" - selected_repository_ids = [github_repository.test.repo_id] - } - `, repoName, value) - - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_organization_variable.variable", "value", - value, - ), - resource.TestCheckResourceAttr( - "github_actions_organization_variable.variable", "visibility", - "selected", - ), - resource.TestCheckResourceAttr( - "github_actions_organization_variable.variable", "selected_repository_ids.#", - "1", - ), - resource.TestCheckResourceAttrSet( - "github_actions_organization_variable.variable", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_organization_variable.variable", "updated_at", - ), - ), - } + config := ` +resource "github_actions_organization_variable" "test" { + variable_name = "%s" + value = "%s" + visibility = "private" +} +` resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, - Check: checks["before"], + Config: fmt.Sprintf(config, varName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "value", value), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "visibility", "private"), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, varName, valueUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "value", valueUpdated), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "visibility", "private"), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "updated_at"), + ), }, }, }) }) - t.Run("deletes organization variables without error", func(t *testing.T) { + t.Run("create_update_visibility_selected", func(t *testing.T) { + repoName0 := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)) + repoName1 := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)) + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + varName := fmt.Sprintf("test_%s", randomID) + value := "foo" + valueUpdated := "bar" + config := ` - resource "github_actions_organization_variable" "variable" { - variable_name = "test_variable" - value = "my_variable_value" - visibility = "private" - } - ` +resource "github_repository" "test_0" { + name = "%s" +} + +resource "github_repository" "test_1" { + name = "%s" +} + +resource "github_actions_organization_variable" "test" { + variable_name = "%s" + value = "%s" + visibility = "selected" + + selected_repository_ids = [github_repository.test_%s.repo_id] +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName0, repoName1, varName, value, "0"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "value", value), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "visibility", "selected"), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "selected_repository_ids.#", "1"), + resource.TestCheckResourceAttrPair("github_actions_organization_variable.test", "selected_repository_ids.0", "github_repository.test_0", "repo_id"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, repoName0, repoName1, varName, valueUpdated, "1"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "value", valueUpdated), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "visibility", "selected"), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "selected_repository_ids.#", "1"), + resource.TestCheckResourceAttrPair("github_actions_organization_variable.test", "selected_repository_ids.0", "github_repository.test_1", "repo_id"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_update_change_visibility", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + varName := fmt.Sprintf("test_%s", randomID) + value := "foo" + visibility := "all" + valueUpdated := "bar" + visibilityUpdated := "private" + + config := ` +resource "github_actions_organization_variable" "test" { + variable_name = "%s" + value = "%s" + visibility = "%s" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, varName, value, visibility), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "value", value), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "visibility", visibility), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, varName, valueUpdated, visibilityUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "value", valueUpdated), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "visibility", visibilityUpdated), + resource.TestCheckResourceAttr("github_actions_organization_variable.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_organization_variable.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("destroy", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + varName := fmt.Sprintf("test_%s", randomID) + + config := fmt.Sprintf(` +resource "github_actions_organization_variable" "test" { + variable_name = "%s" + value = "foo" + visibility = "all" +} +`, varName) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ + { + Config: config, + }, { Config: config, Destroy: true, @@ -145,17 +232,17 @@ func TestAccGithubActionsOrganizationVariable(t *testing.T) { }) }) - t.Run("imports an organization variable without error", func(t *testing.T) { - value := "my_variable_value" - varName := "test_variable" + t.Run("import", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + varName := fmt.Sprintf("test_%s", randomID) config := fmt.Sprintf(` - resource "github_actions_organization_variable" "variable" { - variable_name = "%s" - value = "%s" - visibility = "private" - } - `, varName, value) +resource "github_actions_organization_variable" "test" { + variable_name = "%s" + value = "foo" + visibility = "all" +} +`, varName) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, @@ -165,12 +252,93 @@ func TestAccGithubActionsOrganizationVariable(t *testing.T) { Config: config, }, { - ResourceName: "github_actions_organization_variable.variable", - ImportStateId: varName, + ResourceName: "github_actions_organization_variable.test", ImportState: true, ImportStateVerify: true, }, }, }) }) + + t.Run("error_on_invalid_selected_repository_ids", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + varName := fmt.Sprintf("test_%s", randomID) + + config := fmt.Sprintf(` +resource "github_actions_organization_variable" "test" { + variable_name = "%s" + value = "foo" + visibility = "all" + + selected_repository_ids = [123456] +} +`, varName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("cannot use selected_repository_ids without visibility being set to selected"), + }, + }, + }) + }) + + t.Run("error_on_existing", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + varName := fmt.Sprintf("test_%s", randomID) + + config := fmt.Sprintf(` +resource "github_actions_organization_variable" "test" { + variable_name = "%s" + value = "foo" + visibility = "all" +} +`, varName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: ` +`, + Check: func(*terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + _, err = client.Actions.CreateOrgVariable(ctx, owner, &github.ActionsVariable{ + Name: varName, + Value: "test", + Visibility: github.Ptr("all"), + }) + return err + }, + }, + { + Config: config, + ExpectError: regexp.MustCompile(`Variable already exists`), + Check: func(*terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + _, err = client.Actions.DeleteOrgVariable(ctx, owner, varName) + return err + }, + }, + }, + }) + }) } diff --git a/github/resource_github_actions_secret.go b/github/resource_github_actions_secret.go index a831076a9d..fbd6e2932e 100644 --- a/github/resource_github_actions_secret.go +++ b/github/resource_github_actions_secret.go @@ -7,102 +7,136 @@ import ( "fmt" "log" "net/http" - "strings" "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "golang.org/x/crypto/nacl/box" ) func resourceGithubActionsSecret() *schema.Resource { return &schema.Resource{ - CreateContext: resourceGithubActionsSecretCreateOrUpdate, - ReadContext: resourceGithubActionsSecretRead, - DeleteContext: resourceGithubActionsSecretDelete, - Importer: &schema.ResourceImporter{ - StateContext: resourceGithubActionsSecretImport, - }, - - // Schema migration added to handle the addition of destroy_on_drift field - // Resources created before this field was added need it populated with default value - SchemaVersion: 1, + SchemaVersion: 2, StateUpgraders: []schema.StateUpgrader{ { - Type: resourceGithubActionsSecretResourceV0().CoreConfigSchema().ImpliedType(), - Upgrade: resourceGithubActionsSecretInstanceStateUpgradeV0, + Type: resourceGithubActionsSecretV0().CoreConfigSchema().ImpliedType(), + Upgrade: resourceGithubActionsSecretStateUpgradeV0, Version: 0, }, + { + Type: resourceGithubActionsSecretV1().CoreConfigSchema().ImpliedType(), + Upgrade: resourceGithubActionsSecretStateUpgradeV1, + Version: 1, + }, }, Schema: map[string]*schema.Schema{ "repository": { Type: schema.TypeString, Required: true, - ForceNew: true, Description: "Name of the repository.", }, + "repository_id": { + Type: schema.TypeInt, + Computed: true, + Description: "ID of the repository.", + }, "secret_name": { Type: schema.TypeString, Required: true, ForceNew: true, - Description: "Name of the secret.", ValidateDiagFunc: validateSecretNameFunc, + Description: "Name of the secret.", }, - "encrypted_value": { + "key_id": { Type: schema.TypeString, - ForceNew: true, Optional: true, - Sensitive: true, + Computed: true, ConflictsWith: []string{"plaintext_value"}, - Description: "Encrypted value of the secret using the GitHub public key in Base64 format.", + Description: "ID of the public key used to encrypt the secret.", + }, + "encrypted_value": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + ExactlyOneOf: []string{"encrypted_value", "plaintext_value"}, + Description: "Encrypted value of the secret using the GitHub public key in Base64 format.", }, "plaintext_value": { - Type: schema.TypeString, - ForceNew: true, - Optional: true, - Sensitive: true, - ConflictsWith: []string{"encrypted_value"}, - Description: "Plaintext value of the secret to be encrypted.", + Type: schema.TypeString, + Optional: true, + Sensitive: true, + ExactlyOneOf: []string{"encrypted_value", "plaintext_value"}, + Description: "Plaintext value of the secret to be encrypted.", }, "created_at": { Type: schema.TypeString, Computed: true, - Description: "Date of 'actions_secret' creation.", + Description: "Date of secret creation.", }, "updated_at": { Type: schema.TypeString, Computed: true, - Description: "Date of 'actions_secret' update.", + Description: "Date of secret update.", + }, + "remote_updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of secret update at the remote.", }, "destroy_on_drift": { - Type: schema.TypeBool, - Default: true, - Optional: true, - ForceNew: true, - Description: "Boolean indicating whether to recreate the secret if it's modified outside of Terraform. When `true` (default), Terraform will delete and recreate the secret if it detects external changes. When `false`, Terraform will acknowledge external changes but not recreate the secret.", + Type: schema.TypeBool, + Optional: true, + Deprecated: "This is no longer required and will be removed in a future release. Drift detection is now always performed, and external changes will result in the secret being updated to match the Terraform configuration. If you want to ignore external changes, you can use the `lifecycle` block with `ignore_changes` on the `remote_updated_at` field.", }, }, + + CustomizeDiff: customdiff.All( + diffRepository, + diffSecret, + ), + + CreateContext: resourceGithubActionsSecretCreate, + ReadContext: resourceGithubActionsSecretRead, + UpdateContext: resourceGithubActionsSecretUpdate, + DeleteContext: resourceGithubActionsSecretDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubActionsSecretImport, + }, } } -func resourceGithubActionsSecretCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name +func resourceGithubActionsSecretCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name - repo := d.Get("repository").(string) + repoName := d.Get("repository").(string) secretName := d.Get("secret_name").(string) - plaintextValue := d.Get("plaintext_value").(string) - var encryptedValue string + keyID := d.Get("key_id").(string) + encryptedValue := d.Get("encrypted_value").(string) - keyId, publicKey, err := getPublicKeyDetails(owner, repo, meta) + repo, _, err := client.Repositories.Get(ctx, owner, repoName) if err != nil { return diag.FromErr(err) } + repoID := int(repo.GetID()) + + var publicKey string + if len(keyID) == 0 || len(encryptedValue) == 0 { + ki, pk, err := getPublicKeyDetails(ctx, meta, repoName) + if err != nil { + return diag.FromErr(err) + } + + keyID = ki + publicKey = pk + } + + if len(encryptedValue) == 0 { + plaintextValue := d.Get("plaintext_value").(string) - if encryptedText, ok := d.GetOk("encrypted_value"); ok { - encryptedValue = encryptedText.(string) - } else { encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) if err != nil { return diag.FromErr(err) @@ -110,38 +144,60 @@ func resourceGithubActionsSecretCreateOrUpdate(ctx context.Context, d *schema.Re encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) } - // Create an EncryptedSecret and encrypt the plaintext value into it - eSecret := &github.EncryptedSecret{ + secret := github.EncryptedSecret{ Name: secretName, - KeyID: keyId, + KeyID: keyID, EncryptedValue: encryptedValue, } - _, err = client.Actions.CreateOrUpdateRepoSecret(ctx, owner, repo, eSecret) + _, err = client.Actions.CreateOrUpdateRepoSecret(ctx, owner, repoName, &secret) if err != nil { return diag.FromErr(err) } - d.SetId(buildTwoPartID(repo, secretName)) - return resourceGithubActionsSecretRead(ctx, d, meta) -} - -func resourceGithubActionsSecretRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - - repoName, secretName, err := parseTwoPartID(d.Id(), "repository", "secret_name") + id, err := buildID(repoName, secretName) if err != nil { return diag.FromErr(err) } + d.SetId(id) + + if err := d.Set("repository_id", repoID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("key_id", keyID); err != nil { + return diag.FromErr(err) + } + + // GitHub API does not return on create so we have to lookup the secret to get timestamps + if secret, _, err := client.Actions.GetRepoSecret(ctx, owner, repoName, secretName); err == nil { + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } + + return nil +} + +func resourceGithubActionsSecretRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName := d.Get("repository").(string) + secretName := d.Get("secret_name").(string) secret, _, err := client.Actions.GetRepoSecret(ctx, owner, repoName, secretName) if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) { if ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[INFO] Removing actions secret %s from state because it no longer exists in GitHub", - d.Id()) + log.Printf("[INFO] Removing actions secret %s from state because it no longer exists in GitHub", d.Id()) d.SetId("") return nil } @@ -149,120 +205,173 @@ func resourceGithubActionsSecretRead(ctx context.Context, d *schema.ResourceData return diag.FromErr(err) } - if err = d.Set("created_at", secret.CreatedAt.String()); err != nil { + id, err := buildID(repoName, secretName) + if err != nil { return diag.FromErr(err) } + d.SetId(id) - // This is a drift detection mechanism based on timestamps. - // - // If we do not currently store the "updated_at" field, it means we've only - // just created the resource and the value is most likely what we want it to - // be. - // - // If the resource is changed externally in the meantime then reading back - // the last update timestamp will return a result different than the - // timestamp we've persisted in the state. In that case, we can no longer - // trust that the value (which we don't see) is equal to what we've declared - // previously. - destroyOnDrift := d.Get("destroy_on_drift").(bool) - storedUpdatedAt, hasStoredUpdatedAt := d.GetOk("updated_at") - - if hasStoredUpdatedAt && storedUpdatedAt != secret.UpdatedAt.String() { - log.Printf("[INFO] The secret %s has been externally updated in GitHub", d.Id()) - - if destroyOnDrift { - // Original behavior: mark for recreation - d.SetId("") - return nil - } else { - // Alternative approach: set sensitive values to empty to trigger update plan - // This tells Terraform that the current state is unknown and needs reconciliation - if err = d.Set("encrypted_value", ""); err != nil { - return diag.FromErr(err) - } - if err = d.Set("plaintext_value", ""); err != nil { - return diag.FromErr(err) - } - log.Printf("[INFO] Detected drift but destroy_on_drift=false, clearing sensitive values to trigger update") - } - } else { - // No drift detected, preserve the configured values in state - if err = d.Set("encrypted_value", d.Get("encrypted_value")); err != nil { + // Due to the eventually consistent behavior of this API we may not get created_at/updated_at + // values on the first read after creation, so we only set them here if they are not already set. + if len(d.Get("created_at").(string)) == 0 { + if err = d.Set("created_at", secret.CreatedAt.String()); err != nil { return diag.FromErr(err) } - if err = d.Set("plaintext_value", d.Get("plaintext_value")); err != nil { + } + if len(d.Get("updated_at").(string)) == 0 { + if err = d.Set("updated_at", secret.UpdatedAt.String()); err != nil { return diag.FromErr(err) } - } // Always update the timestamp to prevent repeated drift detection - if err = d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + } + if err = d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { return diag.FromErr(err) } return nil } -func resourceGithubActionsSecretDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client - orgName := meta.(*Owner).name - ctx = context.WithValue(ctx, ctxId, d.Id()) +func resourceGithubActionsSecretUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName := d.Get("repository").(string) + secretName := d.Get("secret_name").(string) + keyID := d.Get("key_id").(string) + encryptedValue := d.Get("encrypted_value").(string) + + var publicKey string + if len(keyID) == 0 || len(encryptedValue) == 0 { + ki, pk, err := getPublicKeyDetails(ctx, meta, repoName) + if err != nil { + return diag.FromErr(err) + } + + keyID = ki + publicKey = pk + } + + if len(encryptedValue) == 0 { + plaintextValue := d.Get("plaintext_value").(string) + + encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) + if err != nil { + return diag.FromErr(err) + } + encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) + } + + secret := github.EncryptedSecret{ + Name: secretName, + KeyID: keyID, + EncryptedValue: encryptedValue, + } - repoName, secretName, err := parseTwoPartID(d.Id(), "repository", "secret_name") + _, err := client.Actions.CreateOrUpdateRepoSecret(ctx, owner, repoName, &secret) if err != nil { return diag.FromErr(err) } - _, err = client.Actions.DeleteRepoSecret(ctx, orgName, repoName, secretName) + id, err := buildID(repoName, secretName) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + if err := d.Set("key_id", keyID); err != nil { + return diag.FromErr(err) + } + + // GitHub API does not return on update so we have to lookup the secret to get timestamps + if secret, _, err := client.Actions.GetRepoSecret(ctx, owner, repoName, secretName); err == nil { + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } else { + if err := d.Set("updated_at", nil); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", nil); err != nil { + return diag.FromErr(err) + } + } - return diag.FromErr(err) + return nil } -func resourceGithubActionsSecretImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name +func resourceGithubActionsSecretDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName := d.Get("repository").(string) + secretName := d.Get("secret_name").(string) - parts := strings.Split(d.Id(), "/") - if len(parts) != 2 { - return nil, fmt.Errorf("invalid ID specified: supplied ID must be written as /") + log.Printf("[INFO] Deleting actions repo secret: %s", d.Id()) + _, err := client.Actions.DeleteRepoSecret(ctx, owner, repoName, secretName) + if err != nil { + return diag.FromErr(err) } - d.SetId(buildTwoPartID(parts[0], parts[1])) + return nil +} + +func resourceGithubActionsSecretImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name - repoName, secretName, err := parseTwoPartID(d.Id(), "repository", "secret_name") + repoName, secretName, err := parseID2(d.Id()) if err != nil { return nil, err } + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, err + } + repoID := int(repo.GetID()) + secret, _, err := client.Actions.GetRepoSecret(ctx, owner, repoName, secretName) if err != nil { return nil, err } - if err = d.Set("repository", repoName); err != nil { + if err := d.Set("repository", repoName); err != nil { return nil, err } - if err = d.Set("secret_name", secretName); err != nil { + if err := d.Set("repository_id", repoID); err != nil { return nil, err } - - // encrypted_value or plaintext_value can not be imported - - if err = d.Set("created_at", secret.CreatedAt.String()); err != nil { + if err := d.Set("secret_name", secretName); err != nil { + return nil, err + } + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return nil, err + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { return nil, err } - if err = d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { return nil, err } return []*schema.ResourceData{d}, nil } -func getPublicKeyDetails(owner, repository string, meta any) (keyId, pkValue string, err error) { - client := meta.(*Owner).v3client - ctx := context.Background() +func getPublicKeyDetails(ctx context.Context, meta *Owner, repository string) (string, string, error) { + client := meta.v3client + owner := meta.name publicKey, _, err := client.Actions.GetRepoPublicKey(ctx, owner, repository) if err != nil { - return keyId, pkValue, err + return "", "", err } return publicKey.GetKeyID(), publicKey.GetKey(), err diff --git a/github/resource_github_actions_secret_migration.go b/github/resource_github_actions_secret_migration.go index 5be1ce20bb..f0f892c3f0 100644 --- a/github/resource_github_actions_secret_migration.go +++ b/github/resource_github_actions_secret_migration.go @@ -2,13 +2,16 @@ package github import ( "context" + "fmt" "log" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -func resourceGithubActionsSecretResourceV0() *schema.Resource { +func resourceGithubActionsSecretV0() *schema.Resource { return &schema.Resource{ + SchemaVersion: 0, + Schema: map[string]*schema.Schema{ "repository": { Type: schema.TypeString, @@ -53,8 +56,64 @@ func resourceGithubActionsSecretResourceV0() *schema.Resource { } } -func resourceGithubActionsSecretInstanceStateUpgradeV0(ctx context.Context, rawState map[string]any, meta any) (map[string]any, error) { +func resourceGithubActionsSecretV1() *schema.Resource { + return &schema.Resource{ + SchemaVersion: 1, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the repository.", + }, + "secret_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the secret.", + ValidateDiagFunc: validateSecretNameFunc, + }, + "encrypted_value": { + Type: schema.TypeString, + ForceNew: true, + Optional: true, + Sensitive: true, + ConflictsWith: []string{"plaintext_value"}, + Description: "Encrypted value of the secret using the GitHub public key in Base64 format.", + }, + "plaintext_value": { + Type: schema.TypeString, + ForceNew: true, + Optional: true, + Sensitive: true, + ConflictsWith: []string{"encrypted_value"}, + Description: "Plaintext value of the secret to be encrypted.", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'actions_secret' creation.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'actions_secret' update.", + }, + "destroy_on_drift": { + Type: schema.TypeBool, + Default: true, + Optional: true, + ForceNew: true, + Description: "Boolean indicating whether to recreate the secret if it's modified outside of Terraform. When `true` (default), Terraform will delete and recreate the secret if it detects external changes. When `false`, Terraform will acknowledge external changes but not recreate the secret.", + }, + }, + } +} + +func resourceGithubActionsSecretStateUpgradeV0(ctx context.Context, rawState map[string]any, _ any) (map[string]any, error) { log.Printf("[DEBUG] GitHub Actions Secret State before migration: %#v", rawState) + // Add the destroy_on_drift field with default value true if it doesn't exist if _, ok := rawState["destroy_on_drift"]; !ok { rawState["destroy_on_drift"] = true @@ -64,3 +123,27 @@ func resourceGithubActionsSecretInstanceStateUpgradeV0(ctx context.Context, rawS return rawState, nil } + +func resourceGithubActionsSecretStateUpgradeV1(ctx context.Context, rawState map[string]any, m any) (map[string]any, error) { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + log.Printf("[DEBUG] GitHub Actions Secret Attributes before migration: %#v", rawState) + + repoName, ok := rawState["repository"].(string) + if !ok { + return nil, fmt.Errorf("repository not found or is not a string") + } + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve repository %s: %w", repoName, err) + } + + rawState["repository_id"] = int(repo.GetID()) + + log.Printf("[DEBUG] GitHub Actions Secret Attributes after migration: %#v", rawState) + + return rawState, nil +} diff --git a/github/resource_github_actions_secret_migration_test.go b/github/resource_github_actions_secret_migration_test.go index 7729d0191a..dfe589c851 100644 --- a/github/resource_github_actions_secret_migration_test.go +++ b/github/resource_github_actions_secret_migration_test.go @@ -1,55 +1,124 @@ package github import ( + "context" "reflect" "testing" ) -func testResourceGithubActionsSecretInstanceStateDataV0() map[string]any { - return map[string]any{ - "id": "test-secret", - "repository": "test-repo", - "secret_name": "test-secret", - "created_at": "2023-01-01T00:00:00Z", - "updated_at": "2023-01-01T00:00:00Z", - "plaintext_value": "secret-value", +func Test_resourceGithubActionsSecretStateUpgradeV0(t *testing.T) { + t.Parallel() + + for _, d := range []struct { + testName string + rawState map[string]any + want map[string]any + shouldError bool + }{ + { + testName: "migrates_v0_to_v1", + rawState: map[string]any{ + "id": "test-repo:test-secret", + "repository": "test-repo", + "secret_name": "test-secret", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + "plaintext_value": "secret-value", + }, + want: map[string]any{ + "id": "test-repo:test-secret", + "repository": "test-repo", + "secret_name": "test-secret", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + "plaintext_value": "secret-value", + "destroy_on_drift": true, + }, + shouldError: false, + }, + { + testName: "migrates_v0_to_v1_with_existing_destroy_on_drift", + rawState: map[string]any{ + "id": "test-repo:test-secret", + "repository": "test-repo", + "secret_name": "test-secret", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + "plaintext_value": "secret-value", + "destroy_on_drift": false, + }, + want: map[string]any{ + "id": "test-repo:test-secret", + "repository": "test-repo", + "secret_name": "test-secret", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + "plaintext_value": "secret-value", + "destroy_on_drift": false, + }, + shouldError: false, + }, + } { + t.Run(d.testName, func(t *testing.T) { + t.Parallel() + + got, err := resourceGithubActionsSecretStateUpgradeV0(context.Background(), d.rawState, nil) + if (err != nil) != d.shouldError { + t.Fatalf("unexpected error state") + } + + if !d.shouldError && !reflect.DeepEqual(got, d.want) { + t.Fatalf("got %+v, want %+v", got, d.want) + } + }) } } -func testResourceGithubActionsSecretInstanceStateDataV0_WithDrift() map[string]any { - v0 := testResourceGithubActionsSecretInstanceStateDataV0() - v0["destroy_on_drift"] = false - return v0 -} +// TODO: Enable this test once we have a pattern to create a mock client for the test. +// func Test_resourceGithubActionsSecretStateUpgradeV1(t *testing.T) { +// t.Parallel() -func testResourceGithubActionsSecretInstanceStateDataV1() map[string]any { - v0 := testResourceGithubActionsSecretInstanceStateDataV0() - v0["destroy_on_drift"] = true - return v0 -} +// for _, d := range []struct { +// testName string +// rawState map[string]any +// want map[string]any +// shouldError bool +// }{ +// { +// testName: "migrates v1 to v2", +// rawState: map[string]any{ +// "id": "test-repo:test-secret", +// "repository": "test-repo", +// "secret_name": "test-secret", +// "created_at": "2023-01-01T00:00:00Z", +// "updated_at": "2023-01-01T00:00:00Z", +// "plaintext_value": "secret-value", +// "destroy_on_drift": true, +// }, +// want: map[string]any{ +// "id": "test-repo:test-secret", +// "repository": "test-repo", +// "repository_id": "123456", +// "secret_name": "test-secret", +// "created_at": "2023-01-01T00:00:00Z", +// "updated_at": "2023-01-01T00:00:00Z", +// "plaintext_value": "secret-value", +// "destroy_on_drift": true, +// }, +// shouldError: false, +// }, +// } { +// t.Run(d.testName, func(t *testing.T) { +// t.Parallel() -func TestGithub_MigrateActionsSecretStateV0toV1(t *testing.T) { - t.Run("without destroy_on_drift", func(t *testing.T) { - expected := testResourceGithubActionsSecretInstanceStateDataV1() - actual, err := resourceGithubActionsSecretInstanceStateUpgradeV0(t.Context(), testResourceGithubActionsSecretInstanceStateDataV0(), nil) - if err != nil { - t.Fatalf("error migrating state: %s", err) - } - - if !reflect.DeepEqual(expected, actual) { - t.Fatalf("\n\nexpected:\n\n%#v\n\ngot:\n\n%#v\n\n", expected, actual) - } - }) - - t.Run("with destroy_on_drift", func(t *testing.T) { - expected := testResourceGithubActionsSecretInstanceStateDataV0_WithDrift() - actual, err := resourceGithubActionsSecretInstanceStateUpgradeV0(t.Context(), testResourceGithubActionsSecretInstanceStateDataV0_WithDrift(), nil) - if err != nil { - t.Fatalf("error migrating state: %s", err) - } - - if !reflect.DeepEqual(expected, actual) { - t.Fatalf("\n\nexpected:\n\n%#v\n\ngot:\n\n%#v\n\n", expected, actual) - } - }) -} +// got, err := resourceGithubActionsSecretStateUpgradeV1(context.Background(), d.rawState, nil) +// if (err != nil) != d.shouldError { +// t.Fatalf("unexpected error state") +// } + +// if !d.shouldError && !reflect.DeepEqual(got, d.want) { +// t.Fatalf("got %+v, want %+v", got, d.want) +// } +// }) +// } +// } diff --git a/github/resource_github_actions_secret_test.go b/github/resource_github_actions_secret_test.go index 0186ef296e..99f812a4cc 100644 --- a/github/resource_github_actions_secret_test.go +++ b/github/resource_github_actions_secret_test.go @@ -1,39 +1,35 @@ package github import ( + "context" "encoding/base64" "fmt" - "strings" "testing" + "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) func TestAccGithubActionsSecret(t *testing.T) { - t.Run("reads a repository public key without error", func(t *testing.T) { + t.Run("create_plaintext", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-secret-%s", testResourcePrefix, randomID) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" + value := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } - - data "github_actions_public_key" "test_pk" { - repository = github_repository.test.name - } - `, repoName) - - check := resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttrSet( - "data.github_actions_public_key.test_pk", "key_id", - ), - resource.TestCheckResourceAttrSet( - "data.github_actions_public_key.test_pk", "key", - ), - ) +resource "github_repository" "test" { + name = "%s" +} + +resource "github_actions_secret" "test" { + repository = github_repository.test.name + secret_name = "%s" + plaintext_value = "%s" +} +`, repoName, secretName, value) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, @@ -41,236 +37,196 @@ func TestAccGithubActionsSecret(t *testing.T) { Steps: []resource.TestStep{ { Config: config, - Check: check, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttr("github_actions_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_actions_secret.test", "encrypted_value"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + ), }, }, }) }) - t.Run("creates and updates secrets without error", func(t *testing.T) { + t.Run("create_update_plaintext", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-secret-%s", testResourcePrefix, randomID) - secretValue := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) - updatedSecretValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" + value := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + updatedValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + + config := ` +resource "github_repository" "test" { + name = "%s" +} - config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } - - resource "github_actions_secret" "plaintext_secret" { - repository = github_repository.test.name - secret_name = "test_plaintext_secret" - plaintext_value = "%s" - } - - resource "github_actions_secret" "encrypted_secret" { - repository = github_repository.test.name - secret_name = "test_encrypted_secret" - encrypted_value = "%s" - } - `, repoName, secretValue, secretValue) - - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_secret.plaintext_secret", "plaintext_value", - secretValue, - ), - resource.TestCheckResourceAttr( - "github_actions_secret.encrypted_secret", "encrypted_value", - secretValue, - ), - resource.TestCheckResourceAttrSet( - "github_actions_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_secret.plaintext_secret", "updated_at", - ), - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_secret.plaintext_secret", "plaintext_value", - updatedSecretValue, - ), - resource.TestCheckResourceAttr( - "github_actions_secret.encrypted_secret", "encrypted_value", - updatedSecretValue, - ), - resource.TestCheckResourceAttrSet( - "github_actions_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_secret.plaintext_secret", "updated_at", - ), - ), - } +resource "github_actions_secret" "test" { + repository = github_repository.test.name + secret_name = "%s" + plaintext_value = "%s" +} +` resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, - Check: checks["before"], + Config: fmt.Sprintf(config, repoName, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttr("github_actions_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_actions_secret.test", "encrypted_value"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + ), }, { - Config: strings.Replace(config, - secretValue, - updatedSecretValue, 2), - Check: checks["after"], + Config: fmt.Sprintf(config, repoName, secretName, updatedValue), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttr("github_actions_secret.test", "plaintext_value", updatedValue), + resource.TestCheckNoResourceAttr("github_actions_secret.test", "encrypted_value"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + ), }, }, }) }) - t.Run("creates and updates repository name without error", func(t *testing.T) { + t.Run("create_update_encrypted", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-secret-%s", testResourcePrefix, randomID) - updatedRepoName := fmt.Sprintf("%srepo-act-secret-%s-upd", testResourcePrefix, randomID) - secretValue := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" + value := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + updatedValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + + config := ` +resource "github_repository" "test" { + name = "%s" +} - config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } - - resource "github_actions_secret" "plaintext_secret" { - repository = github_repository.test.name - secret_name = "test_plaintext_secret" - plaintext_value = "%s" - } - - resource "github_actions_secret" "encrypted_secret" { - repository = github_repository.test.name - secret_name = "test_encrypted_secret" - encrypted_value = "%s" - } - `, repoName, secretValue, secretValue) - - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_secret.plaintext_secret", "repository", - repoName, - ), - resource.TestCheckResourceAttr( - "github_actions_secret.plaintext_secret", "plaintext_value", - secretValue, - ), - resource.TestCheckResourceAttr( - "github_actions_secret.encrypted_secret", "encrypted_value", - secretValue, - ), - resource.TestCheckResourceAttrSet( - "github_actions_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_secret.plaintext_secret", "updated_at", - ), - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_secret.plaintext_secret", "repository", - updatedRepoName, - ), - resource.TestCheckResourceAttr( - "github_actions_secret.plaintext_secret", "plaintext_value", - secretValue, - ), - resource.TestCheckResourceAttr( - "github_actions_secret.encrypted_secret", "encrypted_value", - secretValue, - ), - resource.TestCheckResourceAttrSet( - "github_actions_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_secret.plaintext_secret", "updated_at", - ), - ), - } +resource "github_actions_secret" "test" { + repository = github_repository.test.name + secret_name = "%s" + encrypted_value = "%s" +} +` resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, - Check: checks["before"], + Config: fmt.Sprintf(config, repoName, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_secret.test", "secret_name", secretName), + resource.TestCheckNoResourceAttr("github_actions_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_actions_secret.test", "encrypted_value", value), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + ), }, { - Config: strings.Replace(config, - repoName, - updatedRepoName, 2), - Check: checks["after"], + Config: fmt.Sprintf(config, repoName, secretName, updatedValue), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_secret.test", "secret_name", secretName), + resource.TestCheckNoResourceAttr("github_actions_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_actions_secret.test", "encrypted_value", updatedValue), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + ), }, }, }) }) - t.Run("deletes secrets without error", func(t *testing.T) { + t.Run("create_update_encrypted_with_key", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-secret-%s", testResourcePrefix, randomID) - config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" + value := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + updatedValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + + config := ` +resource "github_repository" "test" { + name = "%s" +} - resource "github_actions_secret" "plaintext_secret" { - repository = github_repository.test.name - secret_name = "test_plaintext_secret" - } +data "github_actions_public_key" "test" { + repository = github_repository.test.name +} - resource "github_actions_secret" "encrypted_secret" { - repository = github_repository.test.name - secret_name = "test_encrypted_secret" - } - `, repoName) +resource "github_actions_secret" "test" { + repository = github_repository.test.name + key_id = data.github_actions_public_key.test.key_id + secret_name = "%s" + encrypted_value = "%s" +} +` resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, - Destroy: true, + Config: fmt.Sprintf(config, repoName, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_secret.test", "secret_name", secretName), + resource.TestCheckNoResourceAttr("github_actions_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_actions_secret.test", "encrypted_value", value), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, repoName, secretName, updatedValue), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_secret.test", "secret_name", secretName), + resource.TestCheckNoResourceAttr("github_actions_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_actions_secret.test", "encrypted_value", updatedValue), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + ), }, }, }) }) - t.Run("respects destroy_on_drift setting", func(t *testing.T) { + t.Run("update_on_drift", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-secret-%s", testResourcePrefix, randomID) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } - - resource "github_actions_secret" "with_drift_true" { - repository = github_repository.test.name - secret_name = "test_drift_true" - plaintext_value = "initial_value" - destroy_on_drift = true - } - - resource "github_actions_secret" "with_drift_false" { - repository = github_repository.test.name - secret_name = "test_drift_false" - plaintext_value = "initial_value" - destroy_on_drift = false - } - - resource "github_actions_secret" "default_behavior" { - repository = github_repository.test.name - secret_name = "test_default" - plaintext_value = "initial_value" - # destroy_on_drift defaults to true - } - `, repoName) +resource "github_repository" "test" { + name = "%s" +} + +resource "github_actions_secret" "test" { + repository = github_repository.test.name + secret_name = "%s" + plaintext_value = "test" +} +`, repoName, secretName) + var beforeCreatedAt string resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, @@ -278,282 +234,325 @@ func TestAccGithubActionsSecret(t *testing.T) { { Config: config, Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_secret.with_drift_true", "destroy_on_drift", "true"), - resource.TestCheckResourceAttr( - "github_actions_secret.with_drift_false", "destroy_on_drift", "false"), - resource.TestCheckResourceAttr( - "github_actions_secret.default_behavior", "destroy_on_drift", "true"), - resource.TestCheckResourceAttr( - "github_actions_secret.with_drift_true", "plaintext_value", "initial_value"), - resource.TestCheckResourceAttr( - "github_actions_secret.with_drift_false", "plaintext_value", "initial_value"), - resource.TestCheckResourceAttr( - "github_actions_secret.default_behavior", "plaintext_value", "initial_value"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_actions_secret.test"].Primary.Attributes["created_at"] + return nil + }, + ), + }, + { + PreConfig: func() { + meta, err := getTestMeta() + if err != nil { + t.Fatal(err.Error()) + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + keyID, _, err := getPublicKeyDetails(ctx, meta, repoName) + if err != nil { + t.Fatal(err.Error()) + } + + _, err = client.Actions.CreateOrUpdateRepoSecret(ctx, owner, repoName, &github.EncryptedSecret{ + Name: secretName, + EncryptedValue: base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")), + KeyID: keyID, + }) + if err != nil { + t.Fatal(err.Error()) + } + }, + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_actions_secret.test"].Primary.Attributes["created_at"] + + if afterCreatedAt != beforeCreatedAt { + return fmt.Errorf("expected resource to be updated, but created_at has been modified: %s", beforeCreatedAt) + } + return nil + }, ), }, }, }) }) + + t.Run("lifecycle_can_ignore_drift", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" + + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" } -// Unit tests for drift detection behavior. -func TestGithubActionsSecretDriftDetection(t *testing.T) { - t.Run("destroyOnDrift true causes recreation on timestamp mismatch", func(t *testing.T) { - originalTimestamp := "2023-01-01T00:00:00Z" - newTimestamp := "2023-01-02T00:00:00Z" - - d := schema.TestResourceDataRaw(t, resourceGithubActionsSecret().Schema, map[string]any{ - "repository": "test-repo", - "secret_name": "test-secret", - "plaintext_value": "test-value", - "destroy_on_drift": true, - "updated_at": originalTimestamp, - }) - d.SetId("test-secret") - - // Test the drift detection logic - simulate what happens in the read function - destroyOnDrift := d.Get("destroy_on_drift").(bool) - if updatedAt, ok := d.GetOk("updated_at"); ok && destroyOnDrift && updatedAt != newTimestamp { - d.SetId("") // This simulates the drift detection - } - - // Should have cleared the ID (marking for recreation) - if d.Id() != "" { - t.Error("Expected ID to be cleared due to drift detection, but it wasn't") - } - }) +resource "github_actions_secret" "test" { + repository = github_repository.test.name + secret_name = "%s" + plaintext_value = "test" - t.Run("destroyOnDrift false clears sensitive values instead of recreating", func(t *testing.T) { - originalTimestamp := "2023-01-01T00:00:00Z" - newTimestamp := "2023-01-02T00:00:00Z" - - d := schema.TestResourceDataRaw(t, resourceGithubActionsSecret().Schema, map[string]any{ - "repository": "test-repo", - "secret_name": "test-secret", - "plaintext_value": "original-value", - "encrypted_value": "original-encrypted", - "destroy_on_drift": false, - "updated_at": originalTimestamp, + lifecycle { + ignore_changes = [remote_updated_at] + } +} +`, repoName, secretName) + + var beforeUpdatedAt string + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + beforeUpdatedAt = s.RootModule().Resources["github_actions_secret.test"].Primary.Attributes["updated_at"] + return nil + }, + ), + }, + { + PreConfig: func() { + meta, err := getTestMeta() + if err != nil { + t.Fatal(err.Error()) + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + keyID, _, err := getPublicKeyDetails(ctx, meta, repoName) + if err != nil { + t.Fatal(err.Error()) + } + + _, err = client.Actions.CreateOrUpdateRepoSecret(ctx, owner, repoName, &github.EncryptedSecret{ + Name: secretName, + EncryptedValue: base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")), + KeyID: keyID, + }) + if err != nil { + t.Fatal(err.Error()) + } + }, + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + afterUpdatedAt := s.RootModule().Resources["github_actions_secret.test"].Primary.Attributes["updated_at"] + + if afterUpdatedAt != beforeUpdatedAt { + return fmt.Errorf("expected resource to ignore drift, but updated_at has been modified: %s", beforeUpdatedAt) + } + return nil + }, + ), + }, + }, }) - d.SetId("test-secret") - - // Simulate drift detection logic when destroy_on_drift is false - destroyOnDrift := d.Get("destroy_on_drift").(bool) - storedUpdatedAt, hasStoredUpdatedAt := d.GetOk("updated_at") - - if hasStoredUpdatedAt && storedUpdatedAt != newTimestamp { - if destroyOnDrift { - // Would clear ID for recreation - d.SetId("") - } else { - // Should clear sensitive values to trigger update - _ = d.Set("encrypted_value", "") - _ = d.Set("plaintext_value", "") - } - _ = d.Set("updated_at", newTimestamp) - } - - // Should NOT have cleared the ID when destroy_on_drift=false - if d.Id() == "" { - t.Error("Expected ID to be preserved when destroy_on_drift=false, but it was cleared") - } - - // Should have cleared sensitive values to trigger update plan - if plaintextValue := d.Get("plaintext_value").(string); plaintextValue != "" { - t.Errorf("Expected plaintext_value to be cleared for update plan, got %s", plaintextValue) - } - - if encryptedValue := d.Get("encrypted_value").(string); encryptedValue != "" { - t.Errorf("Expected encrypted_value to be cleared for update plan, got %s", encryptedValue) - } - - // Should have updated the timestamp - if updatedAt := d.Get("updated_at").(string); updatedAt != newTimestamp { - t.Errorf("Expected timestamp to be updated to %s, got %s", newTimestamp, updatedAt) - } }) - t.Run("destroyOnDrift true still recreates resource on drift", func(t *testing.T) { - originalTimestamp := "2023-01-01T00:00:00Z" - newTimestamp := "2023-01-02T00:00:00Z" + t.Run("update_renamed_repo", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + updatedRepoName := fmt.Sprintf("%s%s-updated", testResourcePrefix, randomID) + + config := ` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_actions_secret" "test" { + repository = github_repository.test.name + secret_name = "test" + plaintext_value = "test" +} +` - d := schema.TestResourceDataRaw(t, resourceGithubActionsSecret().Schema, map[string]any{ - "repository": "test-repo", - "secret_name": "test-secret", - "plaintext_value": "original-value", - "destroy_on_drift": true, // Explicitly set to true - "updated_at": originalTimestamp, + var beforeCreatedAt string + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_actions_secret.test"].Primary.Attributes["created_at"] + return nil + }, + ), + }, + { + Config: fmt.Sprintf(config, updatedRepoName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_actions_secret.test"].Primary.Attributes["created_at"] + + if afterCreatedAt != beforeCreatedAt { + return fmt.Errorf("expected resource to not be recreated, but created_at has been modified: %s", beforeCreatedAt) + } + return nil + }, + ), + }, + }, }) - d.SetId("test-secret") - - // Simulate drift detection logic when destroy_on_drift is true - destroyOnDrift := d.Get("destroy_on_drift").(bool) - storedUpdatedAt, hasStoredUpdatedAt := d.GetOk("updated_at") - - if hasStoredUpdatedAt && storedUpdatedAt != newTimestamp { - if destroyOnDrift { - // Should clear ID for recreation (original behavior) - d.SetId("") - return // Exit early like the real function would - } - } - - // Should have cleared the ID for recreation when destroy_on_drift=true - if d.Id() != "" { - t.Error("Expected ID to be cleared for recreation when destroy_on_drift=true, but it was preserved") - } }) - t.Run("destroyOnDrift true still recreates resource on drift", func(t *testing.T) { - originalTimestamp := "2023-01-01T00:00:00Z" - newTimestamp := "2023-01-02T00:00:00Z" + t.Run("recreate_changed_repo", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + repoName2 := fmt.Sprintf("%supdated-%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository" "test2" { + name = "%s" +} + +resource "github_actions_secret" "test" { + repository = github_repository.test.name + secret_name = "test" + plaintext_value = "test" +} +`, repoName, repoName2) + + configUpdated := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository" "test2" { + name = "%s" +} - d := schema.TestResourceDataRaw(t, resourceGithubActionsSecret().Schema, map[string]any{ - "repository": "test-repo", - "secret_name": "test-secret", - "plaintext_value": "original-value", - "destroy_on_drift": true, // Explicitly set to true - "updated_at": originalTimestamp, +resource "github_actions_secret" "test" { + repository = github_repository.test2.name + secret_name = "test" + plaintext_value = "test" +} +`, repoName, repoName2) + + var beforeCreatedAt string + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_actions_secret.test"].Primary.Attributes["created_at"] + return nil + }, + ), + }, + { + Config: configUpdated, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_secret.test", "updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_actions_secret.test"].Primary.Attributes["created_at"] + + if afterCreatedAt == beforeCreatedAt { + return fmt.Errorf("expected resource to be recreated, but created_at has not been modified: %s", beforeCreatedAt) + } + return nil + }, + ), + }, + }, }) - d.SetId("test-secret") - - // Simulate drift detection logic when destroy_on_drift is true - destroyOnDrift := d.Get("destroy_on_drift").(bool) - storedUpdatedAt, hasStoredUpdatedAt := d.GetOk("updated_at") - - if hasStoredUpdatedAt && storedUpdatedAt != newTimestamp { - if destroyOnDrift { - // Should clear ID for recreation (original behavior) - d.SetId("") - return // Exit early like the real function would - } - } - - // Should have cleared the ID for recreation when destroy_on_drift=true - if d.Id() != "" { - t.Error("Expected ID to be cleared for recreation when destroy_on_drift=true, but it was preserved") - } }) - t.Run("default destroy_on_drift is true", func(t *testing.T) { - d := schema.TestResourceDataRaw(t, resourceGithubActionsSecret().Schema, map[string]any{ - "repository": "test-repo", - "secret_name": "test-secret", - "plaintext_value": "test-value", - // destroy_on_drift not set, should default to true - }) + t.Run("destroy", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) - destroyOnDrift := d.Get("destroy_on_drift").(bool) - if !destroyOnDrift { - t.Error("Expected destroy_on_drift to default to true") - } - }) + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "%s" + } - t.Run("no drift when timestamps match", func(t *testing.T) { - timestamp := "2023-01-01T00:00:00Z" + resource "github_actions_secret" "test" { + repository = github_repository.test.name + secret_name = "test" + plaintext_value = "test" + } +`, repoName) - d := schema.TestResourceDataRaw(t, resourceGithubActionsSecret().Schema, map[string]any{ - "repository": "test-repo", - "secret_name": "test-secret", - "plaintext_value": "test-value", - "destroy_on_drift": true, - "updated_at": timestamp, + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + }, + { + Config: config, + Destroy: true, + }, + }, }) - d.SetId("test-secret") - - // Simulate same timestamp (no external change) - destroyOnDrift := d.Get("destroy_on_drift").(bool) - if updatedAt, ok := d.GetOk("updated_at"); ok && destroyOnDrift && updatedAt != timestamp { - d.SetId("") // This should NOT happen - } - - // Should NOT have cleared the ID - if d.Id() == "" { - t.Error("Expected ID to be preserved when no drift detected, but it was cleared") - } }) - t.Run("destroy_on_drift field defaults", func(t *testing.T) { - // Test that destroy_on_drift defaults to true for backward compatibility - schema := resourceGithubActionsSecret().Schema["destroy_on_drift"] - if schema.Default != true { - t.Error("destroy_on_drift should default to true for backward compatibility") - } - }) + t.Run("import", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" - t.Run("destroy_on_drift field properties", func(t *testing.T) { - resource := resourceGithubActionsSecret() - driftField := resource.Schema["destroy_on_drift"] - - // Should be optional - if driftField.Required { - t.Error("Expected destroy_on_drift to be optional, but it's required") - } - - if !driftField.Optional { - t.Error("Expected destroy_on_drift to be optional") - } - - // Should be boolean type - if driftField.Type.String() != "TypeBool" { - t.Errorf("Expected destroy_on_drift to be TypeBool, got %s", driftField.Type.String()) - } - - // Should have default value of true - if driftField.Default != true { - t.Errorf("Expected destroy_on_drift default to be true, got %v", driftField.Default) - } - - // Should have description - if driftField.Description == "" { - t.Error("Expected destroy_on_drift to have a description") - } - }) + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" } -// Test demonstrating the solution to GitHub issue #964. -func TestGithubActionsSecretIssue964Solution(t *testing.T) { - t.Run("solve issue 964 - prevent recreation when GUI changes secret", func(t *testing.T) { - // This test demonstrates the fix for: - // https://github.com/integrations/terraform-provider-github/issues/964 - - // Scenario: User creates secret with Terraform, then updates value via GitHub GUI - // Expected: With destroy_on_drift=false, Terraform should not recreate the secret +resource "github_actions_secret" "test" { + repository = github_repository.test.name + secret_name = "%s" + plaintext_value = "test" +} +`, repoName, secretName) - d := schema.TestResourceDataRaw(t, resourceGithubActionsSecret().Schema, map[string]any{ - "repository": "my-repo", - "secret_name": "WORKFLOW_PAT", - "plaintext_value": "CHANGE_ME", // Initial placeholder value - "destroy_on_drift": false, // KEY FIX: Prevents recreation + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + }, + { + ResourceName: "github_actions_secret.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"key_id", "plaintext_value"}, + }, + }, }) - d.SetId("WORKFLOW_PAT") - - // Set initial timestamp - originalTime := "2023-01-01T00:00:00Z" - _ = d.Set("updated_at", originalTime) - - // Simulate: User changes secret value via GitHub GUI - // This changes the updated_at timestamp - newTime := "2023-01-01T12:00:00Z" // Later timestamp = external change - - // Test the read function behavior - this is what happens during terraform plan/apply - destroyOnDrift := d.Get("destroy_on_drift").(bool) // false - if updatedAt, ok := d.GetOk("updated_at"); ok && !destroyOnDrift && updatedAt != newTime { - // With destroy_on_drift=false, we update timestamp but don't clear ID - _ = d.Set("updated_at", newTime) - } - - // RESULT: Secret should NOT be marked for recreation - if d.Id() == "" { - t.Error("ISSUE #964 NOT FIXED: Secret was marked for recreation despite destroy_on_drift=false") - } - - // RESULT: Timestamp should be updated to acknowledge the change - if d.Get("updated_at").(string) != newTime { - t.Error("Expected timestamp to be updated to acknowledge external change") - } - - t.Logf("SUCCESS: Issue #964 solved - secret with destroy_on_drift=false does not get recreated on external changes") }) } diff --git a/github/resource_github_actions_secret_validation_test.go b/github/resource_github_actions_secret_validation_test.go deleted file mode 100644 index 84d273a9d1..0000000000 --- a/github/resource_github_actions_secret_validation_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package github - -import ( - "testing" - - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" -) - -func TestResourceGithubActionsSecretValidation(t *testing.T) { - resource := resourceGithubActionsSecret() - - // Verify the resource does NOT have an Update function since all fields are ForceNew - if resource.UpdateContext != nil || resource.UpdateWithoutTimeout != nil { - t.Fatal("github_actions_secret resource should not have an Update function when all fields are ForceNew") - } - - // Verify destroy_on_drift field exists and is configured correctly - destroyOnDriftSchema, exists := resource.Schema["destroy_on_drift"] - if !exists { - t.Fatal("destroy_on_drift field should exist in schema") - } - - if destroyOnDriftSchema.Type != schema.TypeBool { - t.Error("destroy_on_drift should be TypeBool") - } - - if !destroyOnDriftSchema.Optional { - t.Error("destroy_on_drift should be Optional") - } - - if !destroyOnDriftSchema.ForceNew { - t.Error("destroy_on_drift should be ForceNew when no Update function exists") - } - - // Verify all user-configurable fields are ForceNew (which is why Update is unnecessary) - expectedForceNewFields := []string{"repository", "secret_name", "encrypted_value", "plaintext_value", "destroy_on_drift"} - for _, fieldName := range expectedForceNewFields { - field, exists := resource.Schema[fieldName] - if !exists { - continue // Skip fields that don't exist - } - if !field.Computed && (field.Required || field.Optional) && !field.ForceNew { - t.Errorf("Field %s should have ForceNew: true since no Update function exists", fieldName) - } - } -} diff --git a/github/resource_github_actions_variable.go b/github/resource_github_actions_variable.go index e60b8323d3..32ae69a09e 100644 --- a/github/resource_github_actions_variable.go +++ b/github/resource_github_actions_variable.go @@ -7,17 +7,19 @@ import ( "net/http" "github.com/google/go-github/v82/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) func resourceGithubActionsVariable() *schema.Resource { return &schema.Resource{ - Create: resourceGithubActionsVariableCreate, - Read: resourceGithubActionsVariableRead, - Update: resourceGithubActionsVariableUpdate, - Delete: resourceGithubActionsVariableDelete, - Importer: &schema.ResourceImporter{ - StateContext: schema.ImportStatePassthroughContext, + SchemaVersion: 1, + StateUpgraders: []schema.StateUpgrader{ + { + Type: resourceGithubActionsVariableV0().CoreConfigSchema().ImpliedType(), + Upgrade: resourceGithubActionsVariableStateUpgradeV0, + Version: 0, + }, }, Schema: map[string]*schema.Schema{ @@ -26,6 +28,11 @@ func resourceGithubActionsVariable() *schema.Resource { Required: true, Description: "Name of the repository.", }, + "repository_id": { + Type: schema.TypeInt, + Computed: true, + Description: "ID of the repository.", + }, "variable_name": { Type: schema.TypeString, Required: true, @@ -49,103 +56,196 @@ func resourceGithubActionsVariable() *schema.Resource { Description: "Date of 'actions_variable' update.", }, }, + + CustomizeDiff: diffRepository, + + CreateContext: resourceGithubActionsVariableCreate, + ReadContext: resourceGithubActionsVariableRead, + UpdateContext: resourceGithubActionsVariableUpdate, + DeleteContext: resourceGithubActionsVariableDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubActionsVariableImport, + }, } } -func resourceGithubActionsVariableCreate(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - ctx := context.Background() +func resourceGithubActionsVariableCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name - repo := d.Get("repository").(string) - variable := &github.ActionsVariable{ - Name: d.Get("variable_name").(string), + repoName := d.Get("repository").(string) + varName := d.Get("variable_name").(string) + + variable := github.ActionsVariable{ + Name: varName, Value: d.Get("value").(string), } - _, err := client.Actions.CreateRepoVariable(ctx, owner, repo, variable) + _, err := client.Actions.CreateRepoVariable(ctx, owner, repoName, &variable) if err != nil { - return err + return diag.FromErr(err) } - d.SetId(buildTwoPartID(repo, d.Get("variable_name").(string))) - return resourceGithubActionsVariableRead(d, meta) -} + id, err := buildID(repoName, varName) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) -func resourceGithubActionsVariableUpdate(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - ctx := context.Background() + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return diag.FromErr(err) + } + repoID := int(repo.GetID()) - repo := d.Get("repository").(string) - variable := &github.ActionsVariable{ - Name: d.Get("variable_name").(string), - Value: d.Get("value").(string), + if err := d.Set("repository_id", repoID); err != nil { + return diag.FromErr(err) } - _, err := client.Actions.UpdateRepoVariable(ctx, owner, repo, variable) - if err != nil { - return err + // GitHub API does not return on create so we have to lookup the variable to get timestamps + if variable, _, err := client.Actions.GetRepoVariable(ctx, owner, repoName, varName); err == nil { + if err := d.Set("created_at", variable.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", variable.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } } - d.SetId(buildTwoPartID(repo, d.Get("variable_name").(string))) - return resourceGithubActionsVariableRead(d, meta) + return nil } -func resourceGithubActionsVariableRead(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - ctx := context.Background() +func resourceGithubActionsVariableRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name - repoName, variableName, err := parseTwoPartID(d.Id(), "repository", "variable_name") - if err != nil { - return err - } + repoName := d.Get("repository").(string) + varName := d.Get("variable_name").(string) - variable, _, err := client.Actions.GetRepoVariable(ctx, owner, repoName, variableName) + variable, _, err := client.Actions.GetRepoVariable(ctx, owner, repoName, varName) if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) { if ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[INFO] Removing actions variable %s from state because it no longer exists in GitHub", - d.Id()) + log.Printf("[INFO] Removing actions variable %s from state because it no longer exists in GitHub", d.Id()) d.SetId("") return nil } } - return err + return diag.FromErr(err) } - if err = d.Set("repository", repoName); err != nil { - return err - } - if err = d.Set("variable_name", variableName); err != nil { - return err - } if err = d.Set("value", variable.Value); err != nil { - return err + return diag.FromErr(err) } if err = d.Set("created_at", variable.CreatedAt.String()); err != nil { - return err + return diag.FromErr(err) } if err = d.Set("updated_at", variable.UpdatedAt.String()); err != nil { - return err + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubActionsVariableUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName := d.Get("repository").(string) + varName := d.Get("variable_name").(string) + + variable := github.ActionsVariable{ + Name: varName, + Value: d.Get("value").(string), + } + + _, err := client.Actions.UpdateRepoVariable(ctx, owner, repoName, &variable) + if err != nil { + return diag.FromErr(err) + } + + id, err := buildID(repoName, varName) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + // GitHub API does not return on create so we have to lookup the variable to get timestamps + if variable, _, err := client.Actions.GetRepoVariable(ctx, owner, repoName, varName); err == nil { + if err := d.Set("created_at", variable.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", variable.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } else { + if err := d.Set("updated_at", nil); err != nil { + return diag.FromErr(err) + } } return nil } -func resourceGithubActionsVariableDelete(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - orgName := meta.(*Owner).name - ctx := context.WithValue(context.Background(), ctxId, d.Id()) +func resourceGithubActionsVariableDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName := d.Get("repository").(string) + varName := d.Get("variable_name").(string) - repoName, variableName, err := parseTwoPartID(d.Id(), "repository", "variable_name") + _, err := client.Actions.DeleteRepoVariable(ctx, owner, repoName, varName) if err != nil { - return err + return diag.FromErr(err) } - _, err = client.Actions.DeleteRepoVariable(ctx, orgName, repoName, variableName) + return nil +} + +func resourceGithubActionsVariableImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName, varName, err := parseID2(d.Id()) + if err != nil { + return nil, err + } + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, err + } + repoID := int(repo.GetID()) + + variable, _, err := client.Actions.GetRepoVariable(ctx, owner, repoName, varName) + if err != nil { + return nil, err + } + + if err := d.Set("repository", repoName); err != nil { + return nil, err + } + if err := d.Set("repository_id", repoID); err != nil { + return nil, err + } + if err := d.Set("variable_name", varName); err != nil { + return nil, err + } + if err := d.Set("value", variable.Value); err != nil { + return nil, err + } + if err := d.Set("created_at", variable.CreatedAt.String()); err != nil { + return nil, err + } + if err := d.Set("updated_at", variable.UpdatedAt.String()); err != nil { + return nil, err + } - return err + return []*schema.ResourceData{d}, nil } diff --git a/github/resource_github_actions_variable_migration.go b/github/resource_github_actions_variable_migration.go new file mode 100644 index 0000000000..18f9939777 --- /dev/null +++ b/github/resource_github_actions_variable_migration.go @@ -0,0 +1,70 @@ +package github + +import ( + "context" + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubActionsVariableV0() *schema.Resource { + return &schema.Resource{ + SchemaVersion: 0, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + Description: "Name of the repository.", + }, + "variable_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the variable.", + ValidateDiagFunc: validateSecretNameFunc, + }, + "value": { + Type: schema.TypeString, + Required: true, + Description: "Value of the variable.", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'actions_variable' creation.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'actions_variable' update.", + }, + }, + } +} + +func resourceGithubActionsVariableStateUpgradeV0(ctx context.Context, rawState map[string]any, m any) (map[string]any, error) { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + log.Printf("[DEBUG] GitHub Actions Variable Attributes before migration: %#v", rawState) + + repoName, ok := rawState["repository"].(string) + if !ok { + return nil, fmt.Errorf("repository not found or is not a string") + } + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve repository %s: %w", repoName, err) + } + + repoID := int(repo.GetID()) + rawState["repository_id"] = repoID + + log.Printf("[DEBUG] GitHub Actions Variable Attributes after migration: %#v", rawState) + + return rawState, nil +} diff --git a/github/resource_github_actions_variable_migration_test.go b/github/resource_github_actions_variable_migration_test.go new file mode 100644 index 0000000000..43372bd79c --- /dev/null +++ b/github/resource_github_actions_variable_migration_test.go @@ -0,0 +1,51 @@ +package github + +// TODO: Enable this test once we have a pattern to create a mock client for the test. + +// import ( +// "context" +// "reflect" +// "testing" +// ) + +// func Test_resourceGithubActionsVariableStateUpgradeV0(t *testing.T) { +// t.Parallel() + +// for _, d := range []struct { +// testName string +// rawState map[string]any +// want map[string]any +// shouldError bool +// }{ +// { +// testName: "migrates v0 to v1", +// rawState: map[string]any{ +// "id": "my-repo:MY_VARIABLE", +// "repository": "my-repo", +// "variable_name": "MY_VARIABLE", +// "value": "my-value", +// }, +// want: map[string]any{ +// "id": "my-repo:MY_VARIABLE", +// "repository": "my-repo", +// "repository_id": 123456, +// "variable_name": "MY_VARIABLE", +// "value": "my-value", +// }, +// shouldError: false, +// }, +// } { +// t.Run(d.testName, func(t *testing.T) { +// t.Parallel() + +// got, err := resourceGithubActionsVariableStateUpgradeV0(context.Background(), d.rawState, nil) +// if (err != nil) != d.shouldError { +// t.Fatalf("unexpected error state") +// } + +// if !d.shouldError && !reflect.DeepEqual(got, d.want) { +// t.Fatalf("got %+v, want %+v", got, d.want) +// } +// }) +// } +// } diff --git a/github/resource_github_actions_variable_test.go b/github/resource_github_actions_variable_test.go index f08ed44e6d..0612c7268b 100644 --- a/github/resource_github_actions_variable_test.go +++ b/github/resource_github_actions_variable_test.go @@ -1,98 +1,248 @@ package github import ( + "context" "fmt" - "strings" + "regexp" "testing" + "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) func TestAccGithubActionsVariable(t *testing.T) { - t.Run("creates and updates repository variables without error", func(t *testing.T) { + t.Run("create", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + varName := "test" + value := "foo" + + config := ` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_actions_variable" "test" { + repository = github_repository.test.name + variable_name = "%s" + value = "%s" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName, varName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_variable.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_variable.test", "value", value), + resource.TestCheckResourceAttrSet("github_actions_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_variable.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_update", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + varName := "test" + value := "foo" + valueUpdated := "bar" + + config := ` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_actions_variable" "test" { + repository = github_repository.test.name + variable_name = "%s" + value = "%s" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName, varName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_variable.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_variable.test", "value", value), + resource.TestCheckResourceAttrSet("github_actions_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_variable.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, repoName, varName, valueUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_actions_variable.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_actions_variable.test", "variable_name", varName), + resource.TestCheckResourceAttr("github_actions_variable.test", "value", valueUpdated), + resource.TestCheckResourceAttrSet("github_actions_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_variable.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("update_renamed_repo", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + updatedRepoName := fmt.Sprintf("%s%s-updated", testResourcePrefix, randomID) + + config := ` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_actions_variable" "test" { + repository = github_repository.test.name + variable_name = "test" + value = "test" +} +` + + var beforeCreatedAt string + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_variable.test", "updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_actions_variable.test"].Primary.Attributes["created_at"] + return nil + }, + ), + }, + { + Config: fmt.Sprintf(config, updatedRepoName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_variable.test", "updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_actions_variable.test"].Primary.Attributes["created_at"] + + if afterCreatedAt != beforeCreatedAt { + return fmt.Errorf("expected resource to not be recreated, but created_at has been modified: %s", beforeCreatedAt) + } + return nil + }, + ), + }, + }, + }) + }) + + t.Run("recreate_changed_repo", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-var-%s", testResourcePrefix, randomID) - value := "my_variable_value" - updatedValue := "my_updated_variable_value" + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + repoName2 := fmt.Sprintf("%supdated-%s", testResourcePrefix, randomID) config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } - - resource "github_actions_variable" "variable" { - repository = github_repository.test.name - variable_name = "test_variable" - value = "%s" - } - `, repoName, value) - - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_variable.variable", "value", - value, - ), - resource.TestCheckResourceAttrSet( - "github_actions_variable.variable", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_variable.variable", "updated_at", - ), - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_actions_variable.variable", "value", - updatedValue, - ), - resource.TestCheckResourceAttrSet( - "github_actions_variable.variable", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_actions_variable.variable", "updated_at", - ), - ), - } +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository" "test2" { + name = "%s" +} + +resource "github_actions_variable" "test" { + repository = github_repository.test.name + variable_name = "test_variable" + value = "test" +} +`, repoName, repoName2) + + configUpdated := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository" "test2" { + name = "%s" +} +resource "github_actions_variable" "test" { + repository = github_repository.test2.name + variable_name = "test_variable" + value = "test" +} +`, repoName, repoName2) + + var beforeCreatedAt string resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, - Check: checks["before"], + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_variable.test", "updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_actions_variable.test"].Primary.Attributes["created_at"] + return nil + }, + ), }, { - Config: strings.Replace(config, - value, - updatedValue, 1), - Check: checks["after"], + Config: configUpdated, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_actions_variable.test", "created_at"), + resource.TestCheckResourceAttrSet("github_actions_variable.test", "updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_actions_variable.test"].Primary.Attributes["created_at"] + + if afterCreatedAt == beforeCreatedAt { + return fmt.Errorf("expected resource to be recreated, but created_at has not been modified: %s", beforeCreatedAt) + } + return nil + }, + ), }, }, }) }) - t.Run("deletes repository variables without error", func(t *testing.T) { + t.Run("destroy", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-var-%s", testResourcePrefix, randomID) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } +resource "github_repository" "test" { + name = "%s" +} - resource "github_actions_variable" "variable" { - repository = github_repository.test.name - variable_name = "test_variable" - value = "my_variable_value" - } - `, repoName) +resource "github_actions_variable" "test" { + repository = github_repository.test.name + variable_name = "test" + value = "foo" +} +`, repoName) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ + { + Config: config, + }, { Config: config, Destroy: true, @@ -101,23 +251,21 @@ func TestAccGithubActionsVariable(t *testing.T) { }) }) - t.Run("imports repository variables without error", func(t *testing.T) { + t.Run("import", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-act-var-%s", testResourcePrefix, randomID) - varName := "test_variable" - value := "variable_value" + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } +resource "github_repository" "test" { + name = "%s" +} - resource "github_actions_variable" "variable" { - repository = github_repository.test.name - variable_name = "%s" - value = "%s" - } - `, repoName, varName, value) +resource "github_actions_variable" "test" { + repository = github_repository.test.name + variable_name = "test" + value = "foo" +} +`, repoName) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, @@ -127,12 +275,74 @@ func TestAccGithubActionsVariable(t *testing.T) { Config: config, }, { - ResourceName: "github_actions_variable.variable", - ImportStateId: fmt.Sprintf(`%s:%s`, repoName, varName), + ResourceName: "github_actions_variable.test", ImportState: true, ImportStateVerify: true, }, }, }) }) + + t.Run("error_on_existing", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + varName := "test" + + baseConfig := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} +`, repoName) + + config := fmt.Sprintf(` +%s + +resource "github_actions_variable" "test" { + repository = github_repository.test.name + variable_name = "%s" + value = "test" +} +`, baseConfig, varName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: baseConfig, + Check: func(*terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + _, err = client.Actions.CreateRepoVariable(ctx, owner, repoName, &github.ActionsVariable{ + Name: varName, + Value: "test", + }) + return err + }, + }, + { + Config: config, + ExpectError: regexp.MustCompile(`Variable already exists`), + Check: func(*terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + _, err = client.Actions.DeleteRepoVariable(ctx, owner, repoName, varName) + return err + }, + }, + }, + }) + }) } diff --git a/github/resource_github_dependabot_organization_secret.go b/github/resource_github_dependabot_organization_secret.go index 9548de5829..80ac8b67f4 100644 --- a/github/resource_github_dependabot_organization_secret.go +++ b/github/resource_github_dependabot_organization_secret.go @@ -4,29 +4,18 @@ import ( "context" "encoding/base64" "errors" - "fmt" "log" "net/http" "github.com/google/go-github/v82/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) func resourceGithubDependabotOrganizationSecret() *schema.Resource { return &schema.Resource{ - Create: resourceGithubDependabotOrganizationSecretCreateOrUpdate, - Read: resourceGithubDependabotOrganizationSecretRead, - Delete: resourceGithubDependabotOrganizationSecretDelete, - Importer: &schema.ResourceImporter{ - State: func(d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { - if err := d.Set("secret_name", d.Id()); err != nil { - return nil, err - } - return []*schema.ResourceData{d}, nil - }, - }, - Schema: map[string]*schema.Schema{ "secret_name": { Type: schema.TypeString, @@ -35,38 +24,41 @@ func resourceGithubDependabotOrganizationSecret() *schema.Resource { Description: "Name of the secret.", ValidateDiagFunc: validateSecretNameFunc, }, + "key_id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "ID of the public key used to encrypt the secret.", + ConflictsWith: []string{"plaintext_value"}, + }, "encrypted_value": { Type: schema.TypeString, - ForceNew: true, Optional: true, Sensitive: true, - ConflictsWith: []string{"plaintext_value"}, + ExactlyOneOf: []string{"encrypted_value", "plaintext_value"}, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsBase64), Description: "Encrypted value of the secret using the GitHub public key in Base64 format.", - ValidateDiagFunc: toDiagFunc(validation.StringIsBase64, "encrypted_value"), }, "plaintext_value": { - Type: schema.TypeString, - ForceNew: true, - Optional: true, - Sensitive: true, - Description: "Plaintext value of the secret to be encrypted.", - ConflictsWith: []string{"encrypted_value"}, + Type: schema.TypeString, + Optional: true, + Sensitive: true, + ExactlyOneOf: []string{"encrypted_value", "plaintext_value"}, + Description: "Plaintext value of the secret to be encrypted.", }, "visibility": { Type: schema.TypeString, Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"all", "private", "selected"}, false)), Description: "Configures the access that repositories have to the organization secret. Must be one of 'all', 'private' or 'selected'. 'selected_repository_ids' is required if set to 'selected'.", - ValidateDiagFunc: validateValueFunc([]string{"all", "private", "selected"}), - ForceNew: true, }, "selected_repository_ids": { Type: schema.TypeSet, + Set: schema.HashInt, Elem: &schema.Schema{ Type: schema.TypeInt, }, - Set: schema.HashInt, Optional: true, - ForceNew: true, Description: "An array of repository ids that can access the organization secret.", }, "created_at": { @@ -79,115 +71,159 @@ func resourceGithubDependabotOrganizationSecret() *schema.Resource { Computed: true, Description: "Date of 'dependabot_secret' update.", }, + "remote_updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of secret update at the remote.", + }, + }, + + CustomizeDiff: customdiff.All( + diffSecret, + diffSecretVariableVisibility, + ), + + CreateContext: resourceGithubDependabotOrganizationSecretCreate, + ReadContext: resourceGithubDependabotOrganizationSecretRead, + UpdateContext: resourceGithubDependabotOrganizationSecretUpdate, + DeleteContext: resourceGithubDependabotOrganizationSecretDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubDependabotOrganizationSecretImport, }, } } -func resourceGithubDependabotOrganizationSecretCreateOrUpdate(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - ctx := context.Background() +func resourceGithubDependabotOrganizationSecretCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name secretName := d.Get("secret_name").(string) - plaintextValue := d.Get("plaintext_value").(string) - var encryptedValue string - + keyID := d.Get("key_id").(string) + encryptedValue := d.Get("encrypted_value").(string) visibility := d.Get("visibility").(string) - selectedRepositories, hasSelectedRepositories := d.GetOk("selected_repository_ids") - - if visibility != "selected" && hasSelectedRepositories { - return fmt.Errorf("cannot use selected_repository_ids without visibility being set to selected") - } + repoIDs := github.DependabotSecretsSelectedRepoIDs{} - selectedRepositoryIDs := github.DependabotSecretsSelectedRepoIDs{} - - if hasSelectedRepositories { - ids := selectedRepositories.(*schema.Set).List() + if v, ok := d.GetOk("selected_repository_ids"); ok { + ids := v.(*schema.Set).List() for _, id := range ids { - selectedRepositoryIDs = append(selectedRepositoryIDs, int64(id.(int))) + repoIDs = append(repoIDs, int64(id.(int))) } } - keyId, publicKey, err := getDependabotOrganizationPublicKeyDetails(owner, meta) - if err != nil { - return err + var publicKey string + if len(keyID) == 0 || len(encryptedValue) == 0 { + ki, pk, err := getDependabotOrganizationPublicKeyDetails(ctx, meta) + if err != nil { + return diag.FromErr(err) + } + + keyID = ki + publicKey = pk } - if encryptedText, ok := d.GetOk("encrypted_value"); ok { - encryptedValue = encryptedText.(string) - } else { + if len(encryptedValue) == 0 { + plaintextValue := d.Get("plaintext_value").(string) + encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) if err != nil { - return err + return diag.FromErr(err) } encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) } - // Create an DependabotEncryptedSecret and encrypt the plaintext value into it - eSecret := &github.DependabotEncryptedSecret{ + secret := github.DependabotEncryptedSecret{ Name: secretName, - KeyID: keyId, - Visibility: visibility, - SelectedRepositoryIDs: selectedRepositoryIDs, + KeyID: keyID, EncryptedValue: encryptedValue, + Visibility: visibility, + SelectedRepositoryIDs: repoIDs, } - _, err = client.Dependabot.CreateOrUpdateOrgSecret(ctx, owner, eSecret) + _, err := client.Dependabot.CreateOrUpdateOrgSecret(ctx, owner, &secret) if err != nil { - return err + return diag.FromErr(err) } d.SetId(secretName) - return resourceGithubDependabotOrganizationSecretRead(d, meta) + + if err := d.Set("key_id", keyID); err != nil { + return diag.FromErr(err) + } + + // GitHub API does not return on create so we have to lookup the secret to get timestamps + if secret, _, err := client.Dependabot.GetOrgSecret(ctx, owner, secretName); err == nil { + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } + + return nil } -func resourceGithubDependabotOrganizationSecretRead(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - ctx := context.Background() +func resourceGithubDependabotOrganizationSecretRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + secretName := d.Get("secret_name").(string) - secret, _, err := client.Dependabot.GetOrgSecret(ctx, owner, d.Id()) + secret, _, err := client.Dependabot.GetOrgSecret(ctx, owner, secretName) if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) { if ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[WARN] Removing actions secret %s from state because it no longer exists in GitHub", - d.Id()) + log.Printf("[INFO] Removing Dependabot organization secret %s from state because it no longer exists in GitHub", d.Id()) d.SetId("") return nil } } - return err + return diag.FromErr(err) } - if err = d.Set("encrypted_value", d.Get("encrypted_value")); err != nil { - return err + // Due to the eventually consistent behavior of this API we may not get created_at/updated_at + // values on the first read after creation, so we only set them here if they are not already set. + if len(d.Get("created_at").(string)) == 0 { + if err = d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } } - if err = d.Set("plaintext_value", d.Get("plaintext_value")); err != nil { - return err + if len(d.Get("updated_at").(string)) == 0 { + if err = d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } } + if err = d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err = d.Set("created_at", secret.CreatedAt.String()); err != nil { - return err + return diag.FromErr(err) } if err = d.Set("visibility", secret.Visibility); err != nil { - return err + return diag.FromErr(err) } - selectedRepositoryIDs := []int64{} - + repoIDs := []int64{} if secret.Visibility == "selected" { opt := &github.ListOptions{ - PerPage: 30, + PerPage: maxPerPage, } for { - results, resp, err := client.Dependabot.ListSelectedReposForOrgSecret(ctx, owner, d.Id(), opt) + results, resp, err := client.Dependabot.ListSelectedReposForOrgSecret(ctx, owner, secretName, opt) if err != nil { - return err + return diag.FromErr(err) } for _, repo := range results.Repositories { - selectedRepositoryIDs = append(selectedRepositoryIDs, repo.GetID()) + repoIDs = append(repoIDs, repo.GetID()) } if resp.NextPage == 0 { @@ -197,54 +233,173 @@ func resourceGithubDependabotOrganizationSecretRead(d *schema.ResourceData, meta } } - if err = d.Set("selected_repository_ids", selectedRepositoryIDs); err != nil { - return err - } - - // This is a drift detection mechanism based on timestamps. - // - // If we do not currently store the "updated_at" field, it means we've only - // just created the resource and the value is most likely what we want it to - // be. - // - // If the resource is changed externally in the meantime then reading back - // the last update timestamp will return a result different than the - // timestamp we've persisted in the state. In that case, we can no longer - // trust that the value (which we don't see) is equal to what we've declared - // previously. - // - // The only solution to enforce consistency between is to mark the resource - // as deleted (unset the ID) in order to fix potential drift by recreating - // the resource. - if updatedAt, ok := d.GetOk("updated_at"); ok && updatedAt != secret.UpdatedAt.String() { - log.Printf("[WARN] The secret %s has been externally updated in GitHub", d.Id()) - d.SetId("") - } else if !ok { - if err = d.Set("updated_at", secret.UpdatedAt.String()); err != nil { - return err + if err := d.Set("selected_repository_ids", repoIDs); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubDependabotOrganizationSecretUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + secretName := d.Get("secret_name").(string) + keyID := d.Get("key_id").(string) + encryptedValue := d.Get("encrypted_value").(string) + visibility := d.Get("visibility").(string) + repoIDs := github.DependabotSecretsSelectedRepoIDs{} + + if v, ok := d.GetOk("selected_repository_ids"); ok { + ids := v.(*schema.Set).List() + + for _, id := range ids { + repoIDs = append(repoIDs, int64(id.(int))) + } + } + + var publicKey string + if len(keyID) == 0 || len(encryptedValue) == 0 { + ki, pk, err := getDependabotOrganizationPublicKeyDetails(ctx, meta) + if err != nil { + return diag.FromErr(err) + } + + keyID = ki + publicKey = pk + } + + if len(encryptedValue) == 0 { + plaintextValue := d.Get("plaintext_value").(string) + + encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) + if err != nil { + return diag.FromErr(err) + } + encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) + } + + secret := github.DependabotEncryptedSecret{ + Name: secretName, + KeyID: keyID, + EncryptedValue: encryptedValue, + Visibility: visibility, + SelectedRepositoryIDs: repoIDs, + } + + _, err := client.Dependabot.CreateOrUpdateOrgSecret(ctx, owner, &secret) + if err != nil { + return diag.FromErr(err) + } + + if err := d.Set("key_id", keyID); err != nil { + return diag.FromErr(err) + } + + // GitHub API does not return on update so we have to lookup the secret to get timestamps + if secret, _, err := client.Dependabot.GetOrgSecret(ctx, owner, secretName); err == nil { + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } else { + if err := d.Set("updated_at", nil); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", nil); err != nil { + return diag.FromErr(err) } } return nil } -func resourceGithubDependabotOrganizationSecretDelete(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - orgName := meta.(*Owner).name - ctx := context.WithValue(context.Background(), ctxId, d.Id()) +func resourceGithubDependabotOrganizationSecretDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + secretName := d.Get("secret_name").(string) + + log.Printf("[INFO] Deleting Dependabot organization secret: %s", d.Id()) + _, err := client.Dependabot.DeleteOrgSecret(ctx, owner, secretName) + if err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubDependabotOrganizationSecretImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + secretName := d.Id() + + secret, _, err := client.Dependabot.GetOrgSecret(ctx, owner, secretName) + if err != nil { + return nil, err + } + + if err := d.Set("secret_name", secretName); err != nil { + return nil, err + } + if err := d.Set("visibility", secret.Visibility); err != nil { + return nil, err + } + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return nil, err + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return nil, err + } + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return nil, err + } + + selectedRepositoryIDs := []int64{} + if secret.Visibility == "selected" { + opt := &github.ListOptions{ + PerPage: maxPerPage, + } + for { + results, resp, err := client.Dependabot.ListSelectedReposForOrgSecret(ctx, owner, secretName, opt) + if err != nil { + return nil, err + } + + for _, repo := range results.Repositories { + selectedRepositoryIDs = append(selectedRepositoryIDs, repo.GetID()) + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + } + + if err := d.Set("selected_repository_ids", selectedRepositoryIDs); err != nil { + return nil, err + } - log.Printf("[DEBUG] Deleting secret: %s", d.Id()) - _, err := client.Dependabot.DeleteOrgSecret(ctx, orgName, d.Id()) - return err + return []*schema.ResourceData{d}, nil } -func getDependabotOrganizationPublicKeyDetails(owner string, meta any) (keyId, pkValue string, err error) { - client := meta.(*Owner).v3client - ctx := context.Background() +func getDependabotOrganizationPublicKeyDetails(ctx context.Context, meta *Owner) (string, string, error) { + client := meta.v3client + owner := meta.name publicKey, _, err := client.Dependabot.GetOrgPublicKey(ctx, owner) if err != nil { - return keyId, pkValue, err + return "", "", err } return publicKey.GetKeyID(), publicKey.GetKey(), err diff --git a/github/resource_github_dependabot_organization_secret_test.go b/github/resource_github_dependabot_organization_secret_test.go index 90925c52f1..66058b5032 100644 --- a/github/resource_github_dependabot_organization_secret_test.go +++ b/github/resource_github_dependabot_organization_secret_test.go @@ -1,103 +1,545 @@ package github import ( + "context" "encoding/base64" "fmt" - "strings" + "regexp" "testing" + "github.com/google/go-github/v82/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) func TestAccGithubDependabotOrganizationSecret(t *testing.T) { - t.Run("creates and updates secrets without error", func(t *testing.T) { - secretValue := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) - updatedSecretValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + t.Run("create_update_plaintext", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) + + config := ` +resource "github_dependabot_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, secretName, valueUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "plaintext_value", valueUpdated), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_update_encrypted", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) + + config := ` +resource "github_dependabot_organization_secret" "test" { + secret_name = "%s" + encrypted_value = "%s" + visibility = "all" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "encrypted_value", value), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, secretName, valueUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "encrypted_value", valueUpdated), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_update_encrypted_with_key", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) + + config := ` +data "github_dependabot_organization_public_key" "default" {} + +resource "github_dependabot_organization_secret" "test" { + secret_name = "%s" + key_id = data.github_dependabot_organization_public_key.default.key_id + encrypted_value = "%s" + visibility = "all" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "encrypted_value", value), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, secretName, valueUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "encrypted_value", valueUpdated), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_update_visibility_all", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) + + config := ` +resource "github_dependabot_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, secretName, valueUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "plaintext_value", valueUpdated), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", "all"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_update_visibility_private", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) + + config := ` +resource "github_dependabot_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "private" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", "private"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, secretName, valueUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "plaintext_value", valueUpdated), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", "private"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_update_visibility_selected", func(t *testing.T) { + repoName0 := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)) + repoName1 := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)) + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) + + config := ` +resource "github_repository" "test_0" { + name = "%s" +} + +resource "github_repository" "test_1" { + name = "%s" +} + +resource "github_dependabot_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "selected" + + selected_repository_ids = [github_repository.test_%s.repo_id] +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName0, repoName1, secretName, value, "0"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", "selected"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "1"), + resource.TestCheckResourceAttrPair("github_dependabot_organization_secret.test", "selected_repository_ids.0", "github_repository.test_0", "repo_id"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, repoName0, repoName1, secretName, valueUpdated, "1"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "plaintext_value", valueUpdated), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", "selected"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "1"), + resource.TestCheckResourceAttrPair("github_dependabot_organization_secret.test", "selected_repository_ids.0", "github_repository.test_1", "repo_id"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_update_change_visibility", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + visibility := "all" + valueUpdated := base64.StdEncoding.EncodeToString([]byte("bar")) + visibilityUpdated := "private" + + config := ` +resource "github_dependabot_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "%s" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, secretName, value, visibility), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", visibility), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, secretName, valueUpdated, visibilityUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "key_id"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "plaintext_value", valueUpdated), + resource.TestCheckNoResourceAttr("github_dependabot_organization_secret.test", "encrypted_value"), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "visibility", visibilityUpdated), + resource.TestCheckResourceAttr("github_dependabot_organization_secret.test", "selected_repository_ids.#", "0"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("update_on_drift", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) config := fmt.Sprintf(` - resource "github_dependabot_organization_secret" "plaintext_secret" { - secret_name = "test_plaintext_secret" - plaintext_value = "%s" - visibility = "private" - } - - resource "github_dependabot_organization_secret" "encrypted_secret" { - secret_name = "test_encrypted_secret" - encrypted_value = "%s" - visibility = "private" - } - `, secretValue, secretValue) - - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_dependabot_organization_secret.plaintext_secret", "plaintext_value", - secretValue, - ), - resource.TestCheckResourceAttr( - "github_dependabot_organization_secret.encrypted_secret", "encrypted_value", - secretValue, - ), - resource.TestCheckResourceAttrSet( - "github_dependabot_organization_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_dependabot_organization_secret.plaintext_secret", "updated_at", - ), - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_dependabot_organization_secret.plaintext_secret", "plaintext_value", - updatedSecretValue, - ), - resource.TestCheckResourceAttr( - "github_dependabot_organization_secret.encrypted_secret", "encrypted_value", - updatedSecretValue, - ), - resource.TestCheckResourceAttrSet( - "github_dependabot_organization_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_dependabot_organization_secret.plaintext_secret", "updated_at", - ), - ), - } +resource "github_dependabot_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" +} +`, secretName, value) + var beforeCreatedAt string resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, - Check: checks["before"], + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_dependabot_organization_secret.test"].Primary.Attributes["created_at"] + return nil + }, + ), }, { - Config: strings.Replace(config, - secretValue, - updatedSecretValue, 2), - Check: checks["after"], + PreConfig: func() { + meta, err := getTestMeta() + if err != nil { + t.Fatal(err.Error()) + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + keyID, _, err := getOrganizationPublicKeyDetails(ctx, meta) + if err != nil { + t.Fatal(err.Error()) + } + + _, err = client.Actions.CreateOrUpdateOrgSecret(ctx, owner, &github.EncryptedSecret{ + Name: secretName, + EncryptedValue: base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")), + KeyID: keyID, + Visibility: "all", + }) + if err != nil { + t.Fatal(err.Error()) + } + }, + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_dependabot_organization_secret.test"].Primary.Attributes["created_at"] + + if afterCreatedAt != beforeCreatedAt { + return fmt.Errorf("expected resource to be updated, but created_at has been modified: %s", beforeCreatedAt) + } + return nil + }, + ), }, }, }) }) - t.Run("deletes secrets without error", func(t *testing.T) { - config := ` - resource "github_dependabot_organization_secret" "plaintext_secret" { - secret_name = "test_plaintext_secret" - visibility = "private" - } + t.Run("lifecycle_can_ignore_drift", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) - resource "github_dependabot_organization_secret" "encrypted_secret" { - secret_name = "test_encrypted_secret" - visibility = "private" - } - ` + config := fmt.Sprintf(` +resource "github_dependabot_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" + + lifecycle { + ignore_changes = [remote_updated_at] + } +} +`, secretName, value) + + var beforeUpdatedAt string + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + beforeUpdatedAt = s.RootModule().Resources["github_dependabot_organization_secret.test"].Primary.Attributes["updated_at"] + return nil + }, + ), + }, + { + PreConfig: func() { + meta, err := getTestMeta() + if err != nil { + t.Fatal(err.Error()) + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + keyID, _, err := getOrganizationPublicKeyDetails(ctx, meta) + if err != nil { + t.Fatal(err.Error()) + } + + _, err = client.Actions.CreateOrUpdateOrgSecret(ctx, owner, &github.EncryptedSecret{ + Name: secretName, + EncryptedValue: base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")), + KeyID: keyID, + Visibility: "all", + }) + if err != nil { + t.Fatal(err.Error()) + } + }, + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_dependabot_organization_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + afterUpdatedAt := s.RootModule().Resources["github_dependabot_organization_secret.test"].Primary.Attributes["updated_at"] + if afterUpdatedAt != beforeUpdatedAt { + return fmt.Errorf("expected resource to ignore drift, but updated_at has been modified: %s", beforeUpdatedAt) + } + return nil + }, + ), + }, + }, + }) + }) + + t.Run("destroy", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + + config := fmt.Sprintf(` +resource "github_dependabot_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" +} +`, secretName, value) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ + { + Config: config, + }, { Config: config, Destroy: true, @@ -106,23 +548,18 @@ func TestAccGithubDependabotOrganizationSecret(t *testing.T) { }) }) - t.Run("imports secrets without error", func(t *testing.T) { - secretValue := "super_secret_value" + t.Run("import", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) config := fmt.Sprintf(` - resource "github_dependabot_organization_secret" "test_secret" { - secret_name = "test_plaintext_secret" - plaintext_value = "%s" - visibility = "private" - } - `, secretValue) - - check := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_dependabot_organization_secret.test_secret", "plaintext_value", - secretValue, - ), - ) +resource "github_dependabot_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" +} +`, secretName, value) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, @@ -130,13 +567,39 @@ func TestAccGithubDependabotOrganizationSecret(t *testing.T) { Steps: []resource.TestStep{ { Config: config, - Check: check, }, { - ResourceName: "github_dependabot_organization_secret.test_secret", + ResourceName: "github_dependabot_organization_secret.test", ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"plaintext_value"}, + ImportStateVerifyIgnore: []string{"key_id", "plaintext_value", "destroy_on_drift"}, + }, + }, + }) + }) + + t.Run("error_on_invalid_selected_repository_ids", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlpha) + secretName := fmt.Sprintf("test_%s", randomID) + value := base64.StdEncoding.EncodeToString([]byte("foo")) + + config := fmt.Sprintf(` +resource "github_dependabot_organization_secret" "test" { + secret_name = "%s" + plaintext_value = "%s" + visibility = "all" + + selected_repository_ids = [123456] +} +`, secretName, value) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("cannot use selected_repository_ids without visibility being set to selected"), }, }, }) diff --git a/github/resource_github_dependabot_secret.go b/github/resource_github_dependabot_secret.go index 86b3654fb4..d51e7fccef 100644 --- a/github/resource_github_dependabot_secret.go +++ b/github/resource_github_dependabot_secret.go @@ -4,259 +4,363 @@ import ( "context" "encoding/base64" "errors" - "fmt" "log" "net/http" - "strings" "github.com/google/go-github/v82/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "golang.org/x/crypto/nacl/box" ) func resourceGithubDependabotSecret() *schema.Resource { return &schema.Resource{ - Create: resourceGithubDependabotSecretCreateOrUpdate, - Read: resourceGithubDependabotSecretRead, - Delete: resourceGithubDependabotSecretDelete, - Importer: &schema.ResourceImporter{ - State: resourceGithubDependabotSecretImport, + SchemaVersion: 1, + StateUpgraders: []schema.StateUpgrader{ + { + Type: resourceGithubDependabotSecretV0().CoreConfigSchema().ImpliedType(), + Upgrade: resourceGithubDependabotSecretStateUpgradeV0, + Version: 0, + }, }, Schema: map[string]*schema.Schema{ "repository": { Type: schema.TypeString, Required: true, - ForceNew: true, Description: "Name of the repository.", }, + "repository_id": { + Type: schema.TypeInt, + Computed: true, + Description: "ID of the repository.", + }, "secret_name": { Type: schema.TypeString, Required: true, ForceNew: true, - Description: "Name of the secret.", ValidateDiagFunc: validateSecretNameFunc, + Description: "Name of the secret.", }, - "encrypted_value": { + "key_id": { Type: schema.TypeString, - ForceNew: true, Optional: true, - Sensitive: true, + Computed: true, ConflictsWith: []string{"plaintext_value"}, - Description: "Encrypted value of the secret using the GitHub public key in Base64 format.", + Description: "ID of the public key used to encrypt the secret.", + }, + "encrypted_value": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + ExactlyOneOf: []string{"encrypted_value", "plaintext_value"}, + Description: "Encrypted value of the secret using the GitHub public key in Base64 format.", }, "plaintext_value": { - Type: schema.TypeString, - ForceNew: true, - Optional: true, - Sensitive: true, - ConflictsWith: []string{"encrypted_value"}, - Description: "Plaintext value of the secret to be encrypted.", + Type: schema.TypeString, + Optional: true, + Sensitive: true, + ExactlyOneOf: []string{"encrypted_value", "plaintext_value"}, + Description: "Plaintext value of the secret to be encrypted.", }, "created_at": { Type: schema.TypeString, Computed: true, - Description: "Date of 'dependabot_secret' creation.", + Description: "Date of secret creation.", }, "updated_at": { Type: schema.TypeString, Computed: true, - Description: "Date of 'dependabot_secret' update.", + Description: "Date of secret update.", + }, + "remote_updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of secret update at the remote.", }, }, + + CustomizeDiff: customdiff.All( + diffRepository, + diffSecret, + ), + + CreateContext: resourceGithubDependabotSecretCreate, + ReadContext: resourceGithubDependabotSecretRead, + UpdateContext: resourceGithubDependabotSecretUpdate, + DeleteContext: resourceGithubDependabotSecretDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubDependabotSecretImport, + }, } } -func resourceGithubDependabotSecretCreateOrUpdate(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - ctx := context.Background() +func resourceGithubDependabotSecretCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name - repo := d.Get("repository").(string) + repoName := d.Get("repository").(string) secretName := d.Get("secret_name").(string) - plaintextValue := d.Get("plaintext_value").(string) - var encryptedValue string + keyID := d.Get("key_id").(string) + encryptedValue := d.Get("encrypted_value").(string) - keyId, publicKey, err := getDependabotPublicKeyDetails(owner, repo, meta) + repo, _, err := client.Repositories.Get(ctx, owner, repoName) if err != nil { - return err + return diag.FromErr(err) } + repoID := int(repo.GetID()) - if encryptedText, ok := d.GetOk("encrypted_value"); ok { - encryptedValue = encryptedText.(string) - } else { - encryptedBytes, err := encryptDependabotPlaintext(plaintextValue, publicKey) + var publicKey string + if len(keyID) == 0 || len(encryptedValue) == 0 { + ki, pk, err := getDependabotPublicKeyDetails(ctx, meta, repoName) if err != nil { - return err + return diag.FromErr(err) + } + + keyID = ki + publicKey = pk + } + + if len(encryptedValue) == 0 { + plaintextValue := d.Get("plaintext_value").(string) + + encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) + if err != nil { + return diag.FromErr(err) } encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) } - // Create an DependabotEncryptedSecret and encrypt the plaintext value into it - eSecret := &github.DependabotEncryptedSecret{ + secret := github.DependabotEncryptedSecret{ Name: secretName, - KeyID: keyId, + KeyID: keyID, EncryptedValue: encryptedValue, } - _, err = client.Dependabot.CreateOrUpdateRepoSecret(ctx, owner, repo, eSecret) + _, err = client.Dependabot.CreateOrUpdateRepoSecret(ctx, owner, repoName, &secret) if err != nil { - return err + return diag.FromErr(err) } - d.SetId(buildTwoPartID(repo, secretName)) - return resourceGithubDependabotSecretRead(d, meta) -} + id, err := buildID(repoName, secretName) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) -func resourceGithubDependabotSecretRead(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - ctx := context.Background() + if err := d.Set("repository_id", repoID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("key_id", keyID); err != nil { + return diag.FromErr(err) + } - repoName, secretName, err := parseTwoPartID(d.Id(), "repository", "secret_name") - if err != nil { - return err + // GitHub API does not return on create so we have to lookup the secret to get timestamps + if secret, _, err := client.Dependabot.GetRepoSecret(ctx, owner, repoName, secretName); err == nil { + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } } + return nil +} + +func resourceGithubDependabotSecretRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName := d.Get("repository").(string) + secretName := d.Get("secret_name").(string) + secret, _, err := client.Dependabot.GetRepoSecret(ctx, owner, repoName, secretName) if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) { if ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[WARN] Removing actions secret %s from state because it no longer exists in GitHub", - d.Id()) + log.Printf("[INFO] Removing Dependabot secret %s from state because it no longer exists in GitHub", d.Id()) d.SetId("") return nil } } - return err - } - - if err = d.Set("encrypted_value", d.Get("encrypted_value")); err != nil { - return err - } - if err = d.Set("plaintext_value", d.Get("plaintext_value")); err != nil { - return err - } - if err = d.Set("created_at", secret.CreatedAt.String()); err != nil { - return err - } - - // This is a drift detection mechanism based on timestamps. - // - // If we do not currently store the "updated_at" field, it means we've only - // just created the resource and the value is most likely what we want it to - // be. - // - // If the resource is changed externally in the meantime then reading back - // the last update timestamp will return a result different than the - // timestamp we've persisted in the state. In that case, we can no longer - // trust that the value (which we don't see) is equal to what we've declared - // previously. - // - // The only solution to enforce consistency between is to mark the resource - // as deleted (unset the ID) in order to fix potential drift by recreating - // the resource. - if updatedAt, ok := d.GetOk("updated_at"); ok && updatedAt != secret.UpdatedAt.String() { - log.Printf("[WARN] The secret %s has been externally updated in GitHub", d.Id()) - d.SetId("") - } else if !ok { + return diag.FromErr(err) + } + + id, err := buildID(repoName, secretName) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + // Due to the eventually consistent behavior of this API we may not get created_at/updated_at + // values on the first read after creation, so we only set them here if they are not already set. + if len(d.Get("created_at").(string)) == 0 { + if err = d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + } + if len(d.Get("updated_at").(string)) == 0 { if err = d.Set("updated_at", secret.UpdatedAt.String()); err != nil { - return err + return diag.FromErr(err) } } + if err = d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } return nil } -func resourceGithubDependabotSecretDelete(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - orgName := meta.(*Owner).name - ctx := context.WithValue(context.Background(), ctxId, d.Id()) +func resourceGithubDependabotSecretUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name - repoName, secretName, err := parseTwoPartID(d.Id(), "repository", "secret_name") + repoName := d.Get("repository").(string) + secretName := d.Get("secret_name").(string) + keyID := d.Get("key_id").(string) + encryptedValue := d.Get("encrypted_value").(string) + + var publicKey string + if len(keyID) == 0 || len(encryptedValue) == 0 { + ki, pk, err := getDependabotPublicKeyDetails(ctx, meta, repoName) + if err != nil { + return diag.FromErr(err) + } + + keyID = ki + publicKey = pk + } + + if len(encryptedValue) == 0 { + plaintextValue := d.Get("plaintext_value").(string) + + encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) + if err != nil { + return diag.FromErr(err) + } + encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) + } + + secret := github.DependabotEncryptedSecret{ + Name: secretName, + KeyID: keyID, + EncryptedValue: encryptedValue, + } + + _, err := client.Dependabot.CreateOrUpdateRepoSecret(ctx, owner, repoName, &secret) if err != nil { - return err + return diag.FromErr(err) } - log.Printf("[DEBUG] Deleting secret: %s", d.Id()) - _, err = client.Dependabot.DeleteRepoSecret(ctx, orgName, repoName, secretName) + id, err := buildID(repoName, secretName) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) - return err + if err := d.Set("key_id", keyID); err != nil { + return diag.FromErr(err) + } + + // GitHub API does not return on update so we have to lookup the secret to get timestamps + if secret, _, err := client.Dependabot.GetRepoSecret(ctx, owner, repoName, secretName); err == nil { + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + } else { + if err := d.Set("updated_at", nil); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", nil); err != nil { + return diag.FromErr(err) + } + } + + return nil } -func resourceGithubDependabotSecretImport(d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - ctx := context.Background() +func resourceGithubDependabotSecretDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name - parts := strings.Split(d.Id(), "/") - if len(parts) != 2 { - return nil, fmt.Errorf("invalid ID specified: supplied ID must be written as /") + repoName := d.Get("repository").(string) + secretName := d.Get("secret_name").(string) + + log.Printf("[INFO] Deleting Dependabot repo secret: %s", d.Id()) + _, err := client.Dependabot.DeleteRepoSecret(ctx, owner, repoName, secretName) + if err != nil { + return diag.FromErr(err) } - d.SetId(buildTwoPartID(parts[0], parts[1])) + return nil +} + +func resourceGithubDependabotSecretImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName, secretName, err := parseID2(d.Id()) + if err != nil { + return nil, err + } - repoName, secretName, err := parseTwoPartID(d.Id(), "repository", "secret_name") + repo, _, err := client.Repositories.Get(ctx, owner, repoName) if err != nil { return nil, err } + repoID := int(repo.GetID()) secret, _, err := client.Dependabot.GetRepoSecret(ctx, owner, repoName, secretName) if err != nil { return nil, err } - if err = d.Set("repository", repoName); err != nil { + if err := d.Set("repository", repoName); err != nil { return nil, err } - if err = d.Set("secret_name", secretName); err != nil { + if err := d.Set("repository_id", repoID); err != nil { return nil, err } - - // encrypted_value or plaintext_value can not be imported - - if err = d.Set("created_at", secret.CreatedAt.String()); err != nil { + if err := d.Set("secret_name", secretName); err != nil { + return nil, err + } + if err := d.Set("created_at", secret.CreatedAt.String()); err != nil { + return nil, err + } + if err := d.Set("updated_at", secret.UpdatedAt.String()); err != nil { return nil, err } - if err = d.Set("updated_at", secret.UpdatedAt.String()); err != nil { + if err := d.Set("remote_updated_at", secret.UpdatedAt.String()); err != nil { return nil, err } return []*schema.ResourceData{d}, nil } -func getDependabotPublicKeyDetails(owner, repository string, meta any) (keyId, pkValue string, err error) { - client := meta.(*Owner).v3client - ctx := context.Background() +func getDependabotPublicKeyDetails(ctx context.Context, meta *Owner, repository string) (string, string, error) { + client := meta.v3client + owner := meta.name publicKey, _, err := client.Dependabot.GetRepoPublicKey(ctx, owner, repository) if err != nil { - return keyId, pkValue, err + return "", "", err } return publicKey.GetKeyID(), publicKey.GetKey(), err } - -func encryptDependabotPlaintext(plaintext, publicKeyB64 string) ([]byte, error) { - publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64) - if err != nil { - return nil, err - } - - var publicKeyBytes32 [32]byte - copiedLen := copy(publicKeyBytes32[:], publicKeyBytes) - if copiedLen == 0 { - return nil, fmt.Errorf("could not convert publicKey to bytes") - } - - plaintextBytes := []byte(plaintext) - var encryptedBytes []byte - - cipherText, err := box.SealAnonymous(encryptedBytes, plaintextBytes, &publicKeyBytes32, nil) - if err != nil { - return nil, err - } - - return cipherText, nil -} diff --git a/github/resource_github_dependabot_secret_migration.go b/github/resource_github_dependabot_secret_migration.go new file mode 100644 index 0000000000..8873872c19 --- /dev/null +++ b/github/resource_github_dependabot_secret_migration.go @@ -0,0 +1,81 @@ +package github + +import ( + "context" + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubDependabotSecretV0() *schema.Resource { + return &schema.Resource{ + SchemaVersion: 0, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the repository.", + }, + "secret_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the secret.", + ValidateDiagFunc: validateSecretNameFunc, + }, + "encrypted_value": { + Type: schema.TypeString, + ForceNew: true, + Optional: true, + Sensitive: true, + ConflictsWith: []string{"plaintext_value"}, + Description: "Encrypted value of the secret using the GitHub public key in Base64 format.", + }, + "plaintext_value": { + Type: schema.TypeString, + ForceNew: true, + Optional: true, + Sensitive: true, + ConflictsWith: []string{"encrypted_value"}, + Description: "Plaintext value of the secret to be encrypted.", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'dependabot_secret' creation.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'dependabot_secret' update.", + }, + }, + } +} + +func resourceGithubDependabotSecretStateUpgradeV0(ctx context.Context, rawState map[string]any, m any) (map[string]any, error) { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + log.Printf("[DEBUG] GitHub Dependabot Secret Attributes before migration: %#v", rawState) + + repoName, ok := rawState["repository"].(string) + if !ok { + return nil, fmt.Errorf("repository not found or is not a string") + } + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve repository %s: %w", repoName, err) + } + + rawState["repository_id"] = int(repo.GetID()) + + log.Printf("[DEBUG] GitHub Dependabot Secret Attributes after migration: %#v", rawState) + + return rawState, nil +} diff --git a/github/resource_github_dependabot_secret_migration_test.go b/github/resource_github_dependabot_secret_migration_test.go new file mode 100644 index 0000000000..b112a4149b --- /dev/null +++ b/github/resource_github_dependabot_secret_migration_test.go @@ -0,0 +1,51 @@ +package github + +// TODO: Enable this test once we have a pattern to create a mock client for the test. + +// import ( +// "context" +// "reflect" +// "testing" +// ) + +// func Test_resourceGithubDependabotSecretStateUpgradeV0(t *testing.T) { +// t.Parallel() + +// for _, d := range []struct { +// testName string +// rawState map[string]any +// want map[string]any +// shouldError bool +// }{ +// { +// testName: "migrates v0 to v1", +// rawState: map[string]any{ +// "id": "my-repo:MY_VARIABLE", +// "repository": "my-repo", +// "variable_name": "MY_VARIABLE", +// "value": "my-value", +// }, +// want: map[string]any{ +// "id": "my-repo:MY_VARIABLE", +// "repository": "my-repo", +// "repository_id": 123456, +// "variable_name": "MY_VARIABLE", +// "value": "my-value", +// }, +// shouldError: false, +// }, +// } { +// t.Run(d.testName, func(t *testing.T) { +// t.Parallel() + +// got, err := resourceGithubDependabotSecretStateUpgradeV0(context.Background(), d.rawState, nil) +// if (err != nil) != d.shouldError { +// t.Fatalf("unexpected error state") +// } + +// if !d.shouldError && !reflect.DeepEqual(got, d.want) { +// t.Fatalf("got %+v, want %+v", got, d.want) +// } +// }) +// } +// } diff --git a/github/resource_github_dependabot_secret_test.go b/github/resource_github_dependabot_secret_test.go index 62787c8d35..04db8fa79c 100644 --- a/github/resource_github_dependabot_secret_test.go +++ b/github/resource_github_dependabot_secret_test.go @@ -1,238 +1,519 @@ package github import ( + "context" "encoding/base64" "fmt" - "strings" "testing" + "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) func TestAccGithubDependabotSecret(t *testing.T) { - t.Run("reads a repository public key without error", func(t *testing.T) { + t.Run("create_plaintext", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-dependabot-%s", testResourcePrefix, randomID) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" + value := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} - resource "github_repository" "test" { - name = "%s" - } +resource "github_dependabot_secret" "test" { + repository = github_repository.test.name + secret_name = "%s" + plaintext_value = "%s" +} +`, repoName, secretName, value) - data "github_dependabot_public_key" "test_pk" { - repository = github_repository.test.name - } + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_dependabot_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_dependabot_secret.test", "encrypted_value"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + ), + }, + }, + }) + }) - `, repoName) + t.Run("create_update_plaintext", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" + value := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + updatedValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) - check := resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttrSet( - "data.github_dependabot_public_key.test_pk", "key_id", - ), - resource.TestCheckResourceAttrSet( - "data.github_dependabot_public_key.test_pk", "key", - ), - ) + config := ` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_dependabot_secret" "test" { + repository = github_repository.test.name + secret_name = "%s" + plaintext_value = "%s" +} +` resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, - Check: check, + Config: fmt.Sprintf(config, repoName, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_dependabot_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "plaintext_value", value), + resource.TestCheckNoResourceAttr("github_dependabot_secret.test", "encrypted_value"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, repoName, secretName, updatedValue), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_dependabot_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "secret_name", secretName), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "plaintext_value", updatedValue), + resource.TestCheckNoResourceAttr("github_dependabot_secret.test", "encrypted_value"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + ), }, }, }) }) - t.Run("creates and updates secrets without error", func(t *testing.T) { + t.Run("create_update_encrypted", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-dependabot-%s", testResourcePrefix, randomID) - secretValue := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) - updatedSecretValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" + value := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + updatedValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + + config := ` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_dependabot_secret" "test" { + repository = github_repository.test.name + secret_name = "%s" + encrypted_value = "%s" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_dependabot_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "secret_name", secretName), + resource.TestCheckNoResourceAttr("github_dependabot_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "encrypted_value", value), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, repoName, secretName, updatedValue), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_dependabot_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "secret_name", secretName), + resource.TestCheckNoResourceAttr("github_dependabot_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "encrypted_value", updatedValue), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("create_update_encrypted_with_key", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" + value := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + updatedValue := base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")) + + config := ` +resource "github_repository" "test" { + name = "%s" +} + +data "github_dependabot_public_key" "test" { + repository = github_repository.test.name +} + +resource "github_dependabot_secret" "test" { + repository = github_repository.test.name + key_id = data.github_dependabot_public_key.test.key_id + secret_name = "%s" + encrypted_value = "%s" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName, secretName, value), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_dependabot_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "secret_name", secretName), + resource.TestCheckNoResourceAttr("github_dependabot_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "encrypted_value", value), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + ), + }, + { + Config: fmt.Sprintf(config, repoName, secretName, updatedValue), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("github_dependabot_secret.test", "repository", "github_repository.test", "name"), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "secret_name", secretName), + resource.TestCheckNoResourceAttr("github_dependabot_secret.test", "plaintext_value"), + resource.TestCheckResourceAttr("github_dependabot_secret.test", "encrypted_value", updatedValue), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "key_id"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + ), + }, + }, + }) + }) + + t.Run("update_on_drift", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } - - resource "github_dependabot_secret" "plaintext_secret" { - repository = github_repository.test.name - secret_name = "test_plaintext_secret" - plaintext_value = "%s" - } - - resource "github_dependabot_secret" "encrypted_secret" { - repository = github_repository.test.name - secret_name = "test_encrypted_secret" - encrypted_value = "%s" - } - `, repoName, secretValue, secretValue) - - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_dependabot_secret.plaintext_secret", "plaintext_value", - secretValue, - ), - resource.TestCheckResourceAttr( - "github_dependabot_secret.encrypted_secret", "encrypted_value", - secretValue, - ), - resource.TestCheckResourceAttrSet( - "github_dependabot_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_dependabot_secret.plaintext_secret", "updated_at", - ), - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_dependabot_secret.plaintext_secret", "plaintext_value", - updatedSecretValue, - ), - resource.TestCheckResourceAttr( - "github_dependabot_secret.encrypted_secret", "encrypted_value", - updatedSecretValue, - ), - resource.TestCheckResourceAttrSet( - "github_dependabot_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_dependabot_secret.plaintext_secret", "updated_at", - ), - ), - } +resource "github_repository" "test" { + name = "%s" +} +resource "github_dependabot_secret" "test" { + repository = github_repository.test.name + secret_name = "%s" + plaintext_value = "test" +} +`, repoName, secretName) + + var beforeCreatedAt string resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, - Check: checks["before"], + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_dependabot_secret.test"].Primary.Attributes["created_at"] + return nil + }, + ), }, { - Config: strings.Replace(config, - secretValue, - updatedSecretValue, 2), - Check: checks["after"], + PreConfig: func() { + meta, err := getTestMeta() + if err != nil { + t.Fatal(err.Error()) + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + keyID, _, err := getDependabotPublicKeyDetails(ctx, meta, repoName) + if err != nil { + t.Fatal(err.Error()) + } + + _, err = client.Dependabot.CreateOrUpdateRepoSecret(ctx, owner, repoName, &github.DependabotEncryptedSecret{ + Name: secretName, + EncryptedValue: base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")), + KeyID: keyID, + }) + if err != nil { + t.Fatal(err.Error()) + } + }, + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_dependabot_secret.test"].Primary.Attributes["created_at"] + + if afterCreatedAt != beforeCreatedAt { + return fmt.Errorf("expected resource to be updated, but created_at has been modified: %s", beforeCreatedAt) + } + return nil + }, + ), }, }, }) }) - t.Run("creates and updates repository name without error", func(t *testing.T) { + t.Run("lifecycle_can_ignore_drift", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-dependabot-%s", testResourcePrefix, randomID) - updatedRepoName := fmt.Sprintf("%srepo-dependabot-%s-upd", testResourcePrefix, randomID) - secretValue := base64.StdEncoding.EncodeToString([]byte("super_secret_value")) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } - - resource "github_dependabot_secret" "plaintext_secret" { - repository = github_repository.test.name - secret_name = "test_plaintext_secret" - plaintext_value = "%s" - } - - resource "github_dependabot_secret" "encrypted_secret" { - repository = github_repository.test.name - secret_name = "test_encrypted_secret" - encrypted_value = "%s" - } - `, repoName, secretValue, secretValue) - - checks := map[string]resource.TestCheckFunc{ - "before": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_dependabot_secret.plaintext_secret", "repository", - repoName, - ), - resource.TestCheckResourceAttr( - "github_dependabot_secret.plaintext_secret", "plaintext_value", - secretValue, - ), - resource.TestCheckResourceAttr( - "github_dependabot_secret.encrypted_secret", "encrypted_value", - secretValue, - ), - resource.TestCheckResourceAttrSet( - "github_dependabot_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_dependabot_secret.plaintext_secret", "updated_at", - ), - ), - "after": resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr( - "github_dependabot_secret.plaintext_secret", "repository", - updatedRepoName, - ), - resource.TestCheckResourceAttr( - "github_dependabot_secret.plaintext_secret", "plaintext_value", - secretValue, - ), - resource.TestCheckResourceAttr( - "github_dependabot_secret.encrypted_secret", "encrypted_value", - secretValue, - ), - resource.TestCheckResourceAttrSet( - "github_dependabot_secret.plaintext_secret", "created_at", - ), - resource.TestCheckResourceAttrSet( - "github_dependabot_secret.plaintext_secret", "updated_at", - ), - ), - } +resource "github_repository" "test" { + name = "%s" +} +resource "github_dependabot_secret" "test" { + repository = github_repository.test.name + secret_name = "%s" + plaintext_value = "test" + + lifecycle { + ignore_changes = [remote_updated_at] + } +} +`, repoName, secretName) + + var beforeUpdatedAt string resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, - Check: checks["before"], + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + beforeUpdatedAt = s.RootModule().Resources["github_dependabot_secret.test"].Primary.Attributes["updated_at"] + return nil + }, + ), }, { - Config: strings.Replace(config, - repoName, - updatedRepoName, 2), - Check: checks["after"], + PreConfig: func() { + meta, err := getTestMeta() + if err != nil { + t.Fatal(err.Error()) + } + client := meta.v3client + owner := meta.name + ctx := context.Background() + + keyID, _, err := getDependabotPublicKeyDetails(ctx, meta, repoName) + if err != nil { + t.Fatal(err.Error()) + } + + _, err = client.Dependabot.CreateOrUpdateRepoSecret(ctx, owner, repoName, &github.DependabotEncryptedSecret{ + Name: secretName, + EncryptedValue: base64.StdEncoding.EncodeToString([]byte("updated_super_secret_value")), + KeyID: keyID, + }) + if err != nil { + t.Fatal(err.Error()) + } + }, + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "remote_updated_at"), + func(s *terraform.State) error { + afterUpdatedAt := s.RootModule().Resources["github_dependabot_secret.test"].Primary.Attributes["updated_at"] + + if afterUpdatedAt != beforeUpdatedAt { + return fmt.Errorf("expected resource to ignore drift, but updated_at has been modified: %s", beforeUpdatedAt) + } + return nil + }, + ), + }, + }, + }) + }) + + t.Run("update_renamed_repo", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + updatedRepoName := fmt.Sprintf("%s%s-updated", testResourcePrefix, randomID) + + config := ` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_dependabot_secret" "test" { + repository = github_repository.test.name + secret_name = "test" + plaintext_value = "test" +} +` + + var beforeCreatedAt string + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repoName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_dependabot_secret.test"].Primary.Attributes["created_at"] + return nil + }, + ), + }, + { + Config: fmt.Sprintf(config, updatedRepoName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_dependabot_secret.test"].Primary.Attributes["created_at"] + + if afterCreatedAt != beforeCreatedAt { + return fmt.Errorf("expected resource to not be recreated, but created_at has been modified: %s", beforeCreatedAt) + } + return nil + }, + ), }, }, }) }) - t.Run("deletes secrets without error", func(t *testing.T) { + t.Run("recreate_changed_repo", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-dependabot-%s", testResourcePrefix, randomID) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + repoName2 := fmt.Sprintf("%supdated-%s", testResourcePrefix, randomID) + config := fmt.Sprintf(` - resource "github_repository" "test" { - name = "%s" - } +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository" "test2" { + name = "%s" +} + +resource "github_dependabot_secret" "test" { + repository = github_repository.test.name + secret_name = "test" + plaintext_value = "test" +} +`, repoName, repoName2) + + configUpdated := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_repository" "test2" { + name = "%s" +} + +resource "github_dependabot_secret" "test" { + repository = github_repository.test2.name + secret_name = "test" + plaintext_value = "test" +} +`, repoName, repoName2) + + var beforeCreatedAt string + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + func(s *terraform.State) error { + beforeCreatedAt = s.RootModule().Resources["github_dependabot_secret.test"].Primary.Attributes["created_at"] + return nil + }, + ), + }, + { + Config: configUpdated, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "created_at"), + resource.TestCheckResourceAttrSet("github_dependabot_secret.test", "updated_at"), + func(s *terraform.State) error { + afterCreatedAt := s.RootModule().Resources["github_dependabot_secret.test"].Primary.Attributes["created_at"] + + if afterCreatedAt == beforeCreatedAt { + return fmt.Errorf("expected resource to be recreated, but created_at has not been modified: %s", beforeCreatedAt) + } + return nil + }, + ), + }, + }, + }) + }) - resource "github_dependabot_secret" "plaintext_secret" { - repository = github_repository.test.name - secret_name = "test_plaintext_secret" - } + t.Run("destroy", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) - resource "github_dependabot_secret" "encrypted_secret" { - repository = github_repository.test.name - secret_name = "test_encrypted_secret" - } - `, repoName) + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "%s" + } + + resource "github_dependabot_secret" "test" { + repository = github_repository.test.name + secret_name = "test" + plaintext_value = "test" + } +`, repoName) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ + { + Config: config, + }, { Config: config, Destroy: true, @@ -240,4 +521,38 @@ func TestAccGithubDependabotSecret(t *testing.T) { }, }) }) + + t.Run("import", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + secretName := "test" + + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_dependabot_secret" "test" { + repository = github_repository.test.name + secret_name = "%s" + plaintext_value = "test" +} +`, repoName, secretName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + }, + { + ResourceName: "github_dependabot_secret.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"key_id", "plaintext_value"}, + }, + }, + }) + }) } diff --git a/github/resource_github_repository_environment.go b/github/resource_github_repository_environment.go index 078b257b92..6eefc5a478 100644 --- a/github/resource_github_repository_environment.go +++ b/github/resource_github_repository_environment.go @@ -112,11 +112,11 @@ func resourceGithubRepositoryEnvironmentCreate(ctx context.Context, d *schema.Re return diag.FromErr(err) } - if id, err := buildID(repoName, escapeIDPart(envName)); err != nil { + id, err := buildID(repoName, escapeIDPart(envName)) + if err != nil { return diag.FromErr(err) - } else { - d.SetId(id) } + d.SetId(id) return nil } @@ -218,11 +218,11 @@ func resourceGithubRepositoryEnvironmentUpdate(ctx context.Context, d *schema.Re return diag.FromErr(err) } - if id, err := buildID(repoName, escapeIDPart(envName)); err != nil { + id, err := buildID(repoName, escapeIDPart(envName)) + if err != nil { return diag.FromErr(err) - } else { - d.SetId(id) } + d.SetId(id) return nil } diff --git a/github/resource_github_repository_environment_deployment_policy.go b/github/resource_github_repository_environment_deployment_policy.go index a988ab9e1a..a73ac0bb4f 100644 --- a/github/resource_github_repository_environment_deployment_policy.go +++ b/github/resource_github_repository_environment_deployment_policy.go @@ -113,11 +113,11 @@ func resourceGithubRepositoryEnvironmentDeploymentPolicyCreate(ctx context.Conte return diag.FromErr(err) } - if id, err := buildID(repoName, escapeIDPart(envName), strconv.FormatInt(resultKey.GetID(), 10)); err != nil { + id, err := buildID(repoName, escapeIDPart(envName), strconv.FormatInt(resultKey.GetID(), 10)) + if err != nil { return diag.FromErr(err) - } else { - d.SetId(id) } + d.SetId(id) return nil } @@ -195,11 +195,11 @@ func resourceGithubRepositoryEnvironmentDeploymentPolicyUpdate(ctx context.Conte return diag.FromErr(err) } - if id, err := buildID(repoName, escapeIDPart(envName), strconv.FormatInt(resultKey.GetID(), 10)); err != nil { + id, err := buildID(repoName, escapeIDPart(envName), strconv.FormatInt(resultKey.GetID(), 10)) + if err != nil { return diag.FromErr(err) - } else { - d.SetId(id) } + d.SetId(id) return nil } diff --git a/github/util.go b/github/util.go index 97d4dfcb0b..a8a2d96f71 100644 --- a/github/util.go +++ b/github/util.go @@ -88,6 +88,16 @@ func parseID3(id string) (string, string, string, error) { return parts[0], parts[1], parts[2], nil } +// parseID4 splits the id by the idSeparator into four parts. +func parseID4(id string) (string, string, string, string, error) { + parts, err := parseID(id, 4) + if err != nil { + return "", "", "", "", err + } + + return parts[0], parts[1], parts[2], parts[3], nil +} + func checkOrganization(meta any) error { if !meta.(*Owner).IsOrganization { return fmt.Errorf("this resource can only be used in the context of an organization, %q is a user", meta.(*Owner).name) diff --git a/github/util_diff.go b/github/util_diff.go new file mode 100644 index 0000000000..42f20d9e61 --- /dev/null +++ b/github/util_diff.go @@ -0,0 +1,111 @@ +package github + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + + "github.com/google/go-github/v82/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// diffRepository checks if the repository has changed and forces a new resource if the repository ID does not match. +// The resource must have both "repository" and "repository_id" attributes. +func diffRepository(ctx context.Context, diff *schema.ResourceDiff, m any) error { + if len(diff.Id()) == 0 { + return nil + } + + if diff.HasChange("repository") { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + var repoID int + if o, ok := diff.GetOk("repository_id"); ok { + if v, ok := o.(int); ok { + repoID = v + } else { + return fmt.Errorf("repository_id is not an int") + } + } else { + return fmt.Errorf("repository_id is not set") + } + + var repoName string + if o, ok := diff.GetOk("repository"); ok { + if v, ok := o.(string); ok { + repoName = v + } else { + return fmt.Errorf("repository is not a string") + } + } else { + return fmt.Errorf("repository is not set") + } + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + if ghErr.Response.StatusCode != http.StatusNotFound { + return ghErr + } + + log.Printf("[INFO] Repository %s not found when checking repository change diff %s", repoName, diff.Id()) + } else { + return err + } + } else { + log.Printf("[INFO] Repository %s found when checking repository change diff %s", repoName, diff.Id()) + + if repoID != int(repo.GetID()) { + return diff.ForceNew("repository") + } + } + } + + return nil +} + +// diffSecret compares the remote_updated_at and updated_at fields to determine if the secret has changed remotely. +func diffSecret(ctx context.Context, diff *schema.ResourceDiff, _ any) error { + if len(diff.Id()) == 0 { + return nil + } + + if diff.HasChange("remote_updated_at") { + remoteUpdatedAt := diff.Get("remote_updated_at").(string) + if len(remoteUpdatedAt) == 0 { + return nil + } + + updatedAt := diff.Get("updated_at").(string) + if updatedAt != remoteUpdatedAt { + if len(updatedAt) == 0 { + return diff.SetNew("updated_at", remoteUpdatedAt) + } + + return diff.SetNewComputed("updated_at") + } + } + + return nil +} + +// diffSecretVariableVisibility ensures that selected_repository_ids is only set when visibility is set to selected. +func diffSecretVariableVisibility(ctx context.Context, d *schema.ResourceDiff, _ any) error { + if len(d.Id()) == 0 { + return nil + } + + visibility := d.Get("visibility").(string) + if visibility != "selected" { + if _, ok := d.GetOk("selected_repository_ids"); ok { + return fmt.Errorf("cannot use selected_repository_ids without visibility being set to selected") + } + } + + return nil +} diff --git a/github/util_test.go b/github/util_test.go index e18bbb5a4a..38db46353d 100644 --- a/github/util_test.go +++ b/github/util_test.go @@ -328,6 +328,75 @@ func Test_parseID3(t *testing.T) { } } +func Test_parseID4(t *testing.T) { + t.Parallel() + + for _, d := range []struct { + testName string + id string + expect1 string + expect2 string + expect3 string + expect4 string + hasError bool + }{ + { + testName: "valid_four_parts", + id: "part1:part2:part3:part4", + expect1: "part1", + expect2: "part2", + expect3: "part3", + expect4: "part4", + hasError: false, + }, + { + testName: "valid_four_parts_with_extra", + id: "part1:part2:part3:part4:extra", + expect1: "part1", + expect2: "part2", + expect3: "part3", + expect4: "part4:extra", + hasError: false, + }, + { + testName: "invalid_three_parts", + id: "part1:part2:part3", + expect1: "", + expect2: "", + expect3: "", + expect4: "", + hasError: true, + }, + } { + t.Run(d.testName, func(t *testing.T) { + t.Parallel() + + got1, got2, got3, got4, err := parseID4(d.id) + + if d.hasError && err == nil { + t.Fatalf("expected error but got none") + } + if !d.hasError && err != nil { + t.Fatalf("did not expect error but got: %v", err) + } + if !d.hasError { + if got1 != d.expect1 { + t.Fatalf("expected part 1 to be %q but got %q", d.expect1, got1) + } + if got2 != d.expect2 { + t.Fatalf("expected part 2 to be %q but got %q", d.expect2, got2) + } + if got3 != d.expect3 { + t.Fatalf("expected part 3 to be %q but got %q", d.expect3, got3) + } + if got4 != d.expect4 { + t.Fatalf("expected part 4 to be %q but got %q", d.expect4, got4) + } + } + }) + } +} + func TestGithubUtilRole_validation(t *testing.T) { cases := []struct { Value string diff --git a/website/docs/r/actions_environment_secret.html.markdown b/website/docs/r/actions_environment_secret.html.markdown index 8082ab3887..9e8f524abf 100644 --- a/website/docs/r/actions_environment_secret.html.markdown +++ b/website/docs/r/actions_environment_secret.html.markdown @@ -22,61 +22,53 @@ in your code. See below for an example of this abstraction. ## Example Usage ```hcl -resource "github_actions_environment_secret" "example_secret" { - environment = "example_environment" - secret_name = "example_secret_name" - plaintext_value = var.some_secret_string +resource "github_actions_environment_secret" "example_plaintext" { + repository = "example-repo" + environment = "example-environment" + secret_name = "example_secret_name" + plaintext_value = "example-value } -resource "github_actions_environment_secret" "example_secret" { - environment = "example_environment" - secret_name = "example_secret_name" - encrypted_value = var.some_encrypted_secret_string +resource "github_actions_environment_secret" "example_encrypted" { + repository = "example-repo" + environment = "example-environment" + secret_name = "example_secret_name" + key_id = var.key_id + encrypted_value = var.encrypted_secret_string } ``` ```hcl -data "github_repository" "repo" { +data "github_repository" "example" { full_name = "my-org/repo" } -resource "github_repository_environment" "repo_environment" { - repository = data.github_repository.repo.name - environment = "example_environment" +resource "github_repository_environment" "example_plaintext" { + repository = data.github_repository.example.name + environment = "example-environment" } -resource "github_actions_environment_secret" "test_secret" { - repository = data.github_repository.repo.name - environment = github_repository_environment.repo_environment.environment +resource "github_actions_environment_secret" "example_encrypted" { + repository = data.github_repository.example.name + environment = github_repository_environment.example.environment secret_name = "test_secret_name" - plaintext_value = "%s" + plaintext_value = "example-value" } ``` ## Example Lifecycle Ignore Changes -This resource supports the `lifecycle` `ignore_changes` block. This is for use cases where a secret value is created -using a placeholder value and then modified after creation outside the scope of Terraform. This approach ensures only -the initial placeholder value is referenced in your code and in the resulting state file. +This resource supports using the `lifecycle` `ignore_changes` block on `remote_updated_at` to support use cases where a secret value is created using a placeholder value and then modified after creation outside the scope of Terraform. This approach ensures only the initial placeholder value is referenced in your code and in the resulting state file. ```hcl -resource "github_actions_environment_secret" "example_secret" { - environment = "example_environment" - secret_name = "example_secret_name" - plaintext_value = "placeholder" +resource "github_actions_environment_secret" "example_allow_drift" { + repository = "example-repo" + environment = "example-environment" + secret_name = "example_secret_name" + plaintext_value = "placeholder" lifecycle { - ignore_changes = [plaintext_value] - } -} - -resource "github_actions_environment_secret" "example_secret" { - environment = "example_environment" - secret_name = "example_secret_name" - encrypted_value = base64sha256("placeholder") - - lifecycle { - ignore_changes = [encrypted_value] + ignore_changes = [remote_updated_at] } } ``` @@ -85,18 +77,43 @@ resource "github_actions_environment_secret" "example_secret" { The following arguments are supported: +- `repository` - (Required) Name of the repository. +- `environment` - (Required) Name of the environment. +- `secret_name` - (Required) Name of the secret. +- `key_id` - (Optional) ID of the public key used to encrypt the secret. This should be provided when setting `encrypted_value`; if it isn't then the current public key will be looked up, which could cause a missmatch. This conflicts with `plaintext_value`. +- `encrypted_value` - (Optional) Encrypted value of the secret using the GitHub public key in Base64 format. +- `plaintext_value` - (Optional) Plaintext value of the secret to be encrypted. -* `repository` - (Required) Name of the repository. -* `environment` - (Required) Name of the environment. -* `secret_name` - (Required) Name of the secret. -* `encrypted_value` - (Optional) Encrypted value of the secret using the GitHub public key in Base64 format. -* `plaintext_value` - (Optional) Plaintext value of the secret to be encrypted. +~> **Note**: One of either `encrypted_value` or `plaintext_value` must be specified. ## Attributes Reference -* `created_at` - Date of actions_environment_secret creation. -* `updated_at` - Date of actions_environment_secret update. +- `repository_id` - ID of the repository. +- `created_at` - Date the secret was created. +- `updated_at` - Date the secret was last updated by the provider. +- `remote_updated_at` - Date the secret was last updated in GitHub. ## Import -This resource does not support importing. If you'd like to help contribute it, please visit our [GitHub page](https://github.com/integrations/terraform-provider-github)! +This resource can be imported using an ID made of the repository name, environment name (URL escaped), and secret name all separated by a `:`. + +~> **Note**: When importing secrets, the `plaintext_value` or `encrypted_value` fields will not be populated in the state. You may need to ignore changes for these as a workaround if you're not planning on updating the secret through Terraform. + +### Import Block + +The following import imports a GitHub actions environment secret named `mysecret` for the repo `myrepo` and environment `myenv` to a `github_actions_environment_secret` resource named `example`. + +```hcl +import { + to = github_actions_environment_secret.example + id = "myrepo:myenv:mysecret" +} +``` + +### Import Command + +The following command imports a GitHub actions environment secret named `mysecret` for the repo `myrepo` and environment `myenv` to a `github_actions_environment_secret` resource named `example`. + +```shell +terraform import github_actions_environment_secret.example myrepo:myenv:mysecret +``` diff --git a/website/docs/r/actions_environment_variable.html.markdown b/website/docs/r/actions_environment_variable.html.markdown index a6ddc70277..d64141361d 100644 --- a/website/docs/r/actions_environment_variable.html.markdown +++ b/website/docs/r/actions_environment_variable.html.markdown @@ -13,28 +13,29 @@ You must have write access to a repository to use this resource. ## Example Usage ```hcl -resource "github_actions_environment_variable" "example_variable" { - environment = "example_environment" - variable_name = "example_variable_name" - value = "example_variable_value" +resource "github_actions_environment_variable" "example" { + repository = "example-repo" + environment = "example-environment" + variable_name = "example_variable_name" + value = "example-value" } ``` ```hcl -data "github_repository" "repo" { +data "github_repository" "example" { full_name = "my-org/repo" } -resource "github_repository_environment" "repo_environment" { - repository = data.github_repository.repo.name +resource "github_repository_environment" "example" { + repository = data.github_repository.example.name environment = "example_environment" } -resource "github_actions_environment_variable" "example_variable" { - repository = data.github_repository.repo.name - environment = github_repository_environment.repo_environment.environment - variable_name = "example_variable_name" - value = "example_variable_value" +resource "github_actions_environment_variable" "example" { + repository = data.github_repository.example.name + environment = github_repository_environment.example.environment + variable_name = "example_variable_name" + value = "example-value" } ``` @@ -42,19 +43,35 @@ resource "github_actions_environment_variable" "example_variable" { The following arguments are supported: -* `repository` - (Required) Name of the repository. -* `environment` - (Required) Name of the environment. -* `variable_name` - (Required) Name of the variable. -* `value` - (Required) Value of the variable +- `repository` - (Required) Name of the repository. +- `environment` - (Required) Name of the environment. +- `variable_name` - (Required) Name of the variable. +- `value` - (Required) Value of the variable. ## Attributes Reference -* `created_at` - Date of actions_environment_secret creation. -* `updated_at` - Date of actions_environment_secret update. +- `repository_id` - ID of the repository. +- `created_at` - Date the variable was created. +- `updated_at` - Date the variable was last updated. ## Import -This resource can be imported using an ID made of the repository name, environment name (any `:` in the name need to be escaped as `??`), and variable name all separated by a `:`. +This resource can be imported using an ID made of the repository name, environment name (any `:` in the environment name need to be escaped as `??`), and variable name all separated by a `:`. + +### Import Block + +The following import imports a GitHub actions environment variable named `myvariable` for the repo `myrepo` and environment `myenv` to a `github_actions_environment_variable` resource named `example`. + +```hcl +import { + to = github_actions_environment_variable.example + id = "myrepo:myenv:myvariable" +} +``` + +### Import Command + +The following command imports a GitHub actions environment variable named `myvariable` for the repo `myrepo` and environment `myenv` to a `github_actions_environment_variable` resource named `example`. ```shell terraform import github_actions_environment_variable.example myrepo:myenv:myvariable diff --git a/website/docs/r/actions_organization_secret.html.markdown b/website/docs/r/actions_organization_secret.html.markdown index a47f1a9719..3531fc0b29 100644 --- a/website/docs/r/actions_organization_secret.html.markdown +++ b/website/docs/r/actions_organization_secret.html.markdown @@ -22,15 +22,15 @@ in your code. See below for an example of this abstraction. ## Example Usage ```hcl -resource "github_actions_organization_secret" "example_secret" { +resource "github_actions_organization_secret" "example_plaintext" { secret_name = "example_secret_name" - visibility = "private" + visibility = "all" plaintext_value = var.some_secret_string } -resource "github_actions_organization_secret" "example_secret" { +resource "github_actions_organization_secret" "example_encrypted" { secret_name = "example_secret_name" - visibility = "private" + visibility = "all" encrypted_value = var.some_encrypted_secret_string } ``` @@ -40,7 +40,7 @@ data "github_repository" "repo" { full_name = "my-org/repo" } -resource "github_actions_organization_secret" "example_secret" { +resource "github_actions_organization_secret" "example_encrypted" { secret_name = "example_secret_name" visibility = "selected" plaintext_value = var.some_secret_string @@ -55,32 +55,63 @@ resource "github_actions_organization_secret" "example_secret" { } ``` +## Example Lifecycle Ignore Changes + +This resource supports using the `lifecycle` `ignore_changes` block on `remote_updated_at` to support use cases where a secret value is created using a placeholder value and then modified after creation outside the scope of Terraform. This approach ensures only the initial placeholder value is referenced in your code and in the resulting state file. + +```hcl +resource "github_actions_organization_secret" "example_allow_drift" { + secret_name = "example_secret_name" + visibility = "all" + plaintext_value = "placeholder" + + lifecycle { + ignore_changes = [remote_updated_at] + } +} +``` + ## Argument Reference The following arguments are supported: -* `secret_name` - (Required) Name of the secret -* `encrypted_value` - (Optional) Encrypted value of the secret using the GitHub public key in Base64 format. -* `plaintext_value` - (Optional) Plaintext value of the secret to be encrypted -* `visibility` - (Required) Configures the access that repositories have to the organization secret. - Must be one of `all`, `private`, `selected`. `selected_repository_ids` is required if set to `selected`. -* `selected_repository_ids` - (Optional) An array of repository ids that can access the organization secret. -* `destroy_on_drift` - (Optional) Boolean indicating whether to recreate the secret if it's modified outside of Terraform. - When `true` (default), Terraform will delete and recreate the secret if it detects external changes. - When `false`, Terraform will acknowledge external changes but not recreate the secret. Defaults to `true`. +- `secret_name` - (Required) Name of the secret. +- `key_id` - (Optional) ID of the public key used to encrypt the secret. This should be provided when setting `encrypted_value`; if it isn't then the current public key will be looked up, which could cause a missmatch. This conflicts with `plaintext_value`. +- `encrypted_value` - (Optional) Encrypted value of the secret using the GitHub public key in Base64 format. +- `plaintext_value` - (Optional) Plaintext value of the secret to be encrypted. +- `visibility` - (Required) Configures the access that repositories have to the organization secret; must be one of `all`, `private`, or `selected`. +- `selected_repository_ids` - (Optional) An array of repository IDs that can access the organization variable; this requires `visibility` to be set to `selected`. +- `destroy_on_drift` - (**DEPRECATED**) (Optional) This is ignored as drift detection is built into the resource. + +~> **Note**: One of either `encrypted_value` or `plaintext_value` must be specified. ## Attributes Reference -* `created_at` - Date of actions_secret creation. -* `updated_at` - Date of actions_secret update. +- `created_at` - Date the secret was created. +- `updated_at` - Date the secret was last updated by the provider. +- `remote_updated_at` - Date the secret was last updated in GitHub. ## Import -This resource can be imported using an ID made up of the secret name: +This resource can be imported using the secret name as the ID. +~> **Note**: When importing secrets, the `plaintext_value` or `encrypted_value` fields will not be populated in the state. You may need to ignore changes for these as a workaround if you're not planning on updating the secret through Terraform. + +### Import Block + +The following import imports a GitHub actions organization secret named `mysecret` to a `github_actions_organization_secret` resource named `example`. + +```hcl +import { + to = github_actions_organization_secret.example + id = "mysecret" +} ``` -$ terraform import github_actions_organization_secret.test_secret test_secret_name -``` -NOTE: the implementation is limited in that it won't fetch the value of the -`plaintext_value` or `encrypted_value` fields when importing. You may need to ignore changes for these as a workaround. +### Import Command + +The following command imports a GitHub actions organization secret named `mysecret` to a `github_actions_organization_secret` resource named `example`. + +```shell +terraform import github_actions_organization_secret.example mysecret +``` diff --git a/website/docs/r/actions_organization_variable.html.markdown b/website/docs/r/actions_organization_variable.html.markdown index 7501c3d27a..b70b8d9649 100644 --- a/website/docs/r/actions_organization_variable.html.markdown +++ b/website/docs/r/actions_organization_variable.html.markdown @@ -37,21 +37,35 @@ resource "github_actions_organization_variable" "example_variable" { The following arguments are supported: -* `variable_name` - (Required) Name of the variable -* `value` - (Required) Value of the variable -* `visibility` - (Required) Configures the access that repositories have to the organization variable. - Must be one of `all`, `private`, `selected`. `selected_repository_ids` is required if set to `selected`. -* `selected_repository_ids` - (Optional) An array of repository ids that can access the organization variable. +- `variable_name` - (Required) Name of the variable. +- `value` - (Required) Value of the variable. +- `visibility` - (Required) Configures the access that repositories have to the organization variable; must be one of `all`, `private`, or `selected`. +- `selected_repository_ids` - (Optional) An array of repository IDs that can access the organization variable; this requires `visibility` to be set to `selected`. ## Attributes Reference -* `created_at` - Date of actions_variable creation. -* `updated_at` - Date of actions_variable update. +- `created_at` - Date the variable was created. +- `updated_at` - Date the variable was last updated. ## Import -This resource can be imported using an ID made up of the variable name: +This resource can be imported using the variable name as the ID. +### Import Block + +The following import imports a GitHub actions organization variable named `myvariable`to a `github_actions_organization_variable` resource named `example`. + +```hcl +import { + to = github_actions_organization_variable.example + id = "myvariable" +} ``` -$ terraform import github_actions_organization_variable.test_variable test_variable_name + +### Import Command + +The following command imports a GitHub actions organization variable named `myvariable` to a `github_actions_organization_variable` resource named `example`. + +```shell +terraform import github_actions_organization_variable.example myvariable ``` diff --git a/website/docs/r/actions_secret.html.markdown b/website/docs/r/actions_secret.html.markdown index 67516d9f52..e7636cc9d3 100644 --- a/website/docs/r/actions_secret.html.markdown +++ b/website/docs/r/actions_secret.html.markdown @@ -22,47 +22,76 @@ in your code. See below for an example of this abstraction. ## Example Usage ```hcl -data "github_actions_public_key" "example_public_key" { - repository = "example_repository" -} - -resource "github_actions_secret" "example_secret" { +resource "github_actions_secret" "example_plaintext" { repository = "example_repository" secret_name = "example_secret_name" plaintext_value = var.some_secret_string } -resource "github_actions_secret" "example_secret" { +resource "github_actions_secret" "example_encrypted" { repository = "example_repository" secret_name = "example_secret_name" encrypted_value = var.some_encrypted_secret_string } ``` +## Example Lifecycle Ignore Changes + +This resource supports using the `lifecycle` `ignore_changes` block on `remote_updated_at` to support use cases where a secret value is created using a placeholder value and then modified after creation outside the scope of Terraform. This approach ensures only the initial placeholder value is referenced in your code and in the resulting state file. + +```hcl +resource "github_actions_secret" "example_allow_drift" { + repository = "example_repository" + secret_name = "example_secret_name" + plaintext_value = "placeholder" + + lifecycle { + ignore_changes = [remote_updated_at] + } +} +``` + ## Argument Reference The following arguments are supported: -* `repository` - (Required) Name of the repository -* `secret_name` - (Required) Name of the secret -* `encrypted_value` - (Optional) Encrypted value of the secret using the GitHub public key in Base64 format. -* `plaintext_value` - (Optional) Plaintext value of the secret to be encrypted -* `destroy_on_drift` - (Optional) Boolean indicating whether to recreate the secret if it's modified outside of Terraform. - When `true` (default), Terraform will delete and recreate the secret if it detects external changes. - When `false`, Terraform will acknowledge external changes but not recreate the secret. Defaults to `true`. +- `repository` - (Required) Name of the repository. +- `secret_name` - (Required) Name of the secret. +- `key_id` - (Optional) ID of the public key used to encrypt the secret. This should be provided when setting `encrypted_value`; if it isn't then the current public key will be looked up, which could cause a missmatch. This conflicts with `plaintext_value`. +- `encrypted_value` - (Optional) Encrypted value of the secret using the GitHub public key in Base64 format. +- `plaintext_value` - (Optional) Plaintext value of the secret to be encrypted. +- `destroy_on_drift` - (**DEPRECATED**) (Optional) This is ignored as drift detection is built into the resource. + +~> **Note**: One of either `encrypted_value` or `plaintext_value` must be specified. ## Attributes Reference -* `created_at` - Date of actions_secret creation. -* `updated_at` - Date of actions_secret update. +- `repository_id` - ID of the repository. +- `created_at` - Date the secret was created. +- `updated_at` - Date the secret was last updated by the provider. +- `remote_updated_at` - Date the secret was last updated in GitHub. ## Import -This resource can be imported using an ID made up of the `repository` and `secret_name`: +This resource can be imported using an ID made of the repository name, and secret name separated by a `:`. +~> **Note**: When importing secrets, the `plaintext_value` or `encrypted_value` fields will not be populated in the state. You may need to ignore changes for these as a workaround if you're not planning on updating the secret through Terraform. + +### Import Block + +The following import imports a GitHub actions secret named `mysecret` for the repo `myrepo` to a `github_actions_secret` resource named `example`. + +```hcl +import { + to = github_actions_secret.example + id = "myrepo:mysecret" +} ``` -$ terraform import github_actions_secret.example_secret repository/secret_name -``` -NOTE: the implementation is limited in that it won't fetch the value of the -`plaintext_value` or `encrypted_value` fields when importing. You may need to ignore changes for these as a workaround. \ No newline at end of file +### Import Command + +The following command imports a GitHub actions secret named `mysecret` for the repo `myrepo` to a `github_actions_secret` resource named `example`. + +```shell +terraform import github_actions_secret.example myrepo:mysecret +``` diff --git a/website/docs/r/actions_variable.html.markdown b/website/docs/r/actions_variable.html.markdown index d137d58bd6..862e6572db 100644 --- a/website/docs/r/actions_variable.html.markdown +++ b/website/docs/r/actions_variable.html.markdown @@ -10,7 +10,6 @@ description: |- This resource allows you to create and manage GitHub Actions variables within your GitHub repositories. You must have write access to a repository to use this resource. - ## Example Usage ```hcl @@ -25,19 +24,35 @@ resource "github_actions_variable" "example_variable" { The following arguments are supported: -* `repository` - (Required) Name of the repository -* `variable_name` - (Required) Name of the variable -* `value` - (Required) Value of the variable +- `repository` - (Required) Name of the repository. +- `variable_name` - (Required) Name of the variable. +- `value` - (Required) Value of the variable. ## Attributes Reference -* `created_at` - Date of actions_variable creation. -* `updated_at` - Date of actions_variable update. +- `repository_id` - ID of the repository. +- `created_at` - Date the variable was created. +- `updated_at` - Date the variable was last updated. ## Import -GitHub Actions variables can be imported using an ID made up of `repository:variable_name`, e.g. +This resource can be imported using an ID made of the repository name, and variable name separated by a `:`. + +### Import Block +The following import imports a GitHub actions variable named `myvariable` for the repo `myrepo` to a `github_actions_variable` resource named `example`. + +```hcl +import { + to = github_actions_variable.example + id = "myrepo:myvariable" +} ``` -$ terraform import github_actions_variable.myvariable myrepo:myvariable + +### Import Command + +The following command imports a GitHub actions variable named `myvariable` for the repo `myrepo` to a `github_actions_variable` resource named `example`. + +```shell +terraform import github_actions_variable.example myrepo:myvariable ``` diff --git a/website/docs/r/dependabot_organization_secret.html.markdown b/website/docs/r/dependabot_organization_secret.html.markdown index 3c5e266c55..b1a8197b5d 100644 --- a/website/docs/r/dependabot_organization_secret.html.markdown +++ b/website/docs/r/dependabot_organization_secret.html.markdown @@ -22,15 +22,15 @@ in your code. See below for an example of this abstraction. ## Example Usage ```hcl -resource "github_dependabot_organization_secret" "example_secret" { +resource "github_dependabot_organization_secret" "example_plaintext" { secret_name = "example_secret_name" - visibility = "private" + visibility = "all" plaintext_value = var.some_secret_string } resource "github_dependabot_organization_secret" "example_secret" { secret_name = "example_secret_name" - visibility = "private" + visibility = "all" encrypted_value = var.some_encrypted_secret_string } ``` @@ -40,14 +40,14 @@ data "github_repository" "repo" { full_name = "my-org/repo" } -resource "github_dependabot_organization_secret" "example_secret" { +resource "github_dependabot_organization_secret" "example_plaintext" { secret_name = "example_secret_name" visibility = "selected" plaintext_value = var.some_secret_string selected_repository_ids = [data.github_repository.repo.repo_id] } -resource "github_dependabot_organization_secret" "example_secret" { +resource "github_dependabot_organization_secret" "example_encrypted" { secret_name = "example_secret_name" visibility = "selected" encrypted_value = var.some_encrypted_secret_string @@ -55,29 +55,61 @@ resource "github_dependabot_organization_secret" "example_secret" { } ``` +## Example Lifecycle Ignore Changes + +This resource supports using the `lifecycle` `ignore_changes` block on `remote_updated_at` to support use cases where a secret value is created using a placeholder value and then modified after creation outside the scope of Terraform. This approach ensures only the initial placeholder value is referenced in your code and in the resulting state file. + +```hcl +resource "github_dependabot_organization_secret" "example_allow_drift" { + secret_name = "example_secret_name" + visibility = "all" + secret_name = "example_secret_name" + plaintext_value = "placeholder" + + lifecycle { + ignore_changes = [remote_updated_at] + } +} +``` + ## Argument Reference The following arguments are supported: -* `secret_name` - (Required) Name of the secret -* `encrypted_value` - (Optional) Encrypted value of the secret using the GitHub public key in Base64 format. -* `plaintext_value` - (Optional) Plaintext value of the secret to be encrypted -* `visibility` - (Required) Configures the access that repositories have to the organization secret. - Must be one of `all`, `private`, `selected`. `selected_repository_ids` is required if set to `selected`. -* `selected_repository_ids` - (Optional) An array of repository ids that can access the organization secret. +- `secret_name` - (Required) Name of the secret. +- `key_id` - (Optional) ID of the public key used to encrypt the secret. This should be provided when setting `encrypted_value`; if it isn't then the current public key will be looked up, which could cause a missmatch. This conflicts with `plaintext_value`. +- `encrypted_value` - (Optional) Encrypted value of the secret using the GitHub public key in Base64 format. +- `plaintext_value` - (Optional) Plaintext value of the secret to be encrypted. +- `visibility` - (Required) Configures the access that repositories have to the organization secret; must be one of `all`, `private`, or `selected`. +- `selected_repository_ids` - (Optional) An array of repository IDs that can access the organization variable; this requires `visibility` to be set to `selected`. ## Attributes Reference -* `created_at` - Date of dependabot_secret creation. -* `updated_at` - Date of dependabot_secret update. +- `created_at` - Date the secret was created. +- `updated_at` - Date the secret was last updated by the provider. +- `remote_updated_at` - Date the secret was last updated in GitHub. ## Import -This resource can be imported using an ID made up of the secret name: +This resource can be imported using the secret name as the ID. +~> **Note**: When importing secrets, the `plaintext_value` or `encrypted_value` fields will not be populated in the state. You may need to ignore changes for these as a workaround if you're not planning on updating the secret through Terraform. + +### Import Block + +The following import imports a GitHub Dependabot organization secret named `mysecret` to a `github_dependabot_organization_secret` resource named `example`. + +```hcl +import { + to = github_dependabot_organization_secret.example + id = "mysecret" +} ``` -terraform import github_dependabot_organization_secret.test_secret test_secret_name -``` -NOTE: the implementation is limited in that it won't fetch the value of the -`plaintext_value` or `encrypted_value` fields when importing. You may need to ignore changes for these as a workaround. +### Import Command + +The following command imports a GitHub Dependabot organization secret named `mysecret` to a `github_dependabot_organization_secret` resource named `example`. + +```shell +terraform import github_dependabot_organization_secret.example mysecret +``` diff --git a/website/docs/r/dependabot_secret.html.markdown b/website/docs/r/dependabot_secret.html.markdown index 831d40d3c5..68bda004cc 100644 --- a/website/docs/r/dependabot_secret.html.markdown +++ b/website/docs/r/dependabot_secret.html.markdown @@ -22,44 +22,75 @@ in your code. See below for an example of this abstraction. ## Example Usage ```hcl -data "github_dependabot_public_key" "example_public_key" { - repository = "example_repository" -} - -resource "github_dependabot_secret" "example_secret" { +resource "github_dependabot_secret" "example_plaintext" { repository = "example_repository" secret_name = "example_secret_name" plaintext_value = var.some_secret_string } -resource "github_dependabot_secret" "example_secret" { +resource "github_dependabot_secret" "example_encrypted" { repository = "example_repository" secret_name = "example_secret_name" encrypted_value = var.some_encrypted_secret_string } ``` +## Example Lifecycle Ignore Changes + +This resource supports using the `lifecycle` `ignore_changes` block on `remote_updated_at` to support use cases where a secret value is created using a placeholder value and then modified after creation outside the scope of Terraform. This approach ensures only the initial placeholder value is referenced in your code and in the resulting state file. + +```hcl +resource "github_dependabot_secret" "example_allow_drift" { + repository = "example_repository" + secret_name = "example_secret_name" + plaintext_value = "placeholder" + + lifecycle { + ignore_changes = [remote_updated_at] + } +} +``` + ## Argument Reference The following arguments are supported: -* `repository` - (Required) Name of the repository -* `secret_name` - (Required) Name of the secret -* `encrypted_value` - (Optional) Encrypted value of the secret using the GitHub public key in Base64 format. -* `plaintext_value` - (Optional) Plaintext value of the secret to be encrypted +- `repository` - (Required) Name of the repository. +- `secret_name` - (Required) Name of the secret. +- `key_id` - (Optional) ID of the public key used to encrypt the secret. This should be provided when setting `encrypted_value`; if it isn't then the current public key will be looked up, which could cause a missmatch. This conflicts with `plaintext_value`. +- `encrypted_value` - (Optional) Encrypted value of the secret using the GitHub public key in Base64 format. +- `plaintext_value` - (Optional) Plaintext value of the secret to be encrypted. + +~> **Note**: One of either `encrypted_value` or `plaintext_value` must be specified. ## Attributes Reference -* `created_at` - Date of dependabot_secret creation. -* `updated_at` - Date of dependabot_secret update. +- `repository_id` - ID of the repository. +- `created_at` - Date the secret was created. +- `updated_at` - Date the secret was last updated by the provider. +- `remote_updated_at` - Date the secret was last updated in GitHub. ## Import -This resource can be imported using an ID made up of the `repository` and `secret_name`: +This resource can be imported using an ID made of the repository name, and secret name separated by a `:`. +~> **Note**: When importing secrets, the `plaintext_value` or `encrypted_value` fields will not be populated in the state. You may need to ignore changes for these as a workaround if you're not planning on updating the secret through Terraform. + +### Import Block + +The following import imports a GitHub Dependabot secret named `mysecret` for the repo `myrepo` to a `github_dependabot_secret` resource named `example`. + +```hcl +import { + to = github_dependabot_secret.example + id = "myrepo:mysecret" +} ``` -$ terraform import github_dependabot_secret.example_secret example_repository/example_secret -``` -NOTE: the implementation is limited in that it won't fetch the value of the -`plaintext_value` or `encrypted_value` fields when importing. You may need to ignore changes for these as a workaround. +### Import Command + +The following command imports a GitHub Dependabot secret named `mysecret` for the repo `myrepo` to a `github_dependabot_secret` resource named `example`. + +```shell +terraform import github_dependabot_secret.example myrepo:mysecret +```