From 38487e770caad6ecedf4e7cdba40697b567705a9 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 Kubernetes-commit: a5aa858d44651ba48bdce634a39b55be91216614 --- pkg/cmd/edit/edit.go | 7 +- 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 ++++++ pkg/cmd/get/get.go | 14 ++- pkg/cmd/get/get_test.go | 74 ++++++++++++++++ pkg/cmd/patch/patch.go | 26 ++++-- pkg/cmd/patch/patch_test.go | 50 +++++++++++ pkg/cmd/replace/replace.go | 14 +++ pkg/cmd/testing/util.go | 16 ++++ pkg/cmd/util/editor/editoptions.go | 14 ++- pkg/cmd/util/helpers.go | 4 + 17 files changed, 546 insertions(+), 11 deletions(-) create mode 100644 pkg/cmd/edit/testdata/testcase-edit-subresource-status/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-edit-subresource-status/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.request create mode 100644 pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.response create mode 100644 pkg/cmd/edit/testdata/testcase-edit-subresource-status/test.yaml diff --git a/pkg/cmd/edit/edit.go b/pkg/cmd/edit/edit.go index 6b2628970..ddc79f00b 100644 --- a/pkg/cmd/edit/edit.go +++ b/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/pkg/cmd/edit/edit_test.go b/pkg/cmd/edit/edit_test.go index df1d07ba4..4bc92e815 100644 --- a/pkg/cmd/edit/edit_test.go +++ b/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/pkg/cmd/edit/testdata/testcase-edit-subresource-status/0.request b/pkg/cmd/edit/testdata/testcase-edit-subresource-status/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-edit-subresource-status/0.response b/pkg/cmd/edit/testdata/testcase-edit-subresource-status/0.response new file mode 100644 index 000000000..0eb0dad8e --- /dev/null +++ b/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/pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.edited b/pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.edited new file mode 100644 index 000000000..5b00fe733 --- /dev/null +++ b/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/pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.original b/pkg/cmd/edit/testdata/testcase-edit-subresource-status/1.original new file mode 100644 index 000000000..8c6dff1c2 --- /dev/null +++ b/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/pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.request b/pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.request new file mode 100644 index 000000000..8a795e3ed --- /dev/null +++ b/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/pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.response b/pkg/cmd/edit/testdata/testcase-edit-subresource-status/2.response new file mode 100644 index 000000000..e6c018b2c --- /dev/null +++ b/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/pkg/cmd/edit/testdata/testcase-edit-subresource-status/test.yaml b/pkg/cmd/edit/testdata/testcase-edit-subresource-status/test.yaml new file mode 100644 index 000000000..ef5a82ae6 --- /dev/null +++ b/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/pkg/cmd/get/get.go b/pkg/cmd/get/get.go index 3a0d24811..17e0801c3 100644 --- a/pkg/cmd/get/get.go +++ b/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/pkg/cmd/get/get_test.go b/pkg/cmd/get/get_test.go index db083d3a0..41b295557 100644 --- a/pkg/cmd/get/get_test.go +++ b/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/pkg/cmd/patch/patch.go b/pkg/cmd/patch/patch.go index b39404961..165965d7d 100644 --- a/pkg/cmd/patch/patch.go +++ b/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/pkg/cmd/patch/patch_test.go b/pkg/cmd/patch/patch_test.go index 1fce8bfed..138e62d38 100644 --- a/pkg/cmd/patch/patch_test.go +++ b/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/pkg/cmd/replace/replace.go b/pkg/cmd/replace/replace.go index 53c911b5d..40f925cdc 100644 --- a/pkg/cmd/replace/replace.go +++ b/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/pkg/cmd/testing/util.go b/pkg/cmd/testing/util.go index b95ebe3b8..960fb6e13 100644 --- a/pkg/cmd/testing/util.go +++ b/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/pkg/cmd/util/editor/editoptions.go b/pkg/cmd/util/editor/editoptions.go index cc32e5943..c3afcb7ca 100644 --- a/pkg/cmd/util/editor/editoptions.go +++ b/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/pkg/cmd/util/helpers.go b/pkg/cmd/util/helpers.go index bbd66b750..10430a8da 100644 --- a/pkg/cmd/util/helpers.go +++ b/pkg/cmd/util/helpers.go @@ -470,6 +470,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 }