diff --git a/github/data_source_github_codespaces_organization_public_key.go b/github/data_source_github_codespaces_organization_public_key.go new file mode 100644 index 0000000000..3d654df81e --- /dev/null +++ b/github/data_source_github_codespaces_organization_public_key.go @@ -0,0 +1,47 @@ +package github + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func dataSourceGithubCodespacesOrganizationPublicKey() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGithubCodespacesOrganizationPublicKeyRead, + + Schema: map[string]*schema.Schema{ + "key_id": { + Type: schema.TypeString, + Computed: true, + }, + "key": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataSourceGithubCodespacesOrganizationPublicKeyRead(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + + ctx := context.Background() + + publicKey, _, err := client.Codespaces.GetOrgPublicKey(ctx, owner) + if err != nil { + return err + } + + d.SetId(publicKey.GetKeyID()) + d.Set("key_id", publicKey.GetKeyID()) + d.Set("key", publicKey.GetKey()) + + return nil +} diff --git a/github/data_source_github_codespaces_organization_public_key_test.go b/github/data_source_github_codespaces_organization_public_key_test.go new file mode 100644 index 0000000000..d76a0f7c5d --- /dev/null +++ b/github/data_source_github_codespaces_organization_public_key_test.go @@ -0,0 +1,49 @@ +package github + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubCodespacesOrganizationPublicKeyDataSource(t *testing.T) { + + t.Run("queries an organization public key without error", func(t *testing.T) { + + config := ` + data "github_codespaces_organization_public_key" "test" {} + ` + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet( + "data.github_codespaces_organization_public_key.test", "key", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + + }) +} diff --git a/github/data_source_github_codespaces_organization_secrets.go b/github/data_source_github_codespaces_organization_secrets.go new file mode 100644 index 0000000000..6ec8b0bbbd --- /dev/null +++ b/github/data_source_github_codespaces_organization_secrets.go @@ -0,0 +1,78 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v53/github" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func dataSourceGithubCodespacesOrganizationSecrets() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGithubCodespacesOrganizationSecretsRead, + + Schema: map[string]*schema.Schema{ + "secrets": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + }, + "visibility": { + Type: schema.TypeString, + Computed: true, + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubCodespacesOrganizationSecretsRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + + options := github.ListOptions{ + PerPage: 100, + } + + var all_secrets []map[string]string + for { + secrets, resp, err := client.Codespaces.ListOrgSecrets(context.TODO(), owner, &options) + if err != nil { + return err + } + for _, secret := range secrets.Secrets { + new_secret := map[string]string{ + "name": secret.Name, + "visibility": secret.Visibility, + "created_at": secret.CreatedAt.String(), + "updated_at": secret.UpdatedAt.String(), + } + all_secrets = append(all_secrets, new_secret) + + } + if resp.NextPage == 0 { + break + } + options.Page = resp.NextPage + } + + d.SetId(owner) + d.Set("secrets", all_secrets) + + return nil +} diff --git a/github/data_source_github_codespaces_organization_secrets_test.go b/github/data_source_github_codespaces_organization_secrets_test.go new file mode 100644 index 0000000000..d282791d03 --- /dev/null +++ b/github/data_source_github_codespaces_organization_secrets_test.go @@ -0,0 +1,59 @@ +package github + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubCodespacesOrganizationSecretsDataSource(t *testing.T) { + + t.Run("queries organization codespaces secrets from a repository", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + config := fmt.Sprintf(` + resource "github_codespaces_organization_secret" "test" { + secret_name = "org_cs_secret_1_%s" + plaintext_value = "foo" + visibility = "private" + } + `, randomID) + + config2 := config + ` + data "github_codespaces_organization_secrets" "test" { + } + ` + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.github_codespaces_organization_secrets.test", "secrets.#", "1"), + resource.TestCheckResourceAttr("data.github_codespaces_organization_secrets.test", "secrets.0.name", strings.ToUpper(fmt.Sprintf("ORG_CS_SECRET_1_%s", randomID))), + resource.TestCheckResourceAttr("data.github_codespaces_organization_secrets.test", "secrets.0.visibility", "private"), + resource.TestCheckResourceAttrSet("data.github_codespaces_organization_secrets.test", "secrets.0.created_at"), + resource.TestCheckResourceAttrSet("data.github_codespaces_organization_secrets.test", "secrets.0.updated_at"), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc(), + }, + { + Config: config2, + Check: check, + }, + }, + }) + } + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) +} diff --git a/github/data_source_github_codespaces_public_key.go b/github/data_source_github_codespaces_public_key.go new file mode 100644 index 0000000000..8b528ebbfb --- /dev/null +++ b/github/data_source_github_codespaces_public_key.go @@ -0,0 +1,49 @@ +package github + +import ( + "context" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func dataSourceGithubCodespacesPublicKey() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGithubCodespacesPublicKeyRead, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + }, + "key_id": { + Type: schema.TypeString, + Computed: true, + }, + "key": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataSourceGithubCodespacesPublicKeyRead(d *schema.ResourceData, meta interface{}) error { + repository := d.Get("repository").(string) + owner := meta.(*Owner).name + log.Printf("[INFO] Refreshing GitHub Codespaces Public Key from: %s/%s", owner, repository) + + client := meta.(*Owner).v3client + ctx := context.Background() + + publicKey, _, err := client.Codespaces.GetRepoPublicKey(ctx, owner, repository) + if err != nil { + return err + } + + d.SetId(publicKey.GetKeyID()) + d.Set("key_id", publicKey.GetKeyID()) + d.Set("key", publicKey.GetKey()) + + return nil +} diff --git a/github/data_source_github_codespaces_public_key_test.go b/github/data_source_github_codespaces_public_key_test.go new file mode 100644 index 0000000000..727f90e931 --- /dev/null +++ b/github/data_source_github_codespaces_public_key_test.go @@ -0,0 +1,60 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubCodespacesPublicKeyDataSource(t *testing.T) { + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("queries a repository public key without error", func(t *testing.T) { + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%[1]s" + auto_init = true + } + + data "github_codespaces_public_key" "test" { + repository = github_repository.test.id + } + `, randomID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet( + "data.github_codespaces_public_key.test", "key", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + + }) +} diff --git a/github/data_source_github_codespaces_secrets.go b/github/data_source_github_codespaces_secrets.go new file mode 100644 index 0000000000..be4512d0ae --- /dev/null +++ b/github/data_source_github_codespaces_secrets.go @@ -0,0 +1,106 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v53/github" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func dataSourceGithubCodespacesSecrets() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGithubCodespacesSecretsRead, + + Schema: map[string]*schema.Schema{ + "full_name": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "Full name of the repository (in `org/name` format).", + ConflictsWith: []string{"name"}, + }, + "name": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ConflictsWith: []string{"full_name"}, + Description: "The name of the repository.", + }, + "secrets": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubCodespacesSecretsRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + var repoName string + + if fullName, ok := d.GetOk("full_name"); ok { + var err error + owner, repoName, err = splitRepoFullName(fullName.(string)) + if err != nil { + return err + } + } + + if name, ok := d.GetOk("name"); ok { + repoName = name.(string) + } + + if repoName == "" { + return fmt.Errorf("one of %q or %q has to be provided", "full_name", "name") + } + + options := github.ListOptions{ + PerPage: 100, + } + + var all_secrets []map[string]string + for { + secrets, resp, err := client.Codespaces.ListRepoSecrets(ctx, owner, repoName, &options) + if err != nil { + return err + } + for _, secret := range secrets.Secrets { + new_secret := map[string]string{ + "name": secret.Name, + "created_at": secret.CreatedAt.String(), + "updated_at": secret.UpdatedAt.String(), + } + all_secrets = append(all_secrets, new_secret) + } + if resp.NextPage == 0 { + break + } + options.Page = resp.NextPage + } + + d.SetId(repoName) + d.Set("secrets", all_secrets) + + return nil +} diff --git a/github/data_source_github_codespaces_secrets_test.go b/github/data_source_github_codespaces_secrets_test.go new file mode 100644 index 0000000000..c013b4d6ef --- /dev/null +++ b/github/data_source_github_codespaces_secrets_test.go @@ -0,0 +1,72 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubCodespacesSecretsDataSource(t *testing.T) { + + t.Run("queries codespaces secrets from a repository", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_codespaces_secret" "test" { + secret_name = "cs_secret_1" + repository = github_repository.test.name + plaintext_value = "foo" + } + `, randomID) + + config2 := config + ` + data "github_codespaces_secrets" "test" { + name = github_repository.test.name + } + ` + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.github_codespaces_secrets.test", "name", fmt.Sprintf("tf-acc-test-%s", randomID)), + resource.TestCheckResourceAttr("data.github_codespaces_secrets.test", "secrets.#", "1"), + resource.TestCheckResourceAttr("data.github_codespaces_secrets.test", "secrets.0.name", "CS_SECRET_1"), + resource.TestCheckResourceAttrSet("data.github_codespaces_secrets.test", "secrets.0.created_at"), + resource.TestCheckResourceAttrSet("data.github_codespaces_secrets.test", "secrets.0.updated_at"), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc(), + }, + { + Config: config2, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) +} diff --git a/github/data_source_github_codespaces_user_public_key.go b/github/data_source_github_codespaces_user_public_key.go new file mode 100644 index 0000000000..d1988f05dd --- /dev/null +++ b/github/data_source_github_codespaces_user_public_key.go @@ -0,0 +1,41 @@ +package github + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func dataSourceGithubCodespacesUserPublicKey() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGithubCodespacesUserPublicKeyRead, + + Schema: map[string]*schema.Schema{ + "key_id": { + Type: schema.TypeString, + Computed: true, + }, + "key": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataSourceGithubCodespacesUserPublicKeyRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + ctx := context.Background() + + publicKey, _, err := client.Codespaces.GetUserPublicKey(ctx) + if err != nil { + return err + } + + d.SetId(publicKey.GetKeyID()) + d.Set("key_id", publicKey.GetKeyID()) + d.Set("key", publicKey.GetKey()) + + return nil +} diff --git a/github/data_source_github_codespaces_user_public_key_test.go b/github/data_source_github_codespaces_user_public_key_test.go new file mode 100644 index 0000000000..71fec22b42 --- /dev/null +++ b/github/data_source_github_codespaces_user_public_key_test.go @@ -0,0 +1,49 @@ +package github + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubCodespacesUserPublicKeyDataSource(t *testing.T) { + + t.Run("queries an user public key without error", func(t *testing.T) { + + config := ` + data "github_codespaces_user_public_key" "test" {} + ` + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet( + "data.github_codespaces_user_public_key.test", "key", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + t.Skip("organization account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + }) +} diff --git a/github/data_source_github_codespaces_user_secrets.go b/github/data_source_github_codespaces_user_secrets.go new file mode 100644 index 0000000000..fad7df8f93 --- /dev/null +++ b/github/data_source_github_codespaces_user_secrets.go @@ -0,0 +1,78 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v53/github" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func dataSourceGithubCodespacesUserSecrets() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGithubCodespacesUserSecretsRead, + + Schema: map[string]*schema.Schema{ + "secrets": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + }, + "visibility": { + Type: schema.TypeString, + Computed: true, + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubCodespacesUserSecretsRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + + options := github.ListOptions{ + PerPage: 100, + } + + var all_secrets []map[string]string + for { + secrets, resp, err := client.Codespaces.ListUserSecrets(context.TODO(), &options) + if err != nil { + return err + } + for _, secret := range secrets.Secrets { + new_secret := map[string]string{ + "name": secret.Name, + "visibility": secret.Visibility, + "created_at": secret.CreatedAt.String(), + "updated_at": secret.UpdatedAt.String(), + } + all_secrets = append(all_secrets, new_secret) + + } + if resp.NextPage == 0 { + break + } + options.Page = resp.NextPage + } + + d.SetId(owner) + d.Set("secrets", all_secrets) + + return nil +} diff --git a/github/data_source_github_codespaces_user_secrets_test.go b/github/data_source_github_codespaces_user_secrets_test.go new file mode 100644 index 0000000000..f07c5475c0 --- /dev/null +++ b/github/data_source_github_codespaces_user_secrets_test.go @@ -0,0 +1,57 @@ +package github + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubCodespacesUserSecretsDataSource(t *testing.T) { + + t.Run("queries user codespaces secrets from a repository", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + config := fmt.Sprintf(` + resource "github_codespaces_user_secret" "test" { + secret_name = "user_cs_secret_1_%s" + plaintext_value = "foo" + } + `, randomID) + + config2 := config + ` + data "github_codespaces_user_secrets" "test" { + } + ` + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.github_codespaces_user_secrets.test", "secrets.#", "1"), + resource.TestCheckResourceAttr("data.github_codespaces_user_secrets.test", "secrets.0.name", strings.ToUpper(fmt.Sprintf("USER_CS_SECRET_1_%s", randomID))), + resource.TestCheckResourceAttrSet("data.github_codespaces_user_secrets.test", "secrets.0.created_at"), + resource.TestCheckResourceAttrSet("data.github_codespaces_user_secrets.test", "secrets.0.updated_at"), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc(), + }, + { + Config: config2, + Check: check, + }, + }, + }) + } + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + }) +} diff --git a/github/provider.go b/github/provider.go index 8c99d4e74d..8f339dd272 100644 --- a/github/provider.go +++ b/github/provider.go @@ -114,6 +114,9 @@ func Provider() terraform.ResourceProvider { "github_branch_default": resourceGithubBranchDefault(), "github_branch_protection": resourceGithubBranchProtection(), "github_branch_protection_v3": resourceGithubBranchProtectionV3(), + "github_codespaces_organization_secret": resourceGithubCodespacesOrganizationSecret(), + "github_codespaces_secret": resourceGithubCodespacesSecret(), + "github_codespaces_user_secret": resourceGithubCodespacesUserSecret(), "github_dependabot_organization_secret": resourceGithubDependabotOrganizationSecret(), "github_dependabot_organization_secret_repositories": resourceGithubDependabotOrganizationSecretRepositories(), "github_dependabot_secret": resourceGithubDependabotSecret(), @@ -173,6 +176,12 @@ func Provider() terraform.ResourceProvider { "github_branch": dataSourceGithubBranch(), "github_branch_protection_rules": dataSourceGithubBranchProtectionRules(), "github_collaborators": dataSourceGithubCollaborators(), + "github_codespaces_organization_public_key": dataSourceGithubCodespacesOrganizationPublicKey(), + "github_codespaces_organization_secrets": dataSourceGithubCodespacesOrganizationSecrets(), + "github_codespaces_public_key": dataSourceGithubCodespacesPublicKey(), + "github_codespaces_secrets": dataSourceGithubCodespacesSecrets(), + "github_codespaces_user_public_key": dataSourceGithubCodespacesUserPublicKey(), + "github_codespaces_user_secrets": dataSourceGithubCodespacesUserSecrets(), "github_dependabot_organization_public_key": dataSourceGithubDependabotOrganizationPublicKey(), "github_dependabot_organization_secrets": dataSourceGithubDependabotOrganizationSecrets(), "github_dependabot_public_key": dataSourceGithubDependabotPublicKey(), diff --git a/github/resource_github_codespaces_organization_secret.go b/github/resource_github_codespaces_organization_secret.go new file mode 100644 index 0000000000..9ff5ccf001 --- /dev/null +++ b/github/resource_github_codespaces_organization_secret.go @@ -0,0 +1,235 @@ +package github + +import ( + "context" + "encoding/base64" + "fmt" + "log" + "net/http" + + "github.com/google/go-github/v53/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" +) + +func resourceGithubCodespacesOrganizationSecret() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubCodespacesOrganizationSecretCreateOrUpdate, + Read: resourceGithubCodespacesOrganizationSecretRead, + Update: resourceGithubCodespacesOrganizationSecretCreateOrUpdate, + Delete: resourceGithubCodespacesOrganizationSecretDelete, + Importer: &schema.ResourceImporter{ + State: func(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + d.Set("secret_name", d.Id()) + return []*schema.ResourceData{d}, nil + }, + }, + + Schema: map[string]*schema.Schema{ + "secret_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the secret.", + ValidateFunc: 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.", + ValidateFunc: validation.StringIsBase64, + }, + "plaintext_value": { + Type: schema.TypeString, + ForceNew: true, + Optional: true, + Sensitive: true, + Description: "Plaintext value of the secret to be encrypted.", + ConflictsWith: []string{"encrypted_value"}, + }, + "visibility": { + Type: schema.TypeString, + Required: true, + 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'.", + ValidateFunc: validateValueFunc([]string{"all", "private", "selected"}), + ForceNew: true, + }, + "selected_repository_ids": { + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeInt, + }, + Set: schema.HashInt, + Optional: true, + Description: "An array of repository ids that can access the organization secret.", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'codespaces_secret' creation.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'codespaces_secret' update.", + }, + }, + } +} + +func resourceGithubCodespacesOrganizationSecretCreateOrUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + secretName := d.Get("secret_name").(string) + plaintextValue := d.Get("plaintext_value").(string) + var encryptedValue 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") + } + + selectedRepositoryIDs := github.SelectedRepoIDs{} + + if hasSelectedRepositories { + ids := selectedRepositories.(*schema.Set).List() + + for _, id := range ids { + selectedRepositoryIDs = append(selectedRepositoryIDs, int64(id.(int))) + } + } + + keyId, publicKey, err := getCodespacesOrganizationPublicKeyDetails(owner, meta) + if err != nil { + return err + } + + if encryptedText, ok := d.GetOk("encrypted_value"); ok { + encryptedValue = encryptedText.(string) + } else { + encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) + if err != nil { + return err + } + encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) + } + + // Create an EncryptedSecret and encrypt the plaintext value into it + eSecret := &github.EncryptedSecret{ + Name: secretName, + KeyID: keyId, + Visibility: visibility, + SelectedRepositoryIDs: selectedRepositoryIDs, + EncryptedValue: encryptedValue, + } + + _, err = client.Codespaces.CreateOrUpdateOrgSecret(ctx, owner, eSecret) + if err != nil { + return err + } + + d.SetId(secretName) + return resourceGithubCodespacesOrganizationSecretRead(d, meta) +} + +func resourceGithubCodespacesOrganizationSecretRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + secret, _, err := client.Codespaces.GetOrgSecret(ctx, owner, d.Id()) + if err != nil { + if ghErr, ok := err.(*github.ErrorResponse); ok { + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[WARN] Removing actions secret %s from state because it no longer exists in GitHub", + d.Id()) + d.SetId("") + return nil + } + } + return err + } + + d.Set("encrypted_value", d.Get("encrypted_value")) + d.Set("plaintext_value", d.Get("plaintext_value")) + d.Set("created_at", secret.CreatedAt.String()) + d.Set("visibility", secret.Visibility) + + selectedRepositoryIDs := []int64{} + + if secret.Visibility == "selected" { + opt := &github.ListOptions{ + PerPage: 30, + } + for { + results, resp, err := client.Codespaces.ListSelectedReposForOrgSecret(ctx, owner, d.Id(), opt) + if err != nil { + return err + } + + for _, repo := range results.Repositories { + selectedRepositoryIDs = append(selectedRepositoryIDs, repo.GetID()) + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + } + + d.Set("selected_repository_ids", selectedRepositoryIDs) + + // 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 { + d.Set("updated_at", secret.UpdatedAt.String()) + } + + return nil +} + +func resourceGithubCodespacesOrganizationSecretDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + log.Printf("[DEBUG] Deleting secret: %s", d.Id()) + _, err := client.Codespaces.DeleteOrgSecret(ctx, orgName, d.Id()) + return err +} + +func getCodespacesOrganizationPublicKeyDetails(owner string, meta interface{}) (keyId, pkValue string, err error) { + client := meta.(*Owner).v3client + ctx := context.Background() + + publicKey, _, err := client.Codespaces.GetOrgPublicKey(ctx, owner) + if err != nil { + return keyId, pkValue, err + } + + return publicKey.GetKeyID(), publicKey.GetKey(), err +} diff --git a/github/resource_github_codespaces_organization_secret_test.go b/github/resource_github_codespaces_organization_secret_test.go new file mode 100644 index 0000000000..c3adbe80c4 --- /dev/null +++ b/github/resource_github_codespaces_organization_secret_test.go @@ -0,0 +1,186 @@ +package github + +import ( + "encoding/base64" + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubCodespacesOrganizationSecret(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")) + + config := fmt.Sprintf(` + resource "github_codespaces_organization_secret" "plaintext_secret" { + secret_name = "test_plaintext_secret" + plaintext_value = "%s" + visibility = "private" + } + + resource "github_codespaces_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_codespaces_organization_secret.plaintext_secret", "plaintext_value", + secretValue, + ), + resource.TestCheckResourceAttr( + "github_codespaces_organization_secret.encrypted_secret", "encrypted_value", + secretValue, + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_organization_secret.plaintext_secret", "created_at", + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_organization_secret.plaintext_secret", "updated_at", + ), + ), + "after": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_codespaces_organization_secret.plaintext_secret", "plaintext_value", + updatedSecretValue, + ), + resource.TestCheckResourceAttr( + "github_codespaces_organization_secret.encrypted_secret", "encrypted_value", + updatedSecretValue, + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_organization_secret.plaintext_secret", "created_at", + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_organization_secret.plaintext_secret", "updated_at", + ), + ), + } + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: checks["before"], + }, + { + Config: strings.Replace(config, + secretValue, + updatedSecretValue, 2), + Check: checks["after"], + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("deletes secrets without error", func(t *testing.T) { + config := ` + resource "github_codespaces_organization_secret" "plaintext_secret" { + secret_name = "test_plaintext_secret" + visibility = "private" + } + + resource "github_codespaces_organization_secret" "encrypted_secret" { + secret_name = "test_encrypted_secret" + visibility = "private" + } + ` + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Destroy: true, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("imports secrets without error", func(t *testing.T) { + secretValue := "super_secret_value" + + config := fmt.Sprintf(` + resource "github_codespaces_organization_secret" "test_secret" { + secret_name = "test_plaintext_secret" + plaintext_value = "%s" + visibility = "private" + } + `, secretValue) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_codespaces_organization_secret.test_secret", "plaintext_value", + secretValue, + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + ResourceName: "github_codespaces_organization_secret.test_secret", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"plaintext_value"}, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) +} diff --git a/github/resource_github_codespaces_secret.go b/github/resource_github_codespaces_secret.go new file mode 100644 index 0000000000..07b2004bd4 --- /dev/null +++ b/github/resource_github_codespaces_secret.go @@ -0,0 +1,182 @@ +package github + +import ( + "context" + "encoding/base64" + "log" + "net/http" + + "github.com/google/go-github/v53/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func resourceGithubCodespacesSecret() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubCodespacesSecretCreateOrUpdate, + Read: resourceGithubCodespacesSecretRead, + Update: resourceGithubCodespacesSecretCreateOrUpdate, + Delete: resourceGithubCodespacesSecretDelete, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + Description: "Name of the repository.", + }, + "secret_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the secret.", + ValidateFunc: 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 'codespaces_secret' creation.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'codespaces_secret' update.", + }, + }, + } +} + +func resourceGithubCodespacesSecretCreateOrUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + repo := d.Get("repository").(string) + secretName := d.Get("secret_name").(string) + plaintextValue := d.Get("plaintext_value").(string) + var encryptedValue string + + keyId, publicKey, err := getCodespacesPublicKeyDetails(owner, repo, meta) + if err != nil { + return err + } + + if encryptedText, ok := d.GetOk("encrypted_value"); ok { + encryptedValue = encryptedText.(string) + } else { + encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) + if err != nil { + return err + } + encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) + } + + // Create an EncryptedSecret and encrypt the plaintext value into it + eSecret := &github.EncryptedSecret{ + Name: secretName, + KeyID: keyId, + EncryptedValue: encryptedValue, + } + + _, err = client.Codespaces.CreateOrUpdateRepoSecret(ctx, owner, repo, eSecret) + if err != nil { + return err + } + + d.SetId(buildTwoPartID(repo, secretName)) + return resourceGithubCodespacesSecretRead(d, meta) +} + +func resourceGithubCodespacesSecretRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + repoName, secretName, err := parseTwoPartID(d.Id(), "repository", "secret_name") + if err != nil { + return err + } + + secret, _, err := client.Codespaces.GetRepoSecret(ctx, owner, repoName, secretName) + if err != nil { + if ghErr, ok := err.(*github.ErrorResponse); ok { + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[WARN] Removing actions secret %s from state because it no longer exists in GitHub", + d.Id()) + d.SetId("") + return nil + } + } + return err + } + + d.Set("encrypted_value", d.Get("encrypted_value")) + d.Set("plaintext_value", d.Get("plaintext_value")) + d.Set("created_at", secret.CreatedAt.String()) + + // 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 { + d.Set("updated_at", secret.UpdatedAt.String()) + } + + return nil +} + +func resourceGithubCodespacesSecretDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + repoName, secretName, err := parseTwoPartID(d.Id(), "repository", "secret_name") + if err != nil { + return err + } + + log.Printf("[DEBUG] Deleting secret: %s", d.Id()) + _, err = client.Codespaces.DeleteRepoSecret(ctx, orgName, repoName, secretName) + + return err +} + +func getCodespacesPublicKeyDetails(owner, repository string, meta interface{}) (keyId, pkValue string, err error) { + client := meta.(*Owner).v3client + ctx := context.Background() + + publicKey, _, err := client.Codespaces.GetRepoPublicKey(ctx, owner, repository) + if err != nil { + return keyId, pkValue, err + } + + return publicKey.GetKeyID(), publicKey.GetKey(), err +} diff --git a/github/resource_github_codespaces_secret_test.go b/github/resource_github_codespaces_secret_test.go new file mode 100644 index 0000000000..01baaaa190 --- /dev/null +++ b/github/resource_github_codespaces_secret_test.go @@ -0,0 +1,200 @@ +package github + +import ( + "encoding/base64" + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubCodespacesSecret(t *testing.T) { + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("reads a repository public key without error", func(t *testing.T) { + + config := fmt.Sprintf(` + + resource "github_repository" "test" { + name = "tf-acc-test-%s" + } + + data "github_codespaces_public_key" "test_pk" { + repository = github_repository.test.name + } + + `, randomID) + + check := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet( + "data.github_codespaces_public_key.test_pk", "key_id", + ), + resource.TestCheckResourceAttrSet( + "data.github_codespaces_public_key.test_pk", "key", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + + }) + + 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")) + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + } + + resource "github_codespaces_secret" "plaintext_secret" { + repository = github_repository.test.name + secret_name = "test_plaintext_secret" + plaintext_value = "%s" + } + + resource "github_codespaces_secret" "encrypted_secret" { + repository = github_repository.test.name + secret_name = "test_encrypted_secret" + encrypted_value = "%s" + } + `, randomID, secretValue, secretValue) + + checks := map[string]resource.TestCheckFunc{ + "before": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_codespaces_secret.plaintext_secret", "plaintext_value", + secretValue, + ), + resource.TestCheckResourceAttr( + "github_codespaces_secret.encrypted_secret", "encrypted_value", + secretValue, + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_secret.plaintext_secret", "created_at", + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_secret.plaintext_secret", "updated_at", + ), + ), + "after": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_codespaces_secret.plaintext_secret", "plaintext_value", + updatedSecretValue, + ), + resource.TestCheckResourceAttr( + "github_codespaces_secret.encrypted_secret", "encrypted_value", + updatedSecretValue, + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_secret.plaintext_secret", "created_at", + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_secret.plaintext_secret", "updated_at", + ), + ), + } + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: checks["before"], + }, + { + Config: strings.Replace(config, + secretValue, + updatedSecretValue, 2), + Check: checks["after"], + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("deletes secrets without error", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + } + + resource "github_codespaces_secret" "plaintext_secret" { + repository = github_repository.test.name + secret_name = "test_plaintext_secret" + } + + resource "github_codespaces_secret" "encrypted_secret" { + repository = github_repository.test.name + secret_name = "test_encrypted_secret" + } + `, randomID) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Destroy: true, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + + }) +} diff --git a/github/resource_github_codespaces_user_secret.go b/github/resource_github_codespaces_user_secret.go new file mode 100644 index 0000000000..7c03f8e022 --- /dev/null +++ b/github/resource_github_codespaces_user_secret.go @@ -0,0 +1,215 @@ +package github + +import ( + "context" + "encoding/base64" + "log" + "net/http" + + "github.com/google/go-github/v53/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" +) + +func resourceGithubCodespacesUserSecret() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubCodespacesUserSecretCreateOrUpdate, + Read: resourceGithubCodespacesUserSecretRead, + Update: resourceGithubCodespacesUserSecretCreateOrUpdate, + Delete: resourceGithubCodespacesUserSecretDelete, + Importer: &schema.ResourceImporter{ + State: func(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + d.Set("secret_name", d.Id()) + return []*schema.ResourceData{d}, nil + }, + }, + + Schema: map[string]*schema.Schema{ + "secret_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the secret.", + ValidateFunc: 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.", + ValidateFunc: validation.StringIsBase64, + }, + "plaintext_value": { + Type: schema.TypeString, + ForceNew: true, + Optional: true, + Sensitive: true, + Description: "Plaintext value of the secret to be encrypted.", + ConflictsWith: []string{"encrypted_value"}, + }, + "selected_repository_ids": { + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeInt, + }, + Set: schema.HashInt, + Optional: true, + Description: "An array of repository ids that can access the user secret.", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'codespaces_secret' creation.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of 'codespaces_secret' update.", + }, + }, + } +} + +func resourceGithubCodespacesUserSecretCreateOrUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + ctx := context.Background() + + secretName := d.Get("secret_name").(string) + plaintextValue := d.Get("plaintext_value").(string) + var encryptedValue string + + selectedRepositories, hasSelectedRepositories := d.GetOk("selected_repository_ids") + + selectedRepositoryIDs := github.SelectedRepoIDs{} + + if hasSelectedRepositories { + ids := selectedRepositories.(*schema.Set).List() + + for _, id := range ids { + selectedRepositoryIDs = append(selectedRepositoryIDs, int64(id.(int))) + } + } + + keyId, publicKey, err := getCodespacesUserPublicKeyDetails(meta) + if err != nil { + return err + } + + if encryptedText, ok := d.GetOk("encrypted_value"); ok { + encryptedValue = encryptedText.(string) + } else { + encryptedBytes, err := encryptPlaintext(plaintextValue, publicKey) + if err != nil { + return err + } + encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) + } + + // Create an EncryptedSecret and encrypt the plaintext value into it + eSecret := &github.EncryptedSecret{ + Name: secretName, + KeyID: keyId, + SelectedRepositoryIDs: selectedRepositoryIDs, + EncryptedValue: encryptedValue, + } + + _, err = client.Codespaces.CreateOrUpdateUserSecret(ctx, eSecret) + if err != nil { + return err + } + + d.SetId(secretName) + return resourceGithubCodespacesUserSecretRead(d, meta) +} + +func resourceGithubCodespacesUserSecretRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + ctx := context.Background() + + secret, _, err := client.Codespaces.GetUserSecret(ctx, d.Id()) + if err != nil { + if ghErr, ok := err.(*github.ErrorResponse); ok { + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[WARN] Removing actions secret %s from state because it no longer exists in GitHub", + d.Id()) + d.SetId("") + return nil + } + } + return err + } + + d.Set("encrypted_value", d.Get("encrypted_value")) + d.Set("plaintext_value", d.Get("plaintext_value")) + d.Set("created_at", secret.CreatedAt.String()) + + selectedRepositoryIDs := []int64{} + + opt := &github.ListOptions{ + PerPage: 30, + } + for { + results, resp, err := client.Codespaces.ListSelectedReposForUserSecret(ctx, d.Id(), opt) + if err != nil { + return err + } + + for _, repo := range results.Repositories { + selectedRepositoryIDs = append(selectedRepositoryIDs, repo.GetID()) + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + d.Set("selected_repository_ids", selectedRepositoryIDs) + + // 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 { + d.Set("updated_at", secret.UpdatedAt.String()) + } + + return nil +} + +func resourceGithubCodespacesUserSecretDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + log.Printf("[DEBUG] Deleting secret: %s", d.Id()) + _, err := client.Codespaces.DeleteUserSecret(ctx, d.Id()) + return err +} + +func getCodespacesUserPublicKeyDetails(meta interface{}) (keyId, pkValue string, err error) { + client := meta.(*Owner).v3client + ctx := context.Background() + + publicKey, _, err := client.Codespaces.GetUserPublicKey(ctx) + if err != nil { + return keyId, pkValue, err + } + + return publicKey.GetKeyID(), publicKey.GetKey(), err +} diff --git a/github/resource_github_codespaces_user_secret_test.go b/github/resource_github_codespaces_user_secret_test.go new file mode 100644 index 0000000000..4b6d6e1927 --- /dev/null +++ b/github/resource_github_codespaces_user_secret_test.go @@ -0,0 +1,169 @@ +package github + +import ( + "encoding/base64" + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubCodespacesUserSecret(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")) + + config := fmt.Sprintf(` + resource "github_codespaces_user_secret" "plaintext_secret" { + secret_name = "test_plaintext_secret" + plaintext_value = "%s" + } + + resource "github_codespaces_user_secret" "encrypted_secret" { + secret_name = "test_encrypted_secret" + encrypted_value = "%s" + } + `, secretValue, secretValue) + + checks := map[string]resource.TestCheckFunc{ + "before": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_codespaces_user_secret.plaintext_secret", "plaintext_value", + secretValue, + ), + resource.TestCheckResourceAttr( + "github_codespaces_user_secret.encrypted_secret", "encrypted_value", + secretValue, + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_user_secret.plaintext_secret", "created_at", + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_user_secret.plaintext_secret", "updated_at", + ), + ), + "after": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_codespaces_user_secret.plaintext_secret", "plaintext_value", + updatedSecretValue, + ), + resource.TestCheckResourceAttr( + "github_codespaces_user_secret.encrypted_secret", "encrypted_value", + updatedSecretValue, + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_user_secret.plaintext_secret", "created_at", + ), + resource.TestCheckResourceAttrSet( + "github_codespaces_user_secret.plaintext_secret", "updated_at", + ), + ), + } + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: checks["before"], + }, + { + Config: strings.Replace(config, + secretValue, + updatedSecretValue, 2), + Check: checks["after"], + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + }) + + t.Run("deletes secrets without error", func(t *testing.T) { + config := ` + resource "github_codespaces_user_secret" "plaintext_secret" { + secret_name = "test_plaintext_secret" + } + + resource "github_codespaces_user_secret" "encrypted_secret" { + secret_name = "test_encrypted_secret" + } + ` + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Destroy: true, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + }) + + t.Run("imports secrets without error", func(t *testing.T) { + secretValue := "super_secret_value" + + config := fmt.Sprintf(` + resource "github_codespaces_user_secret" "test_secret" { + secret_name = "test_plaintext_secret" + plaintext_value = "%s" + } + `, secretValue) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_codespaces_user_secret.test_secret", "plaintext_value", + secretValue, + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + ResourceName: "github_codespaces_user_secret.test_secret", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"plaintext_value"}, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + }) +} diff --git a/website/docs/d/codespaces_organization_public_key.html.markdown b/website/docs/d/codespaces_organization_public_key.html.markdown new file mode 100644 index 0000000000..2cc891ddc3 --- /dev/null +++ b/website/docs/d/codespaces_organization_public_key.html.markdown @@ -0,0 +1,22 @@ +--- +layout: "github" +page_title: "GitHub: github_codespaces_organization_public_key" +description: |- + Get information on a GitHub Codespaces Organization Public Key. +--- + +# github_codespaces_organization_public_key + +Use this data source to retrieve information about a GitHub Codespaces Organization public key. This data source is required to be used with other GitHub secrets interactions. +Note that the provider `token` must have admin rights to an organization to retrieve it's Codespaces public key. + +## Example Usage + +```hcl +data "github_codespaces_organization_public_key" "example" {} +``` + +## Attributes Reference + +* `key_id` - ID of the key that has been retrieved. +* `key` - Actual key retrieved. diff --git a/website/docs/d/codespaces_organization_secrets.html.markdown b/website/docs/d/codespaces_organization_secrets.html.markdown new file mode 100644 index 0000000000..1368cc1d0e --- /dev/null +++ b/website/docs/d/codespaces_organization_secrets.html.markdown @@ -0,0 +1,28 @@ +--- +layout: "github" +page_title: "GitHub: github_codespaces_organization_secrets" +description: |- + Get codespaces secrets of the organization +--- + +# github\_codespaces\_organization\_secrets + +Use this data source to retrieve the list of codespaces secrets of the organization. + +## Example Usage + +```hcl +data "github_codespaces_organization_secrets" "example" { +} +``` + +## Argument Reference + +## Attributes Reference + + * `secrets` - list of secrets for the repository + * `name` - Secret name + * `visibility` - Secret visibility + * `created_at` - Timestamp of the secret creation + * `updated_at` - Timestamp of the secret last update + diff --git a/website/docs/d/codespaces_public_key.html.markdown b/website/docs/d/codespaces_public_key.html.markdown new file mode 100644 index 0000000000..69bf492d94 --- /dev/null +++ b/website/docs/d/codespaces_public_key.html.markdown @@ -0,0 +1,28 @@ +--- +layout: "github" +page_title: "GitHub: github_codespaces_public_key" +description: |- + Get information on a GitHub Codespaces Public Key. +--- + +# github_codespaces_public_key + +Use this data source to retrieve information about a GitHub Codespaces public key. This data source is required to be used with other GitHub secrets interactions. +Note that the provider `token` must have admin rights to a repository to retrieve it's Codespaces public key. + +## Example Usage + +```hcl +data "github_codespaces_public_key" "example" { + repository = "example_repo" +} +``` + +## Argument Reference + +* `repository` - (Required) Name of the repository to get public key from. + +## Attributes Reference + +* `key_id` - ID of the key that has been retrieved. +* `key` - Actual key retrieved. diff --git a/website/docs/d/codespaces_secrets.html.markdown b/website/docs/d/codespaces_secrets.html.markdown new file mode 100644 index 0000000000..5574013514 --- /dev/null +++ b/website/docs/d/codespaces_secrets.html.markdown @@ -0,0 +1,35 @@ +--- +layout: "github" +page_title: "GitHub: github_codespaces_secrets" +description: |- + Get codespaces secrets for a repository +--- + +# github\_codespaces\_secrets + +Use this data source to retrieve the list of codespaces secrets for a GitHub repository. + +## Example Usage + +```hcl +data "github_codespaces_secrets" "example" { + name = "example_repository" +} + +data "github_codespaces_secrets" "example_2" { + full_name = "org/example_repository" +} +``` + +## Argument Reference + + * `name` - (Optional) The name of the repository. + * `full_name` - (Optional) Full name of the repository (in `org/name` format). + +## Attributes Reference + + * `secrets` - list of codespaces secrets for the repository + * `name` - Secret name + * `created_at` - Timestamp of the secret creation + * `updated_at` - Timestamp of the secret last update + diff --git a/website/docs/d/codespaces_user_public_key.html.markdown b/website/docs/d/codespaces_user_public_key.html.markdown new file mode 100644 index 0000000000..e3f757ad51 --- /dev/null +++ b/website/docs/d/codespaces_user_public_key.html.markdown @@ -0,0 +1,22 @@ +--- +layout: "github" +page_title: "GitHub: github_codespaces_user_public_key" +description: |- + Get information on a GitHub Codespaces User Public Key. +--- + +# github_codespaces_user_public_key + +Use this data source to retrieve information about a GitHub Codespaces User public key. This data source is required to be used with other GitHub secrets interactions. +Note that the provider `token` must have admin rights to an user to retrieve it's Codespaces public key. + +## Example Usage + +```hcl +data "github_codespaces_user_public_key" "example" {} +``` + +## Attributes Reference + +* `key_id` - ID of the key that has been retrieved. +* `key` - Actual key retrieved. diff --git a/website/docs/d/codespaces_user_secrets.html.markdown b/website/docs/d/codespaces_user_secrets.html.markdown new file mode 100644 index 0000000000..fbce1d1415 --- /dev/null +++ b/website/docs/d/codespaces_user_secrets.html.markdown @@ -0,0 +1,28 @@ +--- +layout: "github" +page_title: "GitHub: github_codespaces_user_secrets" +description: |- + Get codespaces secrets of the user +--- + +# github\_codespaces\_user\_secrets + +Use this data source to retrieve the list of codespaces secrets of the user. + +## Example Usage + +```hcl +data "github_codespaces_user_secrets" "example" { +} +``` + +## Argument Reference + +## Attributes Reference + + * `secrets` - list of secrets for the repository + * `name` - Secret name + * `visibility` - Secret visibility + * `created_at` - Timestamp of the secret creation + * `updated_at` - Timestamp of the secret last update + diff --git a/website/docs/r/codespaces_organization_secret.html.markdown b/website/docs/r/codespaces_organization_secret.html.markdown new file mode 100644 index 0000000000..318067d39b --- /dev/null +++ b/website/docs/r/codespaces_organization_secret.html.markdown @@ -0,0 +1,83 @@ +--- +layout: "github" +page_title: "GitHub: github_codespaces_organization_secret" +description: |- + Creates and manages an Codespaces Secret within a GitHub organization +--- + +# github_codespaces_organization_secret + +This resource allows you to create and manage GitHub Codespaces secrets within your GitHub organization. +You must have write access to a repository to use this resource. + +Secret values are encrypted using the [Go '/crypto/box' module](https://godoc.org/golang.org/x/crypto/nacl/box) which is +interoperable with [libsodium](https://libsodium.gitbook.io/doc/). Libsodium is used by GitHub to decrypt secret values. + +For the purposes of security, the contents of the `plaintext_value` field have been marked as `sensitive` to Terraform, +but it is important to note that **this does not hide it from state files**. You should treat state as sensitive always. +It is also advised that you do not store plaintext values in your code but rather populate the `encrypted_value` +using fields from a resource, data source or variable as, while encrypted in state, these will be easily accessible +in your code. See below for an example of this abstraction. + +## Example Usage + +```hcl +resource "github_codespaces_organization_secret" "example_secret" { + secret_name = "example_secret_name" + visibility = "private" + plaintext_value = var.some_secret_string +} + +resource "github_codespaces_organization_secret" "example_secret" { + secret_name = "example_secret_name" + visibility = "private" + encrypted_value = var.some_encrypted_secret_string +} +``` + +```hcl +data "github_repository" "repo" { + full_name = "my-org/repo" +} + +resource "github_codespaces_organization_secret" "example_secret" { + secret_name = "example_secret_name" + visibility = "selected" + plaintext_value = var.some_secret_string + selected_repository_ids = [data.github_repository.repo.repo_id] +} + +resource "github_codespaces_organization_secret" "example_secret" { + secret_name = "example_secret_name" + visibility = "selected" + encrypted_value = var.some_encrypted_secret_string + selected_repository_ids = [data.github_repository.repo.repo_id] +} +``` + +## 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. + +## Attributes Reference + +* `created_at` - Date of codespaces_secret creation. +* `updated_at` - Date of codespaces_secret update. + +## Import + +This resource can be imported using an ID made up of the secret name: + +``` +terraform import github_codespaces_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. diff --git a/website/docs/r/codespaces_secret.html.markdown b/website/docs/r/codespaces_secret.html.markdown new file mode 100644 index 0000000000..a07f539a37 --- /dev/null +++ b/website/docs/r/codespaces_secret.html.markdown @@ -0,0 +1,54 @@ +--- +layout: "github" +page_title: "GitHub: github_codespaces_secret" +description: |- + Creates and manages an Codespaces Secret within a GitHub repository +--- + +# github_codespaces_secret + +This resource allows you to create and manage GitHub Codespaces secrets within your GitHub repositories. +You must have write access to a repository to use this resource. + +Secret values are encrypted using the [Go '/crypto/box' module](https://godoc.org/golang.org/x/crypto/nacl/box) which is +interoperable with [libsodium](https://libsodium.gitbook.io/doc/). Libsodium is used by GitHub to decrypt secret values. + +For the purposes of security, the contents of the `plaintext_value` field have been marked as `sensitive` to Terraform, +but it is important to note that **this does not hide it from state files**. You should treat state as sensitive always. +It is also advised that you do not store plaintext values in your code but rather populate the `encrypted_value` +using fields from a resource, data source or variable as, while encrypted in state, these will be easily accessible +in your code. See below for an example of this abstraction. + +## Example Usage + +```hcl +data "github_codespaces_public_key" "example_public_key" { + repository = "example_repository" +} + +resource "github_codespaces_secret" "example_secret" { + repository = "example_repository" + secret_name = "example_secret_name" + plaintext_value = var.some_secret_string +} + +resource "github_codespaces_secret" "example_secret" { + repository = "example_repository" + secret_name = "example_secret_name" + encrypted_value = var.some_encrypted_secret_string +} +``` + +## 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 + +## Attributes Reference + +* `created_at` - Date of actions_secret creation. +* `updated_at` - Date of actions_secret update. diff --git a/website/docs/r/codespaces_user_secret.html.markdown b/website/docs/r/codespaces_user_secret.html.markdown new file mode 100644 index 0000000000..c3838b382d --- /dev/null +++ b/website/docs/r/codespaces_user_secret.html.markdown @@ -0,0 +1,65 @@ +--- +layout: "github" +page_title: "GitHub: github_codespaces_user_secret" +description: |- + Creates and manages an Codespaces Secret within a GitHub user +--- + +# github_codespaces_user_secret + +This resource allows you to create and manage GitHub Codespaces secrets within your GitHub user. +You must have write access to a repository to use this resource. + +Secret values are encrypted using the [Go '/crypto/box' module](https://godoc.org/golang.org/x/crypto/nacl/box) which is +interoperable with [libsodium](https://libsodium.gitbook.io/doc/). Libsodium is used by GitHub to decrypt secret values. + +For the purposes of security, the contents of the `plaintext_value` field have been marked as `sensitive` to Terraform, +but it is important to note that **this does not hide it from state files**. You should treat state as sensitive always. +It is also advised that you do not store plaintext values in your code but rather populate the `encrypted_value` +using fields from a resource, data source or variable as, while encrypted in state, these will be easily accessible +in your code. See below for an example of this abstraction. + +## Example Usage + +```hcl +data "github_repository" "repo" { + full_name = "my-org/repo" +} + +resource "github_codespaces_user_secret" "example_secret" { + secret_name = "example_secret_name" + plaintext_value = var.some_secret_string + selected_repository_ids = [data.github_repository.repo.repo_id] +} + +resource "github_codespaces_user_secret" "example_secret" { + secret_name = "example_secret_name" + encrypted_value = var.some_encrypted_secret_string + selected_repository_ids = [data.github_repository.repo.repo_id] +} +``` + +## 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 +* `selected_repository_ids` - (Optional) An array of repository ids that can access the user secret. + +## Attributes Reference + +* `created_at` - Date of codespaces_secret creation. +* `updated_at` - Date of codespaces_secret update. + +## Import + +This resource can be imported using an ID made up of the secret name: + +``` +terraform import github_codespaces_user_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. diff --git a/website/github.erb b/website/github.erb index 3db56a1aba..c8b6ec9fbc 100644 --- a/website/github.erb +++ b/website/github.erb @@ -64,6 +64,24 @@
  • github_collaborators
  • +
  • + github_codespaces_organization_public_key +
  • +
  • + github_codespaces_organization_secrets +
  • +
  • + github_codespaces_public_key +
  • +
  • + github_codespaces_secrets +
  • +
  • + github_codespaces_user_public_key +
  • +
  • + github_codespaces_user_secrets +
  • dependabot_organization_public_key
  • @@ -217,6 +235,15 @@
  • github_branch_protection_v3
  • +
  • + github_codespaces_organization_secret +
  • +
  • + github_codespaces_secret +
  • +
  • + github_codespaces_user_secret +
  • github_enterprise_organization