From a5aa858d44651ba48bdce634a39b55be91216614 Mon Sep 17 00:00:00 2001 From: Yuvaraj Kakaraparthi Date: Wed, 7 Jul 2021 08:30:59 -0700 Subject: [PATCH] kubectl: add --support to get, patch, edit and replace commands Co-authored-by: Nikhita Raghunath --- pkg/printers/internalversion/printers.go | 3 +- pkg/printers/internalversion/printers_test.go | 2 +- .../cli-runtime/pkg/resource/builder.go | 32 ++++-- .../cli-runtime/pkg/resource/builder_test.go | 83 +++++++++++++- .../k8s.io/cli-runtime/pkg/resource/helper.go | 15 ++- .../cli-runtime/pkg/resource/helper_test.go | 105 +++++++++++++++++- .../cli-runtime/pkg/resource/visitor.go | 6 +- .../src/k8s.io/kubectl/pkg/cmd/edit/edit.go | 7 +- .../k8s.io/kubectl/pkg/cmd/edit/edit_test.go | 4 + .../0.request | 0 .../0.response | 85 ++++++++++++++ .../testcase-edit-subresource-status/1.edited | 66 +++++++++++ .../1.original | 66 +++++++++++ .../2.request | 5 + .../2.response | 85 ++++++++++++++ .../test.yaml | 27 +++++ staging/src/k8s.io/kubectl/pkg/cmd/get/get.go | 14 ++- .../k8s.io/kubectl/pkg/cmd/get/get_test.go | 74 ++++++++++++ .../src/k8s.io/kubectl/pkg/cmd/patch/patch.go | 26 +++-- .../kubectl/pkg/cmd/patch/patch_test.go | 50 +++++++++ .../k8s.io/kubectl/pkg/cmd/replace/replace.go | 14 +++ .../k8s.io/kubectl/pkg/cmd/testing/util.go | 16 +++ .../pkg/cmd/util/editor/editoptions.go | 14 ++- .../k8s.io/kubectl/pkg/cmd/util/helpers.go | 4 + test/cmd/get.sh | 8 ++ 25 files changed, 775 insertions(+), 36 deletions(-) create mode 100644 staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/0.request create mode 100644 staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/0.response create mode 100644 staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.edited create mode 100644 staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.original create mode 100644 staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.request create mode 100644 staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.response create mode 100644 staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/test.yaml diff --git a/pkg/printers/internalversion/printers.go b/pkg/printers/internalversion/printers.go index 75315883cef..8b8581de640 100644 --- a/pkg/printers/internalversion/printers.go +++ b/pkg/printers/internalversion/printers.go @@ -588,6 +588,7 @@ func AddHandlers(h printers.PrintHandler) { {Name: "Name", Type: "string", Description: metav1.ObjectMeta{}.SwaggerDoc()["name"]}, {Name: "Desired", Type: "integer", Description: autoscalingv1.ScaleSpec{}.SwaggerDoc()["replicas"]}, {Name: "Available", Type: "integer", Description: autoscalingv1.ScaleStatus{}.SwaggerDoc()["replicas"]}, + {Name: "Age", Type: "string", Description: metav1.ObjectMeta{}.SwaggerDoc()["creationTimestamp"]}, } h.TableHandler(scaleColumnDefinitions, printScale) } @@ -2627,7 +2628,7 @@ func printScale(obj *autoscaling.Scale, options printers.GenerateOptions) ([]met row := metav1.TableRow{ Object: runtime.RawExtension{Object: obj}, } - row.Cells = append(row.Cells, obj.Name, obj.Spec.Replicas, obj.Status.Replicas) + row.Cells = append(row.Cells, obj.Name, obj.Spec.Replicas, obj.Status.Replicas, translateTimestampSince(obj.CreationTimestamp)) return []metav1.TableRow{row}, nil } diff --git a/pkg/printers/internalversion/printers_test.go b/pkg/printers/internalversion/printers_test.go index 5736f25a9c6..ab811231fcb 100644 --- a/pkg/printers/internalversion/printers_test.go +++ b/pkg/printers/internalversion/printers_test.go @@ -5841,7 +5841,7 @@ func TestPrintScale(t *testing.T) { }, expected: []metav1.TableRow{ { - Cells: []interface{}{"test-autoscaling", int32(2), int32(1)}, + Cells: []interface{}{"test-autoscaling", int32(2), int32(1), string("0s")}, }, }, }, diff --git a/staging/src/k8s.io/cli-runtime/pkg/resource/builder.go b/staging/src/k8s.io/cli-runtime/pkg/resource/builder.go index d9faf9b4cc6..78040a87800 100644 --- a/staging/src/k8s.io/cli-runtime/pkg/resource/builder.go +++ b/staging/src/k8s.io/cli-runtime/pkg/resource/builder.go @@ -83,7 +83,8 @@ type Builder struct { limitChunks int64 requestTransforms []RequestTransform - resources []string + resources []string + subresource string namespace string allNamespace bool @@ -555,6 +556,13 @@ func (b *Builder) TransformRequests(opts ...RequestTransform) *Builder { return b } +// Subresource instructs the builder to retrieve the object at the +// subresource path instead of the main resource path. +func (b *Builder) Subresource(subresource string) *Builder { + b.subresource = subresource + return b +} + // SelectEverythingParam func (b *Builder) SelectAllParam(selectAll bool) *Builder { if selectAll && (b.labelSelector != nil || b.fieldSelector != nil) { @@ -886,6 +894,10 @@ func (b *Builder) visitBySelector() *Result { if len(b.resources) == 0 { return result.withError(fmt.Errorf("at least one resource must be specified to use a selector")) } + if len(b.subresource) != 0 { + return result.withError(fmt.Errorf("subresource cannot be used when bulk resources are specified")) + } + mappings, err := b.resourceMappings() if err != nil { result.err = err @@ -1007,10 +1019,11 @@ func (b *Builder) visitByResource() *Result { } info := &Info{ - Client: client, - Mapping: mapping, - Namespace: selectorNamespace, - Name: tuple.Name, + Client: client, + Mapping: mapping, + Namespace: selectorNamespace, + Name: tuple.Name, + Subresource: b.subresource, } items = append(items, info) } @@ -1071,10 +1084,11 @@ func (b *Builder) visitByName() *Result { visitors := []Visitor{} for _, name := range b.names { info := &Info{ - Client: client, - Mapping: mapping, - Namespace: selectorNamespace, - Name: name, + Client: client, + Mapping: mapping, + Namespace: selectorNamespace, + Name: name, + Subresource: b.subresource, } visitors = append(visitors, info) } diff --git a/staging/src/k8s.io/cli-runtime/pkg/resource/builder_test.go b/staging/src/k8s.io/cli-runtime/pkg/resource/builder_test.go index e990ec5ed34..5206e6cc22c 100644 --- a/staging/src/k8s.io/cli-runtime/pkg/resource/builder_test.go +++ b/staging/src/k8s.io/cli-runtime/pkg/resource/builder_test.go @@ -150,6 +150,14 @@ func streamTestData() (io.Reader, *v1.PodList, *v1.ServiceList) { return r, pods, svc } +func subresourceTestData(name string) *v1.Pod { + return &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "test", ResourceVersion: "10"}, + Spec: V1DeepEqualSafePodSpec(), + Status: V1DeepEqualSafePodStatus(), + } +} + func JSONToYAMLOrDie(in []byte) []byte { data, err := yaml.JSONToYAML(in) if err != nil { @@ -915,6 +923,37 @@ func TestResourceByName(t *testing.T) { t.Errorf("unexpected resource mapping: %#v", mapping) } } +func TestSubresourceByName(t *testing.T) { + pod := subresourceTestData("foo") + b := newDefaultBuilderWith(fakeClientWith("", t, map[string]string{ + "/namespaces/test/pods/foo/status": runtime.EncodeOrDie(corev1Codec, pod), + })).NamespaceParam("test") + + test := &testVisitor{} + singleItemImplied := false + + if b.Do().Err() == nil { + t.Errorf("unexpected non-error") + } + + b.ResourceTypeOrNameArgs(true, "pods", "foo").Subresource("status") + + err := b.Do().IntoSingleItemImplied(&singleItemImplied).Visit(test.Handle) + if err != nil || !singleItemImplied || len(test.Infos) != 1 { + t.Fatalf("unexpected response: %v %t %#v", err, singleItemImplied, test.Infos) + } + if !apiequality.Semantic.DeepEqual(pod, test.Objects()[0]) { + t.Errorf("unexpected object: %#v", test.Objects()[0]) + } + + mapping, err := b.Do().ResourceMapping() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mapping.Resource != (schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}) { + t.Errorf("unexpected resource mapping: %#v", mapping) + } +} func TestRestMappingErrors(t *testing.T) { pods, _ := testData() @@ -1260,8 +1299,9 @@ func TestResourceTuple(t *testing.T) { expectNoErr := func(err error) bool { return err == nil } expectErr := func(err error) bool { return err != nil } testCases := map[string]struct { - args []string - errFn func(error) bool + args []string + subresource string + errFn func(error) bool }{ "valid": { args: []string{"pods/foo"}, @@ -1303,6 +1343,16 @@ func TestResourceTuple(t *testing.T) { args: []string{"bar/"}, errFn: expectErr, }, + "valid status subresource": { + args: []string{"pods/foo"}, + subresource: "status", + errFn: expectNoErr, + }, + "valid status subresource for multiple with name indirection": { + args: []string{"pods/foo", "pod/bar"}, + subresource: "status", + errFn: expectNoErr, + }, } for k, tt := range testCases { t.Run("using default namespace", func(t *testing.T) { @@ -1311,14 +1361,18 @@ func TestResourceTuple(t *testing.T) { if requireObject { pods, _ := testData() expectedRequests = map[string]string{ - "/namespaces/test/pods/foo": runtime.EncodeOrDie(corev1Codec, &pods.Items[0]), - "/namespaces/test/pods/bar": runtime.EncodeOrDie(corev1Codec, &pods.Items[0]), - "/nodes/foo": runtime.EncodeOrDie(corev1Codec, &v1.Node{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}), + "/namespaces/test/pods/foo": runtime.EncodeOrDie(corev1Codec, &pods.Items[0]), + "/namespaces/test/pods/bar": runtime.EncodeOrDie(corev1Codec, &pods.Items[0]), + "/namespaces/test/pods/foo/status": runtime.EncodeOrDie(corev1Codec, subresourceTestData("foo")), + "/namespaces/test/pods/bar/status": runtime.EncodeOrDie(corev1Codec, subresourceTestData("bar")), + "/nodes/foo": runtime.EncodeOrDie(corev1Codec, &v1.Node{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}), } } b := newDefaultBuilderWith(fakeClientWith(k, t, expectedRequests)). NamespaceParam("test").DefaultNamespace(). - ResourceTypeOrNameArgs(true, tt.args...).RequireObject(requireObject) + ResourceTypeOrNameArgs(true, tt.args...). + RequireObject(requireObject). + Subresource(tt.subresource) r := b.Do() @@ -1557,6 +1611,23 @@ func TestListObjectWithDifferentVersions(t *testing.T) { } } +func TestListObjectSubresource(t *testing.T) { + pods, _ := testData() + labelKey := metav1.LabelSelectorQueryParam(corev1GV.String()) + b := newDefaultBuilderWith(fakeClientWith("", t, map[string]string{ + "/namespaces/test/pods?" + labelKey: runtime.EncodeOrDie(corev1Codec, pods), + })). + NamespaceParam("test"). + ResourceTypeOrNameArgs(true, "pods"). + Subresource("status"). + Flatten() + + _, err := b.Do().Object() + if err == nil || !strings.Contains(err.Error(), "subresource cannot be used when bulk resources are specified") { + t.Fatalf("unexpected response: %v", err) + } +} + func TestWatch(t *testing.T) { _, svc := testData() w, err := newDefaultBuilderWith(fakeClientWith("", t, map[string]string{ diff --git a/staging/src/k8s.io/cli-runtime/pkg/resource/helper.go b/staging/src/k8s.io/cli-runtime/pkg/resource/helper.go index 684802e8857..34664bb2f40 100644 --- a/staging/src/k8s.io/cli-runtime/pkg/resource/helper.go +++ b/staging/src/k8s.io/cli-runtime/pkg/resource/helper.go @@ -37,6 +37,8 @@ var metadataAccessor = meta.NewAccessor() type Helper struct { // The name of this resource as the server would recognize it Resource string + // The name of the subresource as the server would recognize it + Subresource string // A RESTClient capable of mutating this resource. RESTClient RESTClient // True if the resource type is scoped to namespaces @@ -77,11 +79,18 @@ func (m *Helper) WithFieldManager(fieldManager string) *Helper { return m } +// Subresource sets the helper to access (/[ns//]/) +func (m *Helper) WithSubresource(subresource string) *Helper { + m.Subresource = subresource + return m +} + func (m *Helper) Get(namespace, name string) (runtime.Object, error) { req := m.RESTClient.Get(). NamespaceIfScoped(namespace, m.NamespaceScoped). Resource(m.Resource). - Name(name) + Name(name). + SubResource(m.Subresource) return req.Do(context.TODO()).Get() } @@ -237,6 +246,7 @@ func (m *Helper) Patch(namespace, name string, pt types.PatchType, data []byte, NamespaceIfScoped(namespace, m.NamespaceScoped). Resource(m.Resource). Name(name). + SubResource(m.Subresource). VersionedParams(options, metav1.ParameterCodec). Body(data). Do(context.TODO()). @@ -261,7 +271,7 @@ func (m *Helper) Replace(namespace, name string, overwrite bool, obj runtime.Obj } if version == "" && overwrite { // Retrieve the current version of the object to overwrite the server object - serverObj, err := c.Get().NamespaceIfScoped(namespace, m.NamespaceScoped).Resource(m.Resource).Name(name).Do(context.TODO()).Get() + serverObj, err := c.Get().NamespaceIfScoped(namespace, m.NamespaceScoped).Resource(m.Resource).Name(name).SubResource(m.Subresource).Do(context.TODO()).Get() if err != nil { // The object does not exist, but we want it to be created return m.replaceResource(c, m.Resource, namespace, name, obj, options) @@ -283,6 +293,7 @@ func (m *Helper) replaceResource(c RESTClient, resource, namespace, name string, NamespaceIfScoped(namespace, m.NamespaceScoped). Resource(resource). Name(name). + SubResource(m.Subresource). VersionedParams(options, metav1.ParameterCodec). Body(obj). Do(context.TODO()). diff --git a/staging/src/k8s.io/cli-runtime/pkg/resource/helper_test.go b/staging/src/k8s.io/cli-runtime/pkg/resource/helper_test.go index 3c5198aa4cb..71b2d4faad1 100644 --- a/staging/src/k8s.io/cli-runtime/pkg/resource/helper_test.go +++ b/staging/src/k8s.io/cli-runtime/pkg/resource/helper_test.go @@ -68,6 +68,17 @@ func V1DeepEqualSafePodSpec() corev1.PodSpec { } } +func V1DeepEqualSafePodStatus() corev1.PodStatus { + return corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Status: corev1.ConditionTrue, + Type: corev1.PodReady, + }, + }, + } +} + func TestHelperDelete(t *testing.T) { tests := []struct { name string @@ -257,11 +268,12 @@ func TestHelperCreate(t *testing.T) { func TestHelperGet(t *testing.T) { tests := []struct { - name string - Err bool - Req func(*http.Request) bool - Resp *http.Response - HttpErr error + name string + subresource string + Err bool + Req func(*http.Request) bool + Resp *http.Response + HttpErr error }{ { name: "test1", @@ -301,6 +313,35 @@ func TestHelperGet(t *testing.T) { return true }, }, + { + name: "test with subresource", + subresource: "status", + Resp: &http.Response{ + StatusCode: http.StatusOK, + Header: header(), + Body: objBody(&corev1.Pod{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"}, ObjectMeta: metav1.ObjectMeta{Name: "foo"}}), + }, + Req: func(req *http.Request) bool { + if req.Method != "GET" { + t.Errorf("unexpected method: %#v", req) + return false + } + parts := splitPath(req.URL.Path) + if parts[1] != "bar" { + t.Errorf("url doesn't contain namespace: %#v", req) + return false + } + if parts[2] != "foo" { + t.Errorf("url doesn't contain name: %#v", req) + return false + } + if parts[3] != "status" { + t.Errorf("url doesn't contain subresource: %#v", req) + return false + } + return true + }, + }, } for i, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -313,6 +354,7 @@ func TestHelperGet(t *testing.T) { modifier := &Helper{ RESTClient: client, NamespaceScoped: true, + Subresource: tt.subresource, } obj, err := modifier.Get("bar", "foo") @@ -382,6 +424,34 @@ func TestHelperList(t *testing.T) { return true }, }, + { + name: "test with", + Resp: &http.Response{ + StatusCode: http.StatusOK, + Header: header(), + Body: objBody(&corev1.PodList{ + Items: []corev1.Pod{{ + ObjectMeta: metav1.ObjectMeta{Name: "foo"}, + }, + }, + }), + }, + Req: func(req *http.Request) bool { + if req.Method != "GET" { + t.Errorf("unexpected method: %#v", req) + return false + } + if req.URL.Path != "/namespaces/bar" { + t.Errorf("url doesn't contain name: %#v", req.URL) + return false + } + if req.URL.Query().Get(metav1.LabelSelectorQueryParam(corev1GV.String())) != labels.SelectorFromSet(labels.Set{"foo": "baz"}).String() { + t.Errorf("url doesn't contain query parameters: %#v", req.URL) + return false + } + return true + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -501,6 +571,7 @@ func TestHelperReplace(t *testing.T) { Object runtime.Object Namespace string NamespaceScoped bool + Subresource string ExpectPath string ExpectObject runtime.Object @@ -592,6 +663,29 @@ func TestHelperReplace(t *testing.T) { Resp: &http.Response{StatusCode: http.StatusOK, Header: header(), Body: objBody(&metav1.Status{Status: metav1.StatusSuccess})}, Req: expectPut, }, + { + Name: "test7 - with status subresource", + Namespace: "bar", + NamespaceScoped: true, + Subresource: "status", + Object: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "foo"}, + Status: V1DeepEqualSafePodStatus(), + }, + ExpectPath: "/namespaces/bar/foo/status", + ExpectObject: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "10"}, + Status: V1DeepEqualSafePodStatus(), + }, + Overwrite: true, + HTTPClient: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + if req.Method == "PUT" { + return &http.Response{StatusCode: http.StatusOK, Header: header(), Body: objBody(&metav1.Status{Status: metav1.StatusSuccess})}, nil + } + return &http.Response{StatusCode: http.StatusOK, Header: header(), Body: objBody(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "10"}})}, nil + }), + Req: expectPut, + }, } for _, tt := range tests { t.Run(tt.Name, func(t *testing.T) { @@ -605,6 +699,7 @@ func TestHelperReplace(t *testing.T) { modifier := &Helper{ RESTClient: client, NamespaceScoped: tt.NamespaceScoped, + Subresource: tt.Subresource, } _, err := modifier.Replace(tt.Namespace, "foo", tt.Overwrite, tt.Object) if (err != nil) != tt.Err { diff --git a/staging/src/k8s.io/cli-runtime/pkg/resource/visitor.go b/staging/src/k8s.io/cli-runtime/pkg/resource/visitor.go index 48ee0235810..d5df9f3dd40 100644 --- a/staging/src/k8s.io/cli-runtime/pkg/resource/visitor.go +++ b/staging/src/k8s.io/cli-runtime/pkg/resource/visitor.go @@ -78,12 +78,16 @@ type Info struct { // defined. If retrieved from the server, the Builder expects the mapping client to // decide the final form. Use the AsVersioned, AsUnstructured, and AsInternal helpers // to alter the object versions. + // If Subresource is specified, this will be the object for the subresource. Object runtime.Object // Optional, this is the most recent resource version the server knows about for // this type of resource. It may not match the resource version of the object, // but if set it should be equal to or newer than the resource version of the // object (however the server defines resource version). ResourceVersion string + // Optional, if specified, the object is the most recent value of the subresource + // returned by the server if available. + Subresource string } // Visit implements Visitor @@ -93,7 +97,7 @@ func (i *Info) Visit(fn VisitorFunc) error { // Get retrieves the object from the Namespace and Name fields func (i *Info) Get() (err error) { - obj, err := NewHelper(i.Client, i.Mapping).Get(i.Namespace, i.Name) + obj, err := NewHelper(i.Client, i.Mapping).WithSubresource(i.Subresource).Get(i.Namespace, i.Name) if err != nil { if errors.IsNotFound(err) && len(i.Namespace) > 0 && i.Namespace != metav1.NamespaceDefault && i.Namespace != metav1.NamespaceAll { err2 := i.Client.Get().AbsPath("api", "v1", "namespaces", i.Namespace).Do(context.TODO()).Error() diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/edit/edit.go b/staging/src/k8s.io/kubectl/pkg/cmd/edit/edit.go index 6b26289706e..ddc79f00ba5 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/edit/edit.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/edit/edit.go @@ -63,7 +63,10 @@ var ( kubectl edit job.v1.batch/myjob -o json # Edit the deployment 'mydeployment' in YAML and save the modified config in its annotation - kubectl edit deployment/mydeployment -o yaml --save-config`)) + kubectl edit deployment/mydeployment -o yaml --save-config + + # Edit the deployment/mydeployment's status subresource + kubectl edit deployment mydeployment --subresource='status'`)) ) // NewCmdEdit creates the `edit` command @@ -80,6 +83,7 @@ func NewCmdEdit(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra ValidArgsFunction: util.ResourceTypeAndNameCompletionFunc(f), Run: func(cmd *cobra.Command, args []string) { cmdutil.CheckErr(o.Complete(f, args, cmd)) + cmdutil.CheckErr(o.Validate()) cmdutil.CheckErr(o.Run()) }, } @@ -96,5 +100,6 @@ func NewCmdEdit(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra "Defaults to the line ending native to your platform.") cmdutil.AddFieldManagerFlagVar(cmd, &o.FieldManager, "kubectl-edit") cmdutil.AddApplyAnnotationVarFlags(cmd, &o.ApplyAnnotation) + cmdutil.AddSubresourceFlags(cmd, &o.Subresource, "If specified, edit will operate on the subresource of the requested object.", editor.SupportedSubresources...) return cmd } diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/edit/edit_test.go b/staging/src/k8s.io/kubectl/pkg/cmd/edit/edit_test.go index df1d07ba4a1..4bc92e81535 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/edit/edit_test.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/edit/edit_test.go @@ -53,6 +53,7 @@ type EditTestCase struct { Output string `yaml:"outputFormat"` OutputPatch string `yaml:"outputPatch"` SaveConfig string `yaml:"saveConfig"` + Subresource string `yaml:"subresource"` Namespace string `yaml:"namespace"` ExpectedStdout []string `yaml:"expectedStdout"` ExpectedStderr []string `yaml:"expectedStderr"` @@ -253,6 +254,9 @@ func TestEdit(t *testing.T) { if len(testcase.SaveConfig) > 0 { cmd.Flags().Set("save-config", testcase.SaveConfig) } + if len(testcase.Subresource) > 0 { + cmd.Flags().Set("subresource", testcase.Subresource) + } cmdutil.BehaviorOnFatal(func(str string, code int) { errBuf.WriteString(str) diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/0.request b/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/0.request new file mode 100644 index 00000000000..e69de29bb2d diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/0.response b/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/0.response new file mode 100644 index 00000000000..0eb0dad8eb9 --- /dev/null +++ b/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/0.response @@ -0,0 +1,85 @@ +{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "annotations": { + "deployment.kubernetes.io/revision": "1" + }, + "creationTimestamp": "2021-06-23T17:01:10Z", + "generation": 5, + "labels": { + "app": "nginx" + }, + "name": "nginx", + "namespace": "edit-test", + "resourceVersion": "121107", + "uid": "a598ee47-9635-482b-bacb-16c9e3ade05c" + }, + "spec": { + "progressDeadlineSeconds": 600, + "replicas": 3, + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "app": "nginx" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": "25%", + "maxUnavailable": "25%" + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "nginx" + } + }, + "spec": { + "containers": [ + { + "image": "gcr.io/kakaraparthy-devel/nginx:latest", + "imagePullPolicy": "Always", + "name": "nginx", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "availableReplicas": 3, + "conditions": [ + { + "lastTransitionTime": "2021-06-23T17:01:10Z", + "lastUpdateTime": "2021-06-23T17:01:18Z", + "message": "ReplicaSet \"nginx-6f5fdbd667\" has successfully progressed.", + "reason": "NewReplicaSetAvailable", + "status": "True", + "type": "Progressing" + }, + { + "lastTransitionTime": "2021-06-23T17:59:01Z", + "lastUpdateTime": "2021-06-23T17:59:01Z", + "message": "Deployment has minimum availability.", + "reason": "MinimumReplicasAvailable", + "status": "True", + "type": "Available" + } + ], + "observedGeneration": 5, + "readyReplicas": 3, + "replicas": 3, + "updatedReplicas": 3 + } +} \ No newline at end of file diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.edited b/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.edited new file mode 100644 index 00000000000..5b00fe733a9 --- /dev/null +++ b/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.edited @@ -0,0 +1,66 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "1" + creationTimestamp: "2021-06-23T17:01:10Z" + generation: 5 + labels: + app: nginx + name: nginx + namespace: edit-test + resourceVersion: "121107" + uid: a598ee47-9635-482b-bacb-16c9e3ade05c +spec: + progressDeadlineSeconds: 600 + replicas: 3 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: nginx + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + creationTimestamp: null + labels: + app: nginx + spec: + containers: + - image: gcr.io/kakaraparthy-devel/nginx:latest + imagePullPolicy: Always + name: nginx + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 +status: + availableReplicas: 3 + conditions: + - lastTransitionTime: "2021-06-23T17:01:10Z" + lastUpdateTime: "2021-06-23T17:01:18Z" + message: ReplicaSet "nginx-6f5fdbd667" has successfully progressed. + reason: NewReplicaSetAvailable + status: "True" + type: Progressing + - lastTransitionTime: "2021-06-23T17:59:01Z" + lastUpdateTime: "2021-06-23T17:59:01Z" + message: Deployment has minimum availability. + reason: MinimumReplicasAvailable + status: "True" + type: Available + observedGeneration: 5 + readyReplicas: 3 + replicas: 4 + updatedReplicas: 3 diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.original b/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.original new file mode 100644 index 00000000000..8c6dff1c22d --- /dev/null +++ b/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.original @@ -0,0 +1,66 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "1" + creationTimestamp: "2021-06-23T17:01:10Z" + generation: 5 + labels: + app: nginx + name: nginx + namespace: edit-test + resourceVersion: "121107" + uid: a598ee47-9635-482b-bacb-16c9e3ade05c +spec: + progressDeadlineSeconds: 600 + replicas: 3 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: nginx + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + creationTimestamp: null + labels: + app: nginx + spec: + containers: + - image: gcr.io/kakaraparthy-devel/nginx:latest + imagePullPolicy: Always + name: nginx + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 +status: + availableReplicas: 3 + conditions: + - lastTransitionTime: "2021-06-23T17:01:10Z" + lastUpdateTime: "2021-06-23T17:01:18Z" + message: ReplicaSet "nginx-6f5fdbd667" has successfully progressed. + reason: NewReplicaSetAvailable + status: "True" + type: Progressing + - lastTransitionTime: "2021-06-23T17:59:01Z" + lastUpdateTime: "2021-06-23T17:59:01Z" + message: Deployment has minimum availability. + reason: MinimumReplicasAvailable + status: "True" + type: Available + observedGeneration: 5 + readyReplicas: 3 + replicas: 3 + updatedReplicas: 3 diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.request b/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.request new file mode 100644 index 00000000000..8a795e3ed39 --- /dev/null +++ b/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.request @@ -0,0 +1,5 @@ +{ + "status": { + "replicas": 4 + } +} \ No newline at end of file diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.response b/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.response new file mode 100644 index 00000000000..e6c018b2cfe --- /dev/null +++ b/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.response @@ -0,0 +1,85 @@ +{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "annotations": { + "deployment.kubernetes.io/revision": "1" + }, + "creationTimestamp": "2021-06-23T17:01:10Z", + "generation": 5, + "labels": { + "app": "nginx" + }, + "name": "nginx", + "namespace": "edit-test", + "resourceVersion": "121107", + "uid": "a598ee47-9635-482b-bacb-16c9e3ade05c" + }, + "spec": { + "progressDeadlineSeconds": 600, + "replicas": 3, + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "app": "nginx" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": "25%", + "maxUnavailable": "25%" + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "nginx" + } + }, + "spec": { + "containers": [ + { + "image": "gcr.io/kakaraparthy-devel/nginx:latest", + "imagePullPolicy": "Always", + "name": "nginx", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "availableReplicas": 3, + "conditions": [ + { + "lastTransitionTime": "2021-06-23T17:01:10Z", + "lastUpdateTime": "2021-06-23T17:01:18Z", + "message": "ReplicaSet \"nginx-6f5fdbd667\" has successfully progressed.", + "reason": "NewReplicaSetAvailable", + "status": "True", + "type": "Progressing" + }, + { + "lastTransitionTime": "2021-06-23T17:59:01Z", + "lastUpdateTime": "2021-06-23T17:59:01Z", + "message": "Deployment has minimum availability.", + "reason": "MinimumReplicasAvailable", + "status": "True", + "type": "Available" + } + ], + "observedGeneration": 5, + "readyReplicas": 3, + "replicas": 4, + "updatedReplicas": 3 + } +} \ No newline at end of file diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/test.yaml b/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/test.yaml new file mode 100644 index 00000000000..ef5a82ae6f9 --- /dev/null +++ b/staging/src/k8s.io/kubectl/pkg/cmd/edit/testdata/testcase-edit-subresource-status/test.yaml @@ -0,0 +1,27 @@ +description: edit the status subresource +mode: edit +args: + - deployment + - nginx +namespace: edit-test +subresource: status +expectedStdOut: + - deployment.apps/nginx edited +expectedExitCode: 0 +steps: + - type: request + expectedMethod: GET + expectedPath: /apis/extensions/v1beta1/namespaces/edit-test/deployments/nginx/status + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response + - type: edit + expectedInput: 1.original + resultingOutput: 1.edited + - type: request + expectedMethod: PATCH + expectedPath: /apis/apps/v1/namespaces/edit-test/deployments/nginx/status + expectedContentType: application/strategic-merge-patch+json + expectedInput: 2.request + resultingStatusCode: 200 + resultingOutput: 2.response diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/get/get.go b/staging/src/k8s.io/kubectl/pkg/cmd/get/get.go index 3a0d2481179..17e0801c34b 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/get/get.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/get/get.go @@ -51,6 +51,7 @@ import ( "k8s.io/kubectl/pkg/scheme" "k8s.io/kubectl/pkg/util/i18n" "k8s.io/kubectl/pkg/util/interrupt" + "k8s.io/kubectl/pkg/util/slice" "k8s.io/kubectl/pkg/util/templates" utilpointer "k8s.io/utils/pointer" ) @@ -78,6 +79,7 @@ type GetOptions struct { AllNamespaces bool Namespace string ExplicitNamespace bool + Subresource string ServerPrint bool @@ -132,7 +134,10 @@ var ( kubectl get rc,services # List one or more resources by their type and names - kubectl get rc/web service/frontend pods/web-pod-13je7`)) + kubectl get rc/web service/frontend pods/web-pod-13je7 + + # List status subresource for a single pod. + kubectl get pod web-pod-13je7 --subresource status`)) ) const ( @@ -140,6 +145,8 @@ const ( useServerPrintColumns = "server-print" ) +var supportedSubresources = []string{"status", "scale"} + // NewGetOptions returns a GetOptions with default chunk size 500. func NewGetOptions(parent string, streams genericclioptions.IOStreams) *GetOptions { return &GetOptions{ @@ -197,6 +204,7 @@ func NewCmdGet(parent string, f cmdutil.Factory, streams genericclioptions.IOStr cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, "identifying the resource to get from a server.") cmdutil.AddChunkSizeFlag(cmd, &o.ChunkSize) cmdutil.AddLabelSelectorFlagVar(cmd, &o.LabelSelector) + cmdutil.AddSubresourceFlags(cmd, &o.Subresource, "If specified, gets the subresource of the requested object.", supportedSubresources...) return cmd } @@ -331,6 +339,9 @@ func (o *GetOptions) Validate(cmd *cobra.Command) error { if o.OutputWatchEvents && !(o.Watch || o.WatchOnly) { return cmdutil.UsageErrorf(cmd, "--output-watch-events option can only be used with --watch or --watch-only") } + if len(o.Subresource) > 0 && !slice.ContainsString(supportedSubresources, o.Subresource, nil) { + return fmt.Errorf("invalid subresource value: %q. Must be one of %v", o.Subresource, supportedSubresources) + } return nil } @@ -484,6 +495,7 @@ func (o *GetOptions) Run(f cmdutil.Factory, cmd *cobra.Command, args []string) e FilenameParam(o.ExplicitNamespace, &o.FilenameOptions). LabelSelectorParam(o.LabelSelector). FieldSelectorParam(o.FieldSelector). + Subresource(o.Subresource). RequestChunksOf(chunkSize). ResourceTypeOrNameArgs(true, args...). ContinueOnError(). diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/get/get_test.go b/staging/src/k8s.io/kubectl/pkg/cmd/get/get_test.go index db083d3a031..41b29555768 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/get/get_test.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/get/get_test.go @@ -242,6 +242,60 @@ foo } } +func TestGetObjectSubresourceStatus(t *testing.T) { + _, _, replicationcontrollers := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Resp: &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &replicationcontrollers.Items[0])}, + } + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdGet("kubectl", tf, streams) + cmd.SetOutput(buf) + cmd.Flags().Set("subresource", "status") + cmd.Run(cmd, []string{"replicationcontrollers", "rc1"}) + + expected := `NAME AGE +rc1 +` + + if e, a := expected, buf.String(); e != a { + t.Errorf("expected\n%v\ngot\n%v", e, a) + } +} + +func TestGetObjectSubresourceScale(t *testing.T) { + _, _, replicationcontrollers := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Resp: &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: replicationControllersScaleSubresourceTableObjBody(codec, replicationcontrollers.Items[0])}, + } + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdGet("kubectl", tf, streams) + cmd.SetOutput(buf) + cmd.Flags().Set("subresource", "scale") + cmd.Run(cmd, []string{"replicationcontrollers", "rc1"}) + + expected := `NAME DESIRED AVAILABLE +rc1 1 0 +` + + if e, a := expected, buf.String(); e != a { + t.Errorf("expected\n%v\ngot\n%v", e, a) + } +} + func TestGetTableObjects(t *testing.T) { pods, _, _ := cmdtesting.TestData() @@ -2902,3 +2956,23 @@ func emptyTableObjBody(codec runtime.Codec) io.ReadCloser { } return cmdtesting.ObjBody(codec, table) } + +func replicationControllersScaleSubresourceTableObjBody(codec runtime.Codec, replicationControllers ...corev1.ReplicationController) io.ReadCloser { + table := &metav1.Table{ + ColumnDefinitions: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Description: metav1.ObjectMeta{}.SwaggerDoc()["name"]}, + {Name: "Desired", Type: "integer", Description: autoscalingv1.ScaleSpec{}.SwaggerDoc()["replicas"]}, + {Name: "Available", Type: "integer", Description: autoscalingv1.ScaleStatus{}.SwaggerDoc()["replicas"]}, + }, + } + + for i := range replicationControllers { + b := bytes.NewBuffer(nil) + codec.Encode(&replicationControllers[i], b) + table.Rows = append(table.Rows, metav1.TableRow{ + Object: runtime.RawExtension{Raw: b.Bytes()}, + Cells: []interface{}{replicationControllers[i].Name, replicationControllers[i].Spec.Replicas, replicationControllers[i].Status.Replicas}, + }) + } + return cmdtesting.ObjBody(codec, table) +} diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/patch/patch.go b/staging/src/k8s.io/kubectl/pkg/cmd/patch/patch.go index b39404961a4..165965d7de3 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/patch/patch.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/patch/patch.go @@ -41,6 +41,7 @@ import ( "k8s.io/kubectl/pkg/scheme" "k8s.io/kubectl/pkg/util" "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/slice" "k8s.io/kubectl/pkg/util/templates" ) @@ -56,10 +57,11 @@ type PatchOptions struct { ToPrinter func(string) (printers.ResourcePrinter, error) Recorder genericclioptions.Recorder - Local bool - PatchType string - Patch string - PatchFile string + Local bool + PatchType string + Patch string + PatchFile string + Subresource string namespace string enforceNamespace bool @@ -94,9 +96,14 @@ var ( kubectl patch pod valid-pod -p '{"spec":{"containers":[{"name":"kubernetes-serve-hostname","image":"new image"}]}}' # Update a container's image using a JSON patch with positional arrays - kubectl patch pod valid-pod --type='json' -p='[{"op": "replace", "path": "/spec/containers/0/image", "value":"new image"}]'`)) + kubectl patch pod valid-pod --type='json' -p='[{"op": "replace", "path": "/spec/containers/0/image", "value":"new image"}]' + + # Update a deployment's replicas through the scale subresource using a merge patch. + kubectl patch deployment nginx-deployment --subresource='scale' --type='merge' -p '{"spec":{"replicas":2}}'`)) ) +var supportedSubresources = []string{"status", "scale"} + func NewPatchOptions(ioStreams genericclioptions.IOStreams) *PatchOptions { return &PatchOptions{ RecordFlags: genericclioptions.NewRecordFlags(), @@ -133,6 +140,7 @@ func NewCmdPatch(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobr cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, "identifying the resource to update") cmd.Flags().BoolVar(&o.Local, "local", o.Local, "If true, patch will operate on the content of the file, not the server-side resource.") cmdutil.AddFieldManagerFlagVar(cmd, &o.fieldManager, "kubectl-patch") + cmdutil.AddSubresourceFlags(cmd, &o.Subresource, "If specified, patch will operate on the subresource of the requested object.", supportedSubresources...) return cmd } @@ -192,7 +200,9 @@ func (o *PatchOptions) Validate() error { return fmt.Errorf("--type must be one of %v, not %q", sets.StringKeySet(patchTypes).List(), o.PatchType) } } - + if len(o.Subresource) > 0 && !slice.ContainsString(supportedSubresources, o.Subresource, nil) { + return fmt.Errorf("invalid subresource value: %q. Must be one of %v", o.Subresource, supportedSubresources) + } return nil } @@ -224,6 +234,7 @@ func (o *PatchOptions) RunPatch() error { LocalParam(o.Local). NamespaceParam(o.namespace).DefaultNamespace(). FilenameParam(o.enforceNamespace, &o.FilenameOptions). + Subresource(o.Subresource). ResourceTypeOrNameArgs(false, o.args...). Flatten(). Do() @@ -255,7 +266,8 @@ func (o *PatchOptions) RunPatch() error { helper := resource. NewHelper(client, mapping). DryRun(o.dryRunStrategy == cmdutil.DryRunServer). - WithFieldManager(o.fieldManager) + WithFieldManager(o.fieldManager). + WithSubresource(o.Subresource) patchedObj, err := helper.Patch(namespace, name, patchType, patchBytes, nil) if err != nil { return err diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/patch/patch_test.go b/staging/src/k8s.io/kubectl/pkg/cmd/patch/patch_test.go index 1fce8bfeda1..138e62d383e 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/patch/patch_test.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/patch/patch_test.go @@ -21,6 +21,8 @@ import ( "strings" "testing" + jsonpath "github.com/exponent-io/jsonpath" + corev1 "k8s.io/api/core/v1" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/resource" "k8s.io/client-go/rest/fake" @@ -190,3 +192,51 @@ func TestPatchObjectFromFileOutput(t *testing.T) { t.Errorf("unexpected output: %s", buf.String()) } } + +func TestPatchSubresource(t *testing.T) { + pod := cmdtesting.SubresourceTestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + expectedStatus := corev1.PodRunning + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/pods/foo/status" && (m == "PATCH" || m == "GET"): + obj := pod + + // ensure patched object reflects successful + // patch edits from the client + if m == "PATCH" { + obj.Status.Phase = expectedStatus + } + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, obj)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + stream, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdPatch(tf, stream) + cmd.Flags().Set("namespace", "test") + cmd.Flags().Set("patch", `{"status":{"phase":"Running"}}`) + cmd.Flags().Set("output", "json") + cmd.Flags().Set("subresource", "status") + cmd.Run(cmd, []string{"pod/foo"}) + + decoder := jsonpath.NewDecoder(buf) + var actualStatus corev1.PodPhase + decoder.SeekTo("status", "phase") + decoder.Decode(&actualStatus) + // check the status.phase value is updated in the response + if actualStatus != expectedStatus { + t.Errorf("unexpected pod status to be set to %s got: %s", expectedStatus, actualStatus) + } +} diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/replace/replace.go b/staging/src/k8s.io/kubectl/pkg/cmd/replace/replace.go index 53c911b5d11..40f925cdcf5 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/replace/replace.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/replace/replace.go @@ -40,6 +40,7 @@ import ( "k8s.io/kubectl/pkg/scheme" "k8s.io/kubectl/pkg/util" "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/slice" "k8s.io/kubectl/pkg/util/templates" "k8s.io/kubectl/pkg/validation" ) @@ -67,6 +68,8 @@ var ( kubectl replace --force -f ./pod.json`)) ) +var supportedSubresources = []string{"status", "scale"} + type ReplaceOptions struct { PrintFlags *genericclioptions.PrintFlags RecordFlags *genericclioptions.RecordFlags @@ -92,6 +95,8 @@ type ReplaceOptions struct { Recorder genericclioptions.Recorder + Subresource string + genericclioptions.IOStreams fieldManager string @@ -132,6 +137,7 @@ func NewCmdReplace(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobr cmd.Flags().StringVar(&o.Raw, "raw", o.Raw, "Raw URI to PUT to the server. Uses the transport specified by the kubeconfig file.") cmdutil.AddFieldManagerFlagVar(cmd, &o.fieldManager, "kubectl-replace") + cmdutil.AddSubresourceFlags(cmd, &o.Subresource, "If specified, replace will operate on the subresource of the requested object.", supportedSubresources...) return cmd } @@ -238,6 +244,10 @@ func (o *ReplaceOptions) Validate(cmd *cobra.Command) error { } } + if len(o.Subresource) > 0 && !slice.ContainsString(supportedSubresources, o.Subresource, nil) { + return fmt.Errorf("invalid subresource value: %q. Must be one of %v", o.Subresource, supportedSubresources) + } + return nil } @@ -262,6 +272,7 @@ func (o *ReplaceOptions) Run(f cmdutil.Factory) error { ContinueOnError(). NamespaceParam(o.Namespace).DefaultNamespace(). FilenameParam(o.EnforceNamespace, &o.DeleteOptions.FilenameOptions). + Subresource(o.Subresource). Flatten(). Do() if err := r.Err(); err != nil { @@ -295,6 +306,7 @@ func (o *ReplaceOptions) Run(f cmdutil.Factory) error { NewHelper(info.Client, info.Mapping). DryRun(o.DryRunStrategy == cmdutil.DryRunServer). WithFieldManager(o.fieldManager). + WithSubresource(o.Subresource). Replace(info.Namespace, info.Name, true, info.Object) if err != nil { return cmdutil.AddSourceToErr("replacing", info.Source, err) @@ -330,6 +342,7 @@ func (o *ReplaceOptions) forceReplace() error { NamespaceParam(o.Namespace).DefaultNamespace(). ResourceTypeOrNameArgs(false, o.BuilderArgs...).RequireObject(false). FilenameParam(o.EnforceNamespace, &o.DeleteOptions.FilenameOptions). + Subresource(o.Subresource). Flatten() if stdinInUse { b = b.StdinInUse() @@ -369,6 +382,7 @@ func (o *ReplaceOptions) forceReplace() error { ContinueOnError(). NamespaceParam(o.Namespace).DefaultNamespace(). FilenameParam(o.EnforceNamespace, &o.DeleteOptions.FilenameOptions). + Subresource(o.Subresource). Flatten() if stdinInUse { b = b.StdinInUse() diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/testing/util.go b/staging/src/k8s.io/kubectl/pkg/cmd/testing/util.go index b95ebe3b87a..960fb6e13c7 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/testing/util.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/testing/util.go @@ -150,6 +150,22 @@ func EmptyTestData() (*corev1.PodList, *corev1.ServiceList, *corev1.ReplicationC return pods, svc, rc } +func SubresourceTestData() *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "test", ResourceVersion: "10"}, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyAlways, + DNSPolicy: corev1.DNSClusterFirst, + TerminationGracePeriodSeconds: &grace, + SecurityContext: &corev1.PodSecurityContext{}, + EnableServiceLinks: &enableServiceLinks, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + } +} + func GenResponseWithJsonEncodedBody(bodyStruct interface{}) (*http.Response, error) { jsonBytes, err := json.Marshal(bodyStruct) if err != nil { diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/util/editor/editoptions.go b/staging/src/k8s.io/kubectl/pkg/cmd/util/editor/editoptions.go index cc32e59434a..c3afcb7cace 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/util/editor/editoptions.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/util/editor/editoptions.go @@ -51,8 +51,11 @@ import ( "k8s.io/kubectl/pkg/cmd/util/editor/crlf" "k8s.io/kubectl/pkg/scheme" "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/slice" ) +var SupportedSubresources = []string{"status"} + // EditOptions contains all the options for running edit cli command. type EditOptions struct { resource.FilenameOptions @@ -84,6 +87,8 @@ type EditOptions struct { updatedResultGetter func(data []byte) *resource.Result FieldManager string + + Subresource string } // NewEditOptions returns an initialized EditOptions instance @@ -184,6 +189,7 @@ func (o *EditOptions) Complete(f cmdutil.Factory, args []string, cmd *cobra.Comm } r := b.NamespaceParam(cmdNamespace).DefaultNamespace(). FilenameParam(enforceNamespace, &o.FilenameOptions). + Subresource(o.Subresource). ContinueOnError(). Flatten(). Do() @@ -198,6 +204,7 @@ func (o *EditOptions) Complete(f cmdutil.Factory, args []string, cmd *cobra.Comm return f.NewBuilder(). Unstructured(). Stream(bytes.NewReader(data), "edited-file"). + Subresource(o.Subresource). ContinueOnError(). Flatten(). Do() @@ -216,6 +223,9 @@ func (o *EditOptions) Complete(f cmdutil.Factory, args []string, cmd *cobra.Comm // Validate checks the EditOptions to see if there is sufficient information to run the command. func (o *EditOptions) Validate() error { + if len(o.Subresource) > 0 && !slice.ContainsString(SupportedSubresources, o.Subresource, nil) { + return fmt.Errorf("invalid subresource value: %q. Must be one of %v", o.Subresource, SupportedSubresources) + } return nil } @@ -561,7 +571,7 @@ func (o *EditOptions) annotationPatch(update *resource.Info) error { if err != nil { return err } - helper := resource.NewHelper(client, mapping).WithFieldManager(o.FieldManager) + helper := resource.NewHelper(client, mapping).WithFieldManager(o.FieldManager).WithSubresource(o.Subresource) _, err = helper.Patch(o.CmdNamespace, update.Name, patchType, patch, nil) if err != nil { return err @@ -699,7 +709,7 @@ func (o *EditOptions) visitToPatch(originalInfos []*resource.Info, patchVisitor } patched, err := resource.NewHelper(info.Client, info.Mapping). - WithFieldManager(o.FieldManager). + WithFieldManager(o.FieldManager).WithSubresource(o.Subresource). Patch(info.Namespace, info.Name, patchType, patch, nil) if err != nil { fmt.Fprintln(o.ErrOut, results.addError(err, info)) diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/util/helpers.go b/staging/src/k8s.io/kubectl/pkg/cmd/util/helpers.go index ac55cf80a2a..9ffab16c682 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/util/helpers.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/util/helpers.go @@ -468,6 +468,10 @@ func AddLabelSelectorFlagVar(cmd *cobra.Command, p *string) { cmd.Flags().StringVarP(p, "selector", "l", *p, "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2). Matching objects must satisfy all of the specified label constraints.") } +func AddSubresourceFlags(cmd *cobra.Command, subresource *string, usage string, allowedSubresources ...string) { + cmd.Flags().StringVar(subresource, "subresource", "", fmt.Sprintf("%s Must be one of %v. This flag is alpha and may change in the future.", usage, allowedSubresources)) +} + type ValidateOptions struct { EnableValidation bool } diff --git a/test/cmd/get.sh b/test/cmd/get.sh index be24af42c10..3350572c4b7 100755 --- a/test/cmd/get.sh +++ b/test/cmd/get.sh @@ -175,6 +175,14 @@ run_kubectl_get_tests() { output_message=$(! kubectl get pod valid-pod --allow-missing-template-keys=false -o go-template='{{.missing}}' "${kube_flags[@]}") kube::test::if_has_string "${output_message}" 'map has no entry for key "missing"' + ## check --subresource=status works + output_message=$(kubectl get "${kube_flags[@]}" pod valid-pod --subresource=status) + kube::test::if_has_string "${output_message}" 'valid-pod' + + ## check --subresource=scale returns an error for pods + output_message=$(! kubectl get pod valid-pod --subresource=scale 2>&1 "${kube_flags[@]:?}") + kube::test::if_has_string "${output_message}" 'the server could not find the requested resource' + ### Test kubectl get watch output_message=$(kubectl get pods -w --request-timeout=1 "${kube_flags[@]}") kube::test::if_has_string "${output_message}" 'STATUS' # headers