From 344e97b543889ba366802f2d16bfb3f022b94f81 Mon Sep 17 00:00:00 2001 From: Jordan Liggitt Date: Mon, 31 Jan 2022 11:46:50 -0500 Subject: [PATCH] Add command to request a bound service account token Kubernetes-commit: fca9b1d9fcc7288ecb93c969ff9907a5def2dc9e --- go.mod | 32 +-- go.sum | 17 +- pkg/cmd/create/create.go | 1 + pkg/cmd/create/create_token.go | 263 ++++++++++++++++++++++ pkg/cmd/create/create_token_test.go | 330 ++++++++++++++++++++++++++++ 5 files changed, 613 insertions(+), 30 deletions(-) create mode 100644 pkg/cmd/create/create_token.go create mode 100644 pkg/cmd/create/create_token_test.go diff --git a/go.mod b/go.mod index c90887ecc..ea18aae95 100644 --- a/go.mod +++ b/go.mod @@ -31,28 +31,30 @@ require ( github.com/stretchr/testify v1.7.0 golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 gopkg.in/yaml.v2 v2.4.0 - k8s.io/api v0.0.0-20220126052120-b2d630a65cb2 - k8s.io/apimachinery v0.0.0-20220129104801-df993592a122 - k8s.io/cli-runtime v0.0.0-20220122014111-e2af539b5326 - k8s.io/client-go v0.0.0-20220202172021-8f44946f6cbe - k8s.io/component-base v0.0.0-20220122012704-f57281d7c18f - k8s.io/component-helpers v0.0.0-20220122012806-c839e8046fed + k8s.io/api v0.0.0 + k8s.io/apimachinery v0.0.0 + k8s.io/cli-runtime v0.0.0 + k8s.io/client-go v0.0.0 + k8s.io/component-base v0.0.0 + k8s.io/component-helpers v0.0.0 k8s.io/klog/v2 v2.40.1 k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 - k8s.io/metrics v0.0.0-20220122014001-342ad4a5669e + k8s.io/metrics v0.0.0 k8s.io/utils v0.0.0-20211208161948-7d6a63dca704 + sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 sigs.k8s.io/kustomize/kustomize/v4 v4.4.1 sigs.k8s.io/kustomize/kyaml v0.13.0 sigs.k8s.io/yaml v1.2.0 ) replace ( - k8s.io/api => k8s.io/api v0.0.0-20220126052120-b2d630a65cb2 - k8s.io/apimachinery => k8s.io/apimachinery v0.0.0-20220129104801-df993592a122 - k8s.io/cli-runtime => k8s.io/cli-runtime v0.0.0-20220122014111-e2af539b5326 - k8s.io/client-go => k8s.io/client-go v0.0.0-20220202172021-8f44946f6cbe - k8s.io/code-generator => k8s.io/code-generator v0.0.0-20220121230106-9c1c6b44b559 - k8s.io/component-base => k8s.io/component-base v0.0.0-20220122012704-f57281d7c18f - k8s.io/component-helpers => k8s.io/component-helpers v0.0.0-20220122012806-c839e8046fed - k8s.io/metrics => k8s.io/metrics v0.0.0-20220122014001-342ad4a5669e + k8s.io/api => ../api + k8s.io/apimachinery => ../apimachinery + k8s.io/cli-runtime => ../cli-runtime + k8s.io/client-go => ../client-go + k8s.io/code-generator => ../code-generator + k8s.io/component-base => ../component-base + k8s.io/component-helpers => ../component-helpers + k8s.io/kubectl => ../kubectl + k8s.io/metrics => ../metrics ) diff --git a/go.sum b/go.sum index 1bb8c819e..370606b62 100644 --- a/go.sum +++ b/go.sum @@ -64,11 +64,13 @@ github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk5 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -915,19 +917,6 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -k8s.io/api v0.0.0-20220126052120-b2d630a65cb2 h1:24pR4NHIBxlpFXgBCm8IN5bmu99X4gYfH7lDLUAsK0A= -k8s.io/api v0.0.0-20220126052120-b2d630a65cb2/go.mod h1:dNpXx9aCJUTKbIJ8TuZJwjNPhWcvlxnQMlXNzlbWdfE= -k8s.io/apimachinery v0.0.0-20220129104801-df993592a122 h1:14bzAwJDW8S86wqAvH5sVjI1ilnCWnAKWXomMStJ3Yo= -k8s.io/apimachinery v0.0.0-20220129104801-df993592a122/go.mod h1:x0yrIIAdS2/JR9TKFHtSn8dYQDIw8mTIeAqPvOuEozA= -k8s.io/cli-runtime v0.0.0-20220122014111-e2af539b5326 h1:QAXmbcOYu7xAofVUfsYhakeAOu+DfBGhgJ90Vl/LmYk= -k8s.io/cli-runtime v0.0.0-20220122014111-e2af539b5326/go.mod h1:mOL/5Q3vNmbj+6KjT+ksc7cdYKr6vrnNGTsm8WpmKCQ= -k8s.io/client-go v0.0.0-20220202172021-8f44946f6cbe h1:kfSVh0XrUy2OG1lDWJQ3lbAcaU6RLUIRwWq5XMBYkEE= -k8s.io/client-go v0.0.0-20220202172021-8f44946f6cbe/go.mod h1:zv+Q8PFPKv1qDtAejKxTIGRAiO7e29b9fFGd5JiXjFQ= -k8s.io/code-generator v0.0.0-20220121230106-9c1c6b44b559/go.mod h1:RGBk0F4ZD2LgSn/a0UQWbmrfdaz0dYeEGJ1d8FZhkuU= -k8s.io/component-base v0.0.0-20220122012704-f57281d7c18f h1:w7E6jE+nZJ8H9L+kKxRu/MR1FPmwaG25AtoqHj2QG6w= -k8s.io/component-base v0.0.0-20220122012704-f57281d7c18f/go.mod h1:TsfwhY9IgBLBklEdFfg6Zr0N4KuU8A1Px63NVU/kDHg= -k8s.io/component-helpers v0.0.0-20220122012806-c839e8046fed h1:Lc8+PE2zTWvBSIM+CoT4hVDCc5vngzDFr38aRu8mDzM= -k8s.io/component-helpers v0.0.0-20220122012806-c839e8046fed/go.mod h1:d+au3zqOwmNwq0U7kEJkTLbQv+M2xAYKFCSTrlX4j2E= k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= k8s.io/gengo v0.0.0-20211129171323-c02415ce4185/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= @@ -938,8 +927,6 @@ k8s.io/klog/v2 v2.40.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e/go.mod h1:vHXdDvt9+2spS2Rx9ql3I8tycm3H9FDfdUoIuKCefvw= k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 h1:E3J9oCLlaobFUqsjG9DfKbP2BmgwBL2p7pn0A3dG9W4= k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65/go.mod h1:sX9MT8g7NVZM5lVL/j8QyCCJe8YSMW30QvGZWaCIDIk= -k8s.io/metrics v0.0.0-20220122014001-342ad4a5669e h1:7SOLs0yA+35jbM2GbzRrTB1M/peJnmwzQt7+YzsyFXw= -k8s.io/metrics v0.0.0-20220122014001-342ad4a5669e/go.mod h1:ivbx7LsoIPeeClwrWaE6Mo5DuTM8k37/dpKc6duiz6E= k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= k8s.io/utils v0.0.0-20211208161948-7d6a63dca704 h1:ZKMMxTvduyf5WUtREOqg5LiXaN1KO/+0oOQPRFrClpo= k8s.io/utils v0.0.0-20211208161948-7d6a63dca704/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= diff --git a/pkg/cmd/create/create.go b/pkg/cmd/create/create.go index a7d8f8330..e45215cc9 100644 --- a/pkg/cmd/create/create.go +++ b/pkg/cmd/create/create.go @@ -153,6 +153,7 @@ func NewCmdCreate(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cob cmd.AddCommand(NewCmdCreateJob(f, ioStreams)) cmd.AddCommand(NewCmdCreateCronJob(f, ioStreams)) cmd.AddCommand(NewCmdCreateIngress(f, ioStreams)) + cmd.AddCommand(NewCmdCreateToken(f, ioStreams)) return cmd } diff --git a/pkg/cmd/create/create_token.go b/pkg/cmd/create/create_token.go new file mode 100644 index 000000000..975684c25 --- /dev/null +++ b/pkg/cmd/create/create_token.go @@ -0,0 +1,263 @@ +/* +Copyright 2022 The Kubernetes Authors. + +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 create + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + authenticationv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/cli-runtime/pkg/genericclioptions" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubectl/pkg/util/term" +) + +// TokenOptions is the data required to perform a token request operation. +type TokenOptions struct { + // PrintFlags holds options necessary for obtaining a printer + PrintFlags *genericclioptions.PrintFlags + PrintObj func(obj runtime.Object) error + + // Name and namespace of service account to create a token for + Name string + Namespace string + + // BoundObjectKind is the kind of object to bind the token to. Optional. Can be Pod or Secret. + BoundObjectKind string + // BoundObjectName is the name of the object to bind the token to. Required if BoundObjectKind is set. + BoundObjectName string + // BoundObjectUID is the uid of the object to bind the token to. If unset, defaults to the current uid of the bound object. + BoundObjectUID string + + // Audiences indicate the valid audiences for the requested token. If unset, defaults to the Kubernetes API server audiences. + Audiences []string + + // ExpirationSeconds is the requested token lifetime. Optional. + ExpirationSeconds int64 + + // CoreClient is the API client used to request the token. Required. + CoreClient corev1client.CoreV1Interface + + // IOStreams are the output streams for the operation. Required. + genericclioptions.IOStreams +} + +var ( + tokenLong = templates.LongDesc(`Request a service account token.`) + + tokenExample = templates.Examples(` + # Request a token to authenticate to the kube-apiserver as the service account "myapp" in the current namespace + kubectl create token myapp + + # Request a token for a service account in a custom namespace + kubectl create token myapp --namespace myns + + # Request a token with a custom expiration + kubectl create token myapp --expiration-seconds 600 + + # Request a token with a custom audience + kubectl create token myapp --audience https://example.com + + # Request a token bound to an instance of a Secret object + kubectl create token myapp --bound-object-kind Secret --bound-object-name mysecret + + # Request a token bound to an instance of a Secret object with a specific uid + kubectl create token myapp --bound-object-kind Secret --bound-object-name mysecret --bound-object-uid 0d4691ed-659b-4935-a832-355f77ee47cc +`) + + boundObjectKindToAPIVersion = map[string]string{ + "Pod": "v1", + "Secret": "v1", + } +) + +func NewTokenOpts(ioStreams genericclioptions.IOStreams) *TokenOptions { + return &TokenOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + IOStreams: ioStreams, + } +} + +// NewCmdCreateToken returns an initialized Command for 'create token' sub command +func NewCmdCreateToken(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewTokenOpts(ioStreams) + + cmd := &cobra.Command{ + Use: "token SERVICE_ACCOUNT_NAME", + DisableFlagsInUseLine: true, + Short: "Request a service account token", + Long: tokenLong, + Example: tokenExample, + ValidArgsFunction: util.ResourceNameCompletionFunc(f, "serviceaccount"), + Run: func(cmd *cobra.Command, args []string) { + if err := o.Complete(f, cmd, args); err != nil { + cmdutil.CheckErr(err) + return + } + if err := o.Validate(); err != nil { + cmdutil.CheckErr(err) + return + } + if err := o.Run(); err != nil { + cmdutil.CheckErr(err) + return + } + }, + } + + o.PrintFlags.AddFlags(cmd) + + cmd.Flags().StringArrayVar(&o.Audiences, "audience", o.Audiences, "Audience of the requested token. If unset, defaults to requesting a token for use with the Kubernetes API server. May be repeated to request a token valid for multiple audiences.") + + cmd.Flags().Int64Var(&o.ExpirationSeconds, "expiration-seconds", o.ExpirationSeconds, "Requested lifetime of the issued token. The server may return a token with a longer or shorter lifetime.") + + cmd.Flags().StringVar(&o.BoundObjectKind, "bound-object-kind", o.BoundObjectKind, "Kind of an object to bind the token to. "+ + "Supported kinds are "+strings.Join(sets.StringKeySet(boundObjectKindToAPIVersion).List(), ", ")+". "+ + "If set, --bound-object-name must be provided.") + cmd.Flags().StringVar(&o.BoundObjectName, "bound-object-name", o.BoundObjectName, "Name of an object to bind the token to. "+ + "The token will expire when the object is deleted. "+ + "Requires --bound-object-kind.") + cmd.Flags().StringVar(&o.BoundObjectUID, "bound-object-uid", o.BoundObjectUID, "UID of an object to bind the token to. "+ + "Requires --bound-object-kind and --bound-object-name. "+ + "If unset, the UID of the existing object is used.") + + return cmd +} + +// Complete completes all the required options +func (o *TokenOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + + o.Name, err = NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + client, err := f.KubernetesClientSet() + if err != nil { + return err + } + o.CoreClient = client.CoreV1() + + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + + o.PrintObj = func(obj runtime.Object) error { + return printer.PrintObj(obj, o.Out) + } + + return nil +} + +// Validate makes sure provided values for TokenOptions are valid +func (o *TokenOptions) Validate() error { + if o.CoreClient == nil { + return fmt.Errorf("no client provided") + } + if len(o.Name) == 0 { + return fmt.Errorf("service account name is required") + } + if len(o.Namespace) == 0 { + return fmt.Errorf("--namespace is required") + } + if o.ExpirationSeconds < 0 { + return fmt.Errorf("--expiration-seconds must be positive") + } + for _, aud := range o.Audiences { + if len(aud) == 0 { + return fmt.Errorf("--audience must not be an empty string") + } + } + + if len(o.BoundObjectKind) == 0 { + if len(o.BoundObjectName) > 0 { + return fmt.Errorf("--bound-object-name can only be set if --bound-object-kind is provided") + } + if len(o.BoundObjectUID) > 0 { + return fmt.Errorf("--bound-object-uid can only be set if --bound-object-kind is provided") + } + } else { + if _, ok := boundObjectKindToAPIVersion[o.BoundObjectKind]; !ok { + return fmt.Errorf("supported --bound-object-kind values are %s", strings.Join(sets.StringKeySet(boundObjectKindToAPIVersion).List(), ", ")) + } + if len(o.BoundObjectName) == 0 { + return fmt.Errorf("--bound-object-name is required if --bound-object-kind is provided") + } + } + + return nil +} + +// Run requests a token +func (o *TokenOptions) Run() error { + request := &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: o.Audiences, + }, + } + if o.ExpirationSeconds > 0 { + request.Spec.ExpirationSeconds = &o.ExpirationSeconds + } + if len(o.BoundObjectKind) > 0 { + request.Spec.BoundObjectRef = &authenticationv1.BoundObjectReference{ + Kind: o.BoundObjectKind, + APIVersion: boundObjectKindToAPIVersion[o.BoundObjectKind], + Name: o.BoundObjectName, + UID: types.UID(o.BoundObjectUID), + } + } + + response, err := o.CoreClient.ServiceAccounts(o.Namespace).CreateToken(context.TODO(), o.Name, request, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create token: %v", err) + } + if len(response.Status.Token) == 0 { + return fmt.Errorf("failed to create token: no token in server response") + } + + if o.PrintFlags.OutputFlagSpecified() { + return o.PrintObj(response) + } + + if term.IsTerminal(o.Out) { + // include a newline when printing interactively + fmt.Fprintf(o.Out, "%s\n", response.Status.Token) + } else { + // otherwise just print the token + fmt.Fprintf(o.Out, "%s", response.Status.Token) + } + + return nil +} diff --git a/pkg/cmd/create/create_token_test.go b/pkg/cmd/create/create_token_test.go new file mode 100644 index 000000000..4785547af --- /dev/null +++ b/pkg/cmd/create/create_token_test.go @@ -0,0 +1,330 @@ +/* +Copyright 2022 The Kubernetes Authors. + +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 create + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "reflect" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "k8s.io/utils/pointer" + kjson "sigs.k8s.io/json" + + authenticationv1 "k8s.io/api/authentication/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + cmdtesting "k8s.io/kubectl/pkg/cmd/testing" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" +) + +func TestCreateToken(t *testing.T) { + tests := []struct { + test string + + name string + namespace string + output string + boundObjectKind string + boundObjectName string + boundObjectUID string + audiences []string + expirationSeconds int + + serverResponseToken string + serverResponseError string + + expectRequestPath string + expectTokenRequest *authenticationv1.TokenRequest + + expectStdout string + expectStderr string + }{ + { + test: "simple", + name: "mysa", + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + }, + serverResponseToken: "abc", + expectStdout: "abc", + }, + + { + test: "custom namespace", + name: "custom-sa", + namespace: "custom-ns", + + expectRequestPath: "/api/v1/namespaces/custom-ns/serviceaccounts/custom-sa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + }, + serverResponseToken: "abc", + expectStdout: "abc", + }, + + { + test: "yaml", + name: "mysa", + output: "yaml", + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + }, + serverResponseToken: "abc", + expectStdout: `apiVersion: authentication.k8s.io/v1 +kind: TokenRequest +metadata: + creationTimestamp: null +spec: + audiences: null + boundObjectRef: null + expirationSeconds: null +status: + expirationTimestamp: null + token: abc +`, + }, + + { + test: "bad bound object kind", + name: "mysa", + boundObjectKind: "Foo", + expectStderr: `error: supported --bound-object-kind values are Pod, Secret`, + }, + { + test: "missing bound object name", + name: "mysa", + boundObjectKind: "Pod", + expectStderr: `error: --bound-object-name is required if --bound-object-kind is provided`, + }, + { + test: "invalid bound object name", + name: "mysa", + boundObjectName: "mypod", + expectStderr: `error: --bound-object-name can only be set if --bound-object-kind is provided`, + }, + { + test: "invalid bound object uid", + name: "mysa", + boundObjectUID: "myuid", + expectStderr: `error: --bound-object-uid can only be set if --bound-object-kind is provided`, + }, + { + test: "valid bound object", + name: "mysa", + + boundObjectKind: "Pod", + boundObjectName: "mypod", + boundObjectUID: "myuid", + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + Spec: authenticationv1.TokenRequestSpec{ + BoundObjectRef: &authenticationv1.BoundObjectReference{ + Kind: "Pod", + APIVersion: "v1", + Name: "mypod", + UID: "myuid", + }, + }, + }, + serverResponseToken: "abc", + expectStdout: "abc", + }, + + { + test: "invalid audience", + name: "mysa", + audiences: []string{"test", "", "test2"}, + expectStderr: `error: --audience must not be an empty string`, + }, + { + test: "valid audiences", + name: "mysa", + + audiences: []string{"test,value1", "test,value2"}, + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{"test,value1", "test,value2"}, + }, + }, + serverResponseToken: "abc", + expectStdout: "abc", + }, + + { + test: "invalid expiration", + name: "mysa", + expirationSeconds: -1, + expectStderr: `error: --expiration-seconds must be positive`, + }, + { + test: "valid expiration", + name: "mysa", + + expirationSeconds: 1000, + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + Spec: authenticationv1.TokenRequestSpec{ + ExpirationSeconds: pointer.Int64(1000), + }, + }, + serverResponseToken: "abc", + expectStdout: "abc", + }, + + { + test: "server error", + name: "mysa", + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + }, + serverResponseError: "bad bad request", + expectStderr: `error: failed to create token: "bad bad request" is invalid`, + }, + { + test: "server missing token", + name: "mysa", + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + }, + serverResponseToken: "", + expectStderr: `error: failed to create token: no token in server response`, + }, + } + + for _, test := range tests { + t.Run(test.test, func(t *testing.T) { + defer cmdutil.DefaultBehaviorOnFatal() + sawError := "" + cmdutil.BehaviorOnFatal(func(str string, code int) { + sawError = str + }) + + namespace := "test" + if test.namespace != "" { + namespace = test.namespace + } + tf := cmdtesting.NewTestFactory().WithNamespace(namespace) + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{} + + var code int + var body []byte + if len(test.serverResponseError) > 0 { + code = 422 + response := apierrors.NewInvalid(schema.GroupKind{Group: "", Kind: ""}, test.serverResponseError, nil) + response.ErrStatus.APIVersion = "v1" + response.ErrStatus.Kind = "Status" + body, _ = json.Marshal(response.ErrStatus) + } else { + code = 200 + response := authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "authentication.k8s.io/v1", + Kind: "TokenRequest", + }, + Status: authenticationv1.TokenRequestStatus{Token: test.serverResponseToken}, + } + body, _ = json.Marshal(response) + } + + ns := scheme.Codecs.WithoutConversion() + var tokenRequest *authenticationv1.TokenRequest + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != test.expectRequestPath { + t.Fatalf("expected %q, got %q", test.expectRequestPath, req.URL.Path) + } + data, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + tokenRequest = &authenticationv1.TokenRequest{} + if strictErrs, err := kjson.UnmarshalStrict(data, tokenRequest); err != nil { + t.Fatal(err) + } else if len(strictErrs) > 0 { + t.Fatal(strictErrs) + } + + return &http.Response{ + StatusCode: code, + Body: ioutil.NopCloser(bytes.NewBuffer(body)), + }, nil + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, stdout, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateToken(tf, ioStreams) + if test.output != "" { + cmd.Flags().Set("output", test.output) + } + if test.boundObjectKind != "" { + cmd.Flags().Set("bound-object-kind", test.boundObjectKind) + } + if test.boundObjectName != "" { + cmd.Flags().Set("bound-object-name", test.boundObjectName) + } + if test.boundObjectUID != "" { + cmd.Flags().Set("bound-object-uid", test.boundObjectUID) + } + for _, aud := range test.audiences { + cmd.Flags().Set("audience", aud) + } + if test.expirationSeconds != 0 { + cmd.Flags().Set("expiration-seconds", strconv.Itoa(test.expirationSeconds)) + } + cmd.Run(cmd, []string{test.name}) + + if !reflect.DeepEqual(tokenRequest, test.expectTokenRequest) { + t.Fatalf("unexpected request:\n%s", cmp.Diff(test.expectTokenRequest, tokenRequest)) + } + + if stdout.String() != test.expectStdout { + t.Errorf("unexpected stdout:\n%s", cmp.Diff(test.expectStdout, stdout.String())) + } + if sawError != test.expectStderr { + t.Errorf("unexpected stderr:\n%s", cmp.Diff(test.expectStderr, sawError)) + } + }) + } +}