From 1a7e4257191abad184b12e6c9ed2f3ae9f0b862c Mon Sep 17 00:00:00 2001 From: Marc O'Morain Date: Wed, 5 Feb 2020 16:58:32 +0000 Subject: [PATCH] Add commands for managing contexts Add a new command, `context`: ``` $ circleci context Contexts provide a mechanism for securing and sharing environment variables across projects. The environment variables are defined as name/value pairs and are injected at runtime. Usage: circleci context [command] Available Commands: list List contexts remove Remove a secret from the named context show Show a context store Store an new secret in the named context. The value is read from stdin. ``` Currently, the commands enable users to list contexts, show contexts, and to add and remove variables from contexts. Not yet supported / things to do: - write tests - delete context - CRUD on context groups Co-Authored-By: Alex Engelberg --- api/api_suite_test.go | 13 ++ api/context.go | 288 ++++++++++++++++++++++++++++++++++++++++++ api/context_test.go | 134 ++++++++++++++++++++ client/client.go | 41 +++++- cmd/context.go | 239 +++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + cmd/root_test.go | 2 +- go.mod | 1 + go.sum | 4 + 9 files changed, 721 insertions(+), 2 deletions(-) create mode 100644 api/api_suite_test.go create mode 100644 api/context.go create mode 100644 api/context_test.go create mode 100644 cmd/context.go diff --git a/api/api_suite_test.go b/api/api_suite_test.go new file mode 100644 index 000000000..28066f963 --- /dev/null +++ b/api/api_suite_test.go @@ -0,0 +1,13 @@ +package api_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestApi(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Api Suite") +} diff --git a/api/context.go b/api/context.go new file mode 100644 index 000000000..8eeb39cf5 --- /dev/null +++ b/api/context.go @@ -0,0 +1,288 @@ +// Go functions that expose the Context-related calls in the GraphQL API. +package api + +import ( + "fmt" + "strings" + + "github.com/CircleCI-Public/circleci-cli/client" + "github.com/pkg/errors" +) + +type Resource struct { + Variable string + CreatedAt string + TruncatedValue string +} + +type CircleCIContext struct { + ID string + Name string + CreatedAt string + Groups struct { + } + Resources []Resource +} + +type ContextsQueryResponse struct { + Organization struct { + Id string + Contexts struct { + Edges []struct { + Node CircleCIContext + } + } + } +} + +func improveVcsTypeError(err error) error { + if responseErrors, ok := err.(client.ResponseErrorsCollection); ok { + if len(responseErrors) > 0 { + details := responseErrors[0].Extensions + if details.EnumType == "VCSType" { + allowedValues := strings.ToLower(strings.Join(details.AllowedValues[:], ", ")) + return fmt.Errorf("Invalid vcs-type '%s' provided, expected one of %s", strings.ToLower(details.Value), allowedValues) + } + } + } + return err +} + +func CreateContext(cl *client.Client, contextName, orgName, vcsType string) error { + + org, err := getOrganization(cl, orgName, vcsType) + + if err != nil { + return err + } + + query := ` + mutation CreateContext($input: CreateContextInput!) { + createContext(input: $input) { + ...CreateButton + } + } + + fragment CreateButton on CreateContextPayload { + error { + type + } + } + + ` + + var input struct { + OwnerId string `json:"ownerId"` + OwnerType string `json:"ownerType"` + ContextName string `json:"contextName"` + } + + input.OwnerId = org.Organization.ID + input.OwnerType = "ORGANIZATION" + input.ContextName = contextName + + request := client.NewRequest(query) + request.SetToken(cl.Token) + request.Var("input", input) + + var response struct { + CreateContext struct { + Error struct { + Type string + } + } + } + + if err = cl.Run(request, &response); err != nil { + return improveVcsTypeError(err) + } + + if response.CreateContext.Error.Type != "" { + return fmt.Errorf("Error creating context: %s", response.CreateContext.Error.Type) + } + + return nil +} + +func ListContexts(cl *client.Client, orgName, vcsType string) (*ContextsQueryResponse, error) { + + query := ` + query ContextsQuery($orgName: String!, $vcsType: VCSType!) { + organization(name: $orgName, vcsType: $vcsType) { + id + contexts { + edges { + node { + ...Context + } + } + } + } + } + + fragment Context on Context { + id + name + createdAt + groups { + edges { + node { + ...SecurityGroups + } + } + } + resources { + ...EnvVars + } + } + + fragment EnvVars on EnvironmentVariable { + variable + createdAt + truncatedValue + } + + fragment SecurityGroups on Group { + id + name + } + ` + + request := client.NewRequest(query) + request.SetToken(cl.Token) + + request.Var("orgName", orgName) + request.Var("vcsType", strings.ToUpper(vcsType)) + + var response ContextsQueryResponse + err := cl.Run(request, &response) + return &response, errors.Wrapf(improveVcsTypeError(err), "failed to load context list") +} + +func DeleteEnvironmentVariable(cl *client.Client, contextId, variableName string) error { + query := ` + mutation DeleteEnvVar($input: RemoveEnvironmentVariableInput!) { + removeEnvironmentVariable(input: $input) { + context { + id + resources { + ...EnvVars + } + } + } + } + + fragment EnvVars on EnvironmentVariable { + variable + createdAt + truncatedValue + }` + + var input struct { + ContextId string `json:"contextId"` + Variable string `json:"variable"` + } + + input.ContextId = contextId + input.Variable = variableName + + request := client.NewRequest(query) + request.SetToken(cl.Token) + request.Var("input", input) + + var response struct { + RemoveEnvironmentVariable struct { + Context CircleCIContext + } + } + + err := cl.Run(request, &response) + return errors.Wrap(improveVcsTypeError(err), "failed to delete environment varaible") +} + +func StoreEnvironmentVariable(cl *client.Client, contextId, variableName, secretValue string) error { + query := ` + mutation CreateEnvVar($input: StoreEnvironmentVariableInput!) { + storeEnvironmentVariable(input: $input) { + context { + id + resources { + ...EnvVars + } + } + ...CreateEnvVarButton + } + } + + fragment EnvVars on EnvironmentVariable { + variable + createdAt + truncatedValue + } + + fragment CreateEnvVarButton on StoreEnvironmentVariablePayload { + error { + type + } + }` + + request := client.NewRequest(query) + request.SetToken(cl.Token) + + var input struct { + ContextId string `json:"contextId"` + Variable string `json:"variable"` + Value string `json:"value"` + } + + input.ContextId = contextId + input.Variable = variableName + input.Value = secretValue + + request.Var("input", input) + + var response struct { + StoreEnvironmentVariable struct { + Context CircleCIContext + Error struct { + Type string + } + } + } + + if err := cl.Run(request, &response); err != nil { + return errors.Wrap(improveVcsTypeError(err), "failed to store environment varaible in context") + } + + if response.StoreEnvironmentVariable.Error.Type != "" { + return fmt.Errorf("Error storing environment variable: %s", response.StoreEnvironmentVariable.Error.Type) + } + + return nil +} + +func DeleteContext(cl *client.Client, contextId string) error { + query := ` + mutation DeleteContext($input: DeleteContextInput!) { + deleteContext(input: $input) { + clientMutationId + } + }` + + request := client.NewRequest(query) + request.SetToken(cl.Token) + + var input struct { + ContextId string `json:"contextId"` + } + + input.ContextId = contextId + request.Var("input", input) + + var response struct { + } + + err := cl.Run(request, &response) + + return errors.Wrap(improveVcsTypeError(err), "failed to delete context") +} diff --git a/api/context_test.go b/api/context_test.go new file mode 100644 index 000000000..77bb81b35 --- /dev/null +++ b/api/context_test.go @@ -0,0 +1,134 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + + "github.com/CircleCI-Public/circleci-cli/client" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func createSingleUseGraphQLServer(result interface{}) (*httptest.Server, *client.Client) { + response := client.Response{ + Data: result, + } + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + bytes, err := json.Marshal(response) + Expect(err).ToNot(HaveOccurred()) + _, err = rw.Write(bytes) + Expect(err).ToNot(HaveOccurred()) + })) + client := client.NewClient(server.URL, server.URL, "token", false) + return server, client +} + +var _ = Describe("API", func() { + Describe("FooBar", func() { + It("improveVcsTypeError", func() { + + unrelatedError := errors.New("foo") + + Expect(unrelatedError).Should(Equal(improveVcsTypeError(unrelatedError))) + + errors := []client.ResponseError{ + client.ResponseError{ + Message: "foo", + }, + } + + errors[0].Extensions.EnumType = "VCSType" + errors[0].Extensions.Value = "pear" + errors[0].Extensions.AllowedValues = []string{"apple", "banana"} + var vcsError client.ResponseErrorsCollection = errors + Expect("Invalid vcs-type 'pear' provided, expected one of apple, banana").Should(Equal(improveVcsTypeError(vcsError).Error())) + + }) + }) + + Describe("Create Context", func() { + + It("can handles failure creating contexts", func() { + + var result struct { + CreateContext struct { + Error struct { + Type string + } + } + } + + result.CreateContext.Error.Type = "force-this-error" + + server, client := createSingleUseGraphQLServer(result) + defer server.Close() + err := CreateContext(client, "foo-bar", "test-org", "test-vcs") + Expect(err).To(MatchError("Error creating context: force-this-error")) + + }) + + }) + + It("can handles success creating contexts", func() { + + var result struct { + CreateContext struct { + Error struct { + Type string + } + } + } + + result.CreateContext.Error.Type = "" + + server, client := createSingleUseGraphQLServer(result) + defer server.Close() + + Expect(CreateContext(client, "foo-bar", "test-org", "test-vcs")).To(Succeed()) + + }) + + Describe("List Contexts", func() { + + It("can list contexts", func() { + + ctx := CircleCIContext{ + CreatedAt: "2018-04-24T19:38:37.212Z", + Name: "Sheep", + Resources: []Resource{ + { + CreatedAt: "2018-04-24T19:38:37.212Z", + Variable: "CI", + TruncatedValue: "1234", + }, + }, + } + + list := ContextsQueryResponse{} + + list.Organization.Id = "C3D79A95-6BD5-40B4-9958-AB6BDC4CAD50" + list.Organization.Contexts.Edges = []struct{ Node CircleCIContext }{ + struct{ Node CircleCIContext }{ + Node: ctx, + }, + } + + server, client := createSingleUseGraphQLServer(list) + defer server.Close() + + result, err := ListContexts(client, "test-org", "test-vcs") + Expect(err).NotTo(HaveOccurred()) + Expect(result.Organization.Id).To(Equal("C3D79A95-6BD5-40B4-9958-AB6BDC4CAD50")) + context := result.Organization.Contexts.Edges[0].Node + Expect(context.Name).To(Equal("Sheep")) + Expect(context.Resources).To(HaveLen(1)) + resource := context.Resources[0] + Expect(resource.Variable).To(Equal("CI")) + Expect(resource.TruncatedValue).To(Equal("1234")) + + }) + }) +}) diff --git a/client/client.go b/client/client.go index ff4e524cc..0a7bf4fbc 100644 --- a/client/client.go +++ b/client/client.go @@ -92,9 +92,48 @@ type Response struct { // ResponseErrorsCollection represents a slice of errors returned by the GraphQL server out-of-band from the actual data. type ResponseErrorsCollection []ResponseError +/* +An Example Error for an enum error looks like this: + +{ + "errors": [ + { + "message": "Provided argument value `GRUBHUB' is not member of enum type.", + "locations": [ + { + "line": 3, + "column": 3 + } + ], + "extensions": { + "field": "organization", + "argument": "vcsType", + "value": "GRUBHUB", + "allowed-values": [ + "GITHUB", + "BITBUCKET" + ], + "enum-type": "VCSType" + } + } + ] +} +*/ + // ResponseError represents the key-value pair of data returned by the GraphQL server to represent errors. type ResponseError struct { - Message string + Message string + Locations []struct { + Line int + Column int + } + Extensions struct { + Field string + Argument string + Value string + AllowedValues []string `json:"allowed-values"` + EnumType string `json:"enum-type"` + } } // Error turns a ResponseErrorsCollection into an acceptable error string that can be printed to the user. diff --git a/cmd/context.go b/cmd/context.go new file mode 100644 index 000000000..0e6b7037d --- /dev/null +++ b/cmd/context.go @@ -0,0 +1,239 @@ +package cmd + +import ( + "bufio" + "fmt" + "io/ioutil" + "os" + "strings" + + "github.com/CircleCI-Public/circleci-cli/api" + "github.com/CircleCI-Public/circleci-cli/client" + "github.com/olekukonko/tablewriter" + "github.com/pkg/errors" + + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/spf13/cobra" +) + +func newContextCommand(config *settings.Config) *cobra.Command { + + var cl *client.Client + + initClient := func(cmd *cobra.Command, args []string) { + cl = client.NewClient(config.Host, config.Endpoint, config.Token, config.Debug) + } + + command := &cobra.Command{ + Use: "context", + Short: "Contexts provide a mechanism for securing and sharing environment variables across projects. The environment variables are defined as name/value pairs and are injected at runtime.", + } + + listCommand := &cobra.Command{ + Short: "List contexts", + Use: "list ", + PreRun: initClient, + RunE: func(cmd *cobra.Command, args []string) error { + return listContexts(cl, args[0], args[1]) + }, + Args: cobra.ExactArgs(2), + } + + showContextCommand := &cobra.Command{ + Short: "Show a context", + Use: "show ", + PreRun: initClient, + RunE: func(cmd *cobra.Command, args []string) error { + return showContext(cl, args[0], args[1], args[2]) + }, + Args: cobra.ExactArgs(3), + } + + storeCommand := &cobra.Command{ + Short: "Store an new secret in the named context. The value is read from stdin.", + Use: "store-secret ", + PreRun: initClient, + RunE: func(cmd *cobra.Command, args []string) error { + return storeEnvVar(cl, args[0], args[1], args[2], args[3]) + }, + Args: cobra.ExactArgs(4), + } + + removeCommand := &cobra.Command{ + Short: "Remove a secret from the named context", + Use: "remove-secret ", + PreRun: initClient, + RunE: func(cmd *cobra.Command, args []string) error { + return removeEnvVar(cl, args[0], args[1], args[2], args[3]) + }, + Args: cobra.ExactArgs(4), + } + + createContextCommand := &cobra.Command{ + Short: "Create a new context", + Use: "create ", + PreRun: initClient, + RunE: func(cmd *cobra.Command, args []string) error { + return createContext(cl, args[0], args[1], args[2]) + }, + Args: cobra.ExactArgs(3), + } + + force := false + deleteContextCommand := &cobra.Command{ + Short: "Delete the named context", + Use: "delete ", + PreRun: initClient, + RunE: func(cmd *cobra.Command, args []string) error { + return deleteContext(cl, force, args[0], args[1], args[2]) + }, + Args: cobra.ExactArgs(3), + } + + deleteContextCommand.Flags().BoolVarP(&force, "force", "f", false, "Delete the context without asking for confirmation.") + + command.AddCommand(listCommand) + command.AddCommand(showContextCommand) + command.AddCommand(storeCommand) + command.AddCommand(removeCommand) + command.AddCommand(createContextCommand) + command.AddCommand(deleteContextCommand) + + return command +} + +func listContexts(client *client.Client, vcs, org string) error { + + contexts, err := api.ListContexts(client, org, vcs) + + if err != nil { + return err + + } + + table := tablewriter.NewWriter(os.Stdout) + + table.SetHeader([]string{"Provider", "Organization", "Name", "Created At"}) + + for _, context := range contexts.Organization.Contexts.Edges { + + table.Append([]string{ + vcs, + org, + context.Node.Name, + context.Node.CreatedAt, + }) + } + table.Render() + + return nil +} + +func contextByName(client *client.Client, vcsType, orgName, contextName string) (*api.CircleCIContext, error) { + + contexts, err := api.ListContexts(client, orgName, vcsType) + + if err != nil { + return nil, err + } + + for _, c := range contexts.Organization.Contexts.Edges { + if c.Node.Name == contextName { + return &c.Node, nil + } + } + + return nil, fmt.Errorf("Could not find a context named '%s' in the '%s' organization.", contextName, orgName) +} + +func showContext(client *client.Client, vcsType, orgName, contextName string) error { + + context, err := contextByName(client, vcsType, orgName, contextName) + + if err != nil { + return err + } + + fmt.Printf("Context: %s\n", context.Name) + + table := tablewriter.NewWriter(os.Stdout) + + table.SetHeader([]string{"Environment Variable", "Value"}) + + for _, envVar := range context.Resources { + table.Append([]string{envVar.Variable, "••••" + envVar.TruncatedValue}) + } + table.Render() + + return nil +} + +func readSecretValue() (string, error) { + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + bytes, err := ioutil.ReadAll(os.Stdin) + return string(bytes), err + } else { + fmt.Print("Enter secret value and press enter: ") + reader := bufio.NewReader(os.Stdin) + str, err := reader.ReadString('\n') + return strings.TrimRight(str, "\n"), err + } +} + +func createContext(client *client.Client, vcsType, orgName, contextName string) error { + return api.CreateContext(client, vcsType, orgName, contextName) +} + +func removeEnvVar(client *client.Client, vcsType, orgName, contextName, varName string) error { + context, err := contextByName(client, vcsType, orgName, contextName) + if err != nil { + return err + } + return api.DeleteEnvironmentVariable(client, context.ID, varName) +} + +func storeEnvVar(client *client.Client, vcsType, orgName, contextName, varName string) error { + + context, err := contextByName(client, vcsType, orgName, contextName) + + if err != nil { + return err + } + secretValue, err := readSecretValue() + + if err != nil { + return errors.Wrap(err, "Failed to read secret value from stdin") + } + + return api.StoreEnvironmentVariable(client, context.ID, varName, secretValue) +} + +func askForConfirmation(message string) bool { + fmt.Println(message) + var response string + if _, err := fmt.Scanln(&response); err != nil { + return false + } + return strings.HasPrefix(strings.ToLower(response), "y") +} + +func deleteContext(client *client.Client, force bool, vcsType, orgName, contextName string) error { + + context, err := contextByName(client, vcsType, orgName, contextName) + + if err != nil { + return err + } + + message := fmt.Sprintf("Are you sure that you want to delete this context: %s/%s %s (y/n)?", + vcsType, orgName, context.Name) + + shouldDelete := force || askForConfirmation(message) + + if !shouldDelete { + return errors.New("OK, cancelling") + } + + return api.DeleteContext(client, context.ID) +} diff --git a/cmd/root.go b/cmd/root.go index 34d65be33..3af46503e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -105,6 +105,7 @@ func MakeCommands() *cobra.Command { rootCmd.DisableAutoGenTag = true rootCmd.AddCommand(newTestsCommand()) + rootCmd.AddCommand(newContextCommand(rootOptions)) rootCmd.AddCommand(newQueryCommand(rootOptions)) rootCmd.AddCommand(newConfigCommand(rootOptions)) rootCmd.AddCommand(newOrbCommand(rootOptions)) diff --git a/cmd/root_test.go b/cmd/root_test.go index db60abbe9..ae7accba7 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -15,7 +15,7 @@ var _ = Describe("Root", func() { Describe("subcommands", func() { It("can create commands", func() { commands := cmd.MakeCommands() - Expect(len(commands.Commands())).To(Equal(14)) + Expect(len(commands.Commands())).To(Equal(15)) }) }) diff --git a/go.mod b/go.mod index ffbae83aa..cea22600a 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/lunixbochs/vtclean v0.0.0-20170504063817-d14193dfc626 // indirect github.com/manifoldco/promptui v0.3.0 github.com/mitchellh/mapstructure v1.1.2 + github.com/olekukonko/tablewriter v0.0.4 github.com/onsi/ginkgo v1.7.0 github.com/onsi/gomega v1.4.3 github.com/pkg/errors v0.8.0 diff --git a/go.sum b/go.sum index 87c31e1dd..0c2bab5ba 100644 --- a/go.sum +++ b/go.sum @@ -277,6 +277,8 @@ github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRU github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54= +github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -285,6 +287,8 @@ github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQz github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/monoculum/formam v0.0.0-20180901015400-4e68be1d79ba/go.mod h1:RKgILGEJq24YyJ2ban8EO0RUVSJlF1pGsEvoLEACr/Q= github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q= +github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8= +github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=