From 80ca7b89ff24397e6e25f99b4ef90b40dffd8f6f Mon Sep 17 00:00:00 2001 From: Eytan Avisror Date: Tue, 28 Jun 2022 10:02:13 -0400 Subject: [PATCH] feat: Add "Get" command to display mappings as a table (#46) * fix kubeconfig loading ordering Signed-off-by: Eytan Avisror * Improvements and fixes Signed-off-by: Eytan Avisror * Add unit test Signed-off-by: Eytan Avisror * Add unit-tests for retryer Signed-off-by: Eytan Avisror --- README.md | 17 ++++-- cmd/cli/get.go | 83 ++++++++++++++++++++++++++++ cmd/cli/root.go | 42 ++++++++------ cmd/cli/upsert.go | 3 +- go.mod | 2 + go.sum | 4 ++ pkg/mapper/configmaps.go | 4 +- pkg/mapper/configmaps_test.go | 15 +++++ pkg/mapper/get.go | 45 +++++++++++++++ pkg/mapper/get_test.go | 100 ++++++++++++++++++++++++++++++++++ pkg/mapper/remove.go | 10 +++- pkg/mapper/types.go | 21 +++++-- pkg/mapper/upsert.go | 5 +- 13 files changed, 317 insertions(+), 34 deletions(-) create mode 100644 cmd/cli/get.go create mode 100644 pkg/mapper/get.go create mode 100644 pkg/mapper/get_test.go diff --git a/README.md b/README.md index 697aaf8..da18bae 100644 --- a/README.md +++ b/README.md @@ -106,14 +106,14 @@ $ aws-auth remove-by-username --username ops-user Bootstrap a new node group role ```text -$ aws-auth upsert --maproles --userarn arn:aws:iam::555555555555:role/my-new-node-group-NodeInstanceRole-74RF4UBDUKL6 --username system:node:{{EC2PrivateDNSName}} --groups system:bootstrappers system:nodes +$ aws-auth upsert --maproles --rolearn arn:aws:iam::555555555555:role/my-new-node-group-NodeInstanceRole-74RF4UBDUKL6 --username system:node:{{EC2PrivateDNSName}} --groups system:bootstrappers system:nodes added arn:aws:iam::555555555555:role/my-new-node-group-NodeInstanceRole-74RF4UBDUKL6 to aws-auth ``` You can also add retries with exponential backoff ```text -$ aws-auth upsert --maproles --userarn arn:aws:iam::555555555555:role/my-new-node-group-NodeInstanceRole-74RF4UBDUKL6 --username system:node:{{EC2PrivateDNSName}} --groups system:bootstrappers system:nodes --retry +$ aws-auth upsert --maproles --rolearn arn:aws:iam::555555555555:role/my-new-node-group-NodeInstanceRole-74RF4UBDUKL6 --username system:node:{{EC2PrivateDNSName}} --groups system:bootstrappers system:nodes --retry ``` Retries are configurable using the following flags @@ -128,13 +128,22 @@ Retries are configurable using the following flags Append groups to mapping instead of overwriting by using --append ``` -aws-auth upsert --maproles --rolearn arn:aws:iam::00000000000:role/test --username test --groups test --append +$ aws-auth upsert --maproles --rolearn arn:aws:iam::00000000000:role/test --username test --groups test --append ``` Avoid overwriting username by using --update-username=false ``` -aws-auth upsert --maproles --rolearn arn:aws:iam::00000000000:role/test --username test2 --groups test --update-username=false +$ aws-auth upsert --maproles --rolearn arn:aws:iam::00000000000:role/test --username test2 --groups test --update-username=false +``` + +Use the `get` command to get a detailed view of mappings + +``` +$ aws-auth get + +TYPE ARN USERNAME GROUPS +Role Mapping arn:aws:iam::555555555555:role/my-new-node-group system:node:{{EC2PrivateDNSName}} system:bootstrappers, system:nodes ``` ## Usage as a library diff --git a/cmd/cli/get.go b/cmd/cli/get.go new file mode 100644 index 0000000..0efb6d1 --- /dev/null +++ b/cmd/cli/get.go @@ -0,0 +1,83 @@ +/* + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "log" + "os" + "strings" + + "github.com/keikoproj/aws-auth/pkg/mapper" + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" +) + +var getArgs = &mapper.MapperArguments{ + OperationType: mapper.OperationGet, + IsGlobal: true, +} + +// getCmd represents the base view command when run without subcommands +var getCmd = &cobra.Command{ + Use: "get", + Short: "get provides a detailed summary of the configmap", + Long: `get allows a user to output the aws-auth configmap entires in various formats`, + Run: func(cmd *cobra.Command, args []string) { + k, err := getKubernetesClient(getArgs.KubeconfigPath) + if err != nil { + log.Fatal(err) + } + + worker := mapper.New(k, true) + + d, err := worker.Get(getArgs) + if err != nil { + log.Fatal(err) + } + + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Type", "ARN", "Username", "Groups"}) + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(true) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetCenterSeparator("") + table.SetColumnSeparator("") + table.SetRowSeparator("") + table.SetHeaderLine(false) + table.SetBorder(false) + table.SetTablePadding("\t") + table.SetNoWhiteSpace(true) + data := make([][]string, 0) + + for _, row := range d.MapRoles { + data = append(data, []string{"Role Mapping", row.RoleARN, row.Username, strings.Join(row.Groups, ", ")}) + } + + for _, row := range d.MapUsers { + data = append(data, []string{"User Mapping", row.UserARN, row.Username, strings.Join(row.Groups, ", ")}) + } + + table.AppendBulk(data) + table.Render() + }, +} + +func init() { + rootCmd.AddCommand(getCmd) + getCmd.Flags().StringVar(&getArgs.KubeconfigPath, "kubeconfig", "", "Path to kubeconfig") + getCmd.Flags().StringVar(&getArgs.Format, "format", "table", "The format in which to display results (currently only 'table' supported)") +} diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 2a4d549..cfb6ef8 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -41,32 +41,38 @@ func Execute() { } } -func getKubernetesClient(kubePath string) (kubernetes.Interface, error) { +func getKubernetesConfig() (*rest.Config, error) { var config *rest.Config + config, err := rest.InClusterConfig() + if err != nil { + return getKubernetesLocalConfig() + } + return config, nil +} - if _, inCluster := os.LookupEnv("KUBERNETES_SERVICE_HOST"); inCluster == true { - config, err := rest.InClusterConfig() +func getKubernetesLocalConfig() (*rest.Config, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + clientCfg := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + return clientCfg.ClientConfig() +} + +func getKubernetesClient(kubePath string) (kubernetes.Interface, error) { + var ( + config *rest.Config + err error + ) + + // if kubeconfig path is not provided, try to auto detect + if kubePath == "" { + config, err = getKubernetesConfig() if err != nil { return nil, err } - client, err := kubernetes.NewForConfig(config) + } else { + config, err = clientcmd.BuildConfigFromFlags("", kubePath) if err != nil { return nil, err } - return client, nil - } - - if kubePath == "" { - userHome, _ := os.UserHomeDir() - kubePath = fmt.Sprintf("%v/.kube/config", userHome) - if os.Getenv("KUBECONFIG") != "" { - kubePath = os.Getenv("KUBECONFIG") - } - } - - config, err := clientcmd.BuildConfigFromFlags("", kubePath) - if err != nil { - return nil, err } client, err := kubernetes.NewForConfig(config) diff --git a/cmd/cli/upsert.go b/cmd/cli/upsert.go index 4e77a10..23ff25f 100644 --- a/cmd/cli/upsert.go +++ b/cmd/cli/upsert.go @@ -24,7 +24,8 @@ import ( ) var upsertArgs = &mapper.MapperArguments{ - OperationType: mapper.OperationUpsert, + OperationType: mapper.OperationUpsert, + UpdateUsername: &mapper.UpdateUsernameDefaultValue, } // upsertCmd represents the base command when called without any subcommands diff --git a/go.mod b/go.mod index 170492c..8b2df00 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.17 require ( github.com/jpillora/backoff v1.0.0 + github.com/olekukonko/tablewriter v0.0.5 github.com/onsi/gomega v1.7.0 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v0.0.5 @@ -25,6 +26,7 @@ require ( github.com/imdario/mergo v0.3.7 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/json-iterator/go v1.1.10 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.1 // indirect github.com/spf13/pflag v1.0.5 // indirect diff --git a/go.sum b/go.sum index 6828f06..0955217 100644 --- a/go.sum +++ b/go.sum @@ -151,6 +151,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= @@ -164,6 +166,8 @@ github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8m github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.11.0 h1:JAKSXpt1YjtLA7YpPiqO9ss6sNXEsPfSGdwN0UHqzrw= diff --git a/pkg/mapper/configmaps.go b/pkg/mapper/configmaps.go index bf0753c..7ac2a08 100644 --- a/pkg/mapper/configmaps.go +++ b/pkg/mapper/configmaps.go @@ -62,8 +62,8 @@ func ReadAuthMap(k kubernetes.Interface) (AwsAuthData, *v1.ConfigMap, error) { func CreateAuthMap(k kubernetes.Interface) (*v1.ConfigMap, error) { configMapObject := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Name: "aws-auth", - Namespace: "kube-system", + Name: AwsAuthName, + Namespace: AwsAuthNamespace, }, } configMap, err := k.CoreV1().ConfigMaps(AwsAuthNamespace).Create(context.Background(), configMapObject, metav1.CreateOptions{}) diff --git a/pkg/mapper/configmaps_test.go b/pkg/mapper/configmaps_test.go index d973754..2ca719b 100644 --- a/pkg/mapper/configmaps_test.go +++ b/pkg/mapper/configmaps_test.go @@ -48,6 +48,21 @@ func create_MockConfigMap(client kubernetes.Interface) { } _, err := client.CoreV1().ConfigMaps(AwsAuthNamespace).Create(context.Background(), configMap, metav1.CreateOptions{}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) +} + +func create_MockMalformedConfigMap(client kubernetes.Interface) { + configMap := &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: AwsAuthName, + Namespace: AwsAuthNamespace, + }, + Data: map[string]string{ + "mapRoles": "''", + "mapUsers": "''", + }, + } + _, err := client.CoreV1().ConfigMaps(AwsAuthNamespace).Create(context.Background(), configMap, metav1.CreateOptions{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) } diff --git a/pkg/mapper/get.go b/pkg/mapper/get.go new file mode 100644 index 0000000..9cf102f --- /dev/null +++ b/pkg/mapper/get.go @@ -0,0 +1,45 @@ +/* + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package mapper + +// Upsert update or inserts by rolearn +func (b *AuthMapper) Get(args *MapperArguments) (AwsAuthData, error) { + args.IsGlobal = true + args.Validate() + + if args.WithRetries { + out, err := WithRetry(func() (interface{}, error) { + return b.getAuth() + }, args) + if err != nil { + return AwsAuthData{}, err + } + return out.(AwsAuthData), nil + } + + return b.getAuth() +} + +func (b *AuthMapper) getAuth() (AwsAuthData, error) { + + // Read the config map and return an AuthMap + authData, _, err := ReadAuthMap(b.KubernetesClient) + if err != nil { + return AwsAuthData{}, err + } + + return authData, nil +} diff --git a/pkg/mapper/get_test.go b/pkg/mapper/get_test.go new file mode 100644 index 0000000..8eaffa9 --- /dev/null +++ b/pkg/mapper/get_test.go @@ -0,0 +1,100 @@ +/* + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package mapper + +import ( + "testing" + "time" + + "github.com/onsi/gomega" + "k8s.io/client-go/kubernetes/fake" +) + +func TestMapper_Get(t *testing.T) { + g := gomega.NewWithT(t) + gomega.RegisterTestingT(t) + client := fake.NewSimpleClientset() + mapper := New(client, true) + create_MockConfigMap(client) + + err := mapper.Upsert(&MapperArguments{ + MapRoles: true, + RoleARN: "arn:aws:iam::00000000000:role/node-2", + Username: "system:node:{{EC2PrivateDNSName}}", + Groups: []string{"system:bootstrappers", "system:nodes"}, + }) + g.Expect(err).NotTo(gomega.HaveOccurred()) + + err = mapper.Upsert(&MapperArguments{ + MapUsers: true, + UserARN: "arn:aws:iam::00000000000:user/user-2", + Username: "admin", + Groups: []string{"system:masters"}, + }) + g.Expect(err).NotTo(gomega.HaveOccurred()) + + data, err := mapper.Get(&MapperArguments{}) + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(data).NotTo(gomega.Equal(AwsAuthData{})) + + auth, _, err := ReadAuthMap(client) + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(auth).To(gomega.Equal(data)) +} + +func TestMapper_GetRetry(t *testing.T) { + g := gomega.NewWithT(t) + gomega.RegisterTestingT(t) + client := fake.NewSimpleClientset() + mapper := New(client, true) + + data, err := mapper.Get(&MapperArguments{ + WithRetries: true, + MinRetryTime: 1 * time.Second, + MaxRetryTime: 2 * time.Second, + MaxRetryCount: 5, + }) + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(data).To(gomega.Equal(AwsAuthData{})) + + auth, _, err := ReadAuthMap(client) + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(auth).To(gomega.Equal(data)) +} + +func TestMapper_GetRetryFail(t *testing.T) { + g := gomega.NewWithT(t) + gomega.RegisterTestingT(t) + client := fake.NewSimpleClientset() + mapper := New(client, true) + create_MockMalformedConfigMap(client) + + data, err := mapper.Get(&MapperArguments{ + WithRetries: true, + MinRetryTime: 100 * time.Millisecond, + MaxRetryTime: 200 * time.Millisecond, + MaxRetryCount: 5, + }) + + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring("waiter timed out")) + g.Expect(data).To(gomega.Equal(AwsAuthData{})) + + _, _, err = ReadAuthMap(client) + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring("cannot unmarshal")) + +} diff --git a/pkg/mapper/remove.go b/pkg/mapper/remove.go index ba3a8f3..20e61e0 100644 --- a/pkg/mapper/remove.go +++ b/pkg/mapper/remove.go @@ -27,7 +27,10 @@ func (b *AuthMapper) Remove(args *MapperArguments) error { args.Validate() if args.WithRetries { - return WithRetry(b.removeAuth, args) + _, err := WithRetry(func() (interface{}, error) { + return nil, b.removeAuth(args) + }, args) + return err } return b.removeAuth(args) } @@ -37,7 +40,10 @@ func (b *AuthMapper) RemoveByUsername(args *MapperArguments) error { args.IsGlobal = true args.Validate() if args.WithRetries { - return WithRetry(b.removeAuthByUser, args) + _, err := WithRetry(func() (interface{}, error) { + return nil, b.removeAuthByUser(args) + }, args) + return err } return b.removeAuthByUser(args) } diff --git a/pkg/mapper/types.go b/pkg/mapper/types.go index 7a1fe6a..aaf253a 100644 --- a/pkg/mapper/types.go +++ b/pkg/mapper/types.go @@ -50,7 +50,7 @@ func New(client kubernetes.Interface, isCommandline bool) *AuthMapper { var ( DefaultRetryerBackoffFactor float64 = 2.0 DefaultRetryerBackoffJitter = true - UpdateUsernameArgumentTrue bool = true + UpdateUsernameDefaultValue bool = true ) // AwsAuthData represents the data of the aws-auth configmap @@ -74,11 +74,13 @@ type OperationType string const ( OperationUpsert OperationType = "upsert" OperationRemove OperationType = "remove" + OperationGet OperationType = "get" ) // MapperArguments are the arguments for removing a mapRole or mapUsers type MapperArguments struct { KubeconfigPath string + Format string OperationType OperationType MapRoles bool MapUsers bool @@ -119,6 +121,10 @@ func (args *MapperArguments) Validate() { log.Fatal("error: --username not provided") } + if args.OperationType == OperationGet && args.Format != "table" { + log.Fatal("error: --format only supports value 'table'") + } + if !args.MapUsers && !args.MapRoles { if !args.IsGlobal { log.Fatal("error: must select --mapusers or --maproles") @@ -126,7 +132,7 @@ func (args *MapperArguments) Validate() { } if args.UpdateUsername == nil { - args.UpdateUsername = &UpdateUsernameArgumentTrue + args.UpdateUsername = &UpdateUsernameDefaultValue } } @@ -221,11 +227,14 @@ func (r *RolesAuthMap) AppendGroups(g []string) *RolesAuthMap { return r } -func WithRetry(fn func(*MapperArguments) error, args *MapperArguments) error { +type RetriableFunction func() (interface{}, error) + +func WithRetry(fn RetriableFunction, args *MapperArguments) (interface{}, error) { // Update the config map and return an AuthMap var ( counter int err error + out interface{} bkoff = &backoff.Backoff{ Min: args.MinRetryTime, Max: args.MaxRetryTime, @@ -239,16 +248,16 @@ func WithRetry(fn func(*MapperArguments) error, args *MapperArguments) error { break } - if err = fn(args); err != nil { + if out, err = fn(); err != nil { d := bkoff.Duration() log.Printf("error: %v: will retry after %v", err, d) time.Sleep(d) counter++ continue } - return nil + return out, nil } - return errors.Wrap(err, "waiter timed out") + return out, errors.Wrap(err, "waiter timed out") } type UpsertOptions struct { diff --git a/pkg/mapper/upsert.go b/pkg/mapper/upsert.go index ca11297..184d3a8 100644 --- a/pkg/mapper/upsert.go +++ b/pkg/mapper/upsert.go @@ -25,7 +25,10 @@ func (b *AuthMapper) Upsert(args *MapperArguments) error { args.Validate() if args.WithRetries { - return WithRetry(b.upsertAuth, args) + _, err := WithRetry(func() (interface{}, error) { + return nil, b.upsertAuth(args) + }, args) + return err } return b.upsertAuth(args)