diff --git a/docs/.generated_docs b/docs/.generated_docs index 6cb4e01c471..d951dcad175 100644 --- a/docs/.generated_docs +++ b/docs/.generated_docs @@ -106,6 +106,7 @@ docs/man/man1/kubectl-scale.1 docs/man/man1/kubectl-set-image.1 docs/man/man1/kubectl-set-resources.1 docs/man/man1/kubectl-set-selector.1 +docs/man/man1/kubectl-set-serviceaccount.1 docs/man/man1/kubectl-set-subject.1 docs/man/man1/kubectl-set.1 docs/man/man1/kubectl-stop.1 @@ -201,6 +202,7 @@ docs/user-guide/kubectl/kubectl_set.md docs/user-guide/kubectl/kubectl_set_image.md docs/user-guide/kubectl/kubectl_set_resources.md docs/user-guide/kubectl/kubectl_set_selector.md +docs/user-guide/kubectl/kubectl_set_serviceaccount.md docs/user-guide/kubectl/kubectl_set_subject.md docs/user-guide/kubectl/kubectl_taint.md docs/user-guide/kubectl/kubectl_top.md diff --git a/docs/man/man1/kubectl-set-serviceaccount.1 b/docs/man/man1/kubectl-set-serviceaccount.1 new file mode 100644 index 00000000000..b6fd7a0f989 --- /dev/null +++ b/docs/man/man1/kubectl-set-serviceaccount.1 @@ -0,0 +1,3 @@ +This file is autogenerated, but we've stopped checking such files into the +repository to reduce the need for rebases. Please run hack/generate-docs.sh to +populate this file. diff --git a/docs/user-guide/kubectl/kubectl_set_serviceaccount.md b/docs/user-guide/kubectl/kubectl_set_serviceaccount.md new file mode 100644 index 00000000000..b6fd7a0f989 --- /dev/null +++ b/docs/user-guide/kubectl/kubectl_set_serviceaccount.md @@ -0,0 +1,3 @@ +This file is autogenerated, but we've stopped checking such files into the +repository to reduce the need for rebases. Please run hack/generate-docs.sh to +populate this file. diff --git a/pkg/kubectl/cmd/set/BUILD b/pkg/kubectl/cmd/set/BUILD index 78dc5d2c52c..5be20fc1916 100644 --- a/pkg/kubectl/cmd/set/BUILD +++ b/pkg/kubectl/cmd/set/BUILD @@ -12,6 +12,7 @@ go_library( "set_image.go", "set_resources.go", "set_selector.go", + "set_serviceaccount.go", "set_subject.go", ], visibility = ["//build/visible_to:pkg_kubectl_cmd_set_CONSUMERS"], @@ -42,15 +43,19 @@ go_test( "set_image_test.go", "set_resources_test.go", "set_selector_test.go", + "set_serviceaccount_test.go", "set_subject_test.go", "set_test.go", ], data = [ "//examples:config", + "//test/fixtures", ], library = ":go_default_library", deps = [ "//pkg/api:go_default_library", + "//pkg/api/testapi:go_default_library", + "//pkg/apis/apps:go_default_library", "//pkg/apis/batch:go_default_library", "//pkg/apis/extensions:go_default_library", "//pkg/apis/rbac:go_default_library", diff --git a/pkg/kubectl/cmd/set/set.go b/pkg/kubectl/cmd/set/set.go index d55d2b2cca3..7ff0ae0d3ce 100644 --- a/pkg/kubectl/cmd/set/set.go +++ b/pkg/kubectl/cmd/set/set.go @@ -45,6 +45,6 @@ func NewCmdSet(f cmdutil.Factory, out, err io.Writer) *cobra.Command { cmd.AddCommand(NewCmdResources(f, out, err)) cmd.AddCommand(NewCmdSelector(f, out)) cmd.AddCommand(NewCmdSubject(f, out, err)) - + cmd.AddCommand(NewCmdServiceAccount(f, out, err)) return cmd } diff --git a/pkg/kubectl/cmd/set/set_serviceaccount.go b/pkg/kubectl/cmd/set/set_serviceaccount.go new file mode 100644 index 00000000000..9cde84aa26e --- /dev/null +++ b/pkg/kubectl/cmd/set/set_serviceaccount.go @@ -0,0 +1,182 @@ +/* +Copyright 2017 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 set + +import ( + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/kubernetes/pkg/api" + "k8s.io/kubernetes/pkg/kubectl/cmd/templates" + cmdutil "k8s.io/kubernetes/pkg/kubectl/cmd/util" + "k8s.io/kubernetes/pkg/kubectl/resource" + "k8s.io/kubernetes/pkg/util/i18n" +) + +var ( + serviceaccountResources = ` + replicationcontroller (rc), deployment (deploy), daemonset (ds), job, replicaset (rs), statefulset` + + serviceaccountLong = templates.LongDesc(i18n.T(` + Update ServiceAccount of pod template resources. + + Possible resources (case insensitive) can be: + ` + serviceaccountResources)) + + serviceaccountExample = templates.Examples(i18n.T(` + # Set Deployment nginx-deployment's ServiceAccount to serviceaccount1 + kubectl set serviceaccount deployment nginx-deployment serviceaccount1 + + # Print the result (in yaml format) of updated nginx deployment with serviceaccount from local file, without hitting apiserver + kubectl set sa -f nginx-deployment.yaml serviceaccount1 --local --dry-run -o yaml + `)) +) + +// serviceAccountConfig encapsulates the data required to perform the operation. +type serviceAccountConfig struct { + fileNameOptions resource.FilenameOptions + mapper meta.RESTMapper + encoder runtime.Encoder + out io.Writer + err io.Writer + dryRun bool + shortOutput bool + all bool + record bool + output string + changeCause string + local bool + saPrint func(obj runtime.Object) error + updatePodSpecForObject func(runtime.Object, func(*api.PodSpec) error) (bool, error) + infos []*resource.Info + serviceAccountName string +} + +// NewCmdServiceAccount returns the "set serviceaccount" command. +func NewCmdServiceAccount(f cmdutil.Factory, out, err io.Writer) *cobra.Command { + saConfig := &serviceAccountConfig{ + out: out, + err: err, + } + + cmd := &cobra.Command{ + Use: "serviceaccount (-f FILENAME | TYPE NAME) SERVICE_ACCOUNT", + Aliases: []string{"sa"}, + Short: i18n.T("Update ServiceAccount of a resource"), + Long: serviceaccountLong, + Example: serviceaccountExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(saConfig.Complete(f, cmd, args)) + cmdutil.CheckErr(saConfig.Run()) + }, + } + cmdutil.AddPrinterFlags(cmd) + + usage := "identifying the resource to get from a server." + cmdutil.AddFilenameOptionFlags(cmd, &saConfig.fileNameOptions, usage) + cmd.Flags().BoolVar(&saConfig.all, "all", false, "Select all resources in the namespace of the specified resource types") + cmd.Flags().BoolVar(&saConfig.local, "local", false, "If true, set image will NOT contact api-server but run locally.") + cmdutil.AddRecordFlag(cmd) + cmdutil.AddDryRunFlag(cmd) + return cmd +} + +// Complete configures serviceAccountConfig from command line args. +func (saConfig *serviceAccountConfig) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + saConfig.mapper, _ = f.Object() + saConfig.encoder = f.JSONEncoder() + saConfig.shortOutput = cmdutil.GetFlagString(cmd, "output") == "name" + saConfig.record = cmdutil.GetRecordFlag(cmd) + saConfig.changeCause = f.Command(cmd, false) + saConfig.dryRun = cmdutil.GetDryRunFlag(cmd) + saConfig.output = cmdutil.GetFlagString(cmd, "output") + saConfig.updatePodSpecForObject = f.UpdatePodSpecForObject + saConfig.saPrint = func(obj runtime.Object) error { + return f.PrintObject(cmd, saConfig.local, saConfig.mapper, obj, saConfig.out) + } + cmdNamespace, enforceNamespace, err := f.DefaultNamespace() + if err != nil { + return err + } + if len(args) == 0 { + return errors.New("serviceaccount is required") + } + saConfig.serviceAccountName = args[len(args)-1] + resources := args[:len(args)-1] + builder := f.NewBuilder(!saConfig.local).ContinueOnError(). + NamespaceParam(cmdNamespace).DefaultNamespace(). + FilenameParam(enforceNamespace, &saConfig.fileNameOptions). + Flatten() + if !saConfig.local { + builder.ResourceTypeOrNameArgs(saConfig.all, resources...). + Latest() + } + saConfig.infos, err = builder.Do().Infos() + if err != nil { + return err + } + return nil +} + +// Run creates and applies the patch either locally or calling apiserver. +func (saConfig *serviceAccountConfig) Run() error { + patchErrs := []error{} + patchFn := func(info *resource.Info) ([]byte, error) { + saConfig.updatePodSpecForObject(info.Object, func(podSpec *api.PodSpec) error { + podSpec.ServiceAccountName = saConfig.serviceAccountName + return nil + }) + return runtime.Encode(saConfig.encoder, info.Object) + } + patches := CalculatePatches(saConfig.infos, saConfig.encoder, patchFn) + for _, patch := range patches { + info := patch.Info + if patch.Err != nil { + patchErrs = append(patchErrs, fmt.Errorf("error: %s/%s %v\n", info.Mapping.Resource, info.Name, patch.Err)) + continue + } + if saConfig.local || saConfig.dryRun { + saConfig.saPrint(patch.Info.Object) + continue + } + patched, err := resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, types.StrategicMergePatchType, patch.Patch) + if err != nil { + patchErrs = append(patchErrs, fmt.Errorf("failed to patch ServiceAccountName %v", err)) + continue + } + info.Refresh(patched, true) + if saConfig.record || cmdutil.ContainsChangeCause(info) { + if patch, patchType, err := cmdutil.ChangeResourcePatch(info, saConfig.changeCause); err == nil { + if patched, err = resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, patchType, patch); err != nil { + fmt.Fprintf(saConfig.err, "WARNING: changes to %s/%s can't be recorded: %v\n", info.Mapping.Resource, info.Name, err) + } + } + } + if len(saConfig.output) > 0 { + saConfig.saPrint(patched) + } + cmdutil.PrintSuccess(saConfig.mapper, saConfig.shortOutput, saConfig.out, info.Mapping.Resource, info.Name, saConfig.dryRun, "serviceaccount updated") + } + return utilerrors.NewAggregate(patchErrs) +} diff --git a/pkg/kubectl/cmd/set/set_serviceaccount_test.go b/pkg/kubectl/cmd/set/set_serviceaccount_test.go new file mode 100644 index 00000000000..16cfbdc5dfe --- /dev/null +++ b/pkg/kubectl/cmd/set/set_serviceaccount_test.go @@ -0,0 +1,238 @@ +/* +Copyright 2017 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 set + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "net/http" + "path" + "testing" + + "github.com/stretchr/testify/assert" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest/fake" + "k8s.io/kubernetes/pkg/api" + "k8s.io/kubernetes/pkg/api/testapi" + "k8s.io/kubernetes/pkg/apis/apps" + "k8s.io/kubernetes/pkg/apis/batch" + "k8s.io/kubernetes/pkg/apis/extensions" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" + "k8s.io/kubernetes/pkg/kubectl/resource" + "k8s.io/kubernetes/pkg/printers" +) + +const serviceAccount = "serviceaccount1" +const serviceAccountMissingErrString = "serviceaccount is required" +const resourceMissingErrString = `You must provide one or more resources by argument or filename. +Example resource specifications include: + '-f rsrc.yaml' + '--filename=rsrc.json' + ' ' + ''` + +func TestServiceAccountLocal(t *testing.T) { + inputs := []struct { + yaml string + apiGroup string + }{ + {yaml: "../../../../test/fixtures/doc-yaml/user-guide/replication.yaml", apiGroup: api.GroupName}, + {yaml: "../../../../test/fixtures/doc-yaml/admin/daemon.yaml", apiGroup: extensions.GroupName}, + {yaml: "../../../../test/fixtures/doc-yaml/user-guide/replicaset/redis-slave.yaml", apiGroup: extensions.GroupName}, + {yaml: "../../../../test/fixtures/doc-yaml/user-guide/job.yaml", apiGroup: batch.GroupName}, + {yaml: "../../../../test/fixtures/doc-yaml/user-guide/deployment.yaml", apiGroup: extensions.GroupName}, + {yaml: "../../../../examples/storage/minio/minio-distributed-statefulset.yaml", apiGroup: apps.GroupName}, + } + + f, tf, _, _ := cmdtesting.NewAPIFactory() + tf.Client = &fake.RESTClient{ + APIRegistry: api.Registry, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + tf.Namespace = "test" + out := new(bytes.Buffer) + cmd := NewCmdServiceAccount(f, out, out) + cmd.SetOutput(out) + cmd.Flags().Set("output", "yaml") + cmd.Flags().Set("local", "true") + for _, input := range inputs { + testapi.Default = testapi.Groups[input.apiGroup] + tf.Printer = printers.NewVersionedPrinter(&printers.YAMLPrinter{}, testapi.Default.Converter(), *testapi.Default.GroupVersion()) + saConfig := serviceAccountConfig{fileNameOptions: resource.FilenameOptions{ + Filenames: []string{input.yaml}}, + out: out, + local: true} + err := saConfig.Complete(f, cmd, []string{serviceAccount}) + assert.NoError(t, err) + err = saConfig.Run() + assert.NoError(t, err) + assert.Contains(t, out.String(), "serviceAccountName: "+serviceAccount, fmt.Sprintf("serviceaccount not updated for %s", input.yaml)) + } +} + +func TestServiceAccountRemote(t *testing.T) { + inputs := []struct { + object runtime.Object + apiPrefix, apiGroup string + args []string + }{ + { + object: &extensions.ReplicaSet{ + TypeMeta: metav1.TypeMeta{Kind: "ReplicaSet", APIVersion: api.Registry.GroupOrDie(extensions.GroupName).GroupVersion.String()}, + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + apiPrefix: "/apis", apiGroup: extensions.GroupName, + args: []string{"replicaset", "nginx", serviceAccount}, + }, + { + object: &extensions.DaemonSet{ + TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: api.Registry.GroupOrDie(extensions.GroupName).GroupVersion.String()}, + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + apiPrefix: "/apis", apiGroup: extensions.GroupName, + args: []string{"daemonset", "nginx", serviceAccount}, + }, + { + object: &api.ReplicationController{ + TypeMeta: metav1.TypeMeta{Kind: "ReplicationController", APIVersion: api.Registry.GroupOrDie(api.GroupName).GroupVersion.String()}, + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + apiPrefix: "/api", apiGroup: api.GroupName, + args: []string{"replicationcontroller", "nginx", serviceAccount}}, + { + object: &extensions.Deployment{ + TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: api.Registry.GroupOrDie(extensions.GroupName).GroupVersion.String()}, + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + apiPrefix: "/apis", apiGroup: extensions.GroupName, + args: []string{"deployment", "nginx", serviceAccount}, + }, + { + object: &batch.Job{ + TypeMeta: metav1.TypeMeta{Kind: "Job", APIVersion: api.Registry.GroupOrDie(batch.GroupName).GroupVersion.String()}, + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + apiPrefix: "/apis", apiGroup: batch.GroupName, + args: []string{"job", "nginx", serviceAccount}, + }, + { + object: &apps.StatefulSet{ + TypeMeta: metav1.TypeMeta{Kind: "StatefulSet", APIVersion: api.Registry.GroupOrDie(apps.GroupName).GroupVersion.String()}, + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + apiPrefix: "/apis", apiGroup: apps.GroupName, + args: []string{"statefulset", "nginx", serviceAccount}, + }, + } + for _, input := range inputs { + + groupVersion := api.Registry.GroupOrDie(input.apiGroup).GroupVersion + testapi.Default = testapi.Groups[input.apiGroup] + f, tf, codec, _ := cmdtesting.NewAPIFactory() + tf.Printer = printers.NewVersionedPrinter(&printers.YAMLPrinter{}, testapi.Default.Converter(), *testapi.Default.GroupVersion()) + tf.Namespace = "test" + tf.CategoryExpander = resource.LegacyCategoryExpander + tf.Client = &fake.RESTClient{ + APIRegistry: api.Registry, + NegotiatedSerializer: testapi.Default.NegotiatedSerializer(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + resourcePath := testapi.Default.ResourcePath(input.args[0]+"s", tf.Namespace, input.args[1]) + switch p, m := req.URL.Path, req.Method; { + case p == resourcePath && m == http.MethodGet: + return &http.Response{StatusCode: http.StatusOK, Header: defaultHeader(), Body: objBody(codec, input.object)}, nil + case p == resourcePath && m == http.MethodPatch: + stream, err := req.GetBody() + if err != nil { + return nil, err + } + bytes, err := ioutil.ReadAll(stream) + if err != nil { + return nil, err + } + assert.Contains(t, string(bytes), `"serviceAccountName":`+`"`+serviceAccount+`"`, fmt.Sprintf("serviceaccount not updated for %#v", input.object)) + return &http.Response{StatusCode: http.StatusOK, Header: defaultHeader(), Body: objBody(codec, input.object)}, nil + default: + t.Errorf("%s: unexpected request: %s %#v\n%#v", "serviceaccount", req.Method, req.URL, req) + return nil, fmt.Errorf("unexpected request") + } + }), + VersionedAPIPath: path.Join(input.apiPrefix, groupVersion.String()), + GroupName: input.apiGroup, + } + out := new(bytes.Buffer) + cmd := NewCmdServiceAccount(f, out, out) + cmd.SetOutput(out) + cmd.Flags().Set("output", "yaml") + + saConfig := serviceAccountConfig{ + out: out, + local: false} + err := saConfig.Complete(f, cmd, input.args) + assert.NoError(t, err) + err = saConfig.Run() + assert.NoError(t, err) + } +} + +func TestServiceAccountValidation(t *testing.T) { + inputs := []struct { + args []string + errorString string + }{ + {args: []string{}, errorString: serviceAccountMissingErrString}, + {args: []string{serviceAccount}, errorString: resourceMissingErrString}, + } + for _, input := range inputs { + f, tf, _, _ := cmdtesting.NewAPIFactory() + tf.Client = &fake.RESTClient{ + APIRegistry: api.Registry, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + tf.Namespace = "test" + out := bytes.NewBuffer([]byte{}) + cmd := NewCmdServiceAccount(f, out, out) + cmd.SetOutput(out) + + saConfig := &serviceAccountConfig{} + err := saConfig.Complete(f, cmd, input.args) + assert.EqualError(t, err, input.errorString) + } +} + +func objBody(codec runtime.Codec, obj runtime.Object) io.ReadCloser { + return bytesBody([]byte(runtime.EncodeOrDie(codec, obj))) +} + +func defaultHeader() http.Header { + header := http.Header{} + header.Set("Content-Type", runtime.ContentTypeJSON) + return header +} + +func bytesBody(bodyBytes []byte) io.ReadCloser { + return ioutil.NopCloser(bytes.NewReader(bodyBytes)) +} diff --git a/pkg/kubectl/cmd/testing/fake.go b/pkg/kubectl/cmd/testing/fake.go index 23c27a45653..595d07552ea 100644 --- a/pkg/kubectl/cmd/testing/fake.go +++ b/pkg/kubectl/cmd/testing/fake.go @@ -240,6 +240,7 @@ type TestFactory struct { Err error Command string TmpDir string + CategoryExpander resource.CategoryExpander ClientForMappingFunc func(mapping *meta.RESTMapping) (resource.RESTClient, error) UnstructuredClientForMappingFunc func(mapping *meta.RESTMapping) (resource.RESTClient, error) @@ -622,6 +623,13 @@ func (f *fakeAPIFactory) DiscoveryClient() (discovery.CachedDiscoveryInterface, return cmdutil.NewCachedDiscoveryClient(discoveryClient, cacheDir, time.Duration(10*time.Minute)), nil } +func (f *fakeAPIFactory) CategoryExpander() resource.CategoryExpander { + if f.tf.CategoryExpander != nil { + return f.tf.CategoryExpander + } + return f.Factory.CategoryExpander() +} + func (f *fakeAPIFactory) ClientSetForVersion(requiredVersion *schema.GroupVersion) (internalclientset.Interface, error) { return f.ClientSet() }