diff --git a/github/acc_test.go b/github/acc_test.go index f72d454ee7..89a8e494a3 100644 --- a/github/acc_test.go +++ b/github/acc_test.go @@ -214,6 +214,16 @@ func configureSweepers() { Name: "teams", F: sweepTeams, }) + + resource.AddTestSweepers("user_ssh_keys", &resource.Sweeper{ + Name: "user_ssh_keys", + F: sweepUserSSHKeys, + }) + + resource.AddTestSweepers("user_ssh_signing_keys", &resource.Sweeper{ + Name: "user_ssh_signing_keys", + F: sweepUserSSHSigningKeys, + }) } func sweepTeams(_ string) error { @@ -286,6 +296,66 @@ func sweepRepositories(_ string) error { return nil } +func sweepUserSSHKeys(_ string) error { + fmt.Println("sweeping user SSH keys") + + meta, err := getTestMeta() + if err != nil { + return fmt.Errorf("could not get test meta for sweeper: %w", err) + } + + client := meta.v3client + owner := meta.name + ctx := context.Background() + + keys, _, err := client.Users.ListKeys(ctx, owner, nil) + if err != nil { + return err + } + + for _, k := range keys { + if title := k.GetTitle(); strings.HasPrefix(title, testResourcePrefix) { + fmt.Printf("destroying user SSH key %s\n", title) + + if _, err := client.Users.DeleteKey(ctx, k.GetID()); err != nil { + return err + } + } + } + + return nil +} + +func sweepUserSSHSigningKeys(_ string) error { + fmt.Println("sweeping user SSH signing keys") + + meta, err := getTestMeta() + if err != nil { + return fmt.Errorf("could not get test meta for sweeper: %w", err) + } + + client := meta.v3client + owner := meta.name + ctx := context.Background() + + keys, _, err := client.Users.ListSSHSigningKeys(ctx, owner, nil) + if err != nil { + return err + } + + for _, k := range keys { + if title := k.GetTitle(); strings.HasPrefix(title, testResourcePrefix) { + fmt.Printf("destroying user SSH signing key %s\n", title) + + if _, err := client.Users.DeleteSSHSigningKey(ctx, k.GetID()); err != nil { + return err + } + } + } + + return nil +} + func skipUnauthenticated(t *testing.T) { if testAccConf.authMode == anonymous { t.Skip("Skipping as test mode not authenticated") diff --git a/github/provider.go b/github/provider.go index 2d5d6dc33a..c87888e9d4 100644 --- a/github/provider.go +++ b/github/provider.go @@ -212,6 +212,7 @@ func Provider() *schema.Provider { "github_user_gpg_key": resourceGithubUserGpgKey(), "github_user_invitation_accepter": resourceGithubUserInvitationAccepter(), "github_user_ssh_key": resourceGithubUserSshKey(), + "github_user_ssh_signing_key": resourceGithubUserSshSigningKey(), "github_enterprise_organization": resourceGithubEnterpriseOrganization(), "github_enterprise_actions_runner_group": resourceGithubActionsEnterpriseRunnerGroup(), "github_enterprise_actions_workflow_permissions": resourceGithubEnterpriseActionsWorkflowPermissions(), diff --git a/github/resource_github_user_ssh_key.go b/github/resource_github_user_ssh_key.go index fc18088227..26c10ef99b 100644 --- a/github/resource_github_user_ssh_key.go +++ b/github/resource_github_user_ssh_key.go @@ -3,22 +3,23 @@ package github import ( "context" "errors" - "log" + "fmt" "net/http" "strconv" - "strings" "github.com/google/go-github/v83/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) func resourceGithubUserSshKey() *schema.Resource { return &schema.Resource{ - Create: resourceGithubUserSshKeyCreate, - Read: resourceGithubUserSshKeyRead, - Delete: resourceGithubUserSshKeyDelete, + CreateContext: resourceGithubUserSshKeyCreate, + ReadContext: resourceGithubUserSshKeyRead, + DeleteContext: resourceGithubUserSshKeyDelete, Importer: &schema.ResourceImporter{ - StateContext: schema.ImportStatePassthroughContext, + StateContext: resourceGithubUserSshKeyImport, }, Schema: map[string]*schema.Schema{ @@ -33,15 +34,11 @@ func resourceGithubUserSshKey() *schema.Resource { Required: true, ForceNew: true, Description: "The public SSH key to add to your GitHub account.", - DiffSuppressFunc: func(k, oldV, newV string, d *schema.ResourceData) bool { - newTrimmed := strings.TrimSpace(newV) - return oldV == newTrimmed - }, }, - "url": { - Type: schema.TypeString, + "key_id": { + Type: schema.TypeInt, Computed: true, - Description: "The URL of the SSH key.", + Description: "The unique identifier of the SSH key.", }, "etag": { Type: schema.TypeString, @@ -51,39 +48,40 @@ func resourceGithubUserSshKey() *schema.Resource { } } -func resourceGithubUserSshKeyCreate(d *schema.ResourceData, meta any) error { +func resourceGithubUserSshKeyCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client title := d.Get("title").(string) key := d.Get("key").(string) - ctx := context.Background() - userKey, _, err := client.Users.CreateKey(ctx, &github.Key{ + userKey, resp, err := client.Users.CreateKey(ctx, &github.Key{ Title: github.Ptr(title), Key: github.Ptr(key), }) if err != nil { - return err + return diag.FromErr(err) } - d.SetId(strconv.FormatInt(*userKey.ID, 10)) + d.SetId(strconv.FormatInt(userKey.GetID(), 10)) + + if err = d.Set("key_id", userKey.GetID()); err != nil { + return diag.FromErr(err) + } + if err = d.Set("etag", resp.Header.Get("ETag")); err != nil { + return diag.FromErr(err) + } + if err = d.Set("title", userKey.GetTitle()); err != nil { + return diag.FromErr(err) + } - return resourceGithubUserSshKeyRead(d, meta) + return nil } -func resourceGithubUserSshKeyRead(d *schema.ResourceData, meta any) error { +func resourceGithubUserSshKeyRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client - id, err := strconv.ParseInt(d.Id(), 10, 64) - if err != nil { - return unconvertibleIdErr(d.Id(), err) - } - ctx := context.WithValue(context.Background(), ctxId, d.Id()) - if !d.IsNewResource() { - ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string)) - } - - key, resp, err := client.Users.GetKey(ctx, id) + keyID := d.Get("key_id").(int64) + _, _, err := client.Users.GetKey(ctx, keyID) if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) { @@ -91,40 +89,61 @@ func resourceGithubUserSshKeyRead(d *schema.ResourceData, meta any) error { return nil } if ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[INFO] Removing user SSH key %s from state because it no longer exists in GitHub", - d.Id()) + tflog.Info(ctx, fmt.Sprintf("Removing user SSH key %s from state because it no longer exists in GitHub", d.Id()), map[string]any{ + "ssh_key_id": d.Id(), + }) d.SetId("") return nil } } - return err } + return nil +} - if err = d.Set("etag", resp.Header.Get("ETag")); err != nil { - return err - } - if err = d.Set("title", key.GetTitle()); err != nil { - return err - } - if err = d.Set("key", key.GetKey()); err != nil { - return err - } - if err = d.Set("url", key.GetURL()); err != nil { - return err - } +func resourceGithubUserSshKeyDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client - return nil + keyID := d.Get("key_id").(int64) + resp, err := client.Users.DeleteKey(ctx, keyID) + if resp.StatusCode == http.StatusNotFound { + return nil + } + return diag.FromErr(err) } -func resourceGithubUserSshKeyDelete(d *schema.ResourceData, meta any) error { +func resourceGithubUserSshKeyImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { client := meta.(*Owner).v3client - id, err := strconv.ParseInt(d.Id(), 10, 64) + keyID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid SSH key ID format: %w", err) + } + + key, resp, err := client.Users.GetKey(ctx, keyID) if err != nil { - return unconvertibleIdErr(d.Id(), err) + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + if ghErr.Response.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("SSH key with ID %d not found", keyID) + } + } + return nil, err + } + + d.SetId(strconv.FormatInt(key.GetID(), 10)) + + if err = d.Set("key_id", key.GetID()); err != nil { + return nil, err + } + if err = d.Set("etag", resp.Header.Get("ETag")); err != nil { + return nil, err + } + if err = d.Set("title", key.GetTitle()); err != nil { + return nil, err + } + if err = d.Set("key", key.GetKey()); err != nil { + return nil, err } - ctx := context.WithValue(context.Background(), ctxId, d.Id()) - _, err = client.Users.DeleteKey(ctx, id) - return err + return []*schema.ResourceData{d}, nil } diff --git a/github/resource_github_user_ssh_key_test.go b/github/resource_github_user_ssh_key_test.go index f21ac61239..090eda7619 100644 --- a/github/resource_github_user_ssh_key_test.go +++ b/github/resource_github_user_ssh_key_test.go @@ -16,27 +16,19 @@ import ( func TestAccGithubUserSshKey(t *testing.T) { t.Run("creates and destroys a user SSH key without error", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + name := fmt.Sprintf(`%s-%s`, testResourcePrefix, randomID) testKey := newTestKey() + config := fmt.Sprintf(` resource "github_user_ssh_key" "test" { - title = "tf-acc-test-%s" - key = "%s" + title = "%[1]s" + key = "%[2]s" } - `, randomID, testKey) + `, name, testKey) check := resource.ComposeTestCheckFunc( - resource.TestMatchResourceAttr( - "github_user_ssh_key.test", "title", - regexp.MustCompile(randomID), - ), - resource.TestMatchResourceAttr( - "github_user_ssh_key.test", "key", - regexp.MustCompile("^ssh-rsa "), - ), - resource.TestMatchResourceAttr( - "github_user_ssh_key.test", "url", - regexp.MustCompile("^https://api.github.com/[a-z0-9]+/keys/"), - ), + resource.TestMatchResourceAttr("github_user_ssh_key.test", "title", regexp.MustCompile(randomID)), + resource.TestMatchResourceAttr("github_user_ssh_key.test", "key", regexp.MustCompile("^ssh-rsa ")), ) resource.Test(t, resource.TestCase{ @@ -53,13 +45,15 @@ func TestAccGithubUserSshKey(t *testing.T) { t.Run("imports an individual account SSH key without error", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + name := fmt.Sprintf(`%s-%s`, testResourcePrefix, randomID) testKey := newTestKey() + config := fmt.Sprintf(` resource "github_user_ssh_key" "test" { - title = "tf-acc-test-%s" - key = "%s" + title = "%[1]s" + key = "%[2]s" } - `, randomID, testKey) + `, name, testKey) check := resource.ComposeTestCheckFunc( resource.TestCheckResourceAttrSet("github_user_ssh_key.test", "title"), @@ -87,6 +81,5 @@ func TestAccGithubUserSshKey(t *testing.T) { func newTestKey() string { privateKey, _ := rsa.GenerateKey(rand.Reader, 1024) publicKey, _ := ssh.NewPublicKey(&privateKey.PublicKey) - testKey := strings.TrimRight(string(ssh.MarshalAuthorizedKey(publicKey)), "\n") - return testKey + return strings.TrimRight(string(ssh.MarshalAuthorizedKey(publicKey)), "\n") } diff --git a/github/resource_github_user_ssh_signing_key.go b/github/resource_github_user_ssh_signing_key.go new file mode 100644 index 0000000000..9937d0582f --- /dev/null +++ b/github/resource_github_user_ssh_signing_key.go @@ -0,0 +1,149 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/google/go-github/v83/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubUserSshSigningKey() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceGithubUserSshSigningKeyCreate, + ReadContext: resourceGithubUserSshSigningKeyRead, + DeleteContext: resourceGithubUserSshSigningKeyDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubUserSshSigningKeyImport, + }, + + Schema: map[string]*schema.Schema{ + "title": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "A descriptive name for the new key.", + }, + "key": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The public SSH key to add to your GitHub account.", + }, + "key_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The unique identifier of the SSH signing key.", + }, + "etag": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceGithubUserSshSigningKeyCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + title := d.Get("title").(string) + key := d.Get("key").(string) + + userKey, resp, err := client.Users.CreateSSHSigningKey(ctx, &github.Key{ + Title: github.Ptr(title), + Key: github.Ptr(key), + }) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(strconv.FormatInt(userKey.GetID(), 10)) + + if err = d.Set("key_id", userKey.GetID()); err != nil { + return diag.FromErr(err) + } + if err = d.Set("etag", resp.Header.Get("ETag")); err != nil { + return diag.FromErr(err) + } + if err = d.Set("title", userKey.GetTitle()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubUserSshSigningKeyRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + keyID := d.Get("key_id").(int64) + _, _, err := client.Users.GetSSHSigningKey(ctx, keyID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + if ghErr.Response.StatusCode == http.StatusNotModified { + return nil + } + if ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, fmt.Sprintf("Removing user SSH key %s from state because it no longer exists in GitHub", d.Id()), map[string]any{ + "ssh_signing_key_id": d.Id(), + }) + d.SetId("") + return nil + } + } + } + return nil +} + +func resourceGithubUserSshSigningKeyDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + keyID := d.Get("key_id").(int64) + resp, err := client.Users.DeleteSSHSigningKey(ctx, keyID) + if resp.StatusCode == http.StatusNotFound { + return nil + } + return diag.FromErr(err) +} + +func resourceGithubUserSshSigningKeyImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + client := meta.(*Owner).v3client + + keyID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid SSH signing key ID format: %w", err) + } + + key, resp, err := client.Users.GetSSHSigningKey(ctx, keyID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + if ghErr.Response.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("SSH signing key with ID %d not found", keyID) + } + } + return nil, err + } + + d.SetId(strconv.FormatInt(key.GetID(), 10)) + + if err = d.Set("key_id", key.GetID()); err != nil { + return nil, err + } + if err = d.Set("etag", resp.Header.Get("ETag")); err != nil { + return nil, err + } + if err = d.Set("title", key.GetTitle()); err != nil { + return nil, err + } + if err = d.Set("key", key.GetKey()); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_user_ssh_signing_key_test.go b/github/resource_github_user_ssh_signing_key_test.go new file mode 100644 index 0000000000..b938c52550 --- /dev/null +++ b/github/resource_github_user_ssh_signing_key_test.go @@ -0,0 +1,85 @@ +package github + +import ( + "crypto/rand" + "crypto/rsa" + "fmt" + "regexp" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "golang.org/x/crypto/ssh" +) + +func TestAccGithubUserSshSigningKey(t *testing.T) { + t.Run("creates and destroys a user SSH signing key without error", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + name := fmt.Sprintf(`%s-%s`, testResourcePrefix, randomID) + testKey := newTestSigningKey() + + config := fmt.Sprintf(` + resource "github_user_ssh_signing_key" "test" { + title = "%[1]s" + key = "%[2]s" + } + `, name, testKey) + + check := resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr("github_user_ssh_signing_key.test", "title", regexp.MustCompile(randomID)), + resource.TestMatchResourceAttr("github_user_ssh_signing_key.test", "key", regexp.MustCompile("^ssh-rsa ")), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + }) + + t.Run("imports an individual account SSH signing key without error", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + name := fmt.Sprintf(`%s-%s`, testResourcePrefix, randomID) + testKey := newTestSigningKey() + + config := fmt.Sprintf(` + resource "github_user_ssh_signing_key" "test" { + title = "%[1]s" + key = "%[2]s" + } + `, name, testKey) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_user_ssh_signing_key.test", "title"), + resource.TestCheckResourceAttrSet("github_user_ssh_signing_key.test", "key"), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + ResourceName: "github_user_ssh_signing_key.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) +} + +func newTestSigningKey() string { + privateKey, _ := rsa.GenerateKey(rand.Reader, 1024) + publicKey, _ := ssh.NewPublicKey(&privateKey.PublicKey) + return strings.TrimRight(string(ssh.MarshalAuthorizedKey(publicKey)), "\n") +} diff --git a/website/docs/r/user_ssh_key.html.markdown b/website/docs/r/user_ssh_key.html.markdown index 3827182bd1..23b2e11612 100644 --- a/website/docs/r/user_ssh_key.html.markdown +++ b/website/docs/r/user_ssh_key.html.markdown @@ -24,15 +24,15 @@ resource "github_user_ssh_key" "example" { The following arguments are supported: -* `title` - (Required) A descriptive name for the new key. e.g. `Personal MacBook Air` +* `title` - (Required) A descriptive name for the new key. * `key` - (Required) The public SSH key to add to your GitHub account. ## Attributes Reference The following attributes are exported: -* `id` - The ID of the SSH key -* `url` - The URL of the SSH key +* `key_id` - The unique identifier of the SSH signing key. +* `etag` ## Import diff --git a/website/docs/r/user_ssh_signing_key.html.markdown b/website/docs/r/user_ssh_signing_key.html.markdown new file mode 100644 index 0000000000..9075c3f327 --- /dev/null +++ b/website/docs/r/user_ssh_signing_key.html.markdown @@ -0,0 +1,43 @@ +--- +layout: "github" +page_title: "GitHub: github_user_ssh_signing_key" +description: |- + Provides a GitHub user's SSH signing key resource. +--- + +# github_user_ssh_signing_key + +Provides a GitHub user's SSH signing key resource. + +This resource allows you to add/remove SSH signing keys from your user account. + +## Example Usage + +```hcl +resource "github_user_ssh_signing_key" "example" { + title = "example title" + key = file("~/.ssh/id_rsa.pub") +} +``` + +## Argument Reference + +The following arguments are supported: + +* `title` - (Required) A descriptive name for the new key. +* `key` - (Required) The public SSH signing key to add to your GitHub account. + +## Attributes Reference + +The following attributes are exported: + +* `key_id` - The unique identifier of the SSH signing key. +* `etag` + +## Import + +SSH signing keys can be imported using their ID e.g. + +``` +$ terraform import github_user_ssh_signing_key.example 1234567 +``` diff --git a/website/github.erb b/website/github.erb index 997536b42f..2285f531be 100644 --- a/website/github.erb +++ b/website/github.erb @@ -451,6 +451,9 @@
  • github_user_ssh_key
  • +
  • + github_user_ssh_signing_key +