implement gh secret create and gh secret list
This commit is contained in:
parent
f4152454f2
commit
5309a2089a
9 changed files with 1077 additions and 2 deletions
|
|
@ -227,8 +227,8 @@ func createGist(client *http.Client, hostname, description string, public bool,
|
|||
}
|
||||
requestBody := bytes.NewReader(requestByte)
|
||||
|
||||
apliClient := api.NewClientFromHTTP(client)
|
||||
err = apliClient.REST(hostname, "POST", path, requestBody, &result)
|
||||
apiClient := api.NewClientFromHTTP(client)
|
||||
err = apiClient.REST(hostname, "POST", path, requestBody, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import (
|
|||
releaseCmd "github.com/cli/cli/pkg/cmd/release"
|
||||
repoCmd "github.com/cli/cli/pkg/cmd/repo"
|
||||
creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits"
|
||||
secretCmd "github.com/cli/cli/pkg/cmd/secret"
|
||||
versionCmd "github.com/cli/cli/pkg/cmd/version"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -74,6 +75,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
|
|||
cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil))
|
||||
cmd.AddCommand(gistCmd.NewCmdGist(f))
|
||||
cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams))
|
||||
cmd.AddCommand(secretCmd.NewCmdSecret(f))
|
||||
|
||||
// the `api` command should not inherit any extra HTTP headers
|
||||
bareHTTPCmdFactory := *f
|
||||
|
|
|
|||
183
pkg/cmd/secret/create/create.go
Normal file
183
pkg/cmd/secret/create/create.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package create
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/ghinstance"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmd/secret/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
type CreateOptions struct {
|
||||
HttpClient func() (*http.Client, error)
|
||||
IO *iostreams.IOStreams
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
|
||||
RandomOverride io.Reader
|
||||
|
||||
SecretName string
|
||||
OrgName string
|
||||
Body string
|
||||
Visibility string
|
||||
RepositoryNames []string
|
||||
}
|
||||
|
||||
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
|
||||
opts := &CreateOptions{
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <secret name>",
|
||||
Short: "Create secrets",
|
||||
Long: "Locally encrypt a new secret and send it to GitHub for storage.",
|
||||
Example: heredoc.Doc(`
|
||||
$ cat SECRET.txt | gh secret create NEW_SECRET
|
||||
$ gh secret create NEW_SECRET -b"some literal value"
|
||||
$ gh secret create NEW_SECRET -b"@file.json"
|
||||
$ gh secret create ORG_SECRET --org
|
||||
$ gh secret create ORG_SECRET --org=anotherOrg --visibility=selected -r="repo1,repo2,repo3"
|
||||
$ gh secret create ORG_SECRET --org=anotherOrg --visibility="all"
|
||||
`),
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return &cmdutil.FlagError{Err: errors.New("must pass single secret name")}
|
||||
}
|
||||
if !cmd.Flags().Changed("body") && opts.IO.IsStdinTTY() {
|
||||
return &cmdutil.FlagError{Err: errors.New("no --body specified but nothing on STIDN")}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// support `-R, --repo` override
|
||||
opts.BaseRepo = f.BaseRepo
|
||||
|
||||
opts.SecretName = args[0]
|
||||
|
||||
if cmd.Flags().Changed("visibility") {
|
||||
if opts.OrgName == "" {
|
||||
return &cmdutil.FlagError{Err: errors.New(
|
||||
"--visibility not supported for repository secrets; did you mean to pass --org?")}
|
||||
}
|
||||
|
||||
if opts.Visibility != shared.VisAll && opts.Visibility != shared.VisPrivate && opts.Visibility != shared.VisSelected {
|
||||
return &cmdutil.FlagError{Err: errors.New(
|
||||
"--visibility must be one of `all`, `private`, or `selected`")}
|
||||
}
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("repos") && opts.Visibility != shared.VisSelected {
|
||||
return &cmdutil.FlagError{Err: errors.New(
|
||||
"--repos only supported when --visibility='selected'")}
|
||||
}
|
||||
|
||||
if opts.Visibility == shared.VisSelected && len(opts.RepositoryNames) == 0 {
|
||||
return &cmdutil.FlagError{Err: errors.New(
|
||||
"--repos flag required when --visibility='selected'")}
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
||||
return createRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.OrgName, "org", "", "List secrets for an organization")
|
||||
cmd.Flags().Lookup("org").NoOptDefVal = "@owner"
|
||||
cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `all`, `private`, or `selected`")
|
||||
cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of repository names for `selected` visibility")
|
||||
cmd.Flags().StringVarP(&opts.Body, "body", "b", "-", "Provide either a literal string or a file path; prepend file paths with an @. Reads from STDIN if not provided.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func createRun(opts *CreateOptions) error {
|
||||
body, err := getBody(opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("did not understand secret body: %w", err)
|
||||
}
|
||||
|
||||
c, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create http client: %w", err)
|
||||
}
|
||||
client := api.NewClientFromHTTP(c)
|
||||
|
||||
var baseRepo ghrepo.Interface
|
||||
if opts.OrgName == "" || opts.OrgName == "@owner" {
|
||||
baseRepo, err = opts.BaseRepo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not determine base repo: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
host := ghinstance.OverridableDefault()
|
||||
if opts.OrgName == "@owner" {
|
||||
opts.OrgName = baseRepo.RepoOwner()
|
||||
host = baseRepo.RepoHost()
|
||||
}
|
||||
|
||||
var pk *PubKey
|
||||
if opts.OrgName != "" {
|
||||
pk, err = getOrgPublicKey(client, host, opts.OrgName)
|
||||
} else {
|
||||
pk, err = getRepoPubKey(client, baseRepo)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch public key: %w", err)
|
||||
}
|
||||
|
||||
eBody, err := box.SealAnonymous(nil, body, &pk.Raw, opts.RandomOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt body: %w", err)
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(eBody)
|
||||
|
||||
if opts.OrgName != "" {
|
||||
err = putOrgSecret(client, pk, host, *opts, encoded)
|
||||
} else {
|
||||
err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create secret: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getBody(opts *CreateOptions) (body []byte, err error) {
|
||||
if opts.Body == "-" {
|
||||
body, err = ioutil.ReadAll(opts.IO.In)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read from STDIN: %w", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(opts.Body, "@") {
|
||||
body, err = opts.IO.ReadUserFile(opts.Body[1:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file %s: %w", opts.Body[1:], err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return []byte(opts.Body), nil
|
||||
}
|
||||
334
pkg/cmd/secret/create/create_test.go
Normal file
334
pkg/cmd/secret/create/create_test.go
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
package create
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmd/secret/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewCmdCreate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
wants CreateOptions
|
||||
stdinTTY bool
|
||||
wantsErr bool
|
||||
}{
|
||||
{
|
||||
name: "invalid visibility",
|
||||
cli: "cool_secret --org -v'mistyVeil'",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid visibility",
|
||||
cli: "cool_secret --org -v'selected'",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "no name",
|
||||
cli: "",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "multiple names",
|
||||
cli: "cool_secret good_secret",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "no body, stdin is terminal",
|
||||
cli: "cool_secret",
|
||||
stdinTTY: true,
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "visibility without org",
|
||||
cli: "cool_secret -vall",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "explicit org with selected repo",
|
||||
cli: "--org=coolOrg -vselected -rcoolRepo cool_secret",
|
||||
wants: CreateOptions{
|
||||
SecretName: "cool_secret",
|
||||
Visibility: shared.VisSelected,
|
||||
RepositoryNames: []string{"coolRepo"},
|
||||
Body: "-",
|
||||
OrgName: "coolOrg",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit org with selected repos",
|
||||
cli: `--org=coolOrg -vselected -r="coolRepo,radRepo,goodRepo" cool_secret`,
|
||||
wants: CreateOptions{
|
||||
SecretName: "cool_secret",
|
||||
Visibility: shared.VisSelected,
|
||||
RepositoryNames: []string{"coolRepo", "goodRepo", "radRepo"},
|
||||
Body: "-",
|
||||
OrgName: "coolOrg",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repo",
|
||||
cli: `cool_secret -b"a secret"`,
|
||||
wants: CreateOptions{
|
||||
SecretName: "cool_secret",
|
||||
Visibility: shared.VisPrivate,
|
||||
Body: "a secret",
|
||||
OrgName: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "implicit org",
|
||||
cli: `cool_secret --org -b"@cool.json"`,
|
||||
wants: CreateOptions{
|
||||
SecretName: "cool_secret",
|
||||
Visibility: shared.VisPrivate,
|
||||
Body: "@cool.json",
|
||||
OrgName: "@owner",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "vis all",
|
||||
cli: `cool_secret --org -b"@cool.json" -vall`,
|
||||
wants: CreateOptions{
|
||||
SecretName: "cool_secret",
|
||||
Visibility: shared.VisAll,
|
||||
Body: "@cool.json",
|
||||
OrgName: "@owner",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
io, _, _, _ := iostreams.Test()
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
}
|
||||
|
||||
io.SetStdinTTY(tt.stdinTTY)
|
||||
|
||||
argv, err := shlex.Split(tt.cli)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var gotOpts *CreateOptions
|
||||
cmd := NewCmdCreate(f, func(opts *CreateOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.wants.SecretName, gotOpts.SecretName)
|
||||
assert.Equal(t, tt.wants.Body, gotOpts.Body)
|
||||
assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility)
|
||||
assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName)
|
||||
assert.ElementsMatch(t, tt.wants.RepositoryNames, gotOpts.RepositoryNames)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_createRun_repo(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
|
||||
reg.Register(httpmock.REST("GET", "repos/owner/repo/actions/secrets/public-key"),
|
||||
httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="}))
|
||||
|
||||
reg.Register(httpmock.REST("PUT", "repos/owner/repo/actions/secrets/cool_secret"), httpmock.StatusStringResponse(201, `{}`))
|
||||
|
||||
mockClient := func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
}
|
||||
|
||||
io, _, _, _ := iostreams.Test()
|
||||
|
||||
opts := &CreateOptions{
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
return ghrepo.FromFullName("owner/repo")
|
||||
},
|
||||
HttpClient: mockClient,
|
||||
IO: io,
|
||||
SecretName: "cool_secret",
|
||||
Body: "a secret",
|
||||
// Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7
|
||||
RandomOverride: bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}),
|
||||
}
|
||||
|
||||
err := createRun(opts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
reg.Verify(t)
|
||||
|
||||
data, err := ioutil.ReadAll(reg.Requests[1].Body)
|
||||
assert.NoError(t, err)
|
||||
var payload SecretPayload
|
||||
err = json.Unmarshal(data, &payload)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, payload.KeyID, "123")
|
||||
assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=")
|
||||
}
|
||||
|
||||
func Test_createRun_org(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *CreateOptions
|
||||
wantVisibility string
|
||||
wantRepositories []int
|
||||
}{
|
||||
{
|
||||
name: "explicit org name",
|
||||
opts: &CreateOptions{
|
||||
OrgName: "UmbrellaCorporation",
|
||||
Visibility: shared.VisAll,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "implicit org name",
|
||||
opts: &CreateOptions{
|
||||
OrgName: "@owner",
|
||||
Visibility: shared.VisPrivate,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "selected visibility",
|
||||
opts: &CreateOptions{
|
||||
OrgName: "UmbrellaCorporation",
|
||||
Visibility: shared.VisSelected,
|
||||
RepositoryNames: []string{"birkin", "wesker"},
|
||||
},
|
||||
wantRepositories: []int{1, 2},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
|
||||
orgName := tt.opts.OrgName
|
||||
if orgName == "@owner" {
|
||||
orgName = "NeoUmbrella"
|
||||
}
|
||||
|
||||
reg.Register(httpmock.REST("GET",
|
||||
fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName)),
|
||||
httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="}))
|
||||
|
||||
reg.Register(httpmock.REST("PUT",
|
||||
fmt.Sprintf("orgs/%s/actions/secrets/cool_secret", orgName)),
|
||||
httpmock.StatusStringResponse(201, `{}`))
|
||||
|
||||
if len(tt.opts.RepositoryNames) > 0 {
|
||||
reg.Register(httpmock.GraphQL(`query MapRepositoryNames\b`),
|
||||
httpmock.StringResponse(`{"data":{"birkin":{"databaseId":1},"wesker":{"databaseId":2}}}`))
|
||||
}
|
||||
|
||||
io, _, _, _ := iostreams.Test()
|
||||
|
||||
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
|
||||
return ghrepo.FromFullName("NeoUmbrella/repo")
|
||||
}
|
||||
tt.opts.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
}
|
||||
tt.opts.IO = io
|
||||
tt.opts.SecretName = "cool_secret"
|
||||
tt.opts.Body = "a secret"
|
||||
// Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7
|
||||
tt.opts.RandomOverride = bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5})
|
||||
|
||||
err := createRun(tt.opts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
reg.Verify(t)
|
||||
|
||||
data, err := ioutil.ReadAll(reg.Requests[len(reg.Requests)-1].Body)
|
||||
assert.NoError(t, err)
|
||||
var payload SecretPayload
|
||||
err = json.Unmarshal(data, &payload)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, payload.KeyID, "123")
|
||||
assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=")
|
||||
assert.Equal(t, payload.Visibility, tt.opts.Visibility)
|
||||
assert.ElementsMatch(t, payload.Repositories, tt.wantRepositories)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bodyArg string
|
||||
want string
|
||||
stdin string
|
||||
fromFile bool
|
||||
}{
|
||||
{
|
||||
name: "literal value",
|
||||
bodyArg: "a secret",
|
||||
want: "a secret",
|
||||
},
|
||||
{
|
||||
name: "from stdin",
|
||||
bodyArg: "-",
|
||||
want: "a secret",
|
||||
stdin: "a secret",
|
||||
},
|
||||
{
|
||||
name: "from file",
|
||||
fromFile: true,
|
||||
want: "a secret from a file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
io, stdin, _, _ := iostreams.Test()
|
||||
|
||||
io.SetStdinTTY(false)
|
||||
|
||||
_, err := stdin.WriteString(tt.stdin)
|
||||
assert.NoError(t, err)
|
||||
|
||||
if tt.fromFile {
|
||||
dir := os.TempDir()
|
||||
tmpfile, err := ioutil.TempFile(dir, "testfile*")
|
||||
assert.NoError(t, err)
|
||||
_, err = tmpfile.WriteString(tt.want)
|
||||
assert.NoError(t, err)
|
||||
tt.bodyArg = fmt.Sprintf("@%s", tmpfile.Name())
|
||||
}
|
||||
|
||||
body, err := getBody(&CreateOptions{
|
||||
Body: tt.bodyArg,
|
||||
IO: io,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, string(body), tt.want)
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
138
pkg/cmd/secret/create/http.go
Normal file
138
pkg/cmd/secret/create/http.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package create
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmd/secret/shared"
|
||||
)
|
||||
|
||||
type SecretPayload struct {
|
||||
EncryptedValue string `json:"encrypted_value"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
Repositories []int `json:"selected_repository_ids,omitempty"`
|
||||
KeyID string `json:"key_id"`
|
||||
}
|
||||
|
||||
type PubKey struct {
|
||||
Raw [32]byte
|
||||
ID string `json:"key_id"`
|
||||
Key string
|
||||
}
|
||||
|
||||
func getPubKey(client *api.Client, host, path string) (*PubKey, error) {
|
||||
pk := PubKey{}
|
||||
err := client.REST(host, "GET", path, nil, &pk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pk.Key == "" {
|
||||
return nil, fmt.Errorf("failed to find public key at %s/%s", host, path)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(pk.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode public key: %w", err)
|
||||
}
|
||||
|
||||
copy(pk.Raw[:], decoded[0:32])
|
||||
return &pk, nil
|
||||
}
|
||||
|
||||
func getOrgPublicKey(client *api.Client, host, orgName string) (*PubKey, error) {
|
||||
return getPubKey(client, host, fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName))
|
||||
}
|
||||
|
||||
func getRepoPubKey(client *api.Client, repo ghrepo.Interface) (*PubKey, error) {
|
||||
return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets/public-key",
|
||||
ghrepo.FullName(repo)))
|
||||
}
|
||||
|
||||
func putSecret(client *api.Client, host, path string, payload SecretPayload) error {
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize: %w", err)
|
||||
}
|
||||
requestBody := bytes.NewReader(payloadBytes)
|
||||
|
||||
return client.REST(host, "PUT", path, requestBody, nil)
|
||||
}
|
||||
|
||||
func putOrgSecret(client *api.Client, pk *PubKey, host string, opts CreateOptions, eValue string) error {
|
||||
secretName := opts.SecretName
|
||||
orgName := opts.OrgName
|
||||
visibility := opts.Visibility
|
||||
|
||||
var repositoryIDs []int
|
||||
var err error
|
||||
if orgName != "" && visibility == shared.VisSelected {
|
||||
repositoryIDs, err = mapRepoNameToID(client, host, orgName, opts.RepositoryNames)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to look up IDs for repositories %v: %w", opts.RepositoryNames, err)
|
||||
}
|
||||
}
|
||||
|
||||
payload := SecretPayload{
|
||||
EncryptedValue: eValue,
|
||||
KeyID: pk.ID,
|
||||
Repositories: repositoryIDs,
|
||||
Visibility: visibility,
|
||||
}
|
||||
path := fmt.Sprintf("orgs/%s/actions/secrets/%s", orgName, secretName)
|
||||
|
||||
return putSecret(client, host, path, payload)
|
||||
}
|
||||
|
||||
func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secretName, eValue string) error {
|
||||
payload := SecretPayload{
|
||||
EncryptedValue: eValue,
|
||||
KeyID: pk.ID,
|
||||
}
|
||||
path := fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(repo), secretName)
|
||||
return putSecret(client, repo.RepoHost(), path, payload)
|
||||
}
|
||||
|
||||
func mapRepoNameToID(client *api.Client, host, orgName string, repositoryNames []string) ([]int, error) {
|
||||
queries := make([]string, 0, len(repositoryNames))
|
||||
for _, repoName := range repositoryNames {
|
||||
queries = append(queries, fmt.Sprintf(`
|
||||
%s: repository(owner: %q, name :%q) {
|
||||
databaseId
|
||||
}
|
||||
`, repoName, orgName, repoName))
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`query MapRepositoryNames { %s }`, strings.Join(queries, ""))
|
||||
|
||||
graphqlResult := make(map[string]*struct {
|
||||
DatabaseID int `json:"databaseId"`
|
||||
})
|
||||
|
||||
err := client.GraphQL(host, query, nil, &graphqlResult)
|
||||
|
||||
gqlErr, isGqlErr := err.(*api.GraphQLErrorResponse)
|
||||
if isGqlErr {
|
||||
for _, ge := range gqlErr.Errors {
|
||||
if ge.Type == "NOT_FOUND" {
|
||||
return nil, fmt.Errorf("could not find %s/%s", orgName, ge.Path[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to look up repositories: %w", err)
|
||||
}
|
||||
|
||||
result := make([]int, 0, len(repositoryNames))
|
||||
|
||||
for _, repoName := range repositoryNames {
|
||||
result = append(result, graphqlResult[repoName].DatabaseID)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
156
pkg/cmd/secret/list/list.go
Normal file
156
pkg/cmd/secret/list/list.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/ghinstance"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmd/secret/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type ListOptions struct {
|
||||
HttpClient func() (*http.Client, error)
|
||||
IO *iostreams.IOStreams
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
|
||||
OrgName string
|
||||
}
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
|
||||
opts := &ListOptions{
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List secrets",
|
||||
Long: "List secrets for a repository or organization",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// support `-R, --repo` override
|
||||
opts.BaseRepo = f.BaseRepo
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
||||
return listRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.OrgName, "org", "", "List secrets for an organization")
|
||||
cmd.Flags().Lookup("org").NoOptDefVal = "@owner"
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listRun(opts *ListOptions) error {
|
||||
c, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create http client: %w", err)
|
||||
}
|
||||
client := api.NewClientFromHTTP(c)
|
||||
|
||||
var baseRepo ghrepo.Interface
|
||||
if opts.OrgName == "" || opts.OrgName == "@owner" {
|
||||
baseRepo, err = opts.BaseRepo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not determine base repo: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
orgName := opts.OrgName
|
||||
host := ghinstance.OverridableDefault()
|
||||
if orgName == "@owner" {
|
||||
orgName = baseRepo.RepoOwner()
|
||||
host = baseRepo.RepoHost()
|
||||
}
|
||||
|
||||
var secrets []Secret
|
||||
if orgName != "" {
|
||||
secrets, err = getOrgSecrets(client, host, orgName)
|
||||
} else {
|
||||
secrets, err = getRepoSecrets(client, baseRepo)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get secrets: %w", err)
|
||||
}
|
||||
|
||||
tp := utils.NewTablePrinter(opts.IO)
|
||||
for _, secret := range secrets {
|
||||
tp.AddField(secret.Name, nil, nil)
|
||||
updatedAt := secret.UpdatedAt.Format("2006-01-02")
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
updatedAt = fmt.Sprintf("Updated %s", updatedAt)
|
||||
}
|
||||
tp.AddField(updatedAt, nil, nil)
|
||||
if secret.Visibility != "" {
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
tp.AddField(fmtVisibility(secret), nil, nil)
|
||||
} else {
|
||||
tp.AddField(strings.ToUpper(secret.Visibility), nil, nil)
|
||||
}
|
||||
}
|
||||
tp.EndRow()
|
||||
}
|
||||
|
||||
err = tp.Render()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Secret struct {
|
||||
Name string
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Visibility string
|
||||
}
|
||||
|
||||
func fmtVisibility(s Secret) string {
|
||||
switch s.Visibility {
|
||||
case shared.VisAll:
|
||||
return "Visible to all repositories"
|
||||
case shared.VisPrivate:
|
||||
return "Visible to private repositories"
|
||||
case shared.VisSelected:
|
||||
// TODO print how many? print which ones?
|
||||
return "Visible to selected repositories"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getOrgSecrets(client *api.Client, host, orgName string) ([]Secret, error) {
|
||||
return getSecrets(client, host, fmt.Sprintf("orgs/%s/actions/secrets", orgName))
|
||||
}
|
||||
|
||||
func getRepoSecrets(client *api.Client, repo ghrepo.Interface) ([]Secret, error) {
|
||||
return getSecrets(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets",
|
||||
ghrepo.FullName(repo)))
|
||||
}
|
||||
|
||||
type secretsPayload struct {
|
||||
Secrets []Secret
|
||||
}
|
||||
|
||||
func getSecrets(client *api.Client, host, path string) ([]Secret, error) {
|
||||
result := secretsPayload{}
|
||||
|
||||
err := client.REST(host, "GET", path, nil, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result.Secrets, nil
|
||||
}
|
||||
227
pkg/cmd/secret/list/list_test.go
Normal file
227
pkg/cmd/secret/list/list_test.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmd/secret/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/test"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_NewCmdList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
wants ListOptions
|
||||
}{
|
||||
{
|
||||
name: "repo",
|
||||
cli: "",
|
||||
wants: ListOptions{
|
||||
OrgName: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "implicit org",
|
||||
cli: "--org",
|
||||
wants: ListOptions{
|
||||
OrgName: "@owner",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit org",
|
||||
cli: "--org=UmbrellaCorporation",
|
||||
wants: ListOptions{
|
||||
OrgName: "UmbrellaCorporation",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
io, _, _, _ := iostreams.Test()
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
}
|
||||
|
||||
argv, err := shlex.Split(tt.cli)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var gotOpts *ListOptions
|
||||
cmd := NewCmdList(f, func(opts *ListOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName)
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TODO run tests
|
||||
|
||||
func Test_listRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tty bool
|
||||
opts *ListOptions
|
||||
wantOut []string
|
||||
}{
|
||||
{
|
||||
name: "repo tty",
|
||||
tty: true,
|
||||
opts: &ListOptions{},
|
||||
wantOut: []string{
|
||||
"SECRET_ONE.*Updated 1988-10-11",
|
||||
"SECRET_TWO.*Updated 2020-12-04",
|
||||
"SECRET_THREE.*Updated 1975-11-30",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repo not tty",
|
||||
tty: false,
|
||||
opts: &ListOptions{},
|
||||
wantOut: []string{
|
||||
"SECRET_ONE\t1988-10-11",
|
||||
"SECRET_TWO\t2020-12-04",
|
||||
"SECRET_THREE\t1975-11-30",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit org tty",
|
||||
tty: true,
|
||||
opts: &ListOptions{
|
||||
OrgName: "UmbrellaCorporation",
|
||||
},
|
||||
wantOut: []string{
|
||||
"SECRET_ONE.*Updated 1988-10-11.*Visible to all repositories",
|
||||
"SECRET_TWO.*Updated 2020-12-04.*Visible to private repositories",
|
||||
"SECRET_THREE.*Updated 1975-11-30.*Visible to selected repositories",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit org not tty",
|
||||
tty: false,
|
||||
opts: &ListOptions{
|
||||
OrgName: "UmbrellaCorporation",
|
||||
},
|
||||
wantOut: []string{
|
||||
"SECRET_ONE\t1988-10-11\tALL",
|
||||
"SECRET_TWO\t2020-12-04\tPRIVATE",
|
||||
"SECRET_THREE\t1975-11-30\tSELECTED",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "implicit org not tty",
|
||||
tty: false,
|
||||
opts: &ListOptions{
|
||||
OrgName: "@owner",
|
||||
},
|
||||
wantOut: []string{
|
||||
"SECRET_ONE\t1988-10-11\tALL",
|
||||
"SECRET_TWO\t2020-12-04\tPRIVATE",
|
||||
"SECRET_THREE\t1975-11-30\tSELECTED",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "implicit org not tty",
|
||||
tty: true,
|
||||
opts: &ListOptions{
|
||||
OrgName: "@owner",
|
||||
},
|
||||
wantOut: []string{
|
||||
"SECRET_ONE.*Updated 1988-10-11.*Visible to all repositories",
|
||||
"SECRET_TWO.*Updated 2020-12-04.*Visible to private repositories",
|
||||
"SECRET_THREE.*Updated 1975-11-30.*Visible to selected repositories",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
|
||||
t0, _ := time.Parse("2006-01-02", "1988-10-11")
|
||||
t1, _ := time.Parse("2006-01-02", "2020-12-04")
|
||||
t2, _ := time.Parse("2006-01-02", "1975-11-30")
|
||||
path := "repos/owner/repo/actions/secrets"
|
||||
payload := secretsPayload{}
|
||||
payload.Secrets = []Secret{
|
||||
{
|
||||
Name: "SECRET_ONE",
|
||||
UpdatedAt: t0,
|
||||
},
|
||||
{
|
||||
Name: "SECRET_TWO",
|
||||
UpdatedAt: t1,
|
||||
},
|
||||
{
|
||||
Name: "SECRET_THREE",
|
||||
UpdatedAt: t2,
|
||||
},
|
||||
}
|
||||
if tt.opts.OrgName != "" {
|
||||
payload.Secrets = []Secret{
|
||||
{
|
||||
Name: "SECRET_ONE",
|
||||
UpdatedAt: t0,
|
||||
Visibility: shared.VisAll,
|
||||
},
|
||||
{
|
||||
Name: "SECRET_TWO",
|
||||
UpdatedAt: t1,
|
||||
Visibility: shared.VisPrivate,
|
||||
},
|
||||
{
|
||||
Name: "SECRET_THREE",
|
||||
UpdatedAt: t2,
|
||||
Visibility: shared.VisSelected,
|
||||
},
|
||||
}
|
||||
if tt.opts.OrgName == "@owner" {
|
||||
path = "orgs/owner/actions/secrets"
|
||||
} else {
|
||||
path = fmt.Sprintf("orgs/%s/actions/secrets", tt.opts.OrgName)
|
||||
}
|
||||
}
|
||||
|
||||
reg.Register(httpmock.REST("GET", path), httpmock.JSONResponse(payload))
|
||||
|
||||
io, _, stdout, _ := iostreams.Test()
|
||||
|
||||
io.SetStdoutTTY(tt.tty)
|
||||
|
||||
tt.opts.IO = io
|
||||
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
|
||||
return ghrepo.FromFullName("owner/repo")
|
||||
}
|
||||
tt.opts.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
}
|
||||
|
||||
err := listRun(tt.opts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
reg.Verify(t)
|
||||
|
||||
test.ExpectLines(t, stdout.String(), tt.wantOut...)
|
||||
})
|
||||
}
|
||||
}
|
||||
28
pkg/cmd/secret/secret.go
Normal file
28
pkg/cmd/secret/secret.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package secret
|
||||
|
||||
import (
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
cmdCreate "github.com/cli/cli/pkg/cmd/secret/create"
|
||||
cmdList "github.com/cli/cli/pkg/cmd/secret/list"
|
||||
)
|
||||
|
||||
func NewCmdSecret(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "secret <command>",
|
||||
Short: "Manage GitHub secrets",
|
||||
Long: heredoc.Doc(`
|
||||
Secrets can be set at the repository or organization level for use in GitHub Actions.
|
||||
Run "gh help secret add" to learn how to get started.
|
||||
`),
|
||||
}
|
||||
|
||||
cmdutil.EnableRepoOverride(cmd, f)
|
||||
|
||||
cmd.AddCommand(cmdList.NewCmdList(f, nil))
|
||||
cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil))
|
||||
|
||||
return cmd
|
||||
}
|
||||
7
pkg/cmd/secret/shared/shared.go
Normal file
7
pkg/cmd/secret/shared/shared.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package shared
|
||||
|
||||
const (
|
||||
VisAll = "all"
|
||||
VisPrivate = "private"
|
||||
VisSelected = "selected"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue